JMM(四):浅谈synchronized锁

对于synchronized锁机制而言,准确来说其应该属于JVM的范畴。这里基于行文的连贯性、完整性考虑,故将该部分内容在JMM系列中一并进行介绍

abstract.png

信号量、管程

Semaphore 信号量

Semaphore 信号量是由荷兰计算机科学家Dijkstra提出一种同步机制。其通过信号量的值记录当前可用资源的数量,并利用PV原语进行操作。常见有:计数信号量、二进制信号量,前者为一个任意的整数,后者只有二进制的0、1两种状态

  • P原语:判断信号量值是否大于0。如果是则表明当前还有可用的剩余资源,该线程可以进入临界区并将信号量值减1;否则该线程被阻塞
  • V原语:信号量值加1

当使用二进制信号量时,其效果相当于一个互斥量。但是由于需要保证PV操作必须成对出现,非常容易出错产生Bug,所以在一般开发中很少会直接使用信号量来实现同步

Monitor 管程

Monitor管程作为一种编程语言级别的并发控制解决方案,保证了每次最多只有一个线程能够访问进入临界区。其将对共享资源的访问进行了封装并提供统一的入口、出口,大大简化了并发程序的开发。为此Java语言中通过synchronized关键字提供了管程这一同步工具

synchronized锁的分类

重量级锁

在JDK 1.6之前synchronized的所带来开销是比较大的。其原因就在其使用的锁是基于C++实现的ObjectMonitor对象(下面提供该实现中的关键字段的定义),而ObjectMonitor底层使用则是操作系统提供的Mutex Lock。我们知道操作系统有两种模式:用户态和内核态。当我们来使用ObjectMonitor时就需要向操作系统来申请锁,而这个过程就需要从用户态转换为内核态,开销非常大;与此同时在阻塞等线程调度的过程中也需要切换到内核态。我们在JDK 1.6之前使用synchronized开销非常大原因就在于此,故ObjectMonitor锁被称为重量级锁

1
2
3
4
5
6
7
8
9
10
11
ObjectMonitor() {
...
_count = 0;
_waiters = 0;
_recursions = 0; // 记录锁的重入次数
_owner = NULL; // 指向持有ObjectMonitor对象的线程
_WaitSet = NULL; // 存放处于wait状态的线程
_WaitSetLock = 0 ;
_EntryList = NULL ; //存放处于等待锁block状态的线程
...
}

这里我们对ObjectMonitor锁的基本原理作简要介绍

  1. 当遇到synchronized时,需要获取ObjectMonitor锁时,会进入Entry Set中进行等待
  2. 当某线程获取ObjectMonitor锁后,Owner即指向该线程,处于Active状态。与此同时,ObjectMonitor锁的计数器会加1(离开临界区时ObjectMonitor计数器减1)
  3. 如果该线程在执行过程中由于某种原因转为Wait状态,则会释放锁进入Wait Set
  4. Wait Set中的线程可以通过notify()等方法被唤醒,以重新加入锁的竞争
  5. 临界区中的线程顺利执行完了,则会离开临界区并释放该锁

figure 1.png

  • Entry Set:因等待锁而被阻塞的线程队列
  • Wait Set:处于Wait状态的线程队列
  • Owner:当前获取到锁而进入临界区的线程

我们知道,在Java对象头中的Mark Word会记录锁的状态及相关信息,这里给出64位HotSpot虚拟机环境下Mark Word的相关含义(注意,左侧为高字节,右侧为低字节)

figure 2.jpeg

