饭宝 发表于 2024-5-19 16:56:31

synchronized原理-字节码分析、对象内存布局、锁升级过程、Monitor

本文分析的问题:

[*]synchronized 字节码文件分析之 monitorenter、monitorexit 指令
[*]为什么任何一个Java对象都可以成为一把锁?
[*]对象的内存布局
[*]锁升级过程
[*]Monitor 是什么、源码查看
字节码分析

synchronized的3种利用方式


[*]作用于实例方法,对对象加锁
[*]作用于静态方法,对类加锁
[*]作用于代码块,对 () 里的对象加锁
先说结论:通过 monitorenter、monitorexit 指令来做
synchronized 关键字底层原理属于 JVM 层面的东西。
代码块

monitorenter、monitorexit 指令来做
代码:
public void m1(){
    synchronized (this){

    }
}编译后利用 javap -v xxx.class 下令查看:
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240507173353765-859048278.png

[*]一般情况下就是 1 个 monitorenter 对应 2 个 monitorexit

[*]正常处理正常开释时有一个 monitorexit,考虑到有异常时,锁应该也要被开释,所以也会有一个 monitorexit

[*]极度情况下:如果方法里抛出异常了,就只会有一个 monitorexit 指令
包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块正常执行以及出现异常
的这两种情况下都能被正确开释
异常情况下:只有一个 monitorexit 指令
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240507174128987-822070704.png
实例方法

ACC_SYNCHRONIZED 这个标识来做
代码:
public synchronized void m1(){

}结果:在方法下没有那两个指令,取而代之的是 ACC_SYNCHRONIZED 这个标识。该标识指明了该方法是一个同步方法
JVM通过该 ACC_SYNCHRONIZED 标识来辨别一个方法是否是一个同步方法,从而执行相应的同步调用
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240507174505768-1611426143.png
静态方法

ACC_SYNCHRONIZED这个标识来做
代码:
public static synchronized void m1(){
   
}结果:可以看到照旧通过 ACC_SYNCHRONIZED 这个标识来做。
ACC_STATIC 这个标识是用来区分实例方法和静态方法的,就算不加 synchronized 也会有。
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240507175138940-484421749.png
对象内存布局

为了可以更加直观的看到对象布局,我们可以借助 openjdk 提供的 JOL 工具进行分析。
JOL分析工具

JOL(Java 对象布局)用于分析对象在JVM的巨细和分布
官网:https://openjdk.org/projects/code-tools/jol/

<dependency>
      <groupId>org.openjdk.jol</groupId>
      <artifactId>jol-cli</artifactId>
      <version>0.14</version>
</dependency>对象内存布局

可以看到统共分为三部分:对象头、实例数据、对齐填充
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510110619390-1534005548.png
对象头

在64位体系中,Mark Word 占了 8 个字节,类型指针占了 8 个字节,一共是 16 个字节
Mark Word

MarkWord 中存了一些信息:hashCode的值、gc相关、锁相关
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510110656029-1732480745.png
具体布局

https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240507185608265-1150891758.png

[*]unused:未利用的位置
[*]hashcode:hashcode 的值
[*]age:GC年事(4位最大只能表示15)
[*]biased_lock:是否是偏向锁(0不是;1是)
[*]thread:线程id
[*]epoch:偏向时间戳
[*]ptr_to_lock_record:存储指向栈帧中的锁记录(LockRecord)的指针
[*]ptr_to_heavyweight_monitor:指向重量级锁的指针(也就是指向 ObjectMonitor 的指针)
[*]锁标志位:01 代表有锁;00 代表偏向锁;10 代表轻量级锁;11 代表重量级锁
markOop.hpp 中的 C++ 源码查看:
//32 bits:32位的
//--------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//64 bits:64位的
//--------
//unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//size:64 ----------------------------------------------------->| (CMS free block)
//
//unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)源码地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/objectMonitor.hpp
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240507182152174-952087472.png
Class Pointer

对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象哪个类的实例
我们怎么知道创建的这个对象是什么类型的,就通过这个指针指向方法区的类元信息(kclass pointer)。
可能会进行指针压缩。
实例数据

存放类的属性(Field)数据信息,包括父类的属性信息
对齐填充

虚拟机要求对象起始地址必须是 8 字节的整数倍,所以填充数据不是必须存在的,仅仅是为了字节对齐,这部分内存按 8 字节增补对齐
比如:

[*]只有一个类的话,类内里是空的那就是 16 字节 = MarkWord + 类型指针(不考虑指针压缩的情况下)。这时不必要对齐填充来对齐,由于 16 字节本身就是 8 的整数倍。
[*]但假如此时有了属性,int = 4字节,boolean = 1 字节,加起来 = 16+4+1 = 21 字节,这时就不是 8 的整数 倍的,这时就必要对齐填充来补齐了
class Person {
    int age;
    boolean isFlag;
}利用JOL工具证明

