Java 中的各种锁

自旋锁 | 自旋适应锁

  • Java 中的线程是与操作系统中的线程一一对应的,所以阻塞或者唤醒一个 Java 线程是需要操作系统切换到内核态来完成的,这显然给程序的并发能力带来了一定的性能损耗。
  • 正常情况下锁获取失败就应该阻塞入队,但是有时候可能刚一阻塞,别的线程就释放锁了,然后再唤醒刚刚阻塞的线程,这就显得有点多余了。甚至,如果同步代码块中的内容比较简单,同步资源被锁的时间比较短,可能操作系统状态转换消耗的时间比用户代码执行的时间还要长。
  • 所以说,为了这一小段时间去切换操作系统状态是得不偿失的。
  • 现在绝大多数的计算机都是多核处理器系统,支持两个或以上的线程同时并行执行,综合上述考虑,我们可以让后面请求锁的那个线程 “稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
  • 这个 “稍等一下” 就是自旋,其实就是个 do-while 循环。更底层来说自旋操作就是空转 CPU,执行一些无意义的指令,目的就是不让出 CPU 等待锁的释放。
  • 如果这个线程自旋完成后,前面锁定共享资源的线程已经释放了锁,那么这个线程就可以不必被阻塞而是直接获取共享资源,从而避免切换操作系统状态的开销。

这就是自旋锁。

  • 但是,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源,空转,死等,还不如被阻塞住。这也就是为什么我们说:纵使阻塞会使操作系统陷入内核态,但是它仍然无法被自旋锁替代。
  • 所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数没有成功获得锁,就应当挂起线程。
  • 默认是 10 次,可以使用参数 -XX:PreBlockSpin 来更改。
  • 而在 JDK 1.6 中,对于自选等待的次数这个问题,做出了一次优化,即引入了适应性自旋锁(自适应的自旋锁)。
  • 自适应意味着自旋的次数(时间)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么 JVM 就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间;
  • 如果对于某个锁,很少有线程通过自旋等待成功获得过,那么当以后有线程尝试获取这个锁时, JVM 可能省略掉自旋过程,直接阻塞住线程,避免空转浪费处理器资源。


这就是适应性自旋锁

无锁 | 偏向锁 | 轻量级锁 | 重量级锁

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。

  • 如果测试成功,表示线程已经获得了锁。
  • 如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。

简单来说,偏就是 “偏心” 的意思,就是说这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有该偏向锁的线程将永远不需要再进行 CAS 操作来竞争这个锁。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以只要出现了但凡其他的一个线程来尝试竞争偏向锁,持有偏向锁的那个线程就会释放锁。
显然,如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的,还会带来额外的锁撤销的消耗。

偏向锁 | 轻量级锁 | 重量级锁

抛开无锁这个状态不谈,Java 中的 synchronized 有偏向锁、轻量级锁、重量级锁,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。当条件不满足时,锁就会按照顺序进行升级。

公平锁 | 非公平锁

  • 公平锁:多个线程按照申请锁的顺序来获取锁。在并发环境中,每个线程会先查看此锁维护的等待队列,如果该锁的等待队列为空,则直接占有锁;如果该锁的等待队列不为空,则该线程加入到等待队列的末尾,按照 FIFO 的原则从队列中取出线程,然后占有锁。
  • 非公平锁:线程会先尝试获取锁,如果获取不到,则再采用公平锁的方式也就是进入等待队列。也就是说,多个线程获取锁的顺序,不是按照 FIFO 的顺序,有可能后申请锁的线程比先申请的线程优先获取到锁。
  • 公平锁的优点就是等待锁的线程不会饿死。缺点就是整体效率比非公平锁要低,因为等待队列中除了队头的第一个线程以外,其他所有线程都会被阻塞住,而阻塞线程的唤醒需要操作系统陷入内核态。
  • 非公平锁的优点是相对于公平锁来说可以减少唤醒线程的开销,整体的效率比较高,因为线程有几率不阻塞直接获得锁。缺点就是处于等待队列中的线程可能会饿死,或者说等待很久才能获得锁(饥饿)。

