在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和 对齐填充(Padding)。
而 对象头中则存有 锁状态标示等各种信息,JVM 的锁优化既是基于此。
Java 对象内容分布
对象头
HotSpot虚拟机的对象头包括两部分信息:
第一部分markword,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为MarkWord
。
对象头的另外一部分是klass,类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.
实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
对齐填充
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
codecraft —— Java对象结构及大小计算
Java Object Header 结构
以下内容来自: https://gist.github.com/arturmkrtchyan/43d6135e8a15798cc46c
32 位对象头
在32位系统下,存放Class指针的空间大小是4字节,MarkWord是4字节,对象头为8字节
1 | |----------------------------------------------------------------------------------------|--------------------| |
identity_hashcode
: 保存对象的哈希码age
: 保存对象的分代年龄biased_lock
: 偏向锁标识位lock
: 锁状态标识位thread
: 保存持有偏向锁的线程IDepoch
: 保存偏向时间戳OOP
: 普通对象指针(Ordinary Object Pointer)
64 位对象头
在64位系统下,存放Class指针的空间大小是8字节,MarkWord是8字节,对象头为16字节。
1 | |------------------------------------------------------------------------------------------------------------|--------------------| |
开启指针压缩的 64 位对象头
64位开启指针压缩的情况下,存放Class指针的空间大小是4字节,MarkWord是8字节,对象头为12字节。
指针压缩可以通过参数 -XX:+UseCompressedOops
开启。
1 | |--------------------------------------------------------------------------------------------------------------|--------------------| |
各个状态下的对象头
锁状态 | 存储内容 | 偏向锁标示位 | 锁标示位 |
---|---|---|---|
无锁 | 对象哈希码、对象分代年龄 | 0 | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄 | 1 | 01 |
轻量级锁 | 指向锁记录的指针 | 无 | 00 |
重量级锁 | 执行重量级锁定的指针 | 无 | 10 |
GC标志 | 无 | 无 | 11 |
锁升级过程
JDK中对Synchronized
做的种种优化,其核心都是为了减少获得锁和释放锁所带来的性能消耗,因此引入了偏向锁和轻量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
偏向锁
大多数情况,对象不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,场景是只有一个线程多次访问同步块。原理是通过CAS把线程ID写入锁对象的Mark Word
中,当下次获取锁时发现线程ID相同时,则无需同步,否则撤销偏向,根据情况进入轻量级锁。
获取偏向锁
- 线程要获取某个对象的锁时,访问
Mark Word
中偏向锁的标识是否设置成1
,锁标志位是否为01
,确认是否为可偏向状态。 - 如果为可偏向状态,则比对线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
- 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行步骤5;如果竞争失败,执行步骤4。
- 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(
safepoint
)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word) - 执行同步代码。
释放偏向锁
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为01
)或轻量级锁(标志位为00
)的状态。
偏向锁参数
-XX:+UseBiasedLocking
开启偏向锁-XX:BiasedLockingStartupDelay=0
设置虚拟机一启动就启动偏向锁模式,默认情况下,虚拟机启动4s之后才会启动偏向锁模式-XX:-UseBiasedLocking
关闭偏向锁
轻量级锁
轻量级锁是由偏向所升级而来,在一个线程进入同步块的情况下(该线程占有偏向锁),当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。轻量级锁适合多个线程轮流进入同步块的情况。
轻量级锁的加锁过程
- 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为
01
状态,是否为偏向锁为0
),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record
)的空间,用于存储锁对象目前的Mark Word
的拷贝,官方称之为Displaced Mark Word
。 - 拷贝锁对象头中的
Mark Word
复制到锁记录中 - 拷贝成功后,虚拟机将使用CAS操作尝试将锁对象的
Mark Word
更新为指向Lock Record
的指针,并将Lock Record
里的owner
指针指向锁对象的Mark Word
。如果更新成功,则执行步骤4,否则执行步骤5。 - 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为
00
,即表示此对象处于轻量级锁定状态。 - 如果这个更新操作失败了,虚拟机首先会检查锁对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为
10
,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
轻量级锁的释放
- 通过CAS操作尝试把线程中复制的
Displaced Mark Word
对象替换当前的Mark Word
- 如果替换成功,整个同步过程就完成了
- 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
重量级锁
Synchronized
是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock
来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized
效率低的原因。这种依赖于操作系统Mutex Lock
所实现的锁我们称之为重量级锁。
其它锁优化
适应性自旋(Adaptive Spinning)
从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
锁粗化(Lock Coarsening)
锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。如:
1 | StringBuffer stringBuffer = new StringBuffer(); |
这里每次调用stringBuffer.append
方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
锁消除(-XX:+EliminateLocks)
锁消除即删除不必要的加锁操作。根据 代码逃逸分析技术(-XX:+DoEscapeAnalysis
),如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:
1 | public String append(String str1, String str2) { |
虽然StringBuffer
的append
是一个同步方法,但是这段程序中的StringBuffer
属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。