本文主要从字节码角度、原理角度讨论synchronized的实现原理,以及jvm的锁优化技术。
因为代码、编译后的字节码内容较多,仅截取了与本博客讨论内容相关的代码。重点看标红部分的代码。
一 synchronized
synchronized既可以用在方法上,也可以使用在代码块上,虚拟机对这两种铜鼓使用的是不同的处理逻辑,接下来我们就根据示例的字节码来聊聊虚拟机的处理方式。
1 同步指令
方法级的同步是隐式的,即无需通过字节码指令来控制,它实现在方法调用和返回操作中。方法上添加synchronized以后,编译出来的字节码中会给方法加上ACC_SYNCHRONIZED标识,见后面的示例。虚拟机从方法区常量池的方法表数据结构中判断方法是否拥有ACC_SYNCHRONIZED标识,如果拥有此标识,那么执行线程就要求先持有锁(也称为管程);方法执行过程中其他线程无法获取锁;方法执行结束或者异常退出时释放锁。
代码块的同步是显示的,由monitorenter和monitorexit两条指令完成synchronized块的语义,见后面的示例。
2 静态方法同步
静态同步方法,锁是当前类的class对象。对这一点也比较好理解,静态方法调用前不需要为此类创建一个实例,所以也就不可能锁此类的实例对象。
1) 源码
public static synchronized void syn1() {
System.out.println("syn1");
}
2) 字节码
public static synchronized void syn1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String syn1
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 6: 0
line 7: 8
3 普通方法同步
普通同步方法,锁的是当前实例对象。这也很好理解,因为存在多态性,一个类可能有非常多的实例、子类,所以普通方法执行前需要通过一个被称之为“分派”的过程确定调用方法的版本,所以不可能锁此类的class对象,从这个角度上看,其实很容理解这里锁的必然就只能是当前类的某一个实例。
1) 源码
public synchronized void syn2() {
System.out.println("syn2");
}
2) 字节码
public synchronized void syn2();
descriptor: ()V
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 syn2
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 10: 0
line 11: 8
4 代码块同步
同步代码块,锁是括号中的对象,示例中是this这个实例。从字节码的代码中可以看到,synchronized被转换成monitorenter和monitorexit两个指令。编译器为了保证无论方法通过何种方式完成,执行过monitorenter指令以后必须执行monitorexit指令,所以编译器生成了一段异常处理流程的指令。
1) 源码
public void syn3() {
synchronized (this) {
System.out.println("syn3");
}
}
2) 字节码
public void syn3();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0 // 将this加载到栈顶
1: dup // 复制栈顶元素this,并将复制的元素入栈。栈顶有2个this的引用
2: astore_1 // 将栈顶元素this取出,存放到局部变量表第2个slot中
3: monitorenter // 获取栈顶元素this的锁
4: getstatic #2 // 调用静态方法System.out;
7: ldc #6 // 将常量池中常量syn3推送到栈顶
9: invokevirtual #4 // 执行静态方法println()
12: aload_1 // 将局部变量表第2个slot中的数据this,加载到栈顶
13: monitorexit // 释放栈顶元素this的锁
14: goto 22 // 跳转到22行
17: astore_2 // (异常时执行)将栈顶元素存到局部变量表第3个slot中
18: aload_1 // (异常时执行)将局部变量表第2个slot中的数据this,加载到栈顶
19: monitorexit // (异常时执行)释放栈顶元素this的锁
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
LineNumberTable:
line 14: 0
line 15: 4
line 16: 12
line 17: 22
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 17
locals = [ class com/wzf/greattruth/thread/SynchronizedTester, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
二 对象头与Monitor
1 Mark Word
在内存中,对象包括:对象头、实例数据、对齐填充三部分。其中对象头分为Mark Word、类型指针、数组长度(如果是数组的话)。32位虚拟机的对象头中Mark word结构如下:
锁状态 |
25bit |
4bit |
1bit |
2bit |
|
23bit |
2bit |
|
是否是偏向锁 |
锁标志位 |
|
无锁态 |
对象的hashCode |
分代年龄 |
0 |
01 |
|
偏向锁 |
线程ID |
epoch |
分代年龄 |
1 |
01 |
轻量级锁 |
指向栈中锁标记的指针 |
00 |
|||
重量级锁 |
指向互斥量(重量级锁)的指针 |
10 |
|||
GC标记 |
空 |
11 |
从此表中可以看到,JVM通过对象头中Mark word中锁标志位记录对象的锁状态。
如果锁标志位是01,则通过是否偏向标志位判断是偏向锁还是无锁状态。
如果锁标志位是00,则表明目前对象处于轻量级锁定状态。
如果锁标志位是10,则表明目前对象处于重量级锁定状态。
如果锁标志位是11,则表明前30bit不需要记录信息。
2 Monitor Record
Monitor Record是线程私有的,每一个线程都有一个可用Monitor record列表,同时还有一个全局的可用列表。以32位虚拟机为例,对象被锁住以后,其对象头的Mark word中前30bit是指向重量级锁的指针,即这里的Monitor Record;同时Monitor Record中owner字段中也存放了持有锁的线程的id。
Monitor Record主要包括以下信息:Owner、EntryQ、RcThis、Nest、HashCode、Candidate,其结构如下:
Owner |
初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL; |
EntryQ |
关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。 |
RcThis |
表示blocked或waiting在该monitor record上的所有线程的个数。 |
Nest |
用来实现重入锁的计数。 |
HashCode |
保存从对象头拷贝过来的HashCode值 |
Candidate |
0表示没有需要唤醒的线程。 1表示要唤醒一个继任线程来竞争锁。 用来避免不必要的阻塞或等待线程唤醒。如果每次释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换,从而导致性能严重下降。 |
3 Monitor数据结构
typedef struct monitor {
pthread_mutex_t lock;
Thread *owner;
Object *obj;
int count;
int in_wait;
uintptr_t entering;
int wait_count;
Thread *wait_set;
struct monitor *next;
} Monitor;
三 锁优化
1 锁优化
1) 锁优化技术
- 锁粗化(Lock Coarsening)
- 锁消除(Lock Elimination)
- 自旋与适应性自旋(Spinning & Adaptive Spinning)
- 偏向锁(Biased Locking)
- 轻量级锁(Lightweight Locking)
2) 锁粗化
将多个连续的锁扩展成一个范围更大的锁,用以减少频繁互斥同步导致的性能损耗。
以下代码中,反复对obj加锁、释放锁,会产生不必要的性能损失。
Object obj = new Object() ;
private void wide(){
for( int i = 0 ; i< 10000 ; i++){
synchronized (obj) {
//TODO 业务逻辑
}
}
}
3) 锁消除
JVM及时编译器在运行时,通过逃逸分析,如果判断一段代码中,堆上的所有数据不会逃逸出去从来被其他线程访问到,就可以去除这些锁。
4) 自旋与适应性自旋
一般而言,共享数据的锁定状态只会持续很短的一段时间,为了这个很短的时间去挂起和恢复线程并不值得。如果物理机有多个处理器,能够支持两个以上的线程同时并行执行,那么可以让后面请求锁的线程“稍微等待一会”,即让线程执行一个忙循环(自旋),看看持有锁的线程是否能够很快的释放锁。这种技术被称之为:自旋锁。
JDK1.4引入自旋锁,可以-XX:+UseSpinning来开启自旋锁,JDK1.6中默认开启。自旋过程会占用处理器时间,所以不能无限制自旋,默认自旋次数为10次,可以通过-XX:PreBlockSpin来修改。
JDK1.6引入了适应性自旋。适应性自旋的自旋时间根据之前同一个锁的自旋时间和持有者(线程)的状态来决定,用以期望能减少阻塞的时间。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋很有可能再次成功,进而允许他自旋等待更长时间;如果某个锁,自旋很少成功获得锁,那么以后获取这个锁时可能省略掉自旋过程。
5) 轻量级锁
轻量级锁是在无竞争情况下,使用CAS操作去消除重量级锁因为使用的系统互斥量而产生的性能损耗。JDK1.6引入。
a) 上锁
线程(例如线程A)在进入同步代码块的时候,如果此同步对象没有被锁定(Mark word中锁标志位=01),虚拟机首先在当前线程的栈帧中建立一个锁记录(Lock Record),用于存储锁对象目前的Mark Word的拷贝(此拷贝被称之为Displaced Mark Word)。
接着,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向线程(A)栈帧中Lock Record的指针。如果CAS操作成功,那么这个线程(A)就拥有了该对象的锁,并且对象的Mark Word的锁标志位将被改为00,即处于轻量级锁定状态。
轻量级锁CAS操作前堆栈与对象状态如下:
轻量级锁CAS操作后堆栈与对象状态如下:
如果CAS操作失败,虚拟机首先检查对象的Mark Word是否指向当前线程(A)的栈帧,如果是指向当前线程(A)的栈帧,则说明当前线程(A)已经拥有了这个对象的锁,那么可以直接进入同步块继续执行;如果不是指向当前线程(A)的栈帧,说明这个锁对象已经被其他线程抢占了。
如果另外一个线程(例如B线程)进来尝试获取对象的锁,首先按照上面的过程尝试加轻量级锁,即通过CAS操作,尝试将对象头Mark Word更新为指向当前线程(B)栈帧中Lock Record的指针,因为已经有线程A占用对象锁,所以B线程会进入自旋。如果自旋结束线程B依然无法取得锁,那么对象的锁将膨胀为重量级锁,此时自旋线程B进入阻塞状态,线程A执行完毕以后唤醒阻塞线程。
b) 解锁
轻量级锁的解锁过程也是通过CAS来实现的。如果对象的Mark Word仍然指向线程的锁记录,那么就用CAS操作把对象当前的Mark Word替换回来(使用线程栈帧的锁记录中的Displaced Mark Word进行替换);如果替换成功,整个同步过程就完成了;如果替换失败,说明有其他线程尝试过获取该锁,那么在释放锁的同时,唤醒被挂起的线程(如果替换失败这一段描述,总感觉不正确,不过没有阅读过源码、书上、博客上都是这么写的,所以暂时未做改动)。
6) 偏向锁
偏向锁是在无竞争的情况下将整个同步都消除掉,连CAS操作都省去。JDK1.6引入。
当锁对象第一次被线程获取的时候,虚拟机将对象头中“是否偏向锁”的标志位改为1;同时通过CAS操作将获取锁的线程Id写入到锁对象Mark Word的前23bit中(32位虚拟机)。
如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不进行任何同步操作(例如Locking、Unlocking、以及对mark word的update等)。具体过程如下:下次获取锁的时候,检查当Mark Word中的ThreadId是否和当前线程的Id一致。如果一致,则认为当前线程已经获取了锁,因此不需再次获取锁,略过了轻量级锁和重量级锁的加锁阶段,提高了效率。
当有另外一个线程去尝试获取这个锁时,偏向模式宣告结束。根据对象目前是否处于被锁定状态,撤销“是否偏向锁”标识,恢复到未锁定(锁标识=01)或者轻量级锁定(锁标识=00)的状态,后续过程和轻量级锁加锁过程一致。
偏向锁、轻量级锁的状态转化及对象Mark Word关系如下图所示: