6 ReentrantLock的实现原理

这道题想考察什么?

  1. 是否了解并发相关的理论知识
  2. 是否对于锁机制有个全面的理论认知
  3. 是否对于AQS原理有自己的理解

考察的知识点

  1. 锁的分类(公平锁、重入锁、重力度锁等等)
  2. ReentrantLock实现方式与Synchronized实现方式的异同点

考生应该如何回答

Java中的大部分同步类(Lock、Semaphore、ReentrantLock等)都是基于队列同步器—AQS实现的。AQS原理见《4.5 AQS原理》

在ReentrantLock中有一个抽象类Sync:

private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
    ...    
}

可以看到Sync就继承自AQS,而ReentrantLock的lock解锁、unlock释放锁等操作其实都是借助的sync来完成。

public void lock() {
    sync.lock();
}
public void unlock() {
    sync.release(1);
}

Sync是个抽象类,ReentrantLock根据传入构造方法的布尔型参数实例化出Sync的实现类FairSync和NonfairSync,分别表示公平锁和非公平锁。

public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

ReentrantLock与AQS的关系如下:

【Android面试】2023最新面试专题七:Java并发编程(二)_公平锁

NonfairSync

在ReentrantLock的默认无参构造方法中,sync会被是实例化为:NonfairSync 表示非公平锁。

lock
static final class NonfairSync extends Sync {
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
}

NonfairSync就是一个AQS。因此在执行lock时,会首先利用CAS(《4.4 CAS无锁编程原理》)尝试设置AQS的state为1。如果设置成功表示成功获取锁;否则表示其他线程已经占用,此时会使用AQS#acquire将尝试获取锁失败的线程放入AQS的等待队列进行等待并且将线程挂起。

unlock

lock获取锁需要对state进行加1,那么对于重入锁而言,重入一次就需要对state执行一次加1。这样子,在解锁的时候,每次unlock就对state减一,等到state的值为0的时候,才能唤醒下一个等待线程。

因此ReentrantLock#unlock,实际上就是执行了AQS的release(1):

public void unlock() {
    sync.release(1);
}

FairSync

对于NonfairSync 而言,线程只要执行lock请求,就会马上尝试获取锁,不会管AQS当前管理的等待队列中是否存在正在等待的线程,这对于等待的线程不公平,因此NonfairSync表示非公平锁。

FairSync表示公平锁,会在lock请求进行时,先判断AQS管理的等待队列中是否已经有正在等待的线程,有的话就不会尝试获取锁,直接进入等待队列,保证了公平性。此时FairSync#lock实际上执行的就是AQS的acquire

static final class FairSync extends Sync {
    final void lock() {
        acquire(1);
    }
}

7 Synchronized的原理以及与ReentrantLock的区别。(360)


这道题想考察什么?

  1. 是否了解并发相关的理论知识
  2. 是否对于锁机制有个全面的理论认知
  3. 是否对于AQS原理有自己的理解

考察的知识点

  1. 锁的分类(公平锁、重入锁、重力度锁等等)
  2. ReentrantLock实现方式与Synchronized实现方式的异同点

考生应该如何回答

Synchronized的原理见《8 Synchronized在JDK1.6之后做了哪些优化》

ReentrantLock与Synchronized的区别,除了一个是Java类实现,一个是关键字之外,还包括:

【Android面试】2023最新面试专题七:Java并发编程(二)_公平锁_02

除此之外,ReenTrantLock相对于Synchronized还拥有自己的独有特性:

  • ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
  • ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
  • ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。

8 volatile关键字干了什么?(什么叫指令重排) (字节跳动)


这道题想考察什么?

是否了解volatile关键字与真实场景使用

考察的知识点

volatile关键字的概念在项目中使用与基本知识

考生应该如何回答

volatile是java提供的可以声明在成员属性前的一个关键字。在声明中包含此关键字的作用有:

保证内存可见性

可见性是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。

当对非volatile变量进行读写的时候,每个线程先从主内存拷贝变量到CPU缓存中,如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU cache中。

volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,保证了每次读写变量都从主内存中读,跳过CPU cache这一步。当一个线程修改了这个变量的值,新值对于其他线程是立即得知的。

禁止指令重排

指令重排序是JVM为了优化指令、提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。指令重排序包括编译器重排序和运行时重排序。

latile变量禁止指令重排序。针对volatile修饰的变量,在读写操作指令前后会插入内存屏障,指令重排序时不能把后面的指令重排序到内存屏

示例说明:

示例说明:
double r = 2.1; //(1) 
double pi = 3.14;//(2) 
double area = pi*r*r;//(3)

虽然代码语句的定义顺序为1->2->3,但是计算顺序1->2->3与2->1->3对结果并无影响,所以编译时和运行时可以根据需要对1、2语句进行重排序。

指令重排带来的问题

如果一个操作不是原子的,就会给JVM留下重排的机会。

线程A中
{
    context = loadContext();
    inited = true;
}
 
线程B中
{
    if (inited) 
        fun(context);
}

如果线程A中的指令发生了重排序,那么B中很可能就会拿到一个尚未初始化或尚未初始化完成的context,从而引发程序错误。

禁止指令重排的原理

