简介
如果我们想要保证单个共享变量的原子操作,可以借助CAS来实现,当我们想要保证多个共享变量的原子操作时,那就要把对多个变量的操作代码整合在一起建立临界区,临界区同一时刻只能有一个线程访问。而synchronized关键字就是java老牌的互斥锁,保证操作的原子性、可见性、有序性,同时还保证锁的可重入性。
synchronized使用
- 修饰方法的时候,如果是普通方法,加锁目标是此实例对象(new出来的、存放在堆中的某个对象)
- 修饰方法的时候,如果是静态方法,加锁目标是当前类的class对象(存在方法区的类结构对象)
- 修饰代码块的时候,需要指定某个实例对象或class对象作为加锁目标
jvm对象头
无论哪种方式实现线程同步,都必须指定一个对象并获得此对象的锁才有资格执行同步方法或代码块,synchronized的实现完全依赖于jvm,因此理解synchronized的底层实现,就必须理解对象在jvm是如何存储的,关于锁的那部分数据信息又是如何维护的。
在JVM虚拟机中,对象在内存中的存储布局,一般情况下分为三个区域:
- 对象头(包括标记字段、类型指针)
- 实例数据(存储对象自身定义的数据)
- 对齐填充(jvm要求对象的内存大小必须是8字节整倍数,对齐填充用于补全大小到整倍数)
- 如果对象是数组,还会有个区域记录数组的长度,用于判断数组对象的内存大小
有关对象锁的数据全部存储在对象头区域中,我们使用java提供的jol工具来看看对象的头部信息详细结构(测试为64位操作系统):
1.先添加依赖
1 | <dependency> |
2.创建测试用对象
1 | public class Person { |
3.执行main方法
1 | public static void main(String[] args) { |
4.打印结果
表头代表的含义:
列名 | 描述 |
---|---|
OFFSET | 偏移地址,单位字节 |
SIZE | 占用的内存大小,单位字节 |
TYPE DESCRIPTION | 类型描述,其中object header为对象头类型 |
VALUE | 类型对应的值 |
颜色标记区域代表的含义:
区域 | 描述 |
---|---|
红色 | 标记字段,内部结构比较复杂,而且会不断变化,下面单独讲 |
蓝色 | 类型指针,通常由64位组成,但是我们jvm会默认对其压缩到32位,因此占用4字节 |
绿色 | 实例数据,基本数据类型会直接打印值,引用数据类型显示(object) |
黄色 | 对齐填充,图中对象占用总内存为20字节,因此对齐填充补了4字节确保是8字节倍数 |
与synchronized底层原理关联最为密切的就是红色区域了,这个区域也比其他区域更为复杂一点,标记字段拥有8字节的内存大小(也就是64位),对象锁状态的不同,这64位存储的内容也不同:
标记字段中存储的信息:
- hash:存储对象哈希码,只有在调用hashCode()方法的时候才会生成,默认是没值的
- age:jvm分代年龄,用于判断是否晋升老年代
- biased_lock:偏向锁标识位
- lock:锁状态标识位
- JavaThread:保存持有偏向锁的线程ID
- epoch:保存偏向时间戳(并不是我们理解的long类型时间戳)
- Pointer to Lock Record:指向线程栈中锁记录的地址
- Pointer to Monitor:指向jvm监控对象的地址
无锁状态
所谓无锁状态,就是对象还没有被加过锁,也就是说内部的synchronized修饰的方法还没有任何线程调用过,上面打印的截图是没有调用hashCode()方法的,我们写个调用hashCode()方法的测试代码:
1 | public static void main(String[] args){ |
打印结果:
我们把二进制数据拼接起来,拼接规则是从下至上、从右到左。
最终拼接结果为:00000000 00000000 00000000 01111011 00011101 01111111 11111111 00000001
取出哈希码:1111011 00011101 01111111 11111111
随便找个进制转换器就能算出来结果是:2065530879,与main方法打印的一致。
偏向锁
偏向锁是jdk1.6引入的一项锁优化,意思是偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。
JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化的时间,JVM默认采用延时加载偏向锁的机制(大概4秒左右)。在延迟时间内是没有偏向锁概念的,对象创建完毕后是无锁状态,即使需要进行锁升级也是直接升级到轻量级锁,当到达延迟时间之后创建出来的对象,锁状态都是偏向锁状态。
所以我们直接执行main方法是看不到偏向锁信息的,当然也可以在创建对象之前sleep五秒,不过这个方法太low逼了,JVM提供了取消偏向锁延迟加载命令:-XX:BiasedLockingStartupDelay=0
测试类走起:
1 | public static void main(String[] args) { |
打印结果:
打印结果可以看出,对象还没有被作为加锁对象使用,偏向线程是空的。我们写个持有偏向线程的代码,并且手动调用一次gc看看age有没有增长:
1 | public static void main(String[] args) { |
打印结果:
打印结果中并不存在hashcode,这是因为在HotSpot虚拟机中,偏向锁与hashcode不可以并存(我估计是JavaThread占用的太多,没地方了…),如果在无锁状态调用hashcode方法,直接升级到轻量级锁,如果是偏向锁状态下调用hashcode(),直接进入偏向锁撤销阶段。这种规则仅限于没有重写hashcode()方法的情况下。
偏向锁工作流程图:
CAS获取偏向锁步骤
整个流程图最大的疑问在于CAS获取偏向锁的这一步骤,如果线程A获取偏向锁并开始执行同步代码或方法块期间,线程B试图访问同步方法或代码块,按照我们的理解CAS成功是必然的,因为此刻线程A还在执行临界区代码,不会对标记字段进行修改干扰到线程B,这不就出现2个线程同时进入同步代码了吗?
实时并非如此,无论是无锁状态(001)下的CAS,还是偏向锁状态下的CAS,期望值参数永远是null,也就意味着多个线程同时对无锁状态的同步代码争夺偏向锁,仅有一个线程会成功并成为偏向线程,之后任何线程在尝试CAS获取偏向锁永远是失败的(因为JavaThread已经非null),直接进入偏向锁撤销阶段。
锁撤销
偏向锁的撤销需要到达JVM的STW才会执行,这个时间点内所有字节码都不会执行,紧接着挂起偏向线程,根据isAlive()判断偏向线程状态再做后续处理:
- 如果处于未活动状态,说明偏向线程已经执行完毕并死亡,说明没有发生竞争,直接释放偏向锁。
- 如果处于活动状态并且已经退出同步代码块,说明没有发生竞争,释放偏向锁后需要唤醒线程继续执行。
- 如果处于活动状态并且未退出同步代码块,说明发生竞争,直接升级到轻量级锁。
锁重偏向
通过对撤销步骤的了解不难发现,只有在到达安全点后,偏向线程已经死亡或者退出同步代码块,加锁对象的markword中JavaThread和epoch才会被清空,直到下一个线程获得偏向锁,加锁对象重新偏向另一个线程。
锁批量撤销
JVM会以class为单位,为每个class分配一个偏向锁撤销计数器,每次class的实例被撤销偏向锁时计数器+1,当某个class的计数器达到阈值时(JVM参数控制),JVM会将该class的所有实例批量撤销偏向锁,并且该class后续创建的所有实例都是不可偏向的(直接是轻量级锁)。
锁批量重偏向
重偏向操作需要等到安全点才可以触发,如果刚触发锁撤销操作的时候,偏向线程就执行完同步代码块,那么此时等待安全点是没有任何意义的,并且锁撤销也会占用一定的STW时间。由此可以看出频繁的锁撤销会对性能带来一定影响,为了解决这个问题,JVM引入了批量重偏向概念来减少锁撤销的频率。
与批量撤销的相似,批量重偏向也是在class的计数器达到一定阈值时触发,执行过程:
- 当到达安全点时发现偏向次数到达阈值触发批量重偏向,会对class中的epoch进行+1运算得出epoch_new
- jvm扫描所有该class的实例对象,并筛选出处于偏向锁状态的实例对象,把所有筛选对象的epoch改成epoch_new
- 退出安全点后,有线程需要尝试获取偏向锁,检查加锁对象的epoch与对应class的epoch是否一致
- 如果一致,根据JavaThread是否为自身ID决定撤销锁还是直接进入同步代码(还是原来的逻辑)
- 如果不一致说明偏向锁已经无效,不会因为加锁对象偏向其他线程而触发撤销操作,而是直接尝试CAS获取锁
注:我猜测此时期望值不在是null而是重新获取加锁对象的markword,获取到锁之后还会把class的epoch归零,因为epoch就2位不可能一直递增。
批量重偏向阈值: -XX:BiasedLockingBulkRebiasThreshold = 20锁撤销计数器重置
即使在竞争很少发生的应用中,随着时间的流逝,各class的锁撤销计数器总有到达阈值的时候。比如某个class的所有实例对象一小时才触发一次锁撤销,那么默认40小时后会触发批量锁撤销,后续所有对象的创建全都是轻量级锁。这种竞争程度简直毛毛雨,根本没必要使用轻量级锁增加无意义的性能消耗。对此JVM增加了两次批量锁撤销事件触发时差的阈值判断,如果距离上次批量撤销时差小于等于阈值时差就执行批量锁撤销,否则仅仅将锁撤销计数重置为零。
启用禁用
偏向锁撤销的作用很明显了,根据线程对此临界代码的访问是否发生竞争,来决定将锁恢复到无锁状态还是升级到轻量级。没有发生竞争的情况下,偏向锁的逻辑仍然能保证很好的性能,一旦发生竞争,就需要更高级的锁来最大化性能。偏向锁在竞争稍微激烈的情况下其实没什么卵用,如果你觉得你的应用对于大多数锁的竞争都是比较频繁的,偏向锁完全没有存在的必要,可以设置JVM启动参数来禁用偏向锁(默认延迟打开):
可重入性
偏向锁是在没有发生竞争的情况下才存在,线程拿到偏向锁后成为偏向线程,在没有发生偏向锁撤销情况下,后续访问是没有资源消耗的,可以直接执行临界代码,这就代表偏向锁阶段完全支持可重入。
非公平性
不存在竞争因此也不存在是否公平性可言。
轻量级锁
轻量级锁也是jdk1.6引入的一项锁优化,是在锁发生竞争但竞争不是特别激烈情况下的折中解决方案,降低重量级锁使用过程中的性能消耗。
我们写个测试类(使用 -XX:-UseBiasedLocking命令,禁用偏向锁):
1 | public static void main(String[] args) { |
打印结果:
轻量级锁的标记字段结构很简单,只存储锁标志、锁记录俩个信息,hashcode和age转移到Lock Record中进行存储。
轻量级锁工作流程图:
自旋次数
在自旋竞争锁过程中,如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。这个自旋次数在jdk1.5是写死的参数无法更改,到了jdk1.6版本可以通过jvm参数控制自旋次数(默认10),jdk1.7版本后又去掉了此参数,因为这个时候的jvm已经相当成熟,会根据内部收集的性能日志自己判定自旋次数。
锁释放
持锁线程执行完释放锁后,将拷贝的markword作为期望值,使用CAS修改加锁对象的markword,可以理解为将hashcode、age等信息还回去。有可能此时已经膨胀到重量级锁,加锁对象的markword已经变更,这种情况下CAS必然失败,这时候直接执行重量级锁的唤醒逻辑。
解锁操作为什么要用CAS来操作呢? 这是为了防止在解锁的时候,锁由于竞争的激烈程度再次提高,已经升级到重量级锁并且把其他线程阻塞,这种情况下如果不唤醒阻塞的线程,这些线程将永远阻塞在这里。
可重入性
偏向线程执行过程中遇到锁升级信号(已经发出偏向锁撤销请求),JVM会在该线程栈中分配一个Lock Record,并把加锁对象的markword拷贝进来,如果已经是轻量级锁情况下,线程访问临界代码前也会执行同样操作。这也就意味着持有轻量级锁过程中,加锁对象的hashcode、age等信息转移到了持锁线程的Lock Record中,持锁线程的Lock Record同样也会保存加锁对象markword的地址,两者是互相引用的关系,这样既能保证加锁对象的hashcode、GC年龄随时可以访问,也可以解决可重入的问题。
非公平性
顶多俩线程在竞争,一个在执行,一个在自旋等待,因此也没有是否公平性可言。
重量级锁
轻量级锁膨胀之后,就升级为重量级锁了。重量级锁是依赖对象关联的monitor锁来实现的,每个java对象都有一个与之对应的monitor对象,随着java对象一起创建一起销毁。而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。
在HotSpot虚拟机中,Monitor是基于C++实现的,封装成ObjectMonitor对象,具体成员变量:
属性名 | 默认值 | 属性描述 |
---|---|---|
_header | NULL | 锁对象的原始对象头 |
_count | 0 | 用来记录该线程获取锁的次数 |
_waiters | 0 | 进入wait状态的线程数 |
_recursions | 0 | 锁的重入次数 |
_object | NULL | 关联的锁对象 |
_owner | NULL | 指向持有ObjectMonitor对象的线程,锁释放后设置为null |
_WaitSet | NULL | 调用wait()方法后进入的wait集合 |
_WaitSetLock | 0 | 操作WaitSet链表的锁 |
_Responsible | NULL | 防止搁浅情况 |
_succ | NULL | 假定继承线程 |
_cxq | NULL | 被挂起线程等待重新竞争锁的单向链表,为了避免插入和取出元素的竞争,所以Owner会从列表尾部取元素 |
FreeNext | NULL | Free list linkage |
_EntryList | NULL | 处于block状态的线程集合,被notify唤醒后重新加入竞争也是进入此队列 |
_SpinFreq | NULL | 自旋成功率 |
_SpinClock | 0 | 自旋时钟 |
OwnerlsThread | 0 | 表明当前owner原来持有轻量级锁 |
_previous_owner_tid | 0 | 上一个获取锁的线程id |
写个重量级锁mode:
1 | public static void main(String[] args){ |
打印结果:
阻塞过程
monitor对象在轻量级锁膨胀后初始化,并且将状态设置为膨胀中(INFLATING),在膨胀期间有线程访问直接进入忙等状态。当一个线程尝试获取锁并且获取失败,则将线程封装为ObjectWaiter插入到cxq的队列的队首,进入cxq队列的线程还会再次尝试自旋获取锁,如果还是失败则调用park函数挂起线程。park函数涉及到内核态的切换,因此比较耗时,也是被称为'重'锁的原因。
自旋目的
争夺锁失败插入cxq队列后仍然会进行自旋的目的在于,防止同步块中代代码较少、执行比较快的情况下,频繁的park函数调用导致频繁的内核态的切换影响性能。关于自旋次数在JDK1.6之前默认10次,之后版本改成了适应性自旋由JVM自己控制。
防止搁浅
当线程获得锁后,会去查询当前是否还有其他线程等待获取锁,如果没有则将_Responsible设置为自身,在进入cxq后自旋仍然没获取锁会再次判断_Responsible是否为自身,如果是则调用有时间限制的park方法,估计是考虑到特殊场景下所有线程都处于阻塞导致没有线程进行释放锁操作,出现搁浅情况。
线程释放
当锁被释放后,会从_cxq或_EntryList中挑选一个线程唤醒,被选中的线程为假定继承人赋值给_succ,即使_succ重新加入竞争也不能保证会获取到锁,所以_succ也只能称为假定继承人。
重量级锁工作流程图:
可重入性
monitor通过_owner属性判断线程有无权限进入同步代码块,再根据_recursions属性用来记录重入次数,进入临界代码时+1、退出时-1,由此可以保证重入性。
非公平性
jvm在唤醒线程时会根据内部参数QMode的值决定使用哪种唤醒策略,可能从_cxq中选取一个,也可能从_EntryList中选取一个,_cxq队列的线程也会因为策略被转移到_EntryList队列的首部或尾部。被选中的线程也不保证能拿到锁,因此synchronized是非公平的。
GC标记
如果设置finalize()或许还有一线生机,没设置就等死吧….
锁降级
synchronized是由JVM来实现的,因此锁是否支持降级完全取决于JVM设计者,本文所有技术点均来自HotSpot虚拟机。HotSpot虚拟机在进入安全点的时候,会去检查是否有空闲的monitor,如果有就试图进行降级。在轻量级锁释放锁的时候会将拷贝的markwordCAS修改回去,如果成功,是不是也代表降级为偏向锁了呢?这个问题没有找到答案,以后搞懂了再改。