简朴利用:
public static void main(String[] args) {
    // 获取JVM详细信息
    System.out.println(VM.current().details());
    // 对象头大小 开启指针压缩是12=MarkWord(8)+ClassPointer(4),没开启是16=MarkWord(8)+ClassPointer(8)
    System.out.println(VM.current().objectHeaderSize());
    // 对齐填充   为什么都是8的倍数?
    System.out.println(VM.current().objectAlignment());
}验证对象内存布局

代码:
public static void main(String[] args) {
    Object obj = new Object();
    System.out.println(obj + " 十六进制哈希:" + Integer.toHexString(obj.hashCode()));
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}结果:
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240507184120329-1301755193.png

[*]Object对象,统共占16字节
[*]对象头占 12 个字节,此中:mark-word 占 8 字节、Klass Point 占 4 字节
[*]最后 4 字节,用于数据填充对齐
指针压缩

不是说类型指针是8字节吗,到这里怎么变为4字节了?
那是由于被 指针压缩 了(开启后性能会更好)
下令查看: java -XX:+PrintCommandLineFlags -version
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240507185239059-338182606.png
这个参数就是压缩指针的参数:-XX:+UseCompressedClassPointers +代表开启,- 代表关闭
关闭后,运行后,再次查看:-XX:-UseCompressedClassPointers
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240507185443406-1998191209.png
这次就没有对齐填充了。
锁升级过程

无锁 ---> 偏向锁 ---> 轻量级锁   -> 重量级锁
出现的背景

之前 synchronized 是重量级锁,依靠 Monitor 机制实现。
Monitor 是依靠于底层的操作体系的 Mutex Lock(互斥锁)来实现的线程同步。这种机制必要用户态和内核态之
间来切换。但Mutex是体系方法,由于权限的关系,应用程序调用体系方法时必要切换到内核态来执行。
所以为了是减少用户态和内核态之间的切换。由于这两种状态之间的切换的开销比较高。
先来一个 狗 的类,用来创建对象
class Dog {

}无锁

创建一个对象,没有一个线程来占有它,这时就是无锁
无锁时 Mark Word 的布局:
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |       Normal              |测试代码:
public static void main(String[] args) {
    Dog dog = new Dog();
    dog.hashCode();
    System.out.println(ClassLayout.parseInstance(dog).toPrintable());

    System.out.println("======================================================");
    System.out.println(dog);
    System.out.println("十六进制:" + Integer.toHexString(dog.hashCode()));
    System.out.println("二进制:" + Integer.toBinaryString(dog.hashCode()));
}对照上面的布局来看:从后往前按照 Mark Word 布局来看。每8bit从前今后看。
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510112046112-1816158184.png
偏向锁

当第一个线程来获取到它时(没有竞争/一次就竞争成功),这时它就是偏向锁,只偏向于这一个线程(CAS)
偏向锁、轻量级锁的 Mark Word 的布局:
|--------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2   | unused:1 | age:4 | biased_lock:1 | 01    |      Biased      |
|--------------------------------------------------------------------|--------------------|
|             ptr_to_lock_record:62                        | 00    | Lightweight Locked |
|--------------------------------------------------------------------|--------------------|测试代码:
public static void main(String[] args) {
    Dog dog = new Dog();

    synchronized (dog){
      System.out.println(ClassLayout.parseInstance(dog).toPrintable());
    }
}结果:
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510113007078-101327836.png
这里的锁为什么直接是轻量级锁呢?

由于偏向锁默认是延迟开启的,所以进入了轻量级锁状态。
利用 java -XX:+PrintFlagsInitial | grep BiasedLock 下令在 Git Bash 下执行:
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510113710536-456025053.png
所以必要添加参数 -XX:BiasedLockingStartupDelay=0,让其在程序启动时立刻启动。或者让程序睡了 5 秒后再执行。
添加参数/睡5秒后执行,发现偏向锁出现了:
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510114125941-1054517146.png
1 代表是偏向锁,01 代表有锁
轻量级锁

其实就是自旋锁(底层是CAS)
其它线程来竞争锁,并且竞争失败,会到一个全局安全点来把这个锁升级为轻量级锁
轻量级锁的 Mark Word 布局:
|             ptr_to_lock_record:62                        | 00    | Lightweight Locked |测试代码:通过调用 hashCode() 来得到轻量级锁(由于偏向锁里没有地方存储 hashCode 的值)
public static void main(String[] args) throws InterruptedException {
    Dog dog = new Dog();
    dog.hashCode();
    synchronized (dog) {
      System.out.println(ClassLayout.parseInstance(dog).toPrintable());
    }
}结果:
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510115140350-725846905.png
自旋次数

JDK6之前

[*]默认启用,默认情况下自旋的次数是10次,或者自旋线程数高出CPU核数一半
JDK6之后 自适应自旋锁

