概述

带着以下问题了解 synchronized 关键字

1. synchronized 可以作用在哪里? 分别通过对象锁和类锁进行举例。

2. synchronized 本质上是通过什么保证线程安全的?

  1. 加锁和释放锁的原理。JVM 自动添加 monitorenter 和 monitorexit 字节码。
  2. 可重入原理。加锁计数器。可以理解每一个对象都拥有一个计数器,当线程获取该对象锁后,计数器 +1,释放锁后计数器 -1
  3. synchronized 的 Happens-Before 规则。对同一个监视器的解锁,先行发生于对该监视器的加锁。A 执行结果对 B 可见,并且 A 的执行顺序先于 B。

    3. synchronized 有什么的缺陷? Java Lock 是怎么弥补这些缺陷的。

    4. synchronized 和 Lock 的对比,如何选择?

    5. synchronized 在使用时有何注意事项?

    6. synchronized 修饰的方法在抛出异常时,会释放锁吗?

    会的。JVM 会给我们相当于加一个 try {} finally {}

    7. 多个线程等待同一个 synchronized 锁的时候,JVM 如何选择下一个获取锁的线程?

    8. synchronized 使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?

    9. 我想更加灵活地控制锁的释放和获取 (现在释放锁和获取锁的时机都被规定死了),怎么办?

    可以考虑使用 Lock。

    10. 什么是锁的升级?

    JDK 1.6 版本开始支持 synchronized 升级操作,按无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁进行升级。

    11. 锁可以降级么?

    没有 JDK 标准,完全看各家 JVM 如何实现。像 HotSpot 就支持锁降级,但是这个过程效率比较低。频繁锁升级和降级操作对性能可能造成很大影响。重量级锁降级发生于 STW 阶段,降级对象仅仅能被 VMThread 访问而没有其它 Java Thread 访问的对象。基本认为锁不会降级。

    12. 什么是 JVM 里的偏向锁、轻量级锁、重量级锁?

    13. 不同的 JDK 中对 synchronized 有何优化?

    哪个对象被锁住呢 ?

    | 加锁位置 | 锁的对象 | | —- | —- | | 静态代码块 | Class 对象 | | 同步代码块 | this 对象 | | 同步方法 | this 对象 |

还需要注意:

  1. synchronized 是一把排它锁,任意时刻只能被一个线程所持有。没有获取锁的线程需阻塞等待(被动)
  2. 每个实例对象有自己的一把锁(this),不同实例之间互不影响。
  3. 异常:无论方法正常退出还是抛出异常,JVM 层面都会释放锁。

    字节码分析 synchronized 加锁、释放锁的过程

    1. public class TestSynchronized {
    2. Object lock = new Object();
    3. void m1() { }
    4. synchronized void m2() { }
    5. void m3() {
    6. synchronized (this) { }
    7. }
    8. void m4() {
    9. synchronized (lock) { }
    10. }
    11. }

    上面源码中包含 4 个方法,相关字节码如下:
    m1

    0 return
    

    m2

    0 return
    

    m3

    0 aload_0
    1 dup
    2 astore_1
    3 monitorenter
    4 aload_1
    5 monitorexit
    6 goto 14 (+8)
    9 astore_2
    10 aload_1
    11 monitorexit
    12 aload_2
    13 athrow
    14 return
    

    m4

    0 aload_0
    #1 获取对象
    1 getfield #3 <test/thread/concurent/TestSynchronized.lock : Ljava/lang/Object;>
    4 dup
    5 astore_1
    6 monitorenter
    7 aload_1
    8 monitorexit
    9 goto 17 (+8)
    12 astore_2
    13 aload_1
    14 monitorexit
    15 aload_2
    16 athrow
    17 return
    
  4. m3 m4 唯一不同点是 synchronized 加锁的对象不一样。在字节码体现不同的点就是 #1,通过 getfield 获取 lock 的引用。

  5. 加锁、释放锁分别对应 monitorentermonitorexit

