Java并发编程之synchronized 与 volatile

synchronized

同步代码块一般使用 Java 的 synchronized 关键字来实现,有两种方式对方法进行加锁操作;第一处,在方法签名处加 synchronized 关键字;第二,使用 synchronized(对象或类)进行同步。这里的原则是锁的范围尽可能小,锁的时间尽可能短,能锁对象就不要锁类;能锁代码块,就不要锁方法。

synchronized 锁特性由 JVM 负责实现。JVM 底层是通过监视锁来实现 synchronized 同步的。监视锁即 monitor ,是每个对象都会拥有的隐藏字段。使用 synchronized 时,JVM 会根据 synchronized 的当前使用环境,找到对应对象的 monitor,再根据 monitor 的状态进行加、解锁的判断。例如,线程在进入同步方法或代码块时,会获取该方法或代码块所属对象的 monitor 锁,进行加锁判断。如果成功加锁就成为该 monitor 的唯一持有者。monitor 锁在释放前,不能再被其他线程获取。下面通过字节码学习 synchronized 是如何实现的:

方法元信息中会使用 ACC_SYNCHRONIZED 标识该方法是一个同步方法。同步代码块中会使用 monitorenter 及 monitorexit 两个字节码指令获取和释放 monitor。如果使用了 monitor 进入时 monitor 为0,表示该线程可以持有 monitor 后续代码,并将 monitor 置为 1;如果当前线程已经持有了 monitor,那么 monitor 继续加 1;如果 monitor 不等于 0 ,其他线程就会进入堵塞状态。JVM 对 synchronized 提供三种锁的实现,包括偏向锁、轻量级锁、重量级锁,还提供自动的升级和降级机制。JVM 就是利用 CAS 在对象头上设置线程 ID,标识这个对象偏向于当前线程,这就是偏向锁。

偏向锁是为了在资源没有被多线程竞争的情况下尽量减少锁带来的性能开销。在锁对象的对象头中有一个 ThreadId 字段,当第一个线程访问锁时,如果该锁没有被其他线程访问过,即 ThreadId 字段为空,那么 JVM 让其持有偏向锁,并将 ThreadId 字段的值设置为该线程的ID。当下一次获取锁时,会判断当前线程的 ID 是否与锁对象的ThreadId 一致,如果一致,那么该线程不会再重复获取锁,从而提高了程序的运行效率。如果出现锁的竞争情况,那么偏向锁会被撤销并升级为轻量级锁。如果资源的竞争非常激烈,会升级为重量级锁。偏向锁可以降低无竞争开销,它不是互斥锁,不存在线程竞争的情况,省去再次同步判断的步骤,提升了性能。

volatile

当使用 volatile 修饰变量时,意味着任何对此变量的操作都会在内存中进行,不会产生副本,以保证共享变量的可见性,局部阻止了指令重排的发生。

每个线程都有独占的内存区域,如操作栈、本地变量表等。线程本地内存保存了引用变量在堆内存中的副本,线程对变量的所有操作都在本地内存区域中进行(即操作引用变量副本),执行结束后再同步到堆内存中去。这里会存在着一个时间差,在这个时间差内,该线程对副本的操作,对于其他线程来讲是不可见的。

上面提到了可见性指令重排

可见性:指的是当某个线程对共享变量的进行修改操作时,对于其他线程来说,都是可见的。

指令重排:CPU 在处理信息时也会进行指令优化,分析哪些取数据动作可以合并进行,哪些存数据操作可以合并进行,以此来提高计算机的执行效率。

下面看下设计模式中经典的双重检查懒汉式单例的示例代码

public class LazyDoubleCheckSingleton {

    private static LazyDoubleCheckSingleton instance = null;

    private LazyDoubleCheckSingleton() {}

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

}

使用者在调用 getInstance() 有关,有可能得到初始化未完成的对象。究其原因,与 Java 虚拟机的编译优化有关。对 Java 编译器而言,初始化 LazyDoubleCheckSingleton实例和对象地址写到 instance字段并非原子操作,且这两个阶段的执行顺序是未定义的。多线程并发情况下,假定某个线程执行 new LazyDoubleCheckSingleton() 时,正常情况下要经历以下三个步骤:

  1. 分配内存地址给这个对象,并设置默认值
  2. 初始化对象
  3. 设置 instance 指向步骤 1 刚分配好的内存地址

但是按上面所说的特殊情况,程序可能会碰到当执行完步骤 1 后,步骤 2 和 3 很有可能会出现顺序颠倒,也就是重排序,什么是重排序呢?当某个线程执行代码的顺序和代码在Jav文件中的顺序不一致,代码指令指令并不是严格按照代码语句顺序执行的,它们的顺序被改变了,这就是重排序,也就是下面这种情况

  1. 分配内存地址给这个对象,并设置默认值

  2. 设置 instance 指向步骤 1 刚分配好的内存地址

  3. 初始化对象

所以,当出现重排序情况时, 也就是 instance 已经指向分配好的内存地址,但是 instance 它是没有初始化完成的。也就是说在多线程并发的情况下,其他线程进来拿到 instance ,由于 instance 已经分配好了内存地址,所以 instance 不为 null ,就直接返回 instance 这个没有初始化的实例,系统就会报异常。

而对于单线程情况下,这种重排序的特殊情况,是不会有什么影响的,不会改变程序的执行结果,Java 语言规范是允许那些在单线程内不会改变单线程程序执行结果的重排序,因为单线程下的重排序,反而能提高执行性能。

这就是著名的双重检查锁定(Double-checked Locking)问题,对象引用在没有同步的情况下进行读操作,导致用户可能会获取未构造完成的对象。所以,要想保证不出现以上重排序的情况,我们只需要 volatile 关键字来禁止重排序即可,这样编译器就无法对 instance 进行相关读写操作,对它的读写操作进行指令重排优化,确定对象实例化完成之后才返回引用。

volatile 解决的是多线程共享变量的可见性问题,类似于 synchronized ,但不具备synchronized 的互斥性。另外 volatile 只是轻量级的线程操作可见方式,并非同步方式,如果是多写场景,就一定会发生线程安全问题。如果是一写多读的并发场景,使用 volatile 修饰变量就非常合适。

posted @ 2022-09-04 22:44  追风少年潇歌  阅读(70)  评论(0编辑  收藏  举报