面试官 synchronized 连环问,学会 Monitor 之后轻松拿下
1、简介
我们 Java 程序员编码时谈论的最多的两个字就是对象,Java 中几乎所有的技术都是围绕对象展开。本文将要讲述的 Monitor 并不是 Java 对象,而是在操作系统中关联的“对象”,Monitor 是 Java 重量级锁 synchronized 实现的关键,因此学习 Java 单机同步机制就离不开对 Monitor 的剖析。Monitor 经常被人们称为监视器锁和管程。
2、对象头
Monitor 与 Java 对象头相关联,因此剖析 Monitor 之前必须了解 Java 对象的组成结构。Java 对象在内存中由三部分组成,分别是对象头、实例数据、对齐填充。以 32 位虚拟机为例(64 位不同),对象头(Header)占 8 个字节共 64 位(数组对象头与普通对象头不同,数组对象头 12 个字节共 96 位);实例数据(Instance Data)存储这对象的实际数据,因此大小与实际数据大小一致;对齐填充(Padding)是可选项,用于将内存对齐为 8 字节的整数倍。
普通对象内存组成
对象内存结构.drawio.png
数组对象内存组成
数组对象内存结构.drawio.png
如上两张图展示了 Java 对象内存结构,本文说的 Monitor 和这个有啥关系呢?其实对象头(Header)中的 Mark Word 就是用来存放 Monitor 对象的指针的,在一开始小捌就说了 Monitor 并不是 Java 对象,而是在操作系统中关联的“对象”,因此 Java 对象如果想要和 Monitor 进行关联,就必须在 Java 对象中记录 Monitor 的内存地址,这样才能通过 Java 对象找到这个 Monitor 嘛!
注:Klass Word 存放的是指向对象对应的 Class 对象的指针。
3、Mark Word
可想而知,想要深入探讨 Monitor 肯定避不开 Mark Word,这个时候暴躁的程序员小哥肯定不爽了,你特么不是说 Mark Word 中存放的 Monitor 的内存地址么,我知道了啊……别急,并不是想的那样的,这里稍微有一丢丢复杂,听我慢慢道来。Java 对象在不同的状态下,Mark Word 存储的值完全不同,尤其是在 JDK1.6 对锁优化之后,Mark Word 这 32bits 内存空间,真的是被 Java 大师们压榨到了极致。了解这个需要有一定的 JVM 和 synchronized 知识,如果不懂的话也无所谓,先了解就好,后面我们一起学习 synchronized 锁升级过程。
初始状态下 Java 对象头的 Mark Word 里默认存储的是对象的 hashcode、GC 分代年龄、是否偏向锁和锁标志位
Java 对象头 MarkWord 初始情况.drawio.png
偏向锁 Java 对象头的 Mark Word 存储结构如下
Java 对象头 MarkWord 整体情况-32 位偏向锁.drawio.png
轻量级锁 Java 对象头的 Mark Word 存储结构如下
Java 对象头 MarkWord 轻量级锁情况.drawio.png
重量级锁 Java 对象头的 Mark Word 存储结构如下
Java 对象头 MarkWord 重量级锁情况.drawio.png
4、Monitor
上面铺垫了这么多东西,其实就是为了讲述 Monitor 和 Java 对象头中 Mark Word 的关系,可以看出来只有在重量级锁的情况下 Java 对象头中 Mark Word 才会关联一个 Monitor 对象,那么 Monitor 又是个什么东西呢?我相信你一定很好奇吧!
Monitor 内部分由三部分组成分别是 Owner、EntryList、WaitSet;
Owner 用于记录当前 Monitor 的所属线程
EntryList 是一个链表结构,用于记录阻塞在当前锁对象上的线程
WaitSet 用于记录获取锁之后进入 Waiting 状态的线程
monitor 初始状态.drawio.png
当对象锁发生锁竞争时,在同一时刻只有一个线程能够获取到锁,其他线程会进入阻塞(BLOCKED)状态,此时这些被阻塞的线程就会进入 EntryList 中等待锁持有者释放锁后被唤醒,再次参与锁竞争(非公平)。如下所示,当 Thread1 持有锁时,Thread2、Thread3、Thread4 均无法获取锁从而进入阻塞队列,等待 Thread1 执行完同步代码块之后通知阻塞队列中等待的线程重新竞争锁,竞争成功的线程成为锁拥有者,失败的线程继续在阻塞队列中阻塞。
monitor+blocked.drawio.png
当对象获取到锁之后,由于某些资源并未准备完成,需要等待其他线程去准备资源,此时线程会通过 wait()/notify()等方法进入等待/通知模式,在这种情况下线程释放锁之后会进入 WaitSet,当其他线程准备好资源之后会通知 WaitSet 中等待的线程,WaitSet 中的线程会进入到 EntryList 中,重新参与锁竞争。
monitor+waiting.drawio.png
5、monitorente && monitorexit
知道了 Monitor 是什么,也知道了 Java 对象与 Monitor 之间的关系,但是还有一层疑问;程序在运行过程中是如何知道要给 Java 对象去关联一个 Monitor 呢?这就需要一点点 Java 字节码相关的知识了,Java 的源代码在编译器编译之后生成的 Class 文件中存储的是字节码指令,程序执行本质上是一条条指令按照既定顺序的流水线工作,那这只有一种可能了,编译器在编译成 Java 字节码时做了记号,这个记号就是 monitorente /monitorexit。
我们先来看一段简单的 synchronized 代码块:
然后在 IDEA 中借助 ByteCode Viewer 插件查看类的字节码指令(安装后如果插件未失效可以重启 IDEA)
在 Toolbar 中点击 View 选择 Show Bytecode With Jclasslib
此时找到 synchronized 关键字所在的 main 方法,选择字节码页签,即可看到 main 方法的字节码指令了。
字节码指令如下:
前三行字节码分别表示:
getstatic 获取静态锁对象 LOCK
dup 复制一份 LOCK 对象的引用,用于锁退出
astore_1 复制的引用存入临时变量 1 中 3 dup reference -> slot 1
synchronized 临界区七行字节码分别表示:
monitorenter 关联一个操作系统 Monitor 对象,替换 LOCK 对象的 Mark Word 为 Monitor 地址
getstatic 获取静态变量 count
iadd count++操作
putstatic 赋值++操作后的 count
aload_1 获取 LOCK 对象的引用,上面 dup 复制后 astore_1 指令存储的那份地址
monitorexit 还原 Mark Word,将 Monitor 对象指针替换为 monitorenter 加锁时保存在 Monitor 对象中的数据,如 hashcode、分代年龄等数据;同时唤醒等待在 EntryList 中阻塞等待的线程。
最后几条指令涉及到异常产生时 JVM 释放锁的处理和方法返回,首先要看一个异常表:
异常表中有两行记录:
第一行表示:6 -> 16 行字节码中发生了异常,会跳转到 19 行,这就是 synchronized 加锁的代码区域,如果加锁中出现异常,JVM 会处理异常,正确释放锁
第二行表示:19 -> 22 行字节码中发生了异常,会跳转到 19 行,
未发生异常情况:
goto 24 (+8) 没有产生异常,直接执行 24 行指令
return 方法运行结束
发生异常情况:
astore_2 将异常对象存储到临时变量中 e -> slot 2
aload_1 加载 LOCK 锁对象引用地址
monitorexit 还原 Mark Word,将 Monitor 对象指针替换为 monitorenter 加锁时保存在 Monitor 对象中的数据,如 hashcode、分代年龄等数据;同时唤醒等待在 EntryList 中阻塞等待的线程。
aload_2 加载异常对象
athrow 抛出异常对象
return 方法运行结束
版权声明: 本文为 InfoQ 作者【李子捌】的原创文章。
原文链接:【http://xie.infoq.cn/article/5599464906d3896289839e9a3】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论