上一篇通过构建金字塔结构,来从不同的角度,由浅入深的对synchronized关键字做了介绍,
快速跳转:https://www.cnblogs.com/xyang/p/11631866.html
本文将从底层实现的各个“组件”着手,详细拆解其工作原理。
本文会分为以下2节内容:
第一节:介绍MarkWord和LockRecord两种数据结构,该知识点是理解synchronized关键字底层原理的关键。
第二节:分析偏向锁加锁解锁时机和过程
一.先来了解两种数据结构,你应该了解这些知识点
1.MarkWord:在锁的使用过程中会对锁对象作出相应的操作
在HotSpot虚拟机中,Java对象在内存中存储的布局,分为三个部分:对象头,实例数据,对齐填充。
本文重点关注对象头。
对象头又划分为2或3部分,具体包括:
- MarkWord(后文简称MW,后续详细介绍)
- 类型指针:指向这个对象所属的类的元数据(klass)的指针
- 最后这一部分比较特殊,只有在对象是Java数组时才会存在,记录的是数组的长度。为什么要存在这个记录呢?我们知道,在普通Java对象中,我们可以通过读取对象所属类的元数据,计算出对象的大小。而数组是无法做到的,于是借助这块区域来记录。
本文重点关注MW区域
MW是一块固定大小内存区域,在32位虚拟机中是32个bit,对应的,64位虚拟机中是64个bit。本文以32位虚拟机为例分析。
我们从直观上理解,所谓的头信息,一般都是用来记载一些不易变的信息,例如在http请求头中的各种头信息。在对象头中也是如此,例如hashcode。在JVM虚拟机中为了解决存储空间开销,对象头的MW大小已经固定。那么,要存储的信息有比较多,包括且不限于:锁标志位、GC信息、锁相关信息,总大小远远超出32bit,怎么办呢?
共享存储区域,在不同的时刻,根据需求存储需要的信息。
请参考下图:
锁类型 |
25bit |
4bit |
1bit |
2bit | |
---|---|---|---|---|---|
23bit |
2bit |
是否偏向锁 |
锁标志位 |
||
无锁 |
对象hashcode |
分代年龄 |
0 |
01 |
|
偏向锁 |
线程ID |
epoch |
分代年龄 |
1 |
01 |
轻量级锁 |
指向栈中锁记录的指针 |
00 |
|||
重量级锁 |
指向互斥量 |
10 |
|||
GC标记 |
空 |
11 |
说明:两个标志位最多只能标识4个状态,那么剩下一个怎么办?共享。无锁和偏向锁共享01状态,他们两个的区分
2.LockRecord:
在当前线程的栈中申请LR(LockRecord简称,下同),主要包含两部分,第一步部分可以用于存放MW的副本;第二部分obj,用于指向锁对象。
上述两者的关系用下图表示:
二.偏向锁怎么工作
在对象创建的时候,MW会有一个初始态,要么是无锁态,要么是初始偏向锁态(ThreadId、epoch值都为初始值0)。程序员的世界不存在二义性,最终总会选一个,选择的依据是虚拟机的配置参数,在JDK1.6以后,默认是开启的,如果要禁用掉:-XX:-UseBiasedLocking。
什么时候需要禁用呢?如果能确认程序在大多数情况下,都存在多线程竞争,那么就可以禁用掉偏向锁。没必要每次都走一遍偏向锁->轻量级锁->重量级锁的完整升级流程。
1.先放一张图,直观的描述偏向锁的加锁、解锁、撤销基本流程
2.加锁过程
步骤一:
- LR记录赋值:在当前线程的栈中,申请一个LR,把obj指向锁对象
步骤二:如图中所示,线程T1,执行到同步代码,尝试加偏向锁,【判断三要素:标志位,ThreadId,epoch】:
- 先用标志位判断是否支持偏向锁。锁对象的对象头MW区域后3个bit位的值是101。特别需要注意:如果是001,是无锁状态,代表偏向锁不可用,会走加轻量级锁流程。
- 再用ThreadId值,决定加锁还是重入还是竞争:
- 0值加锁。如果ThreadId=0,代表无任何线程持有该对象的偏向锁,可以执行加锁操作,进入加锁流程;
- 非0值,
- 且为当前线程id,重入。如果ThreadId!=0,就判断其值是否是当前线程的ID,分两种情况:如果是,直接锁重入,不再重复加锁。
- 不为当前线程id,加锁或竞争。非0值,说明是其他线程(图中T2)已获得了同步锁。对象所属Class里也会维护一个epoch值,这里我们简称为cEpoch,比较两者:
- epoch小于类的cEpoch值,加锁。如果epoch<cEpoch,说明发生过批量重偏向,当前锁对象已被“释放”了。此时进行“重偏向”(里说的释放并非真正意义的释放,而是隐含着一层意思:当前线程已经执行完同步块,且在某次重偏向操作中,也检测到这一点,不再维护epoch的最新值,这样新的线程认为此时该偏向锁,可以加锁,直接CAS修改ThreadId即可)
- epoch等于类的cEpoch值,竞争锁。
标准的可加锁状态MW内容如下图所示:
锁类型 |
25bit |
|
4bit |
1bit |
2bit |
|
23bit |
2bit |
|
是否偏向锁 |
锁标志位 |
偏向锁 |
ThreadId==0 |
epoch==n |
分代年龄 |
1 |
01 |
或者
锁类型 |
25bit |
|
4bit |
1bit |
2bit |
|
23bit |
2bit |
|
是否偏向锁 |
锁标志位 |
偏向锁 |
ThreadId!=0 |
epoch==n(小于cepoch) |
分代年龄 |
1 |
01
|
第二步:通过CAS原子操作,把T1的ThreadId写入MW。执行结果有两种情况:
- 写入成功,获得偏向锁,进入同步代码块执行同步逻辑。
- 写入失败,表明在第一步判断和CAS操作之间,有其他线程已获得了锁。走锁竞争逻辑。
2.解锁过程
当前线程执行完同步代码块后,进行解锁,解锁操作比较简单,仅仅将栈中的最近一条LR中的obj赋值为null。这里需要注意,MW中的threadId并不会做修改。
3.锁竞争处理流程
T1尝试加锁,
如果存在锁竞争情况,持有锁的线程T2并不会在发现竞争的第一时间就直接撤销锁,或者升级锁,而是执行到安全点后再处理。
- 此时如果当前线程已执行完同步块代码且线程已不存活,将会撤销锁至无锁状态,然后进入锁升级逻辑。
- 否则,将会走锁升级流程,升级为轻量级锁,且升级完后T2继续持有轻量级锁,继续执行同步代码。
ps:怎么判断是否还在执行同步代码呢?遍历栈中的RL,如果都为null,代表锁已全部释放。
4.批量重偏向和批量撤销
有这样一种场景:如果我们预判竞争不多,大部分情况下是单一线程执行同步块,开启了偏向锁。但是在实际使用环境中,出现了大量的竞争,这时候怎么办呢?停机重新配置参数?恐怕不是最好的方案。如果是我们来设计这个这个Synchronized锁,肯定也会做一些兜底策略。比如这样来做,当某一事件发生了N次,那么就更改一下处理策略?
是的,基本思想差不多,只不过更完善,暂时留一个悬念,在下次揭晓。