(2021-04-01)常见面试题之Java和“锁”不能说的秘密

Posted Mr. Dreamer Z

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了(2021-04-01)常见面试题之Java和“锁”不能说的秘密相关的知识,希望对你有一定的参考价值。

这里写目录标题


前言
Java提供了各种类型丰富的锁,每种锁使用与不同的场景下。本文就大概的来描述一下java提供的那些锁,以及它们不得不提的“故事”。
在我们开始阅读本文时,希望大家带上问题去阅读。

1.线程要不要锁住同步资源?
2.锁住同步资源失败,线程要不要阻塞?
3.多个线程竞争资源的细节?
4.多个线程竞争锁时要不要遵循先来后到?
5.一个线程能不能多次的获取同一把锁,在未释放锁的场景下
6.多个线程能不能共享一把锁


1.悲观锁和乐观锁

悲观锁和乐观锁这一概念,大家应该都很熟悉了。它是一个广义上的概念,体现了对待线程的不同角度。对待先说下概念:

  • 悲观锁,认为自己在使用数据的时候一定会有其他线程来修改数据,所以在获取数据的时候就会先加锁,来确保别的线程没机会搞事情
  • 乐观锁,认为自己再使用数据时不会有其他线程来搞事情,所以不会锁。只是会在更新数据的时候判断有没有其他线程修改了这个数据。如果没有,那么将自己需要修改的数据写入。否则根据不同的方式进行操作(重试或者报错)

乐观锁在java中是通过无锁来实现的,常见的就是CAS操作。


如上图可知,乐观锁更适合度多写少的场景,而悲观锁则适合写多读少的场景。

public final int getAndAddInt(Object var1, long var2, int var4) 
        int var5;
        do 
            var5 = this.getIntVolatile(var1, var2);
         while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    
  public final boolean compareAndSet(int expect, int update) 
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    

从上面的代码可以看出,其实CAS就是一个比较然后替换的过程。它有三个值:内存中,预期值,更新值。当内存值等于预期值时,会进行一个更新操作。会将更新值更新到内存中。但是也可以看出来,如果不等于的话,它会一直循环。

说到CAS,不得不提它的三个问题:
1.ABA问题。一个很直观的例子:比如喝水。A将一杯水倒满,放入冰箱中。B喝了一半,然后又倒满,再次放回原位。C一看水是满的,并且在原位置,以为水是没人喝过的,所以就自己使用。但是其实是B喝过了。
解决方法:设置版本号/使用AtomicStampedReference,这个类会提供一个带有时间戳的方法,它会通过判断原来的值和时间戳进行比对。

2.循环时间开销大。由于以上代码可知,如果一直没办法修改成功,CAS一直进行尝试状态,一直循环,直到成功。

3.只能保证一个共享变量的原子操作。
解决方法:提供AtomicReference可以存放对象

2.自旋锁和自适应自旋锁

首先来谈谈自旋锁,为什么jdk会搞一个自旋锁。大家都知道,线程之间的切换会消耗cpu资源,它所耗费的资源要比你想象的多,在工作繁琐的业务中,线程之间切换的耗时是几乎是可以忽略不计的。但是如果一段代码过于简单,那么线程切换的时间要远远大于代码执行的时间。

从上图可得知,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
但是大家有没有想过,如果该线程一直无法获取到锁,那么它将会永远进行自旋操作。自旋锁虽然避免了线程切换的开销,但是如果出现上述的情况,那么自旋锁只会白白的浪费资源,变得毫无意义。
自旋锁在JDK1.4中引入了,在1.6中默认开启自旋锁,并引入了自适应自旋锁。自适应自旋锁意味着自旋次数不再固定,它会根据上次获取锁的情况,来决定这次自旋的次数。对于某个锁来说,如果上一次自旋等待获得了锁,那么这一次它会认为自己也能获取锁,进而它会加大自旋的次数。但是如果对于某个锁它连续几次都获取失败,那么它在以后的过程中可能会直接阻塞,避免浪费资源。

3.Java对象头

在JVM中,对象在内存中(堆内存)的布局分为三块区域:对象头,实例变量和填充数据。
实例变量:存放类的属性数据信息。
填充数据:用于保证8字节对齐。

对象头:Mark Word、Class pointer、Array length
Mark Word:存储对象的HashCode,分代年龄以及锁标记位等信息。
Class pointer:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。
Array length:数组长度(如果当前对象为数组)。
说完对象头这个概念,我们就可以知道下面所讲的内容的关联性了

4.无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

在JDK1.6之前,synchronized效率都很低,由于它锁的粒度很重。于是在JDK1.6中引入了“偏向锁”和“轻量级锁”,来减少获得锁和释放锁消耗的性能。
存储内容中的信息就是存放于对象头的Mark Word中~

4.1 无锁

无锁就是没有对资源进行锁定,所以线程都能访问同一个资源,但是同时只有一个线程能访问成功。

无锁的状态下是在循环中进行,不会进行线程切换。在有些场合下性能是很高的。