[*]线程如果自旋成功了,那下次自旋的最大次数会增加,由于JVM认为既然上次成功了,那么这一次也很大概率会成功。
[*]反之,如果很少会自旋成功,那么下次会减少自旋的次数其至不自旋,制止CPU空转。
[*]自适应意味着自旋的次数不是固定不变的,而是根据:同一个锁上一次自旋的时间。拥有锁线程的状态来决定。
重量级锁

自旋到肯定次数后,还没获取到锁,会将其升级为重量级锁。那就是壅闭了,用户态和内核态之间的切换
基于 Monitor 的实现,monitorenter 和 monitorexit 指令来实现
重量级锁的 Mark Word 的布局:
|                 ptr_to_heavyweight_monitor:62                  | 10           | Heavyweight Locked |测试代码:
结果:
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510115638114-1315329642.png
锁升级后,hash值去哪?


[*]无锁:就存在 Mark Word 里
[*]偏向锁:没有地方存 hash 值了

[*]如果在 synchronized 前调用了 hashCOde() ,此时偏向锁会升级为轻量级锁
[*]如果在 synchronized 中调用了 hashCode() ,此时偏向锁会升级为重量级锁

[*]轻量级锁:栈帧中的锁记录(Lock Record)里
[*]重量级锁:Mark Word 保存重量级锁的指针,底层实现 ObjectMonitor 类里有字段记录加锁状态的 Mark Word 信息
必须说的 monitor

在 HotSpot 虚拟机中,monitor 是由 C++ 中的 ObjectMonitor 实现。
什么是 Monitor

Monitor 是管程,是同步监视器,是一种同步机制。为了保证数据的安全性
Monitor 有什么用

提供了一种互斥机制。限制同一时刻只能有一个线程进入 Monitor 的临界区,保护数据安全。
用于保护共享数据,制止多线程并发访问导致数据不一致。
synchronized 的重量级锁就是用 Monitor 来实现的
Monitor 的源码分析

每个 Java 对象都自带了一个 monitor 对象,所以每个 Java 对象都可以成为锁。
源码:Java 中的每个对象都继承自 Object 类,而每个 Java 对象在 JVM 内部都有一个 C++ 对象 oopDesc 与其对应,而对应的 oopDesc 内有一个属性是 markOopDesc 对象(这个对象就是 Java 里的 Mark Word),这个 markOopDesc 内有一个 monitor() 方法返回了 ObjectMonitor 对象(hotspot中,这个对象实现了 monitor )
翻译过来就是:Java 中的每个对象都继承自 Object 类,oopDesc 是每个 Java 对象的顶层父类,这个父类内有个属性是 markOopDesc 对象,也就是对象头。这个对象头是存储锁的地方,内里有一个 ObjectMonitor。
每一个被锁住的对象又都会和 Monitor 关联起来,通过对象头里的指针。
oopDesc

这个类是每个 Java 对象的基类。每个 Java 对象在虚拟机内部都会继承这个 C++ 对象。
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510160202058-1211437638.png
源码地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/oop.hpp
markOopDesc

这个类也是 oopDesc 的子类。这个类就是 Mark Word 对象头。
内里有一个 monitor() 方法返回了 ObjectMonitor 对象。
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510160439028-2042333896.png
monitor():
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510160457186-1013202948.png
源码地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/markOop.hpp
ObjectMonitor

在 hotspot 虚拟机中,ObjectMonitor 是 Monitor 的实现。
https://img2024.cnblogs.com/blog/2655715/202405/2655715-20240510161426165-1385717388.png
源码地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/objectMonitor.hpp
为什么任何一个Java对象都可以成为一把锁

Java 中的每个对象都继承自 Object 类,虚拟机源码中 oopDesc 是每个 Java 对象的顶层父类,这个父类内有个属性是 markOopDesc 对象,也就是对象头。这个对象头是存储锁的地方,内里有一个 ObjectMonitor 。而 monitor 的实现就是 ObjectMonitor 对象监视器。
参考资料

大部分参考:

[*]https://www.bilibili.com/video/BV1ar4y1x727/
Mark Word 代码块里的布局参考视频的笔记(图为自画):

[*]https://www.bilibili.com/video/BV16J411h7Rd
少部分参考:

[*]https://www.cnblogs.com/wuzhenzhao/p/10250801.html
[*]https://segmentfault.com/a/1190000037645482
[*]https://www.cnblogs.com/mic112/p/16388456.html
oopDesc、markOopDesc 和 Java 对象之间的关系参考:

[*]https://www.cnblogs.com/mazhimazhi/p/13289686.html
[*]https://blog.csdn.net/qq_31865983/article/details/99173570
openjdk 源码位置参考:内里也有 ObjectMonitor 原理

[*]https://www.cnblogs.com/webor2006/p/11442551.html

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: synchronized原理-字节码分析、对象内存布局、锁升级过程、Monitor