写这篇文章的目的源自于看并发编程艺术的时候, 书上说 synchronized 关键字的锁是放在对象头里的. 索性带着这个问题把这个关键字相关的内容梳理一下.
什么是 synchronized 关键字?
synchronized 关键字是 Java 并发编程中非常重要的一个工具. 它的主要目的是在同一时间只能允许一个线程去访问一段特定的代码, 从而保护一些变量或者数据不会被其他线程所修改. 这感觉就像一群人抢着去上厕所, 而你运气好抢到了, 啪把门一锁, 厕所的那一平方天地在那段时间就只属于你, 即使门外的人排队都排到了地中海(此处排除有人暴力拆厕所的情况).
使用 synchronized 关键字后, 都以对象作为锁, 一般有以下三种实现形式.
对于同步方法, 锁是当前实例对象.
- public synchronized void test1() {
- i++;
- }
复制代码
对于静态同步方法, 锁是当前类的 Class 对象.
- public static synchronized void test2() {
- i++;
- }
复制代码
对于同步代码块, 锁是 synchronized 关键字括号内的对象.
- public void test2() {
- synchronized(this){
- i++;
- }
- }
复制代码
什么是对象头?
在 JVM 中, 对象在内存中的布局分为 3 块: 对象头, 实例数据和对齐填充. 先说说实例数据, 它存储着对象真正的有效信息(程序代码中定义的各种类型的字段内容), 无论是从父类继承来的字段还是子类中定义的. 然后再是对齐填充, 它并没有什么特殊的含义, 仅仅只是起占位符的作用. 原因呢是因为 JVM 要求对象的起始地址必须是 8 个字节的整数倍(对象的大小必须是 8 个字节的整数倍). 而对象头已经是 8 的整数倍了, 如果实例数据没有对齐就需要对齐填充来补全.
重点来了, synchronized 使用的锁都放在对象头里, JVM 中用 2 个字节来储存对象头(如果对象是数组则分配 3 个字节, 多的一个字节用于存储数组的长度). 而对象头包含两部分信息, 分别为 Mark Word 和类型指针. Mark Word 主要用于储存对象自身的运行时数据, 例如对象的 hashCode,GC 分代年龄, 锁状态标志, 线程持有的锁, 偏向线程的 ID, 偏向时间戳等. 而类型指针用于标识 JVM 通过这个指针来确定这个对象是哪个类的实例.
由于对象需要储存的运行时数据过多, Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储更多的信息. 对象在不同的状态下, Mark Word 会存储不同的内容(只放 32 位虚拟机的图表).
锁状态 | 25bit | 4bit | 1bit(是否是偏向锁) | 2bit(锁的标志位) |
---|---|---|---|---|
无锁状态 | 对象的 hashcode | 对象分代年龄 | 0 | 01 |
偏向锁 | 线程 ID + epoch | 对象分代年龄 | 1 | 01 |
锁状态 | 30bit | 2bit(锁的标志位) |
---|---|---|
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁(synchronized) | 指向互斥量 (重量级锁) 的指针 | 10 |
GC 标志 | 空 | 11 |
monitor 对象
这边也就主要分析一下重量级锁, 标志位为 10, 指针指向 monitor 对象的起始地址, 而每一个对象都存在着一个 monitor 与之关联. 在 Hot Spot 中, monitor 是由 ObjectMonitor 类来实现的. 先来看一下 ObjectMonitor 的数据结构.
- ObjectMonitor() {
- _header = NULL;//markOop 对象头
- _count = 0;
- _waiters = 0,// 等待线程数
- _recursions = 0;// 重入次数
- _object = NULL;// 监视器锁寄生的对象. 锁不是平白出现的, 而是寄托存储于对象中.
- _owner = NULL;// 指向获得 ObjectMonitor 对象的线程或基础锁
- _WaitSet = NULL;// 处于 wait 状态的线程, 会被加入到 waitSet;
- _WaitSetLock = 0;
- _Responsible = NULL;
- _succ = NULL;
- _cxq = NULL;
- FreeNext = NULL;
- _EntryList = NULL;// 处于等待锁 block 状态的线程, 会被加入到 entryList;
- _SpinFreq = 0;
- _SpinClock = 0;
- OwnerIsThread = 0;
- _previous_owner_tid = 0;// 监视器前一个拥有者线程的 ID
- }
复制代码
其中有两个队列 _EntryList 和 _WaitSet, 它们是用来保存 ObjectMonitor 对象列表, _owner 指向持有 ObjectMonitor 对象的线程. 当多个线程访问同步代码时, 线程会进入_EntryList 区, 当线程获取对象的 monitor 后 (对于线程获得锁的优先级, 还有待考究) 进入 _Owner 区并且将 _owner 指向获得锁的线程(monitor 对象被线程持有), _count++, 其他线程则继续在 _EntryList 区等待. 若线程调用 wait 方法, 则该线程进入 _WaitSet 区等待被唤醒. 线程执行完后释放 monitor 锁并且对 ObjectMonitor 中的值进行复位. 上面说到 synchronized 使用的锁都放在对象头里, 大概指的就是 Mark Word 中指向互斥量的指针指向的 monitor 对象内存地址了. 由以上可知为什么 Java 中每一个对象都可以作为锁对象了.
monitor 指令
JVM 通过进入和退出 monitor 对象来实现方法和代码块的同步, 但是实现细节不一. 可以使用 javap -verbose XXX.class 命令看代码被编译成字节码后是如何实现同步的.
- Code:
- stack=3, locals=3, args_size=1
- 0: aload_0
- 1: dup
- 2: astore_1
- 3: monitorenter
- 4: aload_0
- 5: dup
- 6: getfield #2 // Field i:I
- 9: iconst_1
- 10: iadd
- 11: putfield #2 // Field i:I
- 14: aload_1
- 15: monitorexit
- 16: goto 24
- 19: astore_2
- 20: aload_1
- 21: monitorexit
- 22: aload_2
- 23: athrow
- 24: return
复制代码
将含有 synchronized 代码块的代码反编译后, 可以看到 monitorenter 和 monitorexit 两条指令. monitorenter 处于代码块开始的位置, 而 monitorenter 与之匹配在代码结束或者异常处. 任何对象都有个 monitor 与之对应, 当 monitor 被持有后, 它就处于锁定状态. 线程执行到 monitorenter 指令时, 会尝试去获得对象的锁(即 monitor 的所有权).
- public synchronized void test1();
- descriptor: ()V
- flags: ACC_PUBLIC, ACC_SYNCHRONIZED
- Code:
- stack=3, locals=1, args_size=1
- 0: aload_0
- 1: dup
- 2: getfield #2 // Field i:I
- 5: iconst_1
- 6: iadd
- 7: putfield #2 // Field i:I
- 10: return
- LineNumberTable:
- line 6: 0
- line 7: 10
复制代码
方法的同步是隐式的, JVM 中使用 method_info 型数据中方法访问标志的 ACC_SYNCHRONIZED 做区分. 当线程执行代码时若发现方法的访问标志中有 ACC_SYNCHRONIZED, 则当前线程持有 monitor 对象. 接下来执行的细节与同步代码块无异. 以上便是 synchronized 关键字修饰的同步方法和同步代码块实现的基本原理了.
synchronized 重入锁
第一次听说重入锁是 ReentrantLock, 后来知道 synchronized 关键字支持隐式重入. 顾名思义, 重入锁就是支持重进入的锁, 支持一个线程可以对资源重复加锁. 对于一个 synchronized 加持的代码块, 其他线程试图访问该代码块时, 线程会阻塞. 若是持有锁的线程再次请求自己持有的锁时, 则能成功获得.
- public synchronized void test1() {
- i++;
- }
- public void test2() {
- synchronized(this){
- test1();
- }
- }
复制代码
当前线程获得锁后, 通过 cas 将_owner 指向当前线程, 若当前线程再次请求获得锁, _owner 指向不变, 执行_recursions++ 记录重入的次数, 若尝试获得锁失败, 则在_EntryList 区域等待. 这种感觉有点像盗梦空间里的梦中梦, 可以重复的进入自己的梦里, 若想正常的醒过来, 只能按原路返回(_recursions--).
马后炮
我买的书上没有关于 synchronized 关键字比较底层的解释, 只能站在网上其他博主的肩膀上, 通过他们文章中对于底层 C++ 代码的解释大致的了解了一下其原理.
最后还是那句话, 学习的最终目的并不是为了面试, 面试只是一个激励学习的动机. 把握面试题, 享受学习新知识的乐趣.
参考:
只会一点 Java -> jdk 源码剖析二: 对象内存布局, synchronized 终极原理 https://www.cnblogs.com/dennyzhangdd/p/6734638.html
深入理解 Java 虚拟机
并发编程的艺术
来源: https://juejin.im/post/5b7903b96fb9a019eb43ae03