volatile关键字提供内存屏障的方式来防止指令被重排,编译器在生成字节码文件时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

JVM内存屏障插入策略:

在每个volatile写操作的前面插入一个StoreStore屏障;

在每个volatile写操作的后面插入一个StoreLoad屏障;

在每个volatile读操作的后面插入一个LoadLoad屏障;

在每个volatile读操作的后面插入一个LoadStore屏障。

指令重排在双重锁定单例模式中的影响 基于双重检验的单例模式(懒汉型)

public class Singleton3 {
    private static Singleton3 instance = null;
 
    private Singleton3() {}
 
    public static Singleton3 getInstance() {
        if (instance == null) {
            synchronized(Singleton3.class) {
                if (instance == null)
                    instance = new Singleton3();// 非原子操作
            }
        }
 
        return instance;
    }
}

instance= new Singleton()并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:

memory =allocate();    //1:分配对象的内存空间 
ctorInstance(memory);  //2:初始化对象 
instance =memory;     //3:设置instance指向刚分配的内存地址

上面操作2依赖于操作1,但是操作3并不依赖于操作2。所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:

memory =allocate();    //1:分配对象的内存空间 
instance =memory;     //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory);  //2:初始化对象

指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。

volatile解决重排

用volatile关键字修饰instance变量,使得instance在读、写操作前后都会插入内存屏障,避免重排序。

public class Singleton3 {
    private static volatile Singleton3 instance = null;
 
    private Singleton3() {}
 
    public static Singleton3 getInstance() {
        if (instance == null) {
            synchronized(Singleton3.class) {
                if (instance == null)
                    instance = new Singleton3();
            }
        }
        return instance;
    }
}

9 volatile 能否保证线程安全?在DCL上的作用是什么?


这道题想考察什么?

  1. 是否了解Java并发编程的相关知识?
  2. 对象创建的过程

考察的知识点

  1. volatile的原理
  2. 编译优化

考生应该如何回答

volatile无法保证线程安全,只能保证变量的可见性,并不能保证变量操作的原子性。

原子性指的是一个或者多个操作在 CPU 执行的过程中不被中断的特性。

public class VolatileTest {

    public volatile static int count = 0;
     
    public static void main(String [] args){
        //开启5个线程
      for(int i = 0;i < 5; i++){
         new Thread(new Runnable() {
     
             @Override
             public void run() {
                 try {
                     Thread.sleep(1000);
                 } catch (InterruptedException e) {
                     e.printStackTrace();
                 }
                 //让count的值自增100次
                 for(int j = 0;j < 100;j++){
                     count++;
                 }
             }
         }).start();
      }
     
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count= " + count);
    }
}

上面代码我们开启5个线程,每个线程当中让静态变量count自增100次。执行之后会发现,最终count的结果值未必是500,有可能小于500。

出现上面情况的原因是因为volatile没有保证原子性。例如A线程获取到count的值为2,此时主存与工作内存数据一致,然后我们执行自增操作,count的值为3,但是主存中的值很有可能被其他线程更新为了8或者其他数目,如果A线程执行更新主存,那数目相当于往下降了。

volatile在单例中的作用

public class SingletonClass { 

  private volatile static SingletonClass instance = null; 

  public static SingletonClass getInstance() { 
    if (instance == null) { 
      synchronized (SingletonClass.class) { 
        if(instance == null) { 
          instance = new SingletonClass(); 
        } 
      } 
    } 
    return instance; 
  } 
  private SingletonClass() { 
  } 
}

上述代码中,我们使用到了双重检测和volatile,为什么在双重检测的基础上还需要加上volatile这是因为我们对象创建的部分,可能会因为指令重排发生变化。一般情况下包括三步:

  1. 分配内存
  2. 初始化对象
  3. 将内存地址赋值给引用

如果发生了指令重排可能会导致第二步内容和第三步内容顺序发生变化,即还没初始化的对象已经赋值给引用,再执行相关操作会发生各类异常问题。

总结

因为volatile不能保证变量操作的原子性,所以试图通过volatile来保证线程安全性是不靠谱的。

volatile在DCL上的作用是防止对象发生指令重排而引起的异常问题。


10 volatile和synchronize有什么区别?(B站 小米 京东)


这道题想考察什么?

是否了解Java并发编程的相关知识?

考察的知识点

  1. volatile的原理
  2. synchronize的原理

考生应该如何回答

  • volatile 只能作用于变量,synchronized 可以作用于变量、方法、对象。
  • volatile 只保证了可见性和有序性,无法保证原子性,synchronized 可以保证线程间的有序性(个人猜测是无法保证线程内的有序性,即线程内的代码可能被 CPU 指令重排序)、原子性和可见性。
  • volatile 线程不阻塞,synchronized 线程阻塞。
  • volatile 本质是告诉 jvm 当前变量在寄存器中的值是不安全的需要从内存中读取;sychronized 则是锁定当前变量,只有当前线程可以访问到该变量其他线程被阻塞。
  • volatile标记的变量不会被编译器优化, synchronized标记的变量可以被编译器优化。

有需要的小伙伴,可以点击下方课程链接详细了解!!!

https://edu.51cto.com/course/32703.html