在 Java 中,synchronized 就是非公平锁的实现,Lock 接口的实现类 ReentrantLock 可以通过构造函数来指定该锁是公平的还是非公平的(默认是非公平的)。

可重入锁 | 不可重入锁

ReetrantLock 和 synchronized 是可重入锁。
可重入锁:也称为递归锁,同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。
有用的链接
https://leetcode-cn.com/leetbook/read/concurrency/at43b7/

共享锁 | 排他锁

image.png
ReentrantReadWriteLock 中的读锁就是共享锁,writeLock 就是排他锁。

Synchronized 关键字

synchronized 的基本使用

  1. 对于普通同步方法(synchronized 修饰的普通方法),锁是当前实例对象。

    1. class Test {
    2. // 普通同步方法
    3. public synchronized void test1() {
    4. ......
    5. }
    6. }
  2. 对于静态同步方法,锁的是当前的Class 对象

    class Test {
     // 静态同步方法
     public static synchronized void test2() {
         ......
     }
    }
    
  3. 对于同步方法块(synchronized 修饰的块),锁是 Synchonized 括号里配置的对象。

    class Test {
     // 同步方法块
     static final Object room = new Object(); // 声明一个锁
     public void test3(){
         synchronized (room) { // 锁住 room 对象
             ......
         }
     }
    }
    

    synchronized 的 Happens-before 关系

    管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是 “同一个锁”,而 “后面” 是指时间上的先后。

这个规则其实就是针对 synchronized 的。JVM 并没有把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 — synchronized

锁的内存语义

  1. 锁释放的内存语义:当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。

举个例子,如图所示,当线程 A 释放锁后,JMM 会把线程 A 本地内存中的 a = 1 刷新到主内存中:
image.png

  1. 锁获取的内存语义:当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被锁保护的临界区代码必须从主内存中读取共享变量。比如说,如图所示,在上图的基础之上,线程 A 执行完了,线程 B 想要获取锁了,JMM 会把线程 B 的本地内存中的 a = 0 设置为无效,从而使得同步代码块必须从主内存中读取共享变量 a = 1:

image.png

Synchronized 底层原理之Monitor与Java 对象头

当一个线程试图访问同步方法或者同步方法块时,它首先必须得到锁才能进入这些代码块,并且退出或抛出异常时必须释放锁。But,编程不是想象,不能说你觉得这是个锁这就是锁,那么,锁到底存储在哪里?锁里面存储的信息又是什么呢?

字节码

  1. 普通同步方法反编译结果

    public class Test {
     // 普通同步方法
     public synchronized void test1() {
         System.out.println("hello");
     }
    }
    

    image.png

  2. 静态同步方法反编译

    class Test {
     // 静态同步方法
     public static synchronized void test2() {
         System.out.println("hello");
     }
    }
    

    截屏2022-02-16 下午2.04.24.png

  3. 同步方法块(synchronized 修饰的块)反编译结果:

    public class Test {
     // 同步方法块
     static final Object room = new Object(); // 声明一个锁
     public void test3(){
         synchronized (room) { // 锁住 room 对象
             System.out.println("hello");
         }
     }
    }
    

    截屏2022-02-16 下午2.08.02.png

  • 可以看见,synchronized 修饰的方法块和方法有所不同,多出了两个指令 monitorenter 和 monitorexit,也就是说在同步方法块中,JVM 使用 monitorenter 和 monitorexit 这两个指令实现同步。
  • 需要注意的是,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。

    初始 ACC_SYNCHRONIZED

  • 方法级别的同步是隐式执行的,是作为方法调用和返回的一部分的,在运行时常量池中通过 ACC_SYNCHRONIZED 标志来区分是同步方法还是普通方法。

  • 当调用设置了 ACC_SYNCHRONIZED 的方法时,执行线程进入监视器(monitor),然后执行这个方法,方法执行完毕后退出监视器。需要注意的是,无论这个方法是正常完成还是突然完成,在执行线程拥有监视器期间,没有其他线程可以进入这个方法。
  • 另外,如果在调用同步方法过程中抛出异常并且同步方法没有处理该异常,则在异常重新抛出同步方法之前,该方法的监视器会自动退出。

    初始 monitorenter、monitorexit

    任何一个对象都与一个监视器(monitor)相关联。 当一个监视器有拥有者(owner)的时候,这个监视器就会被锁定(locked),所谓拥有者(owner)就是说执行 monitorenter 的线程会尝试获得监视器的所有权,或者说尝试获得对象的锁(反过来说就是不加 synchronized 的对象是不会被锁住的)。
    线程在执行同步方法的时候会进入monitorenter 状态, 即进入临界区。但是其他线程仍然可以执行非同步方法。
    另外,每个监视器都维护着一个自己被持有次数(或者说被锁住 locked)的计数器(count),具体如下:

  • 如果与对象关联的监视器的计数器为零,则线程进入监视器成为该监视器的拥有者,并将计数器设置为 1。

  • 当同一个线程再次进入该对象的监视器的时候,计数器会再次自增。
  • 当其他线程想获得该对象关联的监视器的时候,就会被阻塞住,直到该监视器的计数器为 0 才会再次尝试获得其所有权。

