synchronized 实现原理(1.8)

290 阅读6分钟

synchronized 实现原理

从对象头MarkWord说起

在JVM中,一个Java对象在内存的布局,会分为三个区域:对象头、实例数据以及对齐填充:image.png

我们逐个解析

● 对象头:存储MarkWord和类型指针(ClassMetadataAddress指向元空间的Class);如果是数组对象,还会存在数组长度(ArrayLength)

● 实例数据:存放当前对象属性成员信息,以及父类属性成员信息

● 对齐填充:由于虚拟机要求对象起始地址必须是8byte的整数倍,所以虚拟机会对于每个对象做8的倍数填充,如果这个对象的大小(对象头+实例数据大小)已经是8的整数倍了,则不会出现对齐填充

synchronized 在JVM层面的表现

假设现在有这样一段实例代码:

synchronized(obj){
    // 业务逻辑
}

Monitor 对象有三个核心变量 WaitSet , EntryList,Owner

● _owner:指向持有ObjectMonitor对象的线程

● _WaitSet:存放处于wait状态的线程队列

● _EntryList:存放处于等待锁block状态的线程队列

当只有一个线程访问的时候,在JVM 层面,每一个对象会绑定一个Monitor 对象(实质就是obj 对象头的MarkWord 指针指向了Monitor)

image.png

owner 是一个临界资源, JVM 是通过 CAS 操作来保证其线程安全的

此时,如果还有线程过来访问,就会演变成这样子

image.png 新来的线程会经历以下流程:

1.  通过obj 的MarkWord 找到对应的 Monitor

2.  检查Monitor 的Owner ,是否有指针指向

a.  有的话则加入 EntryList 进行等待,直到锁的持有者释放,就会唤醒EntryList 的所有线程,再次以CAS 的方式抢占Owner(非公平式)

b.  没有的话直接 以CAS 的方式获得 Owner,成为锁的持有者

Monitor本质是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”

Synchronized的优化

深入 MardWord

64 位虚拟机 Mark Word 是 64bit 其结构如下:

image.png Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态

偏向锁(1-01)

当线程访问同步块并获取锁时处理流程如下:

1.  检查 mark word 的线程 id 。

2.  如果为空则设置 CAS 替换当前线程 id。如果替换成功则获取锁成功,如果失败则撤销偏向锁。

3.  如果不为空则检查 线程 id为是否为本线程。如果是则获取锁成功,如果失败则撤销偏向锁。

持有偏向锁的线程以后每次进入这个锁相关的同步块时,只需比对一下 mark word 的线程 id 是否为本线程,如果是则获取锁成功。

如果发生线程竞争发生 2、3 步失败的情况则需要撤销偏向锁

image.png

撤销偏向锁的流程:

1.  偏向锁的撤销动作必须等待全局安全点

2.  暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态

3.  撤销偏向锁恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态

轻量级锁(00)

加锁

多个线程竞争偏向锁导致偏向锁升级为轻量级锁

1.  JVM 在当前线程的栈帧中创建 Lock Reocrd,并将对象头中的 Mark Word 复制到 Lock Reocrd 中。(Displaced Mark Word)

2.  线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向 Lock Reocrd 的指针。如果成功则获得锁,如果失败则先检查对象的 Mark Word 是否指向当前线程的栈帧如果是则说明已经获取锁,否则说明其它线程竞争锁则膨胀为重量级锁。

image.png

解锁

1.  使用 CAS 操作将 Mark Word 还原

2.  如果第 1 步执行成功则释放完成

3.  如果第 1 步执行失败则膨胀为重量级锁。

自旋锁

自旋是一种获取锁的机制并不是一个锁状态

在膨胀为重量级锁的过程中或重入时会多次尝试自旋获取锁以避免线程唤醒的开销,但是它会占用 CPU 的时间因此如果同步代码块执行时间很短自旋等待的效果就很好,反之则浪费了 CPU 资源。默认情况下自旋次数是 10 次用户可以使用参数 -XX : PreBlockSpin 来更改。

适应性自旋锁

JDK 6 引入了自适应自旋锁,意味着自旋的次数不在固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果对于某个锁很少自旋成功那么以后有可能省略掉自旋过程以避免资源浪费。

重量级锁(10)

这就是我们一开始讲的JVM 层面的锁

image.png

小结

image.png

image.png

hashcode去哪里了?

● 对于偏向锁,在线程获取偏向锁时,会用Thread ID和epoch值覆盖identity hash code所在的位置。

  •  如果一个对象的hashCode()方法已经被调用过一-次之后,这个对象不能被设置偏向锁。

  •  因为如果可以的话,那Mark Word中的identity hash code必然会被偏向线程ld给覆盖,这就会造成同一个对象前后两次调用hashCode()方法得到的结果不- -致。

    • 所以Java15的版本已经默认不开启偏向锁了*

● 升级为轻量级锁时,JVM会在当前线程的栈帧中创建一个锁记录(LockRecord)空间,用于存储锁对象的MarkWord拷贝,该拷贝中可以包含identity hash code,所以轻量级锁可以和identity hash code共存,哈希码和GC年龄自然保存在此,释放锁后会将这些信息写回到对象头。

● 升级为重量级锁后,MarkWord保存的重量级锁指针,代表重量级锁的ObjectMonitor类里有字段记录非加锁状态下的MarkWord,锁释放后也会将信息写回到对象头。