关于字节码 monitorenter ,官网有以下一段描述:

  1. objectref 必须是一个引用类型。
  2. 每个实例(object)都关联一个 monitor 锁。这个锁有仅且有一个对象可持有。线程调用 monitorenter 仅仅尝试去获得锁,获取锁的过程会出现以下几种情况:
    1. monitor 计数器为 0,意味着没有线程持有这把锁。线程可以进入 monitor 并设置计数器为 1。
    2. 如果 monitor 读数器不为 0,意味着已有其它线程持有这把锁。判断持有锁的线程和当前尝试获取锁的线程是否为同一个线程,如果是,则进入 monitory 并设置计数器 +1
    3. 如果不是同一个线程,那么当前尝试去获取锁的线程会阻塞等待持有锁的线程退出 monitor。当没有人持有这把锁后,JVM 会唤醒所有阻塞在这把锁的线程,这些线程会尝试去竞争获取这把锁,可能会出现饥饿情况。

此外,我们还看到 m2 方法虽然加上 synchronized 关键字,但是字节码并没有 monitorenter。这是为什么呢 ?
其实JVM 文档已经说明原由了,翻译就是,monitorenter 指令并不会用于被 synchronized 关键字修饰的方法,因为 JVM 调用方法时会隐式(implicitly)处理,就像显示使用了 monitorentermonitorexit 一样。
monitorexit 指令则是指针 -1 操作。如果 Monitor 计数器为 0,说明当前没有线程拥有 monitor 所有权。
下图是监视器、同步队列及线程关系:
监视器、同步队列及线程关系.png
我们需要关注无法获取 Monitor 锁的情况,即获取锁失败,该线程就会进入同步状态,线程状态变更为 BLOCKED。当调用 Monitor.Exit 释放锁后,同步队列中的线程就有机会重新获取锁。

保证可见性

基于内存模型和 Happens-Before 规则。
synchronized 的 Happens-Before,前一个拥有锁的线程修改变量对下一个线程可见。

JVM 对 synchronized 优化

