1.同步的语义
下面的内容摘自JSR 133 FAQ:
Synchronization has several aspects. The most well-understood is mutual exclusion – only one thread can hold a monitor at once, so synchronizing on a monitor means that once one thread enters a synchronized block protected by a monitor, no other thread can enter a block protected by that monitor until the first thread exits the synchronized block.
同步有几个方面。最容易理解的是互斥 —— 只有一个线程可以立即持有一个监视器,因此在监视器上进行同步意味着一旦一个线程进入由一个监视器保护的同步块,则其他线程都不能进入该监视器保护的块,直到第一个线程退出同步块。
But there is more to synchronization than mutual exclusion. Synchronization ensures that memory writes by a thread before or during a synchronized block are made visible in a predictable manner to other threads which synchronize on the same monitor. After we exit a synchronized block, we release the monitor, which has the effect of flushing the cache to main memory, so that writes made by this thread can be visible to other threads. Before we can enter a synchronized block, we acquire the monitor, which has the effect of invalidating the local processor cache so that variables will be reloaded from main memory. We will then be able to see all of the writes made visible by the previous release.
但是同步不仅仅是互斥。 同步确保以可预见的方式,使线程在同步块之前或期间对内存的写入对于在同一监视器上同步的其他线程可见。 退出同步块后,我们 释放 该监视器,其有将缓存刷新到主内存的效果, 以便该线程进行的写入对于其他线程可见。 在我们进入一个同步块之前,我们需要 获取 该监视器,该监视器具有使本地处理器缓存无效的作用,以便可以从主内存中重新加载变量。 然后,我们将能够看到以前释放中所有可见的写入。
Discussing this in terms of caches, it may sound as if these issues only affect multiprocessor machines. However, the reordering effects can be easily seen on a single processor. It is not possible, for example, for the compiler to move your code before an acquire or after a release. When we say that acquires and releases act on caches, we are using shorthand for a number of possible effects.
从高速缓存的角度进行讨论,听起来似乎这些问题仅影响多处理器计算机。 但是,重排序效果可以在单个处理器上轻松看到。 例如,编译器不可能在获取之前或释放之后移动代码。 当我们说获取和释放作用于缓存时,我们使用简写来表示多种可能的影响。
The new memory model semantics create a partial ordering on memory operations (read field, write field, lock, unlock) and other thread operations (start and join), where some actions are said to happen before other operations. When one action happens before another, the first is guaranteed to be ordered before and visible to the second. The rules of this ordering are as follows:
新的内存模型语义在内存操作(读字段,写字段,锁定,解锁)和其他线程操作( start 和 join )上创建了部分排序,其中某些操作据说 happen before其他操作。 当一个动作在另一个动作之前发生时,第一个动作被确保排序在第二个动作之前并且对于第二个动作可见。 此排序规则如下:
-
Each action in a thread happens before every action in that thread that comes later in the program’s order.
线程中的每个动作先于该线程中的在程序顺序上后出现的每个动作发生。 -
An unlock on a monitor happens before every subsequent lock on that same monitor.
监视器上的一个解锁发生在 同一个 监视器上的每个后续锁定之前。 -
A write to a volatile field happens before every subsequent read of that same volatile.
对 volatile 字段的每个写操作发生在每次后续读取 同一个 volatile之前。 -
A call to start() on a thread happens before any actions in the started thread.
一个对线程的 start() 的调用发生在被启动线程中的任何操作之前。 -
All actions in a thread happen before any other thread successfully returns from a join() on that thread.
线程中的所有操作发生在其他线程成功从该线程上的 join() 返回之前。
This means that any memory operations which were visible to a thread before exiting a synchronized block are visible to any thread after it enters a synchronized block protected by the same monitor, since all the memory operations happen before the release, and the release happens before the acquire.
这意味着线程在退出同步块之前对一个线程可见的任何内存操作,在进入受同一监视器保护的同步块之后对于任何线程都是可见的,因为所有内存操作都发生在释放之前,而释放发生在获取之前。
可以看到同步的语义包含两点:一个是互斥,一个是保证可见性。
2 synchronized的基本使用
根据Java 语言规范可知:
Java里面的每个对象都关联着一个 monitor,一个线程可以 lock 或者 unlock这个 monitor。
-
对于一个类方法,该方法所在类的Class对象关联的monitor被使用。
-
对于一个实例方法,与this(某个调用该方法的实例对象)关联的monitor被使用。
-
对于一个同步块,即synchronized(obj){…},与obj关联的monitor被使用。
举个栗子:
package synchronizedTest;
class Test {
int count;
//实例同步方法
synchronized void bump() {
count++;
}
static int classCount;
//类同步方法
static synchronized void classBump() {
classCount++;
}
}
上面的代码等价于:
package synchronizedTest;
class BumpTest {
int count;
void bump() {
//同步块
synchronized (this) { count++; }
}
static int classCount;
static void classBump() {
try {
//同步块
synchronized (Class.forName("BumpTest")) {
classCount++;
}
} catch (ClassNotFoundException e) {}
}
}
3.从JVM字节码层面看同步块
反解析下上面两个类对应的字节码文件。
编译成class文件
javac synchronizedTest/Test.java
将Class文件反汇编下
javap -p -v synchronizedTest/Test > synchronizedTest/Test.disasm
类似地:
javac synchronizedTest/BumpTest.java
javap -p -v synchronizedTest/BumpTest > synchronizedTest/BumpTest.disasm
下面重点看下Test.disasm和BumpTest.disasm
BumpTest.bump方法节选
void bump();
descriptor: ()V
flags:
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: dup
6: getfield #2 // Field count:I
9: iconst_1
10: iadd
11: putfield #2 // Field count:I
14: aload_1
15: monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return
Exception table:
from to target type
4 16 19 any
19 22 19 any
LineNumberTable:
line 6: 0
line 7: 24
3.1 monitor_enter的说明:lock特定对象的monitor。
The objectref must be of type reference.
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.
objectref必须是引用类型。
每个对象都与一个监视器关联。 监视器只有在拥有所有者的情况下才被锁定。 执行monitorenter的线程尝试获得与objectref关联的监视器的所有权,如下所示:
如果与objectref关联的监视器的条目计数为零,则线程进入监视器并将其条目计数设置为1。 然后,该线程是监视器的所有者。
如果线程已经拥有与objectref关联的监视器,则它将重新进入监视器,从而条目计数加1。
如果另一个线程已经拥有与objectref关联的监视器,则该线程将阻塞,直到该监视器的条目计数为零为止,然后再次尝试获取所有权。
3.2 monitor_exit的说明: unlock特定对象的monitor。
The objectref must be of type reference.
The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.
objectref必须是引用类型。
执行monitorexit的线程必须是与objectref引用的实例相关联的监视器的所有者。
该线程减少与objectref关联的监视器的条目计数。 结果,如果条目计数的值为零,则线程退出监视器,并且不再是其所有者。 其他被阻塞进入监视器的线程可以尝试进入监视器。
3.3 下面是关于异常表( Exception table)的说明:
上面是bump方法对应的指令,异常表有两行(如下所示),每一行称为异常表条目:
4 16 19 any
19 22 19 any
每个异常表条目监控[from, to)的字节码,如果出现异常,则跳转到target指针对应的字节码执行,type则代表该处理器所能捕获的异常类型(any代表任何异常)。
对应的上面两个异常条目的意思就是:
- 4对应from,16对应to, 19对应target, any对应type;也就是[4,16)指向的字节码指令抛任何异常(any)了,都会跳转到19执行。
- 19对应from,22对应to, 19对应target, any对应type;也就是[19, 22)指向的字节码抛出任何异常(any)了,都会跳转到19执行。
也就是
- 情况一:[4,16)执行没有任何异常,则goto到24,返回。在这种情况下正常加锁
3: monitorenter
,释放锁15: monitorexit
- 情况二:[4,16)抛出任何异常,都跳转19,都会执行到
21: monitorexit
;如果成功了,则异常结束;如果在[19, 22)执行中抛出任何异常,就跳转到19再重新执行一遍。
通过上面的分析,我们可以发现,不管是否抛出异常,synchronized 同步块,都会释放之前获取的锁,也就是 monitorenter 与 monitorexit 始终是成对出现的。
BumpTest.classBump和BumpTest.bump是类似,你可以自己尝试分析下。
4.从JVM字节码层面看同步方法
Test.bump节选
synchronized void bump();
descriptor: ()V
flags: ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field count:I
5: iconst_1
6: iadd
7: putfield #2 // Field count:I
10: return
LineNumberTable:
line 6: 0
line 7: 10
Monitor entry on invocation of a synchronized method, and monitor exit on its return, are handled implicitly by the Java Virtual Machine’s method invocation and return instructions, as if monitorenter and monitorexit were used.
Java虚拟机的方法调用和返回指令,隐式处理了调用同步方法时的monitor entry 和返回时的monitor exit,就像使用了monitorenter 和monitorexit 一样。
所以,同步方法和同步块的实现方式本质上并没有什么不同。
5.从机器码看synchronized
假如我们把BumpTest改成如下代码:
package synchronizedTest;
class BumpTest {
int count;
void bump() {
synchronized (this) { count++; }
}
static int classCount;
static void classBump() {
try {
synchronized (Class.forName("BumpTest")) {
classCount++;
}
} catch (ClassNotFoundException e) {}
}
public static void main(String[] args){
for(int i=0; i< 100000; i++){
classBump();
}
System.out.println(classCount);
}
}
然后重新编译成字节码文件,再得到本地机器指令文件(注意,执行第二条指令还需要hsdis,你可以参考我之前的文章安装)
javac synchronizedTest/BumpTest.java
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly synchronizedTest/BumpTest > synchronizedTest/BumpTest.native
然后我们搜索'classBump'
,发现了下面的机器指令,可以看到在Intel64 CPU 下,synchronized 最终还是用 lock cmpxchg 实现的。
从这里我们可以发现,这里的 synchronized 和之前讲的volatile, Unsafe中的CAS,刚好是用类似的原子指令(比如这里的lock cmpxchg)实现的。
至于BumpTest 和Test中其他同步方法或同步块,你可以试一下,结果是一致的。
6. 同步
但是,并不是所有的同步都是用上述的原子指令实现的(其实是轻量级锁),而是根据不同情况使用不同的锁,锁的类型分为重量级锁,轻量级锁,偏向锁。 下面主要简单地说明这三种锁的实现。
HotSpot JVM 使用一个 two-word 对象头,第一个word是 Class pointer,第二个是 Mark word 用来保存同步,GC 和 hashCode 等相关信息。Mark word使用方式见下图:
6.1 重量级锁 heavyweight monitor
重量级锁对应上述 Mark word 的 tag bits 为10的情况,即此时状态为inflated。
重量级锁会使用操作系统级别的锁定原语 ( OS-level locking primitives, 比如 pthread mutex) 来实现。 这些操作将涉及系统调用,需要从操作系统的用户态切换至内核态,其开销非常之大。重量级锁可以在所有场景使用。
6.2 轻量级锁 lightweight lock
轻量级锁对应上述 Mark word 的 tag bits 为 00 的情况,即此时状态为 lightweight-locked。
轻量级锁是对重量级锁的优化。轻量级锁使用一个或两个 CPU 级别的原子指令(比如 lock cmpxchg),从而避免了使用操作系统级别的锁定原语。
可选的轻量级锁实现算法有 Metalock (CAS in both acquire and release), Thin Locks(CAS in acquire), 和 Relaxed-locks (CAS in acquire).
但轻量级锁适用范围有限,以 Thin Locks 为例子,它适用于这样的对象,这些对象不被争用,不需要对自己执行 wait,notify 或 notifyAll 操作,并且没有锁定到过多的嵌套深度。绝大多数对象都满足上述条件; 那些不满足条件的对象的锁要用重量级锁来实现。
6.3 偏向锁 biased lock
偏向锁在 JDK6 引入,对应上述 Mark word 的 tag bits 为 01 的情况,即此时状态为 unlocked 或 biasable。
偏向锁是对轻量级锁的再优化,尝试在 acquire 和 release 中避免原子指令, 仅在第一次获取时执行一次原子指令,以将锁定线程 ID 安装到 mark word 中。
但偏向锁的适用范围相对轻量级锁来说更加有限,偏向锁适用于单个进程反复获取并释放锁,而其他进程很少访问该锁的情况,即大多数对象在其生命周期中最多只能被一个线程锁定的情况。
更多关于偏向锁的知识,请见 Quickly Reacquirable Locks 和 Biased Locking in HotSpot
6.4 锁的转换
在 HotSpot JVM 中,按照偏向锁,轻量级锁,重量级锁的顺序来尝试获取对象的锁。完整流程见下图:
偏向锁相关流程(对应上图中以1开头的):
如果新分配的对象O是可偏向的但未被偏向(对应上图中1),那么第一次锁定的时候使用 CAS 在 mark word 中插入线程T1的ID(对应上图中1-1),而接下来的锁定仅仅将 mark word 里面的 线程T1的ID 与 当前线程T2的比较,此时可能出现两种情况:
- 情况1:如果线程ID一样,则表明对象O 已偏向当前线程 T2,也就是当前线程 T2 已经锁定对象 O,可以无需 CAS 即可 lock/unlock (对应图中1-2)
- 情况2:如果线程ID不一样,则撤销对T1的偏向, 并需要检查对象O是否可以重偏向。如果可以重偏向,则将对象O重偏向到线程T2(对应上图中1-3);否则将撤销偏向并回退到正常锁定流程(对应上图中以2开头的),此后对象 O 对应的类不可以再被偏向锁定。
轻量级锁和重量级锁流程(对应上图中以2开头的):
如果新分配的对象O对应的类不可偏向,则先尝试通过 CAS 设置 mark word来获取轻量级锁。如果成功,则获取轻量级锁;如果失败,则先判断是否是递归锁定,如果是则表明已经获取锁,如果不是则膨胀为重量级锁。
轻量级锁定时,每次进入同步方法,都会在栈帧中生成一个新的 lock record (锁记录),该锁记录有两个字段displaced hdr和owner,displaced hdr 用来保存锁对象的对象头mark word, owner用来保存指向锁对象的指针。另外,Lock record出于内存对齐的要求,会确保lock record的存储地址最后两位为00 ,这两位刚好用来作为轻量级锁的标识。
轻量级锁定:尝试将 lock record 的 displaced hdr 用来保存锁对象原来的mark word;将lock record的owner指向锁对象;将锁对象原来的mark word 替换为指向lock record的指针;这三步都会在同一个CAS原子地进行尝试。
如果CAS 成功,则表明获取轻量级锁成功,也就是下图所示的情况
如果CAS 失败,则分为递归锁定和需要膨胀到重量级锁两种情况处理。
虚拟机首先测试对象的 mark word 是否指向当前线程的方法栈。
- 如果是,则表明是递归锁定,当前线程已经拥有对象的锁,可以安全地继续执行它。对于这种递归锁定的对象,将 lock record 初始化为0而不是对象的 mark word。(对应上图中的2-2)
- 如果不是,则表明存在两个不同的线程同时在同一个对象上同步,这时需要将轻量级锁膨胀到重量级锁,也就是将指向heavy monitor的指针赋值给 对象的 mark word(对应上图中的2-3)
上面只是关于同步流程的部分总结,关于同步更全面的介绍,请参见Sun 的 Eliminating Synchronization Related Atomic Operations with Biased Locking and Bulk Rebiasing 和 Synchronization in Java SE 6(HotSpot) ,以及 Synchronization (我翻译的Synchronization中英对照版 ,还有 Java虚拟机是怎么实现synchronized的? ,以及在 bytecodeInterpreter.cpp 搜索Lock method if synchronized
。
7.总结
这篇文章首先对 synchronized 的基本使用进行了复习;然后尝试从字节码和本地机器码的角度上看 synchronized 的实现;最后通过查看官方文档弄清synchronized 的实现会分别尝试偏向锁(尝试避免原子指令,仅第一次的时候需要使用原子指令,以将锁定线程的 ID 安装到 header word 中),轻量级锁(在锁定和解锁中使用 一个或两个CPU-level 的原子指令),重量级锁(操作系统级调用),这三种锁实现适用范围越来越大,但代价也越来越大。
其实 synchronized 如何实现对于一般人是无感的,这也是为什么每次 JDK 发布都可能会改善它的性能,我们要做的基本上是根据 JDK 版本理解对应的实现,然后调整一下 相应的 JVM 参数。
8.参考
1.Java语言规范第八版第17章
2.Java虚拟机规范第八版
3.https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
4.https://time.geekbang.org/column/article/13530
5.https://blogs.oracle.com/dave/biased-locking-in-hotspot
6.https://wiki.openjdk.java.net/display/HotSpot/Synchronization
7.https://stackoverflow.com/questions/46312817/does-java-ever-rebias-an-individual-lock
8.https://www.zhihu.com/question/57774251