换句话说,同一个线程可以重复获取同一个对象的锁,只是计数器+1 而已,即synchronized 是可重入的。

Monitor 详解

monitor 监视器 or 管程。 常见的进程同步与互斥机制就是信号量和管程,相比起信号量,管程有一个重要特性:在一个时刻只能有一个进程使用管程,即进程在无法继续执行的时候不能一直占用管程,否则其它进程将永远不能使用管程。也就是说管程天生支持进程互斥。

在 Java 虚拟机(HotSpot)中,管程(monitor)的具体实现是 ObjectMonitor 类
image.png
image.png
几个重要的成员变量:_count, owner, _waitSet, _EntryList
image.png
image.png
Synchronized 是非公平锁
ReetrantLock 可以选择公平或者非公平。

  1. 刚开始 monitor 中 owner(拥有者) 为 null
  2. 当 Thread-2 执行 synchronized(obj) 就会将 monitor 的所有者 owner 置为 Thread-2,同时计数器 count 加 1。注意,每个 monitor 中只能有一个 owner。
  3. 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),即想要获取与 Thread-2 锁住的相同对象 obj 的 monitor,那么这三个线程会转为 BLOCKED 态,进入 EntryList 队列。
  4. Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,注意竞争时是非公平的。
  5. 图中 WaitSet 队列中的 Thread-0,Thread-1 是之前获得过锁,但由于条件不满足(比如调用了 wait 方法等)进入了 WAITING 状态的线程。

Java 对象头

Java 里的对象可以分为对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
截屏2022-02-16 下午3.01.01.png
image.png
例如在 32 位的 HotSpot 虚拟机中,如对象未被同步锁锁定的状态下,Mark Word 的 32 个比特存储空间中的 25 个比特用于存储对象哈希码,4 个比特用于存储对象分代年龄,2 个比特用于存储锁标志位,1 个比特固定为 0,如图所示:
image.png
在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word 可能变化为存储以下 4 种数据,如图所示:
注: 这里指向互斥量(重量级锁)的指针就是指向ObjectMonitor 对象的指针。
image.png
在 64 位虚拟机下,Mark Word 是 64bit 大小的,其存储结构如图所示:
image.png

锁的升级

参考图
image.png

偏向锁

当只有一个线程的时候,不需要平凡的释放和获取锁,只需要在对象头设置偏向的线程ID即可。 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁
1)查看对象头的 Mark Word 中偏向锁的标识以及锁标志位,若 ”是否偏向锁“ 为 1 且 ”锁标志位“ 为 01,则该锁为可偏向状态;
2)若为可偏向状态,则测试 Mark Word 中的线程 ID 是否与当前线程相同,若相同,则不用执行 CAS 操作,直接进入同步块执行,否则进入下一步。
3)当前线程通过 CAS 操作竞争锁,若竞争成功,则使用 CAS 操作将 Mark Word 中线程 ID 设置为当前线程 ID(重新偏向),然后执行同步块;若竞争失败,则进入偏向锁撤销的流程。

