Synchronized关键字及其实现原理

前言

Java 中的 synchronized 有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况,锁会按偏向锁 -> 轻量级锁 -> 重量级锁的顺序升级。

synchronized 代码块

对于被 synchronized 关键字修饰的代码块而言,在编译成字节码时会生成对应的 monitorentermonitorexit 指令分别对应synchronized 同步块的进入和退出。

1
2
3
4
5
6
7
8
9
10
11
public void syncBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
1: monitorenter //进入synchronized方法
//...省略
10: monitorexit //退出synchronized方法
//...省略
20: monitorexit //退出synchronized方法
22: return

有两个 monitorexit 指令的原因是为了保证抛出异常下也能释放锁,所以 javac 为同步代码块添加了一个隐式的 try-finally,在 finally 中会调用 monitorexit 命令释放锁。

synchronized 方法

从以下字节码中可以看出,synchronized 方法并没有 monitorenter 和 monitorexit 指令,而是一个 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM通过该 ACC_SYNCHRONIZED 标识来辨别一个方法是否为同步方法,从而执行相应的获取锁操作。

1
2
3
4
5
6
7
8
9
10
public synchronized void syncMethod();
descriptor: ()V
// 方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String hello method
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return

在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ObjectMonitor() {
_header = NULL; //markOop对象头
_count = 0;
_waiters = 0, //等待线程数
_recursions = 0; //重入次数
_object = NULL; //Monitor锁存储的对象
_owner = NULL; //指向获得ObjectMonitor对象的线程或基础锁
_WaitSet = NULL; //处于wait状态的线程,会被加入到wait set;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到entry set;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ; // _owner is (Thread *) vs SP/BasicLock
_previous_owner_tid = 0;// Monitor前一个拥有者线程的ID
}

ObjectMonitor 中有两个队列,WaitSetEntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象)。

当多个线程同时访问一段同步代码时,首先会进入 EntryList 集合,当线程获取到对象的 monitor 后进入 wner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器 count 加 1。

若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet中等待被唤醒。

若当前线程执行完毕也将释放 monitor 并复位 owner 为 null 并将 count 自减 1,以便其他线程进入获取monitor。整个过程如下图所示。

ObjectMonitor

偏向锁

HotSpot 的作者研究发现在多数情况下,锁不仅不存在多线程竞争,且总是由同一线程多次获得,为了让线程获得锁的代价更低,从 Java 6 开始引入了偏向锁。

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何 CAS 同步操作,这样就省去了大量有关锁申请的操作。

偏向锁的获取过程如下:

  1. 当访问同步代码块中,检查对象头中的 Mark Word 是否存储了线程 ID,如果没有则通过 CAS 设置 Mark Word 为当前线程 ID,如果 CAS 失败,则将撤销偏向锁,升级为轻量级锁。
  2. 下次该线程访问此处同步代码块中,就不需要进行 CAS 操作,只需简单测试一下 Mark Word 中是否存储着当前线程的偏向锁。如果测试成功,则再次获取锁成功。
  3. 如果测试失败,则需要再测试一下 Mark Word 中的锁标志位是否设为了 01 (代表偏向锁),如果为 01,则尝试通过 CAS 设置 Mark Word 为当前线程 ID,如果没有设置,则使用 CAS 竞争锁。

偏向锁的撤销过程如下:

  1. 偏向锁的撤销只在竞争出现时才会出现,只有其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
  2. 偏向锁的撤销需要等待 safepoint (在该时间点上没有正在执行的字节码)。首先会暂停持有偏向锁的线程,然后查看偏向的线程是否存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程会进行锁升级;如果偏向的线程不存活,则将对象头的 Mark Word 设为无锁状态(unlocked),之后再升级为轻量级锁。

此外,可以通过 -XX:UserBiasedLocking=false 来关闭偏向锁,程序会默认进入轻量级锁状态。

对于没有锁竞争的场合,偏向锁有很好的优化效果,因为极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就不适合了,因为这样场合极有可能每次申请锁的线程都是不相同的。

轻量级锁

HotSpot 的作者还发现“对绝大部分的锁,在整个同步周期内都不存在竞争”,即在同步块中的代码很少存在竞争的情况,不同的线程常常交替地执行同步块中的代码。这种情况下,用重量级锁是没必要的,因此引入了轻量级锁的概念。

轻量级锁的获取过程如下:

  1. 线程在执行同步块之前,JVM 会先在当前的线程的栈帧中创建一个 Lock Record,其包括一个用于存储对象头中的 Mark Word 的 Displaced Mark Word 和一个指向对象的指针。
  2. 然后线程尝试通过 CAS 将 Mark Word 替换为指向栈帧中锁记录的指针,如果成功,则当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程会通过自旋来获取锁。

轻量级锁的解锁过程如下:

  1. 轻量级锁的解锁时,会通过 CAS 将 Displaced Mark Word 替换会对象头,如果成功,表示没有竞争。
  2. 如果失败,表示当前锁存在竞争,锁会膨胀为重量级锁。

轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

重量级锁

重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现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 无法对多个共享变量进行操作,但可以通过将多个变量合并为一个变量的方式实现,
  • 本文作者: Marticles
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!