把学习当成一种习惯
选择往往大于努力,越努力越幸运

  本文是基于64位JVM的Java对象头.
  在学习并发编程知识Synchronized时,我们总是难以理解其实现原理,因为偏向锁、轻量级锁、重量级锁都涉及到对象头,所以了解Java对象头是我们深入了解Synchronized的前提条件.

Java对象结构

  编译器把我们完成的Java类编译成 .class 文件,当类加载器将 class 文件载入到JVM时,会生成一个相对应的 Klass 类型的对象(C++),即类的描述元数据,存储在方法区中.
  每当我们 new 创建对象时,都是根据类的描述元数据 Klass 来创建对象oop,并且存储在堆中,存储在堆里的对象oop的结构图如下 :

  • 填充区域 : 填充区域起到了占位的作用,HotSpot JVM 虚拟机要求被管理的对象的大小都是8字节的整数倍,所以填充区域并不是必须的.
  • 实例数据 : 描述真实的对象数据,包括了对象中的所有字段属性信息,它们可能是某个其它对象的地址引用,也可能是基础数据的数据值.
  • 对象头 : Java对象结构中的对象头是实现锁机制的关键,是本文核心重点讨论对象.

Java对象头

  • Mark Word(核心) : 记录了这个对象当前锁机制的记录信息
  • Klass : 这是一个指针区域,这个指针区域指向元数据区中(JDK1.8)该对象所代表的类的描述元数据,这样JVM才知道这个对象是哪一个类的实例
  • ArrayLength : 这个部分是基于数组对象,而对于普通对象,该部分的是没有占位的.

在64位机器环境下,普通对象的对象头大小位128bit,普通对象的对象头如下图 :

【PS : 每个Java对象都有相同的组成部分,那就是对象头,对象头是实现锁机制的关键!!!】

Mark Word

  • Mark Word里默认存储锁状态信息是无锁状态的,即存储的是HashCode、分代年龄、是否偏向锁、标记记位等信息,64bit默认存储结构如下图 :
  • 在运行期间,Mark Word里存储的数据会随着是否偏向锁、锁标志位的变化而变化,如下图五种状态中其中一种,即同一时刻MarkWord只能表示其中一种锁状态.
  • 锁标志位状态只有四种,如下图 :
  • 无锁和偏向锁的锁标志位都是01,而Mark Word有五种锁的状态,那么Mark Word是如何区分五种状态的,如下图,Mark Word的五种状态都有共同的信息字段 : 是否偏向锁(biased_lock)、锁标志位(lock),所以,锁标志位和是否偏向锁 这两个信息字段可以表示五种Mark Word状态.

【PS : Mark Word结构的设计可以在有限的空间内灵活表示五种状态,起到了节约内存空间的作用】

存储线程栈中的Lock Record

  Lock Record用于轻量级锁优化,不同的是存储为偏向锁状态的对象头(Mark Word为偏向锁)中没有包含指向Lock Record的指针,而轻量级锁状态包含指向Lock Record的指针.
  Lock Record 的结构包含 Displaced Mark Word(该字段与轻量级锁关系更加紧密,而与偏向锁一点关系都没有) 和 Object Reference (该字段与偏向锁、轻量级锁、重量级锁都有关系), Displaced Mark Word存储的是锁对象的对象头中 无锁状态的Mark Word , 以便释放锁时恢复Mark Word(例如保留了hash值等信息),Object Reference 存储的是锁对象的对象头的指针,即指向了锁对象的对象头.
  每个线程的线程私有栈里存储着一个Lock Record 或 多个Lock Record(支持可重入), 如下图 :

偏向锁、轻量级锁、重量级锁

  Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了"偏向锁"和"轻量级锁",锁的状态一共有四种 : 无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,随着锁竞争情况这几种状态逐步升级;锁只能升级,不能降级,例如偏向锁升级为轻量级锁后不能降级为偏向锁.

偏向锁

  • 偏向锁 : 大多数情况下,锁不仅不存在多线程竞争,而且总是由同一条线程去多次获得锁;为了让线程获得锁的性能代价更低而引入了偏向锁;偏向锁主要用来优化同一线程多次申请同一个锁的竞争,即当对象被当做同步锁并有一个线程抢到了锁时,则在对象头(Mark Word)设置该线程的线程ID、是否偏向锁设置1、锁标志位设置01等信息,此时的Mark Word 存储的就是偏向锁状态信息.
    • 在创建一个线程并在线程中执行循环监听的场景下,或单线程操作一个线程安全集合时,同一线程每次都需要获取和释放锁,每次操作都会发生用户态与内核态的切换
    • 获取偏向锁的场景 :
      • 在自己的线程栈生成一条Lock Record,然后Object Reference指向对象头,此时Lock Record与对象头就建立了联系.
      • ① : 先判断Mard Word的Thread ID是否有值
      • ② : 如果没有,则表示当前资源没有被其他线程占用,把当前线程ID等信息记录到Mark Word(这里需要CAS操作,可能多条线程修改Mark Word,需要保证原子性)
      • ③ : 如果有,则表示当前资源被线程占用,需要判断该线程是不是自己
      • ④ : 该线程ID是自己的,则表示可重入,直接可以获取(此时在自己的线程栈中继续生成一条新的Lock Record)
      • ⑤ : 该线程ID不是自己的,说明出现其他线程竞争了,当前持有偏向锁的线程就需要撤销了,即当其他线程尝试获取偏向锁才释放锁