偏向锁的撤销

偏向锁的撤销采用了 一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。具体来说,如果当前线程通过 CAS 竞争偏向锁失败,说明存在锁竞争,则进入偏向锁撤销的流程,偏向锁的撤销需要等待 全局安全点 safe point(这个时间点上没有正在执行的代码),其具体步骤如下:

  1. JVM 会先暂停拥有偏向锁的线程,判断持有偏向锁的线程是否还存活。
  2. 如果持有偏向锁的线程存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到轻量级锁竞争的逻辑里。如果持有偏向锁的线程已经不存活或者不在同步块中,则将对象头的 Mark Word 改为无锁状态(01),以允许其他线程竞争锁,之后再升级为轻量级锁;

image.png

轻量级锁加锁过程

如果超过1个线程或者两个线程交替进行,则升级为轻量级锁,另一个线程自旋等待前一个线程释放锁。

  1. Mark Word 的初始状态:在代码即将进入同步块的时候,如果此同步对象没有被锁定,也即 Mark Word 中的锁标志位 “01” + 是否是偏向锁 “0”:

image.png

  1. 在当前线程的栈帧中建立一个锁记录:Java 虚拟机会在将在当前线程的栈帧中建立一个名为 锁记录(Lock Record) 的空间,Lock Record 中有一个字段 displaced_header,用于后续存储锁对象的 Mark Word 的拷贝:

image.png

  1. 复制锁对象的 Mark Word 到锁记录中:把锁对象的 Mark Word 复制到锁记录中,更具体来讲,是将 Mark Word 放到锁记录的 displaced_header 属性中。官方给这个复制过来的记录起名 Displaced Mark Word:

image.png

  1. 使用 CAS 操作更新锁对象的 Mark Word。Java 虚拟机使用 CAS 操作尝试把锁对象的 Mark Word 更新为指向锁记录的指针,并将锁记录里的 owner 指针指向对象的 Mark Word。如果这个更新操作成功了,就表明获取轻量级锁成功,也就是说该线程拥有了这个对象的锁!并且该对象 Mark Word 的锁标志位会被改为 00,即表示此对象处于轻量级锁定状态。

注: mark word 变成指向栈中锁记录的指针。
image.png

假设锁的状态是轻量级锁,下图反应了对象的 Mark word 和线程栈中锁记录的状态,可以看到左边线程栈中包含3个指向当前锁对象的 Lock Record。其中栈中最高位的锁记录为第一次获取轻量级锁时分配的,其 Displaced Mark word 的值为锁对象 obj 加锁之前的 Mark word,之后的每次锁重入都会在线程栈中分配一个 Displaced Mark word 为 null 的锁记录。
image.png

重量级锁

当多个线程竞争一个锁时,就会升级为重量级锁, 即互斥锁。对象头的mark word指向对象对应的objectMonitor, 当monitor被某个线程持有的时候就处于锁定状态。 竞争失败的线程进入阻塞队列等待唤醒。

锁的消除和粗化

锁消除:Lock Elimination

经过逃逸分析,则可以消除锁

逃逸分析 Escape Analysis

逃逸分析是编译器的一种优化技术,它并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
所谓逃逸,包括方法逃逸和线程逃逸,线程逃逸的逃逸程度高于方法逃逸:

  • 当一个对象在方法里面被定义后,它如果被外部方法所引用(例如作为调用参数传递到其他方法中),这种称为方法逃逸;
  • 可能被外部其他线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;

如果虚拟机能够确定一个对象不会发生方法逃逸和线程逃逸,或者逃逸程度比较低(只发生方法逃逸,不发生线程逃逸),则可以为这个对象实例采取不同程度的优化,我们上文说到的锁消除(也称为 “同步消除 Synchronization Elimination”)就是这其中的一种优化手段, 除此之外,还有 栈上分配(Stack Allocations) 和 标量替换(Scalar Replacement)。

锁的粗化

如果虚拟机检查到有这样一串连续的操作都是对同一个对象进行加锁,就会把加锁同步的范围粗化(扩大)到整个操作序列的外部。
简单来说,锁粗化就是把多次加锁请求合并成一次

wait、notify、notifyAll 必须放在 synchronized 同步块中

调用 wait 方法的线程必须拥有此对象的监视器

该方法将当前线程(称为 T)置于此对象的 WaitSet 中,然后放弃该对对象的锁。直到发生以下四种情况之一,该线程才会被唤醒:

  • 其他线程为此对象调用了 notify 方法,并且线程 T 恰好被操作系统选择为要唤醒的线程。
  • 其他线程为此对象调用了 notifyAll 方法,唤醒了所有线程。
  • 其他一些线程 interrupt() 中断了线程 T。
  • 超过了指定的等待时间(当然,如果 timeout 为零,线程会一直等待直到被通知)。

从这段注释我们就已经可以看出来了,调用 wait 方法的前提,那就是必须放在 synchronized 同步块中,因为得拥有对象的监视器啊。
我们不妨写段代码试验下,如果 wait 方法不在同步块中会怎样:

public void test() {
    try {
        new Object().wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

结果就是抛出 IllegalMonitorStateException 异常。
同样的,notify 和 notifyAll 也得放在 synchronized 同步块中用

无效唤醒 Lost Wakeupimage.png

虚假唤醒

wait 应该总是放在循环里的,否则容易出错

synchronized (obj) {
    while (condition does not hold)
        obj.wait(timeout);
    // Perform action appropriate to condition
}

注: wait 将线程放到对象的WaitSet 里, notify, notifyAll 将线程从waitSet 里唤醒。没有抢到锁的线程被阻塞从而进入EntryList

Volatile如何保持可见性与双重校验锁

volatile 的内存语义

  1. volatile 写的内存语义

image.png

  1. volatile 读的内存语义

image.png

  1. volatile 读写和普通读写的区别

两者都通过主内存读写共享变量。volatile 写会把本地变量刷新到主内存同时把其他线程拷贝的共享变量值设置为无效。这样其他线程就只能从主内存读取。 而普通读写会将共享变量读到线程的本地内存,一个线程的修改不能被其他线程感知。

禁止指令重排(内存屏障):

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

image.png
volatile 写和普通写隔开。volatile 写 volatile 读隔开。

  1. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障和一个 LoadStore 屏障


image.png

双重校验锁

关于 volatile 最出名的应用就是单例模式的 双重校验锁(Double Checked Locking,DCL) 写法:

public class SingleTon {
    // 私有化构造方法
    private SingleTon(){}; 

    private static volatile SingleTon instance = null;
    public static SingleTon getInstance() {
            // 第一次校验
            if (instance == null) {     
                synchronized (SingleTon.class) {
                       // 第二次校验
                        if (instance == null) {     
                            instance = new SingleTon();
                        }
                }
            }
    }

     return instance;
}

instance 一定要用 volatile 这个关键字来修饰
这里就是 volatile 第二项特性 - 禁止指令重排的应用。在 Java 语言层面上,创建对象仅仅是一个 new 关键字而已,而在 JVM 中,对象的创建其实并不是一蹴而就的,忽略掉一些 JVM 底层的细节比如设置对象头啥的,对象的创建可以大致分三个步骤:

  • 在堆中为对象分配内存空间
  • 调用构造函数,初始化实例
  • 将栈中的对象引用指向刚分配的内存空间

那么由于 JVM 指令重排优化的存在,有可能第二步和第三步发生交换:

  • 在堆中为对象分配内存空间
  • 将栈中的对象引用指向刚分配的内存空间
  • 调用构造函数,初始化实例

截屏2022-02-17 上午12.31.06.png