上一篇文章介绍了多线程的概念及 synchronized 的使用方法 《synchronized 的使用 (一)》 https://ddnd.cn/2019/03/21/java-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"锁" 事》 https://tech.meituan.com/2018/11/15/java-lock.html
到这里我们也清楚了 synchronized 的语义底层是通过一个 monitor 的对象完成, 其实 wait , notiyf 和 notifyAll 等方法也是依赖于 monitor 对象来完成的, 这也就是为什么需要在同步方法或者同步代码块中调用的原因(需要先获取对象的锁, 才能执行), 否则会抛出 java.lang.IllegalMonitorStateException 的异常
Java 对象的组成
我们知道了线程要访问同步方法, 代码块的时候, 首先需要取得锁, 在退出或者抛出异常的时候又必须释放锁, 那么锁到底是什么? 又储存在哪里?
为了解开这个疑问, 我们需要进入 Java 虚拟机(JVM) 的世界. 在 HotSpot 虚拟机中, Java 对象在内存中储存的布局可以分为 3 块区域: 对象头 , 实例数据 , 对齐填充 . synchronized 使用的锁对象储存在对象头中
对象头
对象头的数据长度在 32 位和 64 位 (未开启压缩指针) 的虚拟机中分别为 32bit 和 64bit . 对象头由以下三个部分组成:
Mark Word: 记录了对象和锁的有关信息, 储存对象自身的运行时数据, 如哈希码(HashCode), GC 分代年龄, 锁标志位, 线程持有的锁, 偏向线程 ID , 偏向时间戳, 对象分代年龄等. 注意这个 Mark Word 结构并不是固定的, 它会随着锁状态标志的变化而变化, 而且里面的数据也会随着锁状态标志的变化而变化, 这样做的目的是为了节省空间 .
类型指针: 指向对象的类元数据的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例.
数组长度: 这个属性只有数组对象才有, 储存着数组对象的长度.
在 32 位虚拟机下, Mark Word 的结构和数据可能为以下 5 种中的一种.
在 64 位虚拟机下, Mark Word 的结构和数据可能为以下 2 种中的一种.
这里重点注意 是否偏向锁 和 锁标志位 , 这两个标识和 synchronized 的锁膨胀息息相关.
实例数据
储存着对象的实际数据, 也就是我们在程序中定义的各种类型的字段内容.
对齐填充
HotSpot 虚拟机的对齐方式为 8 字节对齐, 即一个对象必须为 8 字节的整数倍, 如果不是, 则通过这个对齐填充来占位填充.
synchronized 锁膨胀过程
上文介绍的 "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 . 那么轻量级锁是怎么使用对象头来实现同步的呢, 我们看看具体实现过程.
获取锁过程:
在线程进入同步方法, 同步块的时候, 如果 同步对象锁状态为无锁状态 (锁标志位为 "01" 状态, 是否为偏向锁为 "0") , 虚拟机首先将在当前线程的栈帧中 建立一个名为锁记录(Lock Recored) 的空间 , 用于储存锁对象目前的 Mark Word 的拷贝(官方把这份拷贝加了个 Displaced 前缀, 即 Displaced Mark Word).
将对象头的 Mark Word 拷贝到线程的锁记录 (Lock Recored) 中.
拷贝成功后, 虚拟机将使用 CAS 操作 尝试将对象的 Mark Word 更新为指向 Lock Record 的指针 . 如果这个更新成功了, 则执行步骤
4
, 否则执行步骤
5
.
更新成功, 这个 线程就拥有了该对象的锁, 并且对象 Mark Word 的锁标志位将转变为 "00", 即表示此对象处于轻量级锁的状态. .
更新失败, 虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧, 如果是就说明当前线程已经拥有了这个对象的锁, 可以直接进入同步块继续执行, 否则说明这个锁对象已经被其其它线程抢占了. 进行自旋执行步骤
3
, 如果自旋结束仍然没有获得锁, 轻量级锁就需要膨胀为重量级锁, 锁标志位状态值变为 "10",Mark Word 中储存就是指向 monitor 对象的指针, 当前线程以及后面等待锁的线程也要进入阻塞状态.
释放锁的过程:
使用 CAS 操作将对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来(依据 Mark Word 中锁记录指针是否还指向本线程的锁记录), 如果替换成功, 则执行步骤 2 , 否则执行步骤
3
.
如果替换成功, 整个同步过程就完成了, 恢复到无锁的状态(01).
如果替换失败, 说明有其他线程尝试获取该锁(此时锁已膨胀), 那就要在释放锁的同时, 唤醒被挂起的线程.
偏向锁
偏向锁的目的是消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能 . 如果说轻量级锁是在无竞争的情况下使用 CAS 操作区消除同步使用的互斥量, 那么偏向锁就是在无竞争的情况下把整个同步都消除掉, 连 CAS 操作都不用做了. 偏向锁默认是开启的, 也可以关闭 .
偏向锁 "偏", 就是 "偏心" 的 "偏", 它的意思是这个锁会偏向于第一个获得它的程序, 如果在接下来的执行过程中, 该锁没有被其他的线程获取, 则持有偏向锁的线程将永远不需要再进行同步.
获取锁的过程:
检查 Mark Word 是否为 可偏向锁的状态 , 即是否偏向锁即为 1 即表示支持可偏向锁, 否则为 0 表示不支持可偏向锁.
如果是可偏向锁, 则 检查 Mark Word 储存的线程 ID 是否为当前线程 ID , 如果是则执行同步块, 否则执行步骤
3
.
如果检查到 Mark Word 的 ID 不是本线程的 ID , 则通过 CAS 操作去修改线程 ID 修改成本线程的 ID , 如果修改成功则执行同步代码块, 否则执行步骤
4
.
当拥有该锁的线程到达安全点之后, 挂起这个线程, 升级为轻量级锁.
锁释放的过程:
有其他线程来获取这个锁, 偏向锁的释放采用了一种只有竞争才会释放锁的机制, 线程是不会主动去释放偏向锁, 需要等待其他线程来竞争.
等待全局安全点(在这个是时间点上没有字节码正在执行).
暂停拥有偏向锁的线程, 检查持有偏向锁的线程是否活着, 如果不处于活动状态, 则将对象头设置为无锁状态, 否则设置为被锁定状态. 如果锁对象处于 无锁状态, 则恢复到无锁状态(01) , 以允许其他线程竞争, 如果 锁对象处于锁定状态 , 则挂起持有偏向锁的线程, 并将对象头 Mark Word 的锁记录指针改成当前线程的锁记录, 锁 升级为轻量级锁状态(00) .
锁的转换过程
锁主要存在 4 种状态, 级别从低到高依次是: 无锁状态, 偏向锁状态, 轻量级锁状态和重量级锁状态 , 这几个状态会随着竞争的情况逐渐升级, 这几个锁只有重量级锁是需要使用操作系统底层 mutex 互斥原语来实现, 其他的锁都是使用对象头来实现的. 需要注意锁可以升级, 但是不可以降级.
这里盗个图, 这个图总结的挺好的!
来源: http://www.tuicool.com/articles/3QviYbi