Java并发编程(八)synchronized原理

synchronized原理

synchronized的概念

synchronized的简介

synchronized是一个在Java中比较常见的锁。可以选择在代码中加入synchronized代码块,也可以在方法头加入synchronized的声明。

1
2
3
4
5
6
7
8
9
10
public class SyncTest {
public void syncBlock(){
synchronized (this){
System.out.println("hello block");
}
}
public synchronized void syncMethod(){
System.out.println("hello method");
}
}

在反编译为字节码后,可以看到:

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
{
public void syncBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter // monitorenter指令进入同步块
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String hello block
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit // monitorexit指令退出同步块
14: goto 22
17: astore_2
18: aload_1
19: monitorexit // monitorexit指令退出同步块
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any


public synchronized void syncMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED //添加了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

}

Javac在编译的时候,在第3行生成了monitorenter指令,表示这同步块的进入,在13行使用monitorexit表示同步块的退出。之所以会有两个monitorexit是因为:在保证抛出异常的情况下,也能够释放锁,就相当于Java里面的try-final语句块。

而对于synchronized的方法而言,javac为它生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用的时候,会发现这个关键字,并先尝试去获得锁。

锁的几种形式

在JDK5以及之前,synchronized是重量级锁,每次运行需要同步的代码块的时候,总是锁住所有需要进入的线程,来保持原子性。所以synchronized在这之前都不如其他的同步函数,但在JKD6开始,java引入了新的机制:锁升级。当然,JVM种的锁也是能降级的,只不过条件很苛刻。

对象头

首先就要提到对象头。因为在Java中,任何的对象都可以被加锁,所以必然需要一个映射关系,存储该对象以及其对应的锁信息,这样才能知道当前哪个线程持有锁,哪些线程需要等待。有一种很直观的方法是,使用一个全局的Map,来存储这个映射关系,但这又会涉及到一些问题,比如这个Map怎么保持线程安全,不同的synchronized之间又会出现互相的影响,性能比较差,如果需要同步的对象比较多的话,该Map所占用的内存也会增多。

所以,不如将这些映射关系存储在目标的对象头好了。因为对象头本身也有一些关于hashcode、GC相关的数据,不妨就将这些锁的信息一并封装起来。

在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。

类型指针是指向该对象所属类对象的指针,mark word 用于存储对象的hashcode、GC分代年龄、锁状态等信息。在32位系统上mark word长度为32字节,64位系统上长度为64字节。为了能在有限的空间里存储下更多的数据,其存储格式是不固定的,在32位系统上各状态的格式如下:

s

这就是整个mark word所存储的信息了。可以看到,锁的信息也存在于对象的mark word当中。当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID;当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针。

重量级锁

重量锁就是传统意义上的锁,在JDK5以及之前,使用的都是这样的锁。重量级锁的状态下,对象的mark word为指向一个堆中monitor对象的指针。

一个monitor对象包括这么几个关键字段:ContentionList,EntryList ,WaitSet,owner。

其中ContentionList ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

s2

每当有一个线程尝试去获得锁的时候,如果该锁已经被占用,重量锁就会讲该线程封装成一个ObjectWaiter对象插入到ContentionList队列的尾部,然后暂停当前线程。然后在锁的持有者释放锁之前,会将ContentionList中的所有元素移动到EntryList中去,并唤醒在EntryList的队首线程。

如果一个线程在同步块中调用了Object.wait()方法,会将该线程对应的ObjectWaiter从EntryList移除并加入到WaitSet中等待,然后释放锁。当wait的线程被Object.notify()之后,会将对应的ObjectWaiter从WaitSet移动到EntryList中。继续等待持有锁的线程释放锁。

轻量级锁

JVM的设计者认为,在很多情况下,在运行java程序时,很多同步块的代码可能都不存在所谓的竞争关系,同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。

在线程执行同步块之前,JVM会先在当前的线程的栈帧中创建一个Lock Record,其中包括一个用于存储对象头中的mark word 以及一个指向对象的指针。下图中右边的就是Lock Record:

s3

加锁过程

1.在线程栈中创建一个Lock Record,将其Object reference字段指向锁对象。

2.如果成功使用CAS将对象头重的Mark Word替换为指向锁记录的指针,则获得锁,失败则当前线程尝试使用自旋(循环等待)来获取锁。

3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。然后结束。

4.走到这一步说明发生了竞争,需要膨胀为重量级锁。

解锁过程

1.遍历线程栈,找到所有Object reference字段等于当前锁对象的Lock Record。

2.如果Lock Record的Displaced Mark Word为null,代表这是一次重入,将Object reference设置为null后继续。

3.如果Lock Record的Displaced Mark Word不为null,则利用CAS指令将对象头的 Mark Word 恢复成为Displaced Mark Word。如果成功则继续,负责膨胀为重量级锁。

偏向锁

Java是支持多线程的语言,因此在很多二方包、基础库中为了保证代码在多线程的情况下也能正常运行,也就是我们常说的线程安全,都会加入如synchronized这样的同步语义。但是在应用在实际运行时,很可能只有一个线程会调用相关同步方法。比如下面这个demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.ArrayList;
import java.util.List;

public class SyncDemo1 {

public static void main(String[] args) {
SyncDemo1 syncDemo1 = new SyncDemo1();
for (int i = 0; i < 100; i++) {
syncDemo1.addString("test:" + i);
}
}

private List<String> list = new ArrayList<>();

public synchronized void addString(String s) {
list.add(s);
}

}

在这个demo中为了保证对list操纵时线程安全,对addString方法加了synchronized的修饰,但实际使用时却只有一个线程调用到该方法,对于轻量级锁而言,每次调用addString时,加锁解锁都有一个CAS操作;对于重量级锁而言,加锁也会有一个或多个CAS操作(这里的’一个‘、’多个‘数量词只是针对该demo,并不适用于所有场景)。

在JDK1.6中为了提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只会执行几个简单的命令,而不是开销相对较大的CAS命令。我们来看看偏向锁是如何做的。

对象创建

当JVM启用了偏向锁模式(1.6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式(什么时候会关闭一个class的偏向模式下文会说,默认所有class的偏向模式都是是开启的),那新创建对象的mark word将是可偏向状态,此时mark word中的thread_id(参见上文偏向状态下的mark word格式)为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。

加锁过程

1.当该对象第一次被线程获得锁的时候,发现是匿名偏向状态,则会用CAS指令,将mark word中的thread id由0改成当前线程ID。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。

2.当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过一些额外的检查后(细节见后面的文章),会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。

3.当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁。

由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制。

解锁过程

当有其他线程尝试获得锁时,是根据遍历偏向线程的lock record来确定该线程是否还在执行同步块中的代码。因此偏向锁的解锁很简单,仅仅将栈中的最近一条lock record的obj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。

下图展示了锁状态的转换流程:

s4

另外,偏向锁默认不是立即就启动的,在程序启动后,通常有几秒的延迟,可以通过命令 -XX:BiasedLockingStartupDelay=0在虚拟机中来关闭延迟。

批量重偏向与撤销

从上文偏向锁的加锁解锁过程中可以看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。

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的锁,直接走轻量级锁的逻辑。