moniterentermoniterexit 字节码依赖操作系统的 Mutex Lock 来实现的。但是这里会有用户态内核态切换的开销,与用户层面代码执行相比,陷入内核代价是比较大的。然后,在现在环境中的大部分情况下,同步方法只运行在单线程环境,如果每次都高用 Mutex Lock,将会严重影响性能。
不过,在 JDK 1.6 中对锁的实现进行大量优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁的开销。

  • 锁粗化 (Lock Coarsening):也就是减少不必要的紧连在一起的 unlock,lock 操作,将多个连续的锁扩展成一个范围更大的锁。
  • 锁消除 (Lock Elimination):通过运行时 JIT 编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地 Stack 上进行对象空间的分配 (同时还可以减少 Heap 上的垃圾收集开销)。
  • 轻量级锁 (Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态 (即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在 monitorenter 和 monitorexit 中只需要依靠一条 CAS 原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行 CAS 指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒 (具体处理步骤下面详细讨论)。
  • 偏向锁 (Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的 CAS 原子指令,因为 CAS 原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 适应性自旋 (Adaptive Spinning):当线程在获取轻量级锁的过程中执行 CAS 操作失败时,在进入与 monitor 相关联的操作系统重量级锁 (mutex semaphore) 前会进入忙等待 (Spinning) 然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该 monitor 关联的 semaphore(即互斥锁) 进入到阻塞状态。

    自旋锁、自适应自旋锁

    如果锁占用时间非常短,那么自旋锁性能会非常好。相反,如果锁占用时间长,则会占用 CPU 时间片,白白浪费 CPU 资源。因此,自旋次数应该被限制,不能无限次自旋。在 JDK 中,自旋锁默认的自旋次数为 10 次,用户可以使用 -XX:PreBlockSpin 更改。
    自旋锁由于次数被限定死了,不够灵活和智能。于是,出现了自适应自旋锁。这也是 JDK 1.6 引入的新功能。
    自适应自旋锁意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的持有者的状态来决定。

  • 增加自旋等待时间:获取锁的可能性较大。

  • 减少自旋等待时间:获取锁的可能性较小。比如自旋很少能成功获取到锁。

    锁消除

    JVM 检测被 synchronized 加锁的代码块不可能存在线程竞争,那么可以取消 synchronized。底层是通过逃逸分析来判断。

    锁粗化

    如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。JVM 会检测到这样一连串地操作都是对同一个对象加锁,那么 JVM 会将加锁同步地范围扩展(粗化)到整个一系列操作的外部。

    基础知识

    轻量级锁的出现目的并非是替代重量级锁,而是对大多数情况对低并发竞争的资源的一种优化。它可以减少重量级锁导致线程阻塞所带来的开销,提升程序性能。

    Java 对象头

    要理解轻量级锁,必须先了解 HotSpot 中对象头内存布局,里面记录了关于锁的信息。

Java 对象结构.png

Mark Word 存储对象自身运行时数据,比如 HashCode、GC Age、锁标记位、是否为偏向锁
Kclass point 指向方法区对象类型数据的指针
ArrayLength 只有当数组对象存在时才会有这个值

Mark Word

使用 64 位(long)记录对象的关键信息,包括 hashcode、GC Age、锁标记位等等。
Mark word.jpg
在程序运行期间,这 64bit 的数据根据锁标志位不断变化。同一时刻,Mark Word 只能表示其中一种锁状态。
注意:无锁和偏向锁的锁标志位相同,都是 01。还有一位是偏移向标志位,只有 1bit。

Lock Record

用于轻量级锁优化。每个线程的栈中存储着一个或多个 Lock Record(支持重入)。Lock Record 分成两部分:
Lock Record 存储示意图.png

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获得锁,以降低获取锁的代价。
在大多数场景下,锁总是由同一个线程多次获得,不存在多线程竞争。出现偏向锁目的是为了减少线程获取锁的代价,以提高性能。
JVM 在 1.6 版本默认开启偏向锁模式,当新创建一个对象时,如果该对象所属的 class 没有关闭偏向锁模式,那么该对象的 mark word 将是可偏向状态。此时 mark word 的 thread id = 0。

JDK 15 已废弃偏向锁。

偏向锁加锁

当一个线程访问同步代码块并尝试获取锁时,会在 Mark Word 里存储锁偏向的线程ID。如下图所示:
偏向锁.png

  1. 当锁对象第一次被线程获得锁时,发现 thread id = 0,利用 CAS 将 mark word 中的 thread id 由 0 修改为当前的线程 ID。
    1. 修改成功:代表线程持有该对象的锁,加锁成功。
    2. 修改失败:撤销偏向锁,升级为轻量级锁
  2. 线程重入:当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在进行检查后,会在当前线程的栈中添加一条 Displaced Mark Word 为空的 Lock Record。继续执行同步块代码。由此可见,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单操作就可以重新获得锁。在这种情况下,synchronized 关键字所带来的同步开销基本忽略不计。
  3. 当其它线程也进入同步块,发现已经有偏向线程了,则会执行撤销偏向锁逻辑。通常来说,会在安全点查看被偏向的线程是否存活。其它线程其它地地
    1. 被偏向的线程存活:将偏向锁升级为轻量级锁。原偏向的线程继续拥有锁。当前线程进入锁升级逻辑里。
    2. 被偏向的线程不存活或不再同步块中:将对象的 mark word 修改为无锁状态(unlock),之后再升级为轻量级锁

由此可见,偏向锁升级为轻量级锁的时机是:当锁已发生偏向,即意味着至少有一个线程持有过这把锁。只有一另一个线程尝试获得锁,则该偏向锁就是升级为轻量级锁。当然,这个说法不绝对,因为还存在批量重偏向这一机制。

批量重偏向中,epoch 自增针对的是 klass 和 被当前存活的 thread 持有的 Oop 锁对象。而还存在一种锁对象是:在批量重偏向时,没有被任何 thread 持有(也就是当前没有 thread 在执行对应的 synchronize 代码),但之前被 thread 持有过。所以,这种锁对象的 markword 是偏向状态的,但它的 epoch 与 klass 的 epoch 不相等。在下一次其他 thread 准备持有它时,不会因为当前 thread 的 threadId 和锁对象 markword 中的 threadId 不同而升级为轻量级锁,而是直接 CAS 成偏向当前 thread 的 markWord(因为锁对象的 epoch 与 klass 的 epoch 不同),从而达到批量重偏向的优化效果。

偏向锁解锁过程

当有其它线程尝试获得锁时,根据遍历(从当前线程的栈底向栈顶遍历)偏向线程的 lock record 来确定该线程是否还在执行同步块中的代码。因此,偏向锁解锁很简单,只需要将栈中的最近一条 lock record 的 obj 字段设置为 null。需要注意的是:解释步骤并不需要修改对象头的 thread id。
注意:偏向锁只有遇到其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有运行字节码),并做以下判断:

  1. 线程不存活,则将对象头设置为无锁状态。
  2. 线程存活:
    1. 仍在同步块中。升级为轻量级锁
    2. 不在同步块中。将对象头撤销并设置为无锁状态。

也就是说,如果一个锁已偏向线程 A,当线程 B 尝试获得该锁时,无论线程 A 是什么状态,该锁都会升级成轻量级锁或重量级锁(不考虑批量重偏向的情况)。
偏向锁 JVM 源码分析

批量重偏向与撤销

从上文偏向锁的加锁解锁过程中可以看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到 safe point 时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point 这个词我们在 GC 中经常会提到,其代表了一个状态,在该状态下所有线程都是暂停的(大概这么个意思)。总之,偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM 中增加了一种批量重偏向/撤销的机制。
存在如下两种情况:(见官方论文第 4 小节):

  1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种 case 下,会导致大量的偏向锁撤销操作。
  2. 存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。

批量重偏向(bulk rebias)机制是为了解决第一种场景。批量撤销(bulk revoke)则是为了解决第二种场景。
其做法是:以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该 class 的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认 20)时,JVM 就认为该 class 的偏向锁有问题,因此会进行批量重偏向。每个 class 对象会有一个对应的 epoch 字段,每个处于偏向锁状态对象的 mark word 中也有该字段,其初始值为创建该对象时,class 中的 epoch 的值。每次发生批量重偏向时,就将该值+1,同时遍历 JVM 中所有线程的栈,找到该 class 所有正处于加锁状态的偏向锁,将其 epoch 字段改为新值。下次获得锁时,发现当前对象的 epoch 值和 class 的 epoch 不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其 mark word 的 Thread Id 改成当前线程 Id。
当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认 40),JVM 就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向,之后,对于该 class 的锁,直接走轻量级锁的逻辑。

