JDK 源码阅读008:synchronized 关键字
1 synchronized 常见使用方式
synchronized 在使用的时候,为了更细粒度地控制加锁的范围,以达到更高的并发效率,需要开发人员熟悉 sychronized 常用的使用方式以及每种方式对应的范围。
synchronized 锁包含两个方面:一种是对某个实例对象加锁,另外一种是对这个类进行加锁。对类加锁,也是在针对一个对象实例进行加锁,其实他的意思就是对那个类的Class对象进行加锁。
总而言之,synchronized 可以对两种对象加锁,
- 对象实例
- Class 对象
1.1 synchronized 修饰普通方法
synchronized 修饰普通方法,那么就是对当前这个对象实例在加锁,访问同一个对象实例的synchronized方法,同一时间只有一个线程可以做到,如果是下面那种synchronized代码片段,也是这个意思。
synchronized(myObject) {
// do something
}
但是如果是两个线程,分别进入不同的对象的synchronized方法或者代码片段,这两个线程并不会互相干扰,因为是在不同的对象上加锁。
1.2 synchronized 修饰 this 对象
我们也可以使用 synchronized 来修饰 this 对象,其实意思就是基于当前这个对象实例来加锁,如下代码所示:
synchronized(this) {
// do something
}
1.3 synchronized 修饰一个静态方法
synchronized 修饰一个静态方法,就是对这个类的Class对象加锁,每个类都对应了一个Class对象,那么对同一个类的synchronized静态方法,同一时间只能有一个线程加锁进入其中,下面的那个代码片段,也是这个意思:
synchronized(MyObject.class) {
}
synchronized 能保证原子性、有序性和可见性。下面从分析一下 synchronized 是如何从底层实现这三个特性的。
2 synchronized 底层如何保证原子性
2.1 从字节码角度聊聊 synchronized 关键字
其实 synchronized 底层的原理,是跟 jvm 指令和 monitor 有关系的。先大致说几个概念,后面我们再解释。
- monitor 对象
- 反编译工具:javap
- monitorenter 和 monitorexit 指令
- 可重入锁
你如果用到了synchronized关键字,在底层编译后的jvm指令中,会有monitorenter和monitorexit两个指令
monitorenter
// 代码对应的指令
monitorexit
我们可以来看个示例,如下为一个最简单的代码示例:
public class Test {
public static void main(String[] args) {
synchronized (Test.class) {
System.out.println("hello world");
}
}
}
编译之后得到 Test.class 文件,我们可以使用内置的反编译工具 javap
来看看具体的内容:
javap
命令的具体使用方法我在这里也列一下,也可以使用 javap -help
来查看:
用法: javap <options> <classes>
其中, 可能的选项包括:
-help --help -? 输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示最终常量
-classpath <path> 指定查找用户类文件的位置
-cp <path> 指定查找用户类文件的位置
-bootclasspath <path> 覆盖引导类文件的位置
执行命令javap -verbose -p Test.class
,这里限于篇幅我只截取了部分输出结果:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class com/panson/Test
2: dup
3: astore_1
4: monitorenter
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String hello world
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
可以定位到:
4: monitorenter
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String hello world
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
确实是使用了 monitorenter 和 monitorexit 两个指令。
那么monitorenter指令执行的时候会干什么呢?
每个对象都有一个关联的 monitor,比如一个对象实例就有一个monitor,一个类的Class对象也有一个monitor,如果要对这个对象加锁,那么必须获取这个对象关联的monitor的lock锁
底层原理和思路大概是这样的:monitor里面有一个计数器,从0开始的。如果一个线程要获取monitor的锁,就看看他的计数器是不是0,如果是 0 的话,那么说明没人获取锁,他就可以获取锁了,然后对计数器加 1。
2.2 synchronized 可重入性在字节码上的表现形式
我们还是使用一个简单的代码示例,来看看反编译之后的代码有什么不一样,在该代码示例中,我们使用了两层加锁机制。
代码示例:
public class Test {
public static void main(String[] args) {
synchronized (Test.class) {
System.out.println("hello world");
synchronized (Test.class) {
System.out.println("peace");
}
}
}
}
反编译之后的代码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: ldc #2 // class com/panson/Test
2: dup
3: astore_1
4: monitorenter
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String hello world
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: ldc #2 // class com/panson/Test
15: dup
16: astore_2
17: monitorenter
18: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
21: ldc #6 // String peace
23: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
26: aload_2
27: monitorexit
28: goto 36
31: astore_3
32: aload_2
33: monitorexit
34: aload_3
35: athrow
36: aload_1
37: monitorexit
38: goto 48
41: astore 4
43: aload_1
44: monitorexit
从反编译之后的代码可以看到,monitorenter 和 monitorexit 各自出现了两次,对应着两次 synchronized。可重入锁的直观展示便如上述代码所示。
2.3 synchronized 可重入的加锁与释放锁流程
synchronized(myObject) {
// do something
synchronized(myObject) {
// do something
}
}
如果一个线程第一次synchronized那里,获取到了 myObject 对象的 monitor 的锁,计数器加 1 ,然后第二次 synchronized 那里,会再次获取 myObject 对象的 monitor 的锁,这个就是重入加锁了,然后计数器会再次加 1,变成 2。
这个时候,其他的线程在第一次synchronized那里,会发现说myObject对象的monitor锁的计数器是大于0的,意味着被别人加锁了,然后此时线程就会进入block阻塞状态,什么都干不了,就是等着获取锁。
接着如果出了synchronized修饰的代码片段的范围,就会有一个monitorexit的指令。此时获取锁的线程就会对那个对象的monitor的计数器减1,如果有多次重入加锁就会对应多次减1,直到最后,计数器是0。
然后后面block住阻塞的线程,会再次尝试获取锁,但是只有一个线程可以获取到锁。
总结:sychronized 从底层来说,就是在进入加锁代码块的时候加一个monitorenter的指令,然后针对锁对象关联的monitor累加加锁计数器,同时标识自己这个线程加了锁,同时通过monitor里的加锁计数器可以实现可重入的加锁。
2.4 monitor 对 wait 和 notify 的支持
其实wait和notify关键字的实现也是依托于monitor实现的,有线程执行wait之后,自己会加入一个waitset中等待唤醒获取锁,notifyall操作会从monitor的waitset中唤醒所有的线程,让他们竞争获取锁。
MyObject lock = new MyObject();
synchronized(lock) {
}
Java对象都是分为对象头和实例变量两块的,其中实例变量就是大家平时看到的对象里的那些变量数据。然后对象头包含了两块东西,一个是 Mark Word(包含hashCode、锁数据、GC数据,等等),另一个是 Class Metadata Address(包含了指向类的元数据的指针)。
在Mark Word里就有一个指针,是指向了这个对象实例关联的monitor的地址,这个monitor是c++实现的,不是java实现的。这个monitor实际上是c++实现的一个ObjectMonitor对象。
-
ObjectMonitor 里面包含了一个 _owner指针,指向了持有锁的线程。
-
ObjectMonitor 里还有一个 entrylist,想要加锁的线程全部先进入这个entrylist等待获取机会尝试加锁,实际有机会加锁的线程,就会设置_owner指针指向自己,然后对_count计数器累加1次。
各个线程尝试竞争进行加锁,此时竞争加锁是在JDK 1.6以后优化成了基于CAS来进行加锁,理解为跟之前的Lock API的加锁机制是类似的,CAS操作,操作_count计数器,比如说将_count值尝试从0变为1。
然后释放锁的时候,先是对_count计数器递减1,如果为0了就会设置_owner为null,不再指向自己,代表自己彻底释放锁。
如果获取锁的线程执行wait,就会将计数器递减,同时_owner设置为null,然后自己进入waitset中等待唤醒,别人获取了锁执行notify的时候就会唤醒waitset中的线程竞争尝试获取锁。
有人会问,那尝试加锁这个过程,也就是对_count计数器累加操作,是怎么执行的?如何保证多线程并发的原子性呢?很简单,JDk 1.6之后,对synchronized内的加锁机制做了大量的优化,这里就是优化为CAS加锁的。
3 保证可见性和有序性
int b = 0;
int c = 0;
synchronized(this) { -> monitorenter
Load内存屏障
Acquire内存屏障
int a = b;
c = 1; => synchronized代码块里面还是可能会发生指令重排
Release内存屏障
} -> monitorexit
Store内存屏障
java的并发技术底层很多都对应了内存屏障的使用,包括synchronized,他底层也是依托于各种不同的内存屏障来保证可见性和有序性的。
按照可见性来划分的话,内存屏障可以分为Load屏障和Store屏障。
-
Load屏障的作用是执行refresh处理器缓存的操作,说白了就是对别的处理器更新过的变量,从其他处理器的高速缓存(或者主内存)加载数据到自己的高速缓存来,确保自己看到的是最新的数据。
-
Store屏障的作用是执行flush处理器缓存的操作,说白了就是把自己当前处理器更新的变量的值,都刷新到高速缓存(或者主内存)里去。
在monitorexit指令之后,会有一个Store屏障,让线程把自己在同步代码块里修改的变量的值都执行flush处理器缓存的操作,刷到高速缓存(或者主内存)里去;然后在monitorenter指令之后会加一个Load屏障,执行refresh处理器缓存的操作,把别的处理器修改过的最新值加载到自己高速缓存里来。
所以说通过Load屏障和Store屏障,就可以让synchronized保证可见性。
按照有序性保障来划分的话,还可以分为Acquire屏障和Release屏障。
- 在monitorenter指令之后,Load屏障之后,会加一个Acquire屏障,这个屏障的作用是禁止读操作和读写操作之间发生指令重排序。
- 在monitorexit指令之前,会加一个Release屏障,这个屏障的作用是禁止写操作和读写操作之间发生重排序。
所以说,通过 Acquire屏障和Release屏障,就可以让synchronzied保证有序性,只有synchronized内部的指令可以重排序,但是绝对不会跟外部的指令发生重排序。
总结 synchronized:
- 原子性:加锁和释放锁,ObjectMonitor
- 可见性:加了Load屏障和Store屏障,释放锁flush数据,加锁会refresh数据
- 有序性:Acquire屏障和Release屏障,保证同步代码块内部的指令可以重排,但是同步代码块内部的指令和外面的指令是不能重排的
4 锁消除、锁粗化、偏向锁、轻量级锁、适应锁
4.1 锁消除
锁消除是JIT编译器对synchronized锁做的优化,在编译的时候,JIT会通过逃逸分析技术,来分析synchronized锁对象,是不是只可能被一个线程来加锁,没有其他的线程来竞争加锁,这个时候编译就不用加入monitorenter和monitorexit的指令。
这就是,仅仅一个线程争用锁的时候,就可以消除这个锁了,提升这段代码的执行的效率,因为可能就只有一个线程会来加锁,不涉及到多个线程竞争锁。
4.2 锁粗化
synchronized(this) {
}
synchronized(this) {
}
synchronized(this) {
}
这个意思就是,JIT编译器如果发现有代码里连续多次加锁释放锁的代码,会给合并为一个锁,就是锁粗化,把一个锁给搞粗了,避免频繁多次加锁释放锁。
4.3 偏向锁
这个意思就是说,monitorenter和monitorexit是要使用CAS操作加锁和释放锁的,开销较大,因此如果发现大概率只有一个线程会主要竞争一个锁,那么会给这个锁维护一个偏好(Bias),后面他加锁和释放锁,基于Bias来执行,不需要通过CAS,性能会提升很多。
但是如果有偏好之外的线程来竞争锁,就要收回之前分配的偏好。
4.4 轻量级锁
如果偏向锁没能成功实现,就是因为不同线程竞争锁太频繁了,此时就会尝试采用轻量级锁的方式来加锁,就是将对象头的Mark Word里有一个轻量级锁指针,尝试指向持有锁的线程,然后判断一下是不是自己加的锁,如果是自己加的锁,那就执行代码就好了,如果不是自己加的锁,那就是加锁失败,说明有其他人加了锁,这个时候就是升级为重量级锁。
4.5 适应锁
这是JIT编译器对锁做的另外一个优化,如果各个线程持有锁的时间很短,那么一个线程竞争锁不到,就会暂停,发生上下文切换,让其他线程来执行。但是其他线程很快释放锁了,然后暂停的线程再次被唤醒。也就是说在这种情况下,线程会频繁的上下文切换,导致开销过大。所以对这种线程持有锁时间很短的情况,是可以采取忙等策略的,也就是一个线程没竞争到锁,进入一个while循环不停等待,不会暂停不会发生线程上下文切换,等到机会获取锁就继续执行好了。
这样可以大幅度减少线程上下文的切换,而这种自旋等待获取锁的方式,就是所谓自旋锁,就是不断的自旋尝试获取锁。
如果一个线程持有锁的时间很长,那么其他线程获取不到锁,就会暂停,发生上下文切换,让其他线程来执行,这种自己暂停获取锁的方式,就是所谓的重量级锁。这个根据不同情况自动调整的过程,就是适应锁的意思。