可以看到当使用重量级锁时,锁标志位为10,同时Mark Word中会记录一个指向ObjectMonitor的指针。我们知道调用wait方法会使线程进入Waiting状态,而这个过程显然是需要操作系统通过内核态来完成的。换句话说,这里我们使用wait方法是为了保证synchronized使用的是重量级锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 重量级锁演示
*/
public class MonitorLockDemo {

public static void main(String[] args) throws InterruptedException {

System.out.println("主机字节序: " + ByteOrder.nativeOrder());

List lock = new LinkedList();

System.out.println("---------- 1 ----------");
System.out.println( ClassLayout.parseInstance(lock).toPrintable() );

Thread threadA = new Thread( ()->{
synchronized (lock) {
System.out.println("线程 A 准备进入Waiting状态");
try{
lock.wait(); // 调用wait方法,以保证synchronized使用的是重量级锁
}catch (InterruptedException e) {

}
System.out.println("线程 A 结束");
}
} );

Thread threadB = new Thread( ()->{
synchronized (lock) {
System.out.println("线程 B 准备唤醒 A 线程");
lock.notifyAll();
System.out.println("线程 B 线程结束");
}
} );


threadA.start();
Thread.sleep(5000); // 确保A线程已进入Waiting状态

threadB.start();

threadA.join();
threadB.join();

System.out.println("---------- 2 ----------");
System.out.println( ClassLayout.parseInstance(lock).toPrintable() );
}

}

从测试结果中我们可以看到,当lock对象刚刚被构造出来时,Mark Word的低3位是001,即为无锁状态(或称之为未锁定),这也很好理解,此时其还未作为synchronized的锁。而当两个子线程(线程A、B)结束时,我们可以看到lock对象的Mark Word的锁标志位为10,即表明此时synchronized锁的状态为重量级锁

figure 3.jpeg

偏向锁

很多时候我们发现虽然给一段代码上了锁,但大多数场景下其实只有一个线程需要访问该段代码,其他线程并不会请求锁来执行该段代码(典型的,例如StringBuffer的append方法,其使用了synchronized进行修饰)。在这个时候,如果synchronized依然是向操作系统申请重量级锁来进行同步,显然是大大地增加了CPU的开销。为此,在JDK 1.6中引入了偏向锁的概念。对于偏向锁的实现也很简单,其会在Mark Word中通过CAS操作记录第一个获得该锁的线程ID(即指向该线程的指针)。只要在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程永远不再需要进行同步。所以偏向锁,顾名思义就是偏向于第一个获得该锁的线程。其消除了在无竞争情况下的同步原语,可以大大提高程序的性能。现在就让我们来演示下偏向锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 偏向锁演示
*/
public class BiasedLockDemo {

public static void main(String[] args) throws InterruptedException{

Thread.sleep(6000); // 保证偏向锁机制已启动

System.out.println("主机字节序: " + ByteOrder.nativeOrder());

Byte lock = 123;

System.out.println("---------- 1 ----------");
System.out.println( ClassLayout.parseInstance(lock).toPrintable() );

synchronized (lock) {
System.out.println("Hello World");
}

System.out.println("---------- 2 ----------");
System.out.println( ClassLayout.parseInstance(lock).toPrintable() );

}
}

从测试结果我们可以看到,当lock对象刚刚被构造出来时,Mark Word的低3位是101,其余位均为0,说明此时偏向锁虽然启动了,但是由于还没线程来获得该锁;而在主线程持有该锁之后,我们看到lock对象,依然还是一个偏向锁,同时还记录持有该锁的线程ID信息

figure 4.jpeg

可以通过下面使用虚拟机参数来显式地配置是否为当前虚拟机启用偏向锁,在JDK 1.6中默认启用偏向锁

1
2
3
4
// 启用偏向锁
-XX:+UseBiasedLocking
// 不启用偏向锁
-XX:-UseBiasedLocking

细心的朋友可能已经发现在上面的例子中,我们通过 Thread.sleep(6000) 先让主线程延迟了6秒钟。这就涉及到偏向锁的另外一个问题——偏向锁撤销。我们前面说了,偏向锁是为了提高程序在无竞争条件下的效率性能。当偏向锁检查发现当前请求锁的线程与其所偏向的线程不符时,即发生了数据竞争。这个时候,偏向锁会被撤销转而升级为下面我们即将介绍的轻量级锁。故在明确知道会发生多线程竞争的情况下,依然还使用偏向锁,其效率并不高,因为偏向锁肯定会被撤销而这个过程会同样带来性能上的消耗

我们知道JVM在启动的过程中,会有很多线程进行竞争。所以默认情况下JVM启动时不是立即打开偏向锁的,而是过一段时间再打开。而这个偏向锁的启动延迟时间就是由 -XX:BiasedLockingStartupDelay 参数来进行控制的,单位为毫秒。我们可通过 -XX:+PrintFlagsFinal 来查看JVM该参数的默认值,这里默认值为4000,即4秒。解释到这里,相信大家就清楚了,为什么我们需要让主线程延迟,目的就是为了保证JVM的偏向锁已经打开了

figure 5.jpeg

当然你也直接可以修改偏向锁启动的延迟时间,例如直接设为0(-XX:BiasedLockingStartupDelay=0),这样当JVM启动时偏向锁会被立刻打开,而不需要通过Thread.sleep()来进行延迟。不过还是要说一句,一般情况下该参数的默认值是不推荐修改的

轻量级锁

如上文所说,当出现竞争之后偏向锁会被撤销转而升级为轻量级锁。轻量级锁机制是从JDK 1.6开始引入的,之所以被称之为轻量级锁,是因为该锁机制同样不需要通过使用系统调用而出现用户态、内核态的切换。这里对轻量级锁的基本原理作些许说明,简单来说各线程会先在自己的线程栈内生成一个Lock Record锁记录,然后通过CAS操作将Lock Record锁记录的地址写入到锁对象的Mark Word当中,成功写入的线程即为加锁成功,而其它线程则通过自旋(忙等待)的方式不停地通过CAS操作尝试获取锁。可以看到轻量级锁对于轻度竞争的场景比较适用。因为自旋操作本身同样需要占用CPU时间,所以在出现重度竞争的时候,某个线程如果一直拿不到锁会不停自旋尝试CAS操作,这个时候还不如升级为重量级锁,将该线程挂起阻塞避免浪费CPU资源。讲到这里可以看到,JDK 1.6开始对synchronized锁进行了大量的改进,特别是针对无竞争、轻度竞争等场景进行优化大大提高了synchronized锁的性能

其它锁优化技术

JDK 1.6对于synchronized锁还引入其它一些优化技术,来显著提高其性能

自旋锁、自适应自旋锁

自旋锁可以认为是一种锁的优化技术。前面我们多次提到阻塞挂起线程需要进行用户态、内核态的转换,考虑这样一个场景某个线程虽然不能立即获得锁,但是只需要稍微等待一小会就可以拿到该锁,显然此处让该线程自旋等待锁的释放要比先挂起再恢复的效率要高。但是自旋锁并不能代替阻塞,因为自旋操作过程中是在白白浪费CPU资源,所以当自旋时间过长时,就需要挂起阻塞该线程了。在JDK 1.4中开始引入自旋锁,默认为关闭的,可以通过 -XX:+UseSpinning 选项开启,与此同时还提供了一个 -XX:PreBlockSpin 选项来指定自旋阈值,默认为10次

而在JDK 1.6中,则引入了自适应自旋锁。其自旋的时间不是固定的,而是通过上一次在该锁的自旋时间及锁的拥有者状态等信息进行决策的。简单来说,如果上次自旋等待拿到了锁,那么本次自旋时间就允许多一点;如果之前的自旋等待操作很少拿到锁,那么本次自旋时间阈值就短一点,甚至干脆直接进入阻塞不进行自旋等待以避免CPU资源的浪费

锁粗化

如果JVM检测到有一连串的连续操作,反复进行加锁释放,那么JVM就会把锁的范围扩大粗化,来cover住这一系列的操作。即将多次加锁合并为了一次加锁

锁消除

利用JIT的逃逸分析技术,对于不可能存在数据竞争的锁,将会直接消除掉来提高程序性能

参考文献

  1. Java并发编程之美 翟陆续、薛宾田著
  2. 深入理解Java虚拟机·第2版 周志明著
  3. 深入理解Java虚拟机·第3版 周志明著
0%