上一篇文章介绍了多线程的概念及 synchronized 的使用方法 《synchronized的使用(一)》 ,但是仅仅会用还是不够的,只有了解其底层实现才能在开发过程中运筹帷幄,所以本篇探讨 synchronized 的实现原理及锁升级(膨胀)的过程。
synchronized 是依赖于 JVM 来实现同步的,在同步方法和代码块的原理有点区别。
我们在代码块加上 synchronized 关键字
public void synSay() {
synchronized (object) {
System.out.println("synSay----" + Thread.currentThread().getName());
}
}
编译之后,我们利用反编译命令 javap -v xxx.class 查看对应的字节码,这里为了减少篇幅,我就只粘贴对应的方法的字节码。
public void synSay();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: getfield #2 // Field object:Ljava/lang/String;
4: dup
5: astore_1
6: monitorenter
7: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
10: new #4 // class java/lang/StringBuilder
13: dup
14: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
17: ldc #6 // String synSay----
19: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
22: invokestatic #8 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
25: invokevirtual #9 // Method java/lang/Thread.getName:()Ljava/lang/String;
28: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
31: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
34: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
37: aload_1
38: monitorexit
39: goto 47
42: astore_2
43: aload_1
44: monitorexit
45: aload_2
46: athrow
47: return
Exception table:
from to target type
7 39 42 any
42 45 42 any
LineNumberTable:
line 21: 0
line 22: 7
line 23: 37
line 24: 47
LocalVariableTable:
Start Length Slot Name Signature
0 48 0 this Lcn/T1;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 42
locals = [ class cn/T1, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
可以发现 synchronized 同步代码块是通过加 monitorenter 和 monitorexit 指令实现的。
每个对象都有个 监视器锁(monitor) ,当 monitor 被占用的时候就代表对象处于锁定状态,而 monitorenter 指令的作用就是获取 monitor 的所有权, monitorexit 的作用是释放 monitor 的所有权,这两者的工作流程如下:
monitorenter:
monitor 的进入数为0,则线程进入到 monitor ,然后将进入数设置为 1 ,该线程称为 monitor 的所有者。 monitor (即 monitor 进入数不为0),然后该线程又重新进入 monitor ,则将 monitor 的进入数 +1 ,这个即为 锁的重入 。 monitor ,则该线程进入到 阻塞状态,知道 monitor 的进入数为0,该线程再去重新尝试获取 monitor 的所有权 。 monitorexit:执行该指令的线程必须是 monitor 的所有者,指令执行时, monitor 进入数 -1 ,如果 -1 后进入数为 0 ,那么线程退出 monitor ,不再是这个 monitor 的所有者。这个时候其它阻塞的线程可以尝试获取 monitor 的所有权。
在方法上加上 synchronized 关键字
synchronized public void synSay() {
System.out.println("synSay----" + Thread.currentThread().getName());
}
编译之后,我们利用反编译命令 javap -v xxx.class 查看对应的字节码,这里为了减少篇幅,我就只粘贴对应的方法的字节码。
public synchronized void synSay();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: ldc #5 // String synSay----
12: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
15: invokestatic #7 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
18: invokevirtual #8 // Method java/lang/Thread.getName:()Ljava/lang/String;
21: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
30: return
LineNumberTable:
line 20: 0
line 21: 30
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 this Lcn/T1;
从字节码上看,加有 synchronized 关键字的方法,常量池中比普通的方法多了个 ACC_SYNCHRONIZED 标识, JVM 就是根据这个标识来实现方法的同步。
当调用方法的时候,调用指令会检查方法是否有 ACC_SYNCHRONIZED 标识,有的话 线程需要先获取 monitor ,获取成功才能继续执行方法,方法执行完毕之后,线程再释放 monitor ,同一个 monitor 同一时刻只能被一个线程拥有。
synchronized 同步代码块的时候通过加入字节码 monitorenter 和 monitorexit 指令来实现 monitor 的获取和释放,也就是需要 JVM通过字节码显式的去获取和释放monitor实现同步 ,而synchronized同步方法的时候,没有使用这两个指令,而是检查方法的 ACC_SYNCHRONIZED 标志是否被设置,如果设置了则线程需要先去获取monitor,执行完毕了线程再释放monitor,也就是不需要JVM去显式的实现。
这两个同步方式实际都是通过获取monitor和释放monitor来实现同步的,而monitor的实现依赖于底层操作系统的 mutex 互斥原语,而操作系统实现线程之间的切换的时候需要从用户态转到内核态,这个转成过程开销比较大。
线程获取、释放 monitor 的过程如下:
线程尝试获取 monitor 的所有权,如果获取失败说明 monitor 被其他线程占用,则将线程加入到的 同步队列 中,等待其他线程释放 monitor , 当其他线程释放 monitor 后,有可能刚好有线程来获取 monitor 的所有权,那么系统会将 monitor 的所有权给这个线程,而不会去唤醒同步队列的第一个节点去获取,所以 synchronized 是非公平锁 。如果线程获取 monitor 成功则进入到 monitor 中,并且将其进入数 +1 。
关于什么是公平锁、非公平锁可以参考一下美团技术团队写的 《不可不说的Java“锁”事》
到这里我们也清楚了 synchronized 的语义底层是通过一个 monitor 的对象完成,其实 wait 、 notiyf 和 notifyAll 等方法也是依赖于 monitor 对象来完成的, 这也就是为什么需要在同步方法或者同步代码块中调用的原因(需要先获取对象的锁,才能执行),否则会抛出 java.lang.IllegalMonitorStateException 的异常
我们知道了线程要访问同步方法、代码块的时候,首先需要取得锁,在退出或者抛出异常的时候又必须释放锁,那么锁到底是什么?又储存在哪里?
为了解开这个疑问,我们需要进入 Java虚拟机(JVM) 的世界。在 HotSpot 虚拟机中, Java 对象在内存中储存的布局可以分为 3 块区域: 对象头 、 实例数据 、 对齐填充 。 synchronized使用的锁对象储存在对象头中
对象头的数据长度在 32 位和 64 位(未开启压缩指针)的虚拟机中分别为 32bit 和 64bit 。对象头由以下三个部分组成:
GC 分代年龄、锁标志位、线程持有的锁、偏向线程 ID 、偏向时间戳、对象分代年龄等。 注意这个Mark Word结构并不是固定的,它会随着锁状态标志的变化而变化,而且里面的数据也会随着锁状态标志的变化而变化,这样做的目的是为了节省空间 。 在 32 位虚拟机下, Mark Word 的结构和数据可能为以下 5 种中的一种。
在 64 位虚拟机下, Mark Word 的结构和数据可能为以下 2 种中的一种。
这里重点注意 是否偏向锁 和 锁标志位 ,这两个标识和 synchronized 的锁膨胀息息相关。
储存着对象的实际数据,也就是我们在程序中定义的各种类型的字段内容。
HotSpot 虚拟机的对齐方式为 8 字节对齐,即一个对象必须为 8 字节的整数倍,如果不是,则通过这个对齐填充来占位填充。
上文介绍的 “ synchronized 实现原理” 实际是synchronized实现 重量级锁的原理 ,那么上文频繁提到 monitor 对象和对象又存在什么关系呢,或者说 monitor 对象储存在对象的哪个地方呢?
在对象的对象头中,当锁的状态为重量级锁的时候,它的指针即指向 monitor 对象 ,如图:
那锁的状态为其它状态的时候是不是就没用上 monitor 对象?答案:是的。
这也是 JVM 对 synchronized 的优化,我们知道重量级锁的实现是基于底层操作系统的 mutex 互斥原语的,这个开销是很大的。所以 JVM 对 synchronized 做了优化, JVM 先利用对象头实现锁的功能,如果线程的竞争过大则会将锁升级(膨胀)为重量级锁,也就是使用 monitor 对象。当然 JVM 对锁的优化不仅仅只有这个,还有引入适应性自旋、锁消除、锁粗化、轻量级锁、偏向锁等。
那么锁的是怎么进行膨胀的或者依据什么来膨胀,这也就是本篇需要介绍的重点,首先我们需要了解几个概念。
自旋:当有个线程 A 去请求某个锁的时候,这个锁正在被其它线程占用,但是线程 A 并不会马上进入阻塞状态,而是循环请求锁(自旋)。这样做的目的是因为很多时候持有锁的线程会很快释放锁的,线程 A 可以尝试一直请求锁,没必要被挂起放弃 CPU 时间片,因为线程被挂起然后到唤醒这个过程开销很大,当然如果线程 A 自旋指定的时间还没有获得锁,仍然会被挂起。
自适应性自旋:自适应性自旋是自旋的升级、优化,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。例如 线程如果自旋成功了,那么下次自旋的次数会增多 ,因为 JVM 认为既然上次成功了,那么这次自旋也很有可能成功,那么它会允许自旋的次数更多。反之,如果 对于某个锁,自旋很少成功 ,那么在以后获取这个锁的时候,自旋的次数会变少甚至忽略,避免浪费处理器资源。有了自适应性自旋,随着程序运行和性能监控信息的不断完善, JVM 对程序锁的状况预测就会变得越来越准确, JVM 也就变得越来越聪明。
锁消除是指虚拟机即时编译器在运行时, 对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除 。
在使用锁的时候,需要让同步块的作用范围尽可能小,这样做的目的是 为了使需要同步的操作数量尽可能小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁 。
所谓 轻量级锁 是相对于使用底层操作系统 mutex 互斥原语实现同步的 重量级锁 而言的,因为轻量级锁同步的 实现是基于对象头的Mark Word 。那么轻量级锁是怎么使用对象头来实现同步的呢,我们看看具体实现过程。
Mark Word 拷贝到线程的锁记录(Lock Recored)中。 CAS 操作 尝试将对象的 Mark Word 更新为指向 Lock Record 的指针 。如果这个更新成功了,则执行步骤 4 ,否则执行步骤 5 。
Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其其它线程抢占了。 进行自旋执行步骤 3 ,如果自旋结束仍然没有获得锁,轻量级锁就需要膨胀为重量级锁,锁标志位状态值变为”10”,Mark Word中储存就是指向 monitor 对象的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。
CAS 操作将对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来(依据 Mark Word 中锁记录指针是否还指向本线程的锁记录),如果替换成功,则执行步骤 2 ,否则执行步骤 3 。 偏向锁的目的是消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能 。如果说轻量级锁是在无竞争的情况下使用 CAS 操作区消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不用做了。 偏向锁默认是开启的,也可以关闭 。
偏向锁”偏”,就是”偏心”的”偏”,它的意思是这个锁会偏向于第一个获得它的程序,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
Mark Word 是否为 可偏向锁的状态 ,即是否偏向锁即为1即表示支持可偏向锁,否则为0表示不支持可偏向锁。 Mark Word 储存的线程 ID 是否为当前线程 ID ,如果是则执行同步块,否则执行步骤 3 。 Mark Word 的 ID 不是本线程的 ID ,则通过 CAS 操作去修改线程 ID 修改成本线程的 ID ,如果修改成功则执行同步代码块,否则执行步骤 4 。 Mark Word 的锁记录指针改成当前线程的锁记录,锁 升级为轻量级锁状态(00) 。
锁主要存在 4 种状态,级别从低到高依次是: 无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态 ,这几个状态会随着竞争的情况逐渐升级,这几个锁只有重量级锁是需要使用操作系统底层 mutex 互斥原语来实现,其他的锁都是使用对象头来实现的。 需要注意锁可以升级,但是不可以降级。
这里盗个图,这个图总结的挺好的!