【PS : 此时Lock Record的Object Reference指向了锁对象的对象头,而锁对象的对象头并没有存储指向第一个获取到锁的Lock Record指针】

  • 偏向锁的撤销 : 一旦出现其他线程竞争资源时,偏向锁就需要撤销了.偏向锁的撤销,需要等待全局安全点(safepoint,GC时会让所有线程阻塞的停顿点),即在这个时间点上没有正在执行的字节码;JVM会先暂停持有该偏向锁的线程,同时检查该线程是否还在执行该方法(是否活跃 : 此时会先遍历JVM中维护的一个处于存活状态的线程集合,与对象头中的线程id比较),如果是,则升级锁,反之则设置为无锁状态并进行锁升级.
    【PS : 全局安全点(safepoint)这个词在GC中经常会提到,简单来说就是其代表了一个状态,在该状态下所有线程都是暂停的】
    【PS : 安全点的机制特别像Java官方提供的同步器,如CyclicBarrier,CountDownLatch等,一定要等待所有线程到达某个点才可以再进行一些操作,操作完毕后再释放线程继续执行】
    【PS : 安全点操作:出于各种原因,但一定要等所有线程到达安全点才可以执行的操作】

  • 偏向锁的释放 : 遍历线程栈的所有Lock Record,把ObjectReference切断,即ObjectReference = null.

    • 把ObjectReference设置为null,但是锁对象的对象头的Mark Word还是没改变,依然是偏向了之前的线程,那还是没释放锁的嘛.的确是,线程退出临界区时候,并没有释放偏向锁,这么做的目的为 : 当再次需要获取锁的时候,只需要简单判断是否是重入,即可快速获取锁,而不用每次都CAS,这也是偏向锁在只有一个线程访问锁的情景下高效的核心所在.

  在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加 JVM 参数关闭偏向锁来调优系统性能 :


// 该设置会让程序默认进入轻量级锁状态

-XX:-UseBiasedLocking=false // 关闭偏向锁(默认打开)

偏向锁小结

  • 当出现锁资源访问的时候,都会在当前线程栈生成一条Lock Record,并且ObjectReference将指向锁对象的对象头 Mark Word,该设置可能出现多线程,需要CAS操作.
  • 多线程情况下竞争同一个锁资源,偏向锁的撤销会影响效率.
  • 偏向锁的重入计数依靠线程栈里Lock Record个数.
  • 偏向锁撤销失败,最终会升级为轻量级锁
  • 偏向锁退出时并没有修改Mark Word,也就是没有释放锁
  • 偏向锁相对轻量级锁来说,当同一线程去再次获取锁的时候,不用进行CAS操作,提高了性能.(轻量级锁在同一线程情况下每次去获取锁,在无锁的状态下,每次都要进行一次CAS操作)

【PS : 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁】
【PS : 偏向锁的撤销是很复杂的,这种复杂性已经成为理解代码的障碍,也阻碍了对同步系统进行重构,而且现如今基本都是多核系统,偏向锁的劣势越来越明显,所以,在Java 15废弃了偏向锁

轻量级锁

  • 轻量级锁 : 轻量级锁与Lock Record更加紧密,Displaced Mark Word字段就用上了,用以存储无锁状态的Mark Word;线程进入同步资源后,会在线程栈中创建一个Lock Record,通过CAS把该Lock Record的地址设置在锁对象的对象头 Mark Word 中,如果CAS成功,则表示竞争成功,即此时的Mark Word里的锁记录指针指向了该Lock Record,表示该Lock Record所在的线程获取了轻量锁;否则竞争失败,进行自旋重试,重试一定程度后将锁膨胀成重量级锁;
  • 轻量级锁的重入 : 当获取持有锁的线程进行重入获取锁时,首先判断当前是轻量级锁,则再判断当前Mark Word存储的Lock Record指针是否同一线程,如果是,此时会在线程栈中再创建一条Lock Record,只是该Lock Record的Displaced Mark Word字段为空,ObjectReferece还是指向锁对象的对象头 Mark Word,这条 Displaced Mark Word字段为空的 Lock Record起到了重入计数作用,即有多少条Lock Record的ObjectReference指向当前线程,并且Displaced Mark Word为空,则说明重入多少次.
  • 轻量级锁的释放 : 同偏向锁一样也是遍历线程栈的Lock Record,与偏向锁不同的是,轻量级锁是真的释放了锁,即修改Mark Word为无锁状态;对于轻量级锁的释放需要通过CAS来设置锁对象的对象头的 Mark Word,因为存在一种可能 : 线程一释放锁期间,线程二已经膨胀为重量级锁了,即Mark Word存储的指针是指向ObjectMonitor,此时线程一释放锁,是不能成功修改Mark Word的,即通过CAS失败,当然线程一的CAS会失败,最终可能膨胀为重量级锁.