4.2 偏向锁

偏向锁的情况,是一个资源长时间被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

大多数情况,锁总是由同一线程获取,不存在多线程竞争,所以出现了偏向锁。

4.3 轻量级锁

指当锁是偏向锁时,此资源这时被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
如果当前只有一个等待线程,则该线程通过自旋进行等待,但是如果自旋超过一定次数。或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

4.4 重量级锁

升级为重量级锁时,等待锁的线程都会进入阻塞状态。

注意:只有重量级锁状态时,等待的线程才会进入阻塞状态。

5.公平锁和非公平锁

简单的先解释一个公平锁和非公平锁。
公平锁:
公平锁如上图所示,新来的线程想要获取资源。回到队列的末尾进行排队。然后除了队列第一个线程其余的都会进行阻塞,等待该它们使用资源时,才会被上一个线程唤醒。在执行资源时,它会去管理员那里获取锁。缺点就是效率低。

非公平锁:

非公平锁比公平锁多了一个步骤,就是会先尝试插队,如果插队失败,那么才会去排队。前提是,A执行完毕。把锁还给管理员,但是管理员还没有允许下一个人去吃饭时。由于插队,不需要入队列,原本队列的人只能继续等待。



如上图可以知,公平锁和非公平锁最大的区别就是hasQueuedPredecessors()这个方法。我们进去看看

可以看到它就是判断是否还有队列在排队,并且
1.判断当前是否有队列在等待
2.判断头节点后面是否还有其他节点在排队
3.判断当前想插队的线程是否是头节点后面的那个节点所对应的线程

简单来说,这就是公平锁和非公平锁的区别。

6.独占锁和共享锁

独占锁:这个锁只能被唯一一个线程使用;
共享锁:这个锁可以被多个线程使用;
这里大家其实可能知道ReentrantReadWriteLock提供了读写锁,读锁就是刚刚讲到的共享锁,写锁则是独占锁。

简单的来说,它就做了这几件事情

      /*
             * Walkthrough:
             * 1. If read count nonzero or write count nonzero
             *    and owner is a different thread, fail.
             * 2. If count would saturate, fail. (This can only
             *    happen if count is already nonzero.)
             * 3. Otherwise, this thread is eligible for lock if
             *    it is either a reentrant acquire or
             *    queue policy allows it. If so, update state
             *    and set owner.
             */
  1. 如果读取计数不为零或写入计数不为零,而且持有者不是当前线程,失败。
  2. 如果计数将饱和(这就是有一个),则失败。(这只能如果计数已非零时发生。)
  3. 否则,如果它要么是可重入的获取,要么是队列策略允许它。如果是,请更新状态设置所有者。


简单来说,读锁支持共享。但是如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。

下面用一个demo来简单的演示一下:

public class ReadWriteLockDemo 

    public static void main(String[] args) 
        MyCache myCache = new MyCache();
        for(int i=1;i<=1;i++)
          final int tempInt = i;
//            new Thread(()->
//                myCache.get(tempInt+"");
//            ,String.valueOf(i)).start();

          new Thread(()->
                myCache.put(tempInt+"",tempInt+"");
            ,String.valueOf(i)).start();
        

        for(int i=1;i<=5;i++)
            final int tempInt = i;
            new Thread(()->
                myCache.get(tempInt+"");
            ,String.valueOf(i)).start();
        
    


import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class MyCache 

    private volatile Map<String,Object> map = new HashMap<>();

    private ReentrantReadWriteLock  lock = new ReentrantReadWriteLock();

    public void put(String key,String val)
        lock.writeLock().lock();
        try 
            System.out.println(Thread.currentThread().getName()+"\\t 正在写入:"+key);
            TimeUnit.MILLISECONDS.sleep(300);
            map.put(key, val);
            System.out.println(Thread.currentThread().getName()+"\\t 写入完成");
        catch (Exception e)
            e.printStackTrace();
        finally 
            lock.writeLock().unlock();
        
    


    public void get(String key)
        lock.readLock().lock();
        try 
            System.out.println(Thread.currentThread().getName()+"\\t 正在读取:"+key);
            TimeUnit.MILLISECONDS.sleep(300);
            Object result = map.get(key);
            System.out.println(Thread.currentThread().getName()+"\\t 读取完成"+result);
        catch (Exception e)
            e.printStackTrace();
        finally 
            lock.readLock().unlock();
        
    

通过该实例可以看出来,写锁的独占锁,读锁是共享锁。
再将注释的代码放开,可以看出。读写和写锁互不干扰。加了读锁就无法加在写锁,反之亦然。

以上是关于(2021-04-01)常见面试题之Java和“锁”不能说的秘密的主要内容,如果未能解决你的问题,请参考以下文章

java 关于锁常见面试题

Java多线程常见面试题-第一节:锁策略CAS和Synchronized原理

Java中的常见面试题

常见面试题知识点之:分布式锁

Java常见面试题精选

Java常见面试题(第六弹):分布式锁的实现方式有哪三种?