偏向锁的意义

偏向锁的存在是基于这么一个事实:在多数环境下,锁总是由一个线程多次获得,并不存在锁竞争。因此,偏向锁只需要一次 CAS 设置线程 ID,接下来通过判断线程 ID 是否相等判断线程是否已经拥有这把锁。试想如果没有偏向锁,每次线程进来都需要 CAS 设置一下,这样的开销其实是可以避免的。

轻量级锁

前提是锁是偏向锁时,被其它的线程所访问,此刻偏向锁就会升级为轻量级锁。其它线程会通过自旋方式尝试获取锁,线程不会被阻塞,也就不会陷入内核态,从而减少上下文切换开销,提升性能。

轻量级加锁

  1. 准备锁记录:线程在执行同步块之前,JVM 会先在当前线程的栈帧中创建用于存储锁记录(Lock Record)的空间。并将对象头中的 Mark Word 复制到锁记录中,称为 Displaced Mark Word。
  2. 尝试修改锁对象:线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。

    1. 修改成功,意味着当前线程获取锁成功。
    2. 修改失败,意味着其它线程竞争锁,当前线程尝试使用自旋来获取锁。
    3. 膨胀:CAS 重试一次次数后仍然失败,则将锁膨胀为重量级锁。

      轻量级解锁

  3. 恢复原状。使用 CAS 操作将 Displaced Mark Word 替换回到锁的对象头。

    1. 修改成功,意味着没有竞争发生。
    2. 修改失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

轻量级锁 JVM 源码解析
为什么 JVM 选择在线程栈中添加 Displaced Mark Word 为 null 的 Lock Record 来表示锁重入次数 ?
锁重入次数一定要记录下来。因为每次解锁都需要对应一次加锁。只有当解锁次数等于加锁次数,才意味着锁真正被释放。一种简单的方案是将重入次数记录在对象头的 Mark Word 中。但可惜 64bit 对象头太小了,已经存放不下该信息了。所以就借助栈空间存储。

首先A线程通过CAS替换markword替换成功,表明A线程获取了锁,这里应该大家都没问题。然后是B线程,你的问题取决于B线程是什么时候到达synchronized的。1.如果它和A几乎同时到达,B就会看到对象头当前处于无锁状态,B就会进行CAS去尝试替换对象的markword,那么A和B之中必定只有一个线程会成功,另一个就是失败的。2.如果它是在A已经CAS完成后才到达的synchronized,那么B就会看到对象的markword就不是无锁状态,B就会知道当前存在并发,它接下去要做就是锁膨胀流程,这个时候B要把markword改成inflating状态就是全部都是0(同样也是使用CAS),来告诉其他线程正在进行锁膨胀。不知道我的回答能否解决你心中的疑惑

重量级锁

重量级锁实现原理是基于 Monitor 对象实现。 首先,我们首先了解 Monitor 长什么样子。在 JVM 源码中,Monitor 是由 ObjectMonitor 实现的(源码文件:ObjectMonitor.hpp),内部变量整理如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;    // 锁的计数器,获取锁时count数值加1,释放锁时count值减1,直到
    _waiters      = 0,    // 等待线程数
    _recursions   = 0;    // 锁的重入次数
    _object       = NULL; 
    _owner        = NULL; // 指向持有ObjectMonitor对象的线程地址
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; // 阻塞在EntryList上的单向线程列表
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

其中,_owner_WaitSet_EntryList 是三个核心字段,转换关系如下:
Synchronized Monitor 转换关系图.png
从上图可以总结获取 Monitor 和释放 Monitor 的流程如下:

  1. 当多个线程同时访问同步代码块时,首先会进入到 EntryList 中,然后通过 CAS 的方式尝试将 Monitor 中的 owner 字段设置为当前线程,同时 count 加1,若发现之前的 owner 的值就是指向当前线程的,recursions 也需要加1。如果 CAS 尝试获取锁失败,则进入到EntryList 中。
  2. 当获取锁的线程调用 wait() 方法,则会将 owner 设置为 null,同时 count 减1,recursions 减1,当前线程加入到 WaitSet 中,等待被唤醒。
  3. 当前线程执行完同步代码块时,则会释放锁,count 减 1,recursions 减 1。当 recursions 的值为 0时,说明线程已经释放了锁。

之前提到过一个常见面试题,为什么 wait()notify() 等方法要在同步方法或同步代码块中来执行呢,这里就能找到原因,是因为 wait()notify() 方法需要借助 ObjectMonitor 对象内部方法来完成。

锁的优缺点对比

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步快的场景
轻量级锁 竞争的线程不会阻塞,
提高了响应速度
如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 追求响应时间,同步快执行速度非常快
重量级锁 线程竞争不适用自旋,
不会消耗CPU

synchronized 与 Lock

优点 缺陷
synchronized JVM 自带,使用简单。
JDK 1.6 大幅升级,性能尚可。
适用于写多读少的场景
① 不够灵活,每个锁仅有一个单一条件(某个对象),相比,读写锁更加灵活。② 无法知道是否获取锁成功。③ 效率低。代码块不会主动释放锁,试图获取锁的时候不能设置超时,不能中断一个正在使用锁的线程。
Lock ① 可中断已持有锁的线程。② 灵活。可分为读写锁。③ 可获取锁的状态 ① 需要显示进行加锁和解锁动作。

多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock 的 lockInterruptibly() 方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后 ReentrantLock 响应这个中断,不再让这个线程继续等待。有了这个机制,使用 ReentrantLock 时就不会像 synchronized 那样产生死锁了。