轻量级锁小结

  • 轻量级锁想要获取锁需要对Mark Word进行修改,可能会有多线程竞争修改,因此需要借助CAS
  • 成功获取轻量锁的第一个Lock Record的指针会被存储到Mark Word中(可重入的Lock Record只是Object Reference指向锁对象的对象头)
  • 如果初始锁为无锁状态,则每次进入都需要一次CAS尝试修改为轻量级锁,否则判断是否重入
  • 如果不满足2的条件,进行自旋重试,重试达到次数后则膨胀为重量级锁
  • 轻量级锁退出时即释放锁,变为无锁状态
  • 自旋会消耗CPU,所以一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于 CAS 重试状态,占用 CPU 资源,反而会增加系统性能开销.

自旋与重量级

  轻量级锁自旋抢锁失败后,就会膨胀为重量级锁,并且挂起进入阻塞状态后进入到等待队列等待线程的唤醒,这里阻塞、唤醒就涉及到了用户态和内核态的切换,降低系统性能.
  自旋 : 如果此时持有锁的线程在很短的时间内释放了锁,此时刚进入等待队列的线程又要被唤醒申请资源,这无疑是消耗性能的,而且大多数情况下线程持有锁的时间都不会太长,线程被挂起阻塞可能会得不偿失,所以JVM 提供了一种自旋,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞
  自旋会消耗CPU,所以自旋并不是永久的自旋,而需要控制次数.


// 可设置 JVM 参数来关闭自旋锁,优化系统性能
-XX:-UseSpinning // 参数关闭自旋锁优化 (默认打开) 
-XX:PreBlockSpin // 参数修改默认的自旋次数。JDK1.7 后,去掉此参数,由 jvm 控制

【PS : 重量级锁的设计思想与管程有联系,所以下篇文章再学习】

偏向锁、轻量级锁、重量级锁 --- 总结

  • 三者都需要与线程栈的Lock Record关联,尤其是轻量级锁使用到了Diplaced Mark Word,偏向锁和重量级锁只用到了Object Reference字段.
  • 偏向锁和轻量级锁的加锁解锁是围绕Mark Word 和 Lock Record的关联关系,而重量级锁围绕的是自己向JVM申请的ObjectMonitor对象(重量级锁的情况下,Mark Word存储着指向ObjectMonitor对象的指针)
  • 偏向锁和轻量级锁依靠Lock Record个数来记录重入的次数,而重量级锁通过
    ObjectMonitor的整型变量来记录

三者应用场景

  • 偏向锁 : 偏向锁适合在只有一个线程访问锁的场景,在此种场景下,线程只需要执行一次CAS获取偏向锁,后续该线程可重入访问该锁时仅仅只需要简单的判断Mark Word的线程ID即可
  • 轻量级锁 : 轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争,此种场景下,线程每次获取锁只需要执行一次CAS即可
  • 重量级锁 : 重量级锁适合在多线程竞争环境下访问锁,执行临界区的时间比较长,由于竞争激烈,自旋后未获取到锁的线程将会被挂起进入等待队列,等待持有锁的线程释放锁后唤醒它.此种场景下,线程每次都需要进行多次CAS操作,操作失败将会被放入队列里等待唤醒.

  值得注意的是 : 进入重量级锁状态后,线程的阻塞、唤醒操作将严重涉及到操作系统用户态与内核态的切换问题,将严重影响系统性能,所以Java JDB 1.6 引入了 "偏向锁" 和 "轻量锁" 来尽量避免线程用户态与内核态的频繁切换.
  现在我们知道了 : 我们应该尽量使 Synchronized 同步锁处于轻量级锁或偏向锁,这样才能提高 Synchronized 同步锁的性能,而通过减小锁粒度来降低锁竞争也是一种最常用的优化方法.

结束语

  • 原创不易
  • 希望看完这篇文章的你有所收获!

相关参考资料


目录