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

简介: 本文分析的问题:1. synchronized 字节码文件分析之 monitorenter、monitorexit 指令 2. 为什么任何一个Java对象都可以成为一把锁? 3. 对象的内存结构 4. 锁升级过程 (无锁、偏向锁、轻量级锁、重量级锁)5. Monitor 是什么、源码查看(hotspot虚拟机源码)6. JOL工具使用

本文分析的问题:

  1. synchronized 字节码文件分析之 monitorenter、monitorexit 指令
  2. 为什么任何一个Java对象都可以成为一把锁?
  3. 对象的内存结构
  4. 锁升级过程
  5. Monitor 是什么、源码查看

synchronized是基于monitor实现的,线程在获取锁的时候,实际上是获取了一个 monitor 对象,然后用它来进行加锁的。

字节码分析

synchronized的3种使用方式

  1. 作用于实例方法,对对象加锁
  2. 作用于静态方法,对类加锁
  3. 作用于代码块,对 () 里的对象加锁

先说结论:通过 monitorenter、monitorexit 指令来做

synchronized 关键字底层原理属于 JVM 层面的东西。

代码块

monitorenter、monitorexit 指令来做

代码:

public void m1(){
    synchronized (this){
    }
}

编译后使用 javap -v xxx.class 命令查看:

  • 一般情况下就是 1 个 monitorenter 对应 2 个 monitorexit
  • 正常处理正常释放时有一个 monitorexit,考虑到有异常时,锁应该也要被释放,所以也会有一个 monitorexit
  • 极端情况下:如果方法里抛出异常了,就只会有一个 monitorexit 指令

包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块正常执行以及出现异常

的这两种情况下都能被正确释放

异常情况下:只有一个 monitorexit 指令

实例方法

ACC_SYNCHRONIZED 这个标识来做

代码:

public synchronized void m1(){
}

结果:在方法下没有那两个指令,取而代之的是 ACC_SYNCHRONIZED 这个标识。该标识指明了该方法是一个同步方法

JVM通过该 ACC_SYNCHRONIZED 标识来辨别一个方法是否是一个同步方法,从而执行相应的同步调用

静态方法

ACC_SYNCHRONIZED这个标识来做

代码:

public static synchronized void m1(){
    
}

结果:可以看到还是通过 ACC_SYNCHRONIZED 这个标识来做。

ACC_STATIC 这个标识是用来区分实例方法和静态方法的,就算不加 synchronized 也会有。

对象内存结构

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

JOL分析工具

JOL(Java 对象布局)用于分析对象在JVM的大小和分布

官网:https://openjdk.org/projects/code-tools/jol/

<!-- 
      https://mvnrepository.com/artifact/org.openjdk.jol/jol-cli
      定位:分析对象在JVM的大小和分布 
  -->
  <dependency>
      <groupId>org.openjdk.jol</groupId>
      <artifactId>jol-cli</artifactId>
      <version>0.14</version>
  </dependency>

对象内存结构

可以看到总共分为三部分:对象头、实例数据、对齐填充

对象头

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

Mark Word

MarkWord 中存了一些信息:hashCode的值、gc相关、锁相关

具体结构

  • 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 代表有锁(通过偏向锁标志位 1/0 表示是否是偏向锁);00 代表轻量级锁;10 代表重量级锁

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

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());
}

结果:

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

不是说类型指针是8字节吗,到这里怎么变为4字节了?

那是因为被 指针压缩 了(开启后性能会更好)

命令查看: java -XX:+PrintCommandLineFlags -version

这个参数就是压缩指针的参数:-XX:+UseCompressedClassPointers +代表开启,- 代表关闭

关闭后,运行后,再次查看:-XX:-UseCompressedClassPointers

这次就没有对齐填充了。

锁升级过程

无锁 ---> 偏向锁 ---> 轻量级锁   -> 重量级锁

出现的背景

之前 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从前往后看。

偏向锁

当第一个线程来获取到它时(没有竞争/一次就竞争成功),这时它就是偏向锁,只偏向于这一个线程(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());
    }
}

结果:

这里的锁为什么直接是轻量级锁呢?

因为偏向锁默认是延迟开启的,所以进入了轻量级锁状态。

使用 java -XX:+PrintFlagsInitial | grep BiasedLock 命令在 Git Bash 下执行:

所以需要添加参数 -XX:BiasedLockingStartupDelay=0,让其在程序启动时立刻启动。或者让程序睡了 5 秒后再执行。

添加参数/睡5秒后执行,发现偏向锁出现了:

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());
    }
}

结果:

自旋次数

JDK6之前

  • 默认启用,默认情况下自旋的次数是10次,或者自旋线程数超过CPU核数一半

JDK6之后 自适应自旋锁

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

重量级锁

自旋到一定次数后,还没获取到锁,会将其升级为重量级锁。那就是阻塞了,用户态和内核态之间的切换

基于 Monitor 的实现,monitorenter 和 monitorexit 指令来实现

重量级锁的 Mark Word 的结构:

|           ptr_to_heavyweight_monitor:62                    | 10    | Heavyweight Locked |

测试代码:

public static void main(String[] args) throws InterruptedException {
    Dog dog = new Dog();

    new Thread(()->{
        synchronized (dog) {
            System.out.println(ClassLayout.parseInstance(dog).toPrintable());
        }
    },"t1").start();

    new Thread(()->{
        synchronized (dog) {
            System.out.println(ClassLayout.parseInstance(dog).toPrintable());
        }
    },"t2").start();
}

结果:

锁升级后,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 的源码分析

线程在获取锁的时候,实际上是获取了一个 monitor 对象

每个 Java 对象都自带了一个 monitor 对象,所以每个 Java 对象都可以成为锁。

源码:Java 中的每个对象都继承自 Object 类,而每个 Java 对象在 JVM 内部都有一个 C++ 对象 oopDesc 与其对应,而对应的 oopDesc 内有一个属性是 markOopDesc 对象(这个对象就是 Java 里的 Mark Word),这个 markOopDesc 内有一个 monitor() 方法返回了 ObjectMonitor 对象(hotspot中,这个对象实现了 monitor )

oopDesc

这个类是每个 Java 对象的基类。每个 Java 对象在虚拟机内部都会继承这个 C++ 对象。

源码地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/oop.hpp

markOopDesc

这个类也是 oopDesc 的子类。这个类就是 Mark Word 对象头。

里面有一个 monitor() 方法返回了 ObjectMonitor 对象。

monitor():

源码地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/markOop.hpp

ObjectMonitor

在 hotspot 虚拟机中,ObjectMonitor 是 Monitor 的实现。

源码地址:https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/objectMonitor.hpp

为什么任何一个Java对象都可以成为一把锁

synchronized是基于monitor实现的,线程在获取锁的时候,实际上是获取了一个 monitor 对象。而Java 中的每个对象都继承自 Object 类,虚拟机源码中 oopDesc 是每个 Java 对象的顶层父类,这个父类内有个属性是 markOopDesc 对象,也就是对象头。这个对象头是存储锁的地方,里面有一个 ObjectMonitor 。而 monitor 的实现就是 ObjectMonitor 对象监视器。

每一个被锁住的对象又都会和 Monitor 关联起来,通过对象头里的指针。

参考资料

大部分参考:

  1. https://www.bilibili.com/video/BV1ar4y1x727/

Mark Word 代码块里的结构参考视频的笔记(图为自画):

  1. https://www.bilibili.com/video/BV16J411h7Rd

少部分参考:

  1. https://www.cnblogs.com/wuzhenzhao/p/10250801.html
  2. https://segmentfault.com/a/1190000037645482
  3. https://www.cnblogs.com/mic112/p/16388456.html

oopDesc、markOopDesc 和 Java 对象之间的关系参考:

  1. https://www.cnblogs.com/mazhimazhi/p/13289686.html
  2. https://blog.csdn.net/qq_31865983/article/details/99173570

为什么每一个Java对象都可以成为一把锁?

openjdk 源码位置参考:里面也有 ObjectMonitor 原理

  1. https://www.cnblogs.com/webor2006/p/11442551.html
相关文章
|
2月前
|
Web App开发 监控 JavaScript
监控和分析 JavaScript 内存使用情况
【10月更文挑战第30天】通过使用上述的浏览器开发者工具、性能分析工具和内存泄漏检测工具,可以有效地监控和分析JavaScript内存使用情况,及时发现和解决内存泄漏、过度内存消耗等问题,从而提高JavaScript应用程序的性能和稳定性。在实际开发中,可以根据具体的需求和场景选择合适的工具和方法来进行内存监控和分析。
|
2月前
|
算法 JavaScript 前端开发
新生代和老生代内存划分的原理是什么?
【10月更文挑战第29天】新生代和老生代内存划分是JavaScript引擎为了更高效地管理内存、提高垃圾回收效率而采用的一种重要策略,它充分考虑了不同类型对象的生命周期和内存使用特点,通过不同的垃圾回收算法和晋升机制,实现了对内存的有效管理和优化。
|
1月前
|
缓存 监控 算法
Python内存管理:掌握对象的生命周期与垃圾回收机制####
本文深入探讨了Python中的内存管理机制,特别是对象的生命周期和垃圾回收过程。通过理解引用计数、标记-清除及分代收集等核心概念,帮助开发者优化程序性能,避免内存泄漏。 ####
47 3
|
2月前
|
并行计算 算法 测试技术
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面,旨在通过综合策略提升程序性能,满足实际需求。
67 1
|
2月前
|
JavaScript
如何使用内存快照分析工具来分析Node.js应用的内存问题?
需要注意的是,不同的内存快照分析工具可能具有不同的功能和操作方式,在使用时需要根据具体工具的说明和特点进行灵活运用。
53 3
|
2月前
|
Java
JVM运行时数据区(内存结构)
1)虚拟机栈:每次调用方法都会在虚拟机栈中产生一个栈帧,每个栈帧中都有方法的参数、局部变量、方法出口等信息,方法执行完毕后释放栈帧 (2)本地方法栈:为native修饰的本地方法提供的空间,在HotSpot中与虚拟机合二为一 (3)程序计数器:保存指令执行的地址,方便线程切回后能继续执行代码
27 3
|
2月前
|
开发框架 监控 .NET
【Azure App Service】部署在App Service上的.NET应用内存消耗不能超过2GB的情况分析
x64 dotnet runtime is not installed on the app service by default. Since we had the app service running in x64, it was proxying the request to a 32 bit dotnet process which was throwing an OutOfMemoryException with requests >100MB. It worked on the IaaS servers because we had the x64 runtime install
|
2月前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
160 4
|
2月前
|
Web App开发 JavaScript 前端开发
使用 Chrome 浏览器的内存分析工具来检测 JavaScript 中的内存泄漏
【10月更文挑战第25天】利用 Chrome 浏览器的内存分析工具,可以较为准确地检测 JavaScript 中的内存泄漏问题,并帮助我们找出潜在的泄漏点,以便采取相应的解决措施。
370 9
|
3月前
|
存储 算法 Java
聊聊jvm的内存结构, 以及各种结构的作用
【10月更文挑战第27天】JVM(Java虚拟机)的内存结构主要包括程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和运行时常量池。各部分协同工作,为Java程序提供高效稳定的内存管理和运行环境,确保程序的正常执行、数据存储和资源利用。
70 10

热门文章

最新文章

相关实验场景

更多