前言
Java 中的 synchronized 有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况,锁会按偏向锁 -> 轻量级锁 -> 重量级锁的顺序升级。
synchronized 代码块
对于被 synchronized 关键字修饰的代码块而言,在编译成字节码时会生成对应的 monitorenter 和 monitorexit 指令分别对应synchronized 同步块的进入和退出。
1 | public void syncBlock(); |
有两个 monitorexit 指令的原因是为了保证抛出异常下也能释放锁,所以 javac 为同步代码块添加了一个隐式的 try-finally,在 finally 中会调用 monitorexit 命令释放锁。
synchronized 方法
从以下字节码中可以看出,synchronized 方法并没有 monitorenter 和 monitorexit 指令,而是一个 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM通过该 ACC_SYNCHRONIZED 标识来辨别一个方法是否为同步方法,从而执行相应的获取锁操作。
1 | public synchronized void syncMethod(); |
在 Java 6 之前,monitor 的实现完全是依靠操作系统内部的互斥锁 Mutex Lock,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作,性能也很低。但在 Java 6 开始,提供了三种不同的 monitor 实现:偏向锁、轻量级锁和重量级锁,大大改进了其性能。
表现形式
- 对于普通 synchronized 方法,锁的是当前实例对象
- 对于静态 synchronized 方法,锁的是当前类的 Class 对象
- 对于 synchronized 代码块,锁的是 synchronized() 括号里配置的对象
对象头与 Monitor
Java 对象头
synchronized 使用的锁对象是存储在 Java 对象头里的,对于普通对象而言,其对象头中有两类信息:Mark Word 和类型指针 Class Metadata Address,对于数组而言则还有一个记录数组长度的 Array length,锁的信息就存储于 Mark Word 中。
在 32 位系统上 Mark Word 长度为 32bit,64 位系统上长度为 64bit,在 32位 系统上各状态的格式如下:
当对象状态为偏向锁(biasable)时,Mark Word 存储的是偏向的线程 ID;当状态为轻量级锁(lightweight locked)时,Mark Word 存储的是指向线程栈中 Lock Record 的指针;当状态为重量级锁(inflated)时,为指向堆中的 Monitor 对象的指针。
Java 中的 Monitor
可以看出,Monitor 对象存在于每个 Java 对象的对象头中(实际存储的是指向 Monitor 的指针),synchronized 便是通过这种方式获取锁的。
在 HotSpot 虚拟机中,Monitor 是由 ObjectMonitor 实现的,源码 ObjectMonitor.hpp 如下:
1 | ObjectMonitor() { |
ObjectMonitor 中有两个队列,WaitSet 和 EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象)。
当多个线程同时访问一段同步代码时,首先会进入 EntryList 集合,当线程获取到对象的 monitor 后进入 wner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器 count 加 1。
若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet中等待被唤醒。
若当前线程执行完毕也将释放 monitor 并复位 owner 为 null 并将 count 自减 1,以便其他线程进入获取monitor。整个过程如下图所示。
偏向锁
HotSpot 的作者研究发现在多数情况下,锁不仅不存在多线程竞争,且总是由同一线程多次获得,为了让线程获得锁的代价更低,从 Java 6 开始引入了偏向锁。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何 CAS 同步操作,这样就省去了大量有关锁申请的操作。
偏向锁的获取过程如下:
- 当访问同步代码块中,检查对象头中的 Mark Word 是否存储了线程 ID,如果没有则通过 CAS 设置 Mark Word 为当前线程 ID,如果 CAS 失败,则将撤销偏向锁,升级为轻量级锁。
- 下次该线程访问此处同步代码块中,就不需要进行 CAS 操作,只需简单测试一下 Mark Word 中是否存储着当前线程的偏向锁。如果测试成功,则再次获取锁成功。
- 如果测试失败,则需要再测试一下 Mark Word 中的锁标志位是否设为了 01 (代表偏向锁),如果为 01,则尝试通过 CAS 设置 Mark Word 为当前线程 ID,如果没有设置,则使用 CAS 竞争锁。
偏向锁的撤销过程如下:
- 偏向锁的撤销只在竞争出现时才会出现,只有其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
- 偏向锁的撤销需要等待 safepoint (在该时间点上没有正在执行的字节码)。首先会暂停持有偏向锁的线程,然后查看偏向的线程是否存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程会进行锁升级;如果偏向的线程不存活,则将对象头的 Mark Word 设为无锁状态(unlocked),之后再升级为轻量级锁。
此外,可以通过 -XX:UserBiasedLocking=false
来关闭偏向锁,程序会默认进入轻量级锁状态。
对于没有锁竞争的场合,偏向锁有很好的优化效果,因为极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就不适合了,因为这样场合极有可能每次申请锁的线程都是不相同的。
轻量级锁
HotSpot 的作者还发现“对绝大部分的锁,在整个同步周期内都不存在竞争”,即在同步块中的代码很少存在竞争的情况,不同的线程常常交替地执行同步块中的代码。这种情况下,用重量级锁是没必要的,因此引入了轻量级锁的概念。
轻量级锁的获取过程如下:
- 线程在执行同步块之前,JVM 会先在当前的线程的栈帧中创建一个 Lock Record,其包括一个用于存储对象头中的 Mark Word 的 Displaced Mark Word 和一个指向对象的指针。
- 然后线程尝试通过 CAS 将 Mark Word 替换为指向栈帧中锁记录的指针,如果成功,则当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程会通过自旋来获取锁。
轻量级锁的解锁过程如下:
- 轻量级锁的解锁时,会通过 CAS 将 Displaced Mark Word 替换会对象头,如果成功,表示没有竞争。
- 如果失败,表示当前锁存在竞争,锁会膨胀为重量级锁。
轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
重量级锁
重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现Java中的线程同步。重量级锁的状态下,对象的 mark word 为指向一个堆中 monitor 对象的指针。
一旦升级到重量级锁,则就不会恢复到轻量级锁的这状态。当锁处于重量级锁状态时,其他尝试获取锁的线程都会被阻塞,当持有锁的线程释放锁之后之后会唤醒这些线程,被唤醒的线程就会参与到新一轮的竞锁过程中。
注意,synchronized 锁为非公平锁。公平锁是指多个线程按照申请锁的顺序来获取锁。非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,后申请的线程可能会比先申请的线程优先获取锁,可能会造成优先级反转或者饥饿现象。非公平锁的优点在于吞吐量比公平锁大。
三种锁的对比
锁 | 优点 | 缺点 | 使用场景 | Mark Word 存储数据 |
---|---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 只有一个线程访问同步块场景。 | 线程ID |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间,同步块执行速度非常快。 | 指向栈帧中锁记录的指针 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量,同步块执行速度较长。 | 指向Moniotr的指针 |
锁升级原理
synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。
锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
其他
CAS
CAS 全称为 Compare and Swap ,即比较与交换,CAS 操作需要输入一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
CAS 操作存在以下三大问题:
- ABA 问题: 当旧值为 A ,变成了 B,又变成了 A,此时使用 CAS 操作就不会发现值没有变化,实际上却变化了。ABA 问题可以通过版本号来解决,JDK 中的 Atomic 包中提供了一个类 AtomicStampedReference ,该类的 compareAndSet 方法会检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,两者都相等才以原子方式设置新值。
- 循环时间长开销大:若自旋 CAS 长时间不成功,则会给 CPU 带来十分大的开销。
- 只能保证一个共享变量的原子操作:CAS 无法对多个共享变量进行操作,但可以通过将多个变量合并为一个变量的方式实现,