synchronized深入
1.如何解决线程安全问题
采用的方案是序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问
Java中,提供了两种锁来实现同步互斥访问:synchronized和Lock
加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问)
2.原理简介
原理
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低
优化
在JDK1.5之后做了优化(大于等于1.6)通过一些优化使得内置锁的并发性能已经基本与Lock持平
四种状态
synchronized有四种状态:无锁,偏向锁,轻量级锁,重量级锁
四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能进行锁降级
字节码
synchronized关键字被编译成字节码后会被翻译成monitorenter和monitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置
3.对象深入
介绍
synchronized很多重要的信息,都存放在对象结构中,所以要先学一下对象结构
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
- 对象头:
- Mark Word:存储对象的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。它是动态变化的,根据对象的状态不同而变化
- 类型指针(Class Pointer):指向对象的类元数据的指针,使得能够访问对象属于的类的信息
- 实例数据:存储对象的实际有效信息,也就是我们在类中所定义的各种类型的字段内容。JVM可能会对数据进行对齐优化,以提高访问效率
- 对齐填充:为了确保内存访问的高效性,JVM会对对象的大小进行内存对齐。可选字段,通常存在于对象的末尾,用于确保对象的大小是8字节的倍数,这样可以使得下一个对象从对齐的内存地址开始,减少访问时的开销
Mark Word
介绍
标记字段相对比较复杂。在不同的对象状态(有无锁,是否处于垃圾回收的标记中)下存放的内容是不同的,同时在64位(又分为是否开启指针压缩),32位虚拟机中的布局都不同。这里以64位开启指针压缩为例
从右向左看,右边是低位,左边是高位
64位虚拟机下的Mark Word组成
- 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态,只有最后2位锁标识11有效。
- biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是01,没办法区分,这里引入一位的偏向锁标识位
- 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代
- 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中
- 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作
- epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁
- ptr_to_lock_record:占62位,在轻量级锁的状态下,指向栈帧中锁记录的指针
- ptr_to_heavyweight_monitor:占62位,在重量级锁的状态下,指向对象监视器Monitor的指针
JOL打印对象的内存布局
什么是JOL
JOL是用于分析JVM中对象布局的一款专业工具。工具中使用Unsafe,JVMTI和Serviceability Agent(SA)等虚拟机技术来打印实际的对象内存布局
依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
打印对象内存布局
System.out.println(ClassLayout.parseInstance(对象).toPrintable());
打印结果如下:
注意
- 对象头大小不对是因为开启了指针压缩
- HashCode全是0是因为是懒加载的,只有你调用了对象的hashCode()方法才会保存值到对象头中
其他两种情况的标记字段
指针压缩
在64位的Java虚拟机中,Klass Pointer以及对象数据中的对象引用都需要占用8个字节,为了减少这部分的内存使用量,64位虚拟机使用指针压缩技术,将堆中原本8个字节的指针压缩成4个字节,此功能默认开启
对齐填充
省略,这里不是JVM专栏不会进行那么细致的讲解,对于理解synchronized够用即可
4.Monitor
介绍
synchronized在JVM中的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步
Monitor被翻译位监视器或管程,每个Java对象都天然关联一个Monitor对象
在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的
ObjectMonitor.hpp
ObjectMonitor(){
// 指向对象头
_header = NULL;
// 记录重入锁的数量
_count = 0;
// 记录正在等待获取这个对象锁的线程数量
_waiters = 0,
// 记录同一个线程的重入次数
_recursions = 0;
_object = NULL;
// 标识拥有该monitor的线程
_owner = NULL;
// 等待线程组成的双向循环链表
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
// 多线程竞争锁进入时的单向链表
_cxq = NULL ;
FreeNext = NULL ;
// 处于等待锁block状态的线程,会被加入到EntryList中
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
工作流程
- 首先会进入EntryList集合,当当线程获取到对象的Monitor后,进入Owner区域并把Monitor中的Owner变量设置为当前线程,同时Monitor中的计数器count加1
- 若线程调用wait()方法,将释放当前持有的Monitor,Owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒
- 若当前线程执行完毕,也将释放Monitor并复位count的值,以便其他线程进入获取Monitor
5.锁分类
介绍
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK1.6中默认是开启偏向锁和轻量级锁的
无锁
单线程运行,没有其他的线程来竞争,说明该对象处于无锁状态(无线程竞争它)这偏向锁标识位是0、锁状态01
偏向锁
为什么需要偏向锁
偏向锁主要解决无竞争下的锁性能问题
在实际场景中,如果一个同步块没有多个线程竞争,而且总是由同一个线程多次重入获取锁,如果每次还有阻塞线程,唤醒CPU从用户态转核心态,那么对于CPU是一种资源的浪费,为了解决这类问题,就引入了偏向锁的概念
所以,引入偏向锁的目的是认为当前环境下是不存在多线程竞争的场景,可以认为是单线程环境,同一个线程多次持有锁,减少单线程环境下获取锁带来的不必要
介绍
偏向锁是JDK1.6引入的一种锁优化机制
锁对象会偏向于第一个获得它的线程,当一个线程访问同步代码块并获取锁时,该锁会进入偏向模式,锁标志的状态将被设置为偏向01,并且锁的拥有者被设置为当前线程。当该线程执行完同步代码块后,线程并不会主动释放偏向锁。当线程再次进入同步代码块时,会首先判断此时持有锁的线程与它是否为同一线程,如果是则正常往下执行,由于此前是没有释放锁的,所以这次就不会有任何的获取锁操作
所以,偏向锁是指当一段同步代码一直被同一个线程所访问时,就不存在所谓的多线程竞争了,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能
偏向锁的锁释放是一个被动过程,线程不会主动释放偏向锁,只有当其他线程来竞争偏向锁时,JVM才会检测到锁的状态并触发撤销。但是撤销需要等待全局安全点(所有线程会暂停),JVM会在全局安全点时判断锁对象是否处于被锁定状态,如果没有被锁定,且持有锁的线程不处于活动状态,则将对象头设置为无锁状态,并撤销偏向锁
偏向锁在竞争不激烈的情况下,效率非常高
工作流程
- 如果不存在线程竞争的一个线程获得了锁,那么锁就进入偏向状态
- 此时Mark Word的结构变为偏向锁结构,锁对象的锁标志位(lock)被改为 01,偏向标志位(biased_lock)被改为1
- 然后线程的ID记录在锁对象的Mark Word中(使用 CAS 操作完成)
- 以后该线程获取锁的时,判断一下线程ID和标志位,就可以直接进入同步块,连CAS操作都不需要,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能
偏向锁延迟
偏向锁是默认是延迟的(大概5秒),不会在程序启动时立即生效,如果想避免延迟,可以加以下VM参数来禁用延迟
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
因为JVM在启动的时候需要加载资源,这些对象加上偏向锁没有任何意义,不启用偏向锁能减少了大量偏向锁撤销的成本,所以会延迟5秒
禁用偏向锁
可以通过-XX:-UseBiasedLocking来禁用偏向锁
偏向锁膨胀
如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表明在这个对象锁上已经存在竞争了。JVM检查原来持有该对象锁的占有线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后进行重新偏向,偏向为抢锁线程
如果JVM检查到原来的线程依然存活,则进一步检查占有线程的调用堆栈,是否通过锁记录持有偏向锁。如果存在锁记录,则表明原来的线程还在使用偏执锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨胀为轻量级锁
偏向锁撤销
- 在一个安全点停止拥有锁的线程
- 遍历线程的栈帧,检查是否存在存在锁记录。如果存在锁记录的话,需要清空锁记录,使其变成无锁状态,并修复锁记录指向的Mark Word,清除其线程ID
- 将当前锁升级成轻量级锁
- 唤醒当前线程
缺点
如果锁对象时常被多条线程竞争,那偏向锁就是多余的,并且其撤销的过程会带来一些性能开销
轻量级锁
介绍
轻量级锁的目的在于它认为系统当前的竞争环境不是很激烈,如果采取阻塞和唤醒线程的方式,则会过多地消耗系统资源。如果某个线程没有获取到轻量级锁,则采取自旋的方式来判断锁资源是否已被释放。这种方式减少了上线文的切换
重量级锁使用了操作系统底层的互斥锁(Mutex Lock),会导致线程在用户态和核心态之间频繁切换,从而带来较大的性能损耗
但是长时间的自旋操作是非常消耗资源的,一个线程获取了轻量级锁,其他线程就只能在那里“空耗”,它们不释放CPU资源,但也不做任何事,这种现象叫做忙等(busy-waiting)。所以,我们是允许短时间的忙等,用它来换取线程在用户态和内核态之间切换的开销
工作流程
分析
加锁流程分析所用的代码
static final Object obj=new Object();
public static void method1(){
synchronized(obj){
method2();
}
}
public static void mehtod2(){
synchronized(obj){
}
}
栈帧中的锁记录对象的属性
- Displaced Mark Word:存储要加锁对象的markword
- owner:第二部分是加锁对象的指针
加锁流程分析
- 当线程Thread-0遇到synchronized代码块的时候开始加锁,首先会在线程的栈帧中创建一个锁记录对象(Lock Record)对象(每个栈帧都会包含一个锁记录的结构)
- 让锁记录中的Object reference指向锁对象,并尝试用CAS替换Object的Mark Word,将Mark Word的值存入锁记录,CAS交换这一步骤表示的就是加锁。
3. 如果CAS交换成功,那么对象头存储了锁记录对象的地址和状态00,表示由该线程给对象加锁
- 如果CAS失败有两种情况:
- 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀的过程。
- 如果是自己执行了synchronized锁重入,那么再添加了一条Lock Record作为重入的次数
- 当这个线程再次尝试获取这个锁(method1中调用method2),会在创建一个新的栈帧栈帧中的第一部分为null因为已经有一个栈帧关联过了
解锁流程分析
- 当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
- 当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用CAS将Mark Word的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁的解锁流程
锁膨胀
介绍
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
分析示例代码
static Object obj=new Object();
public static void method1(){
synchronized(obj){
}
}
流程分析
- 发生竞争
2. 在竞争锁的时候Thread-1会加轻量级锁失败,进入锁膨胀流程
1. 为Object对象申请Monitor锁,让Object指向重量级锁地址
2. 然后Thread-1线程进入Monitor的EntryList线程进入阻塞状态
- 虽然锁升级为了重量级锁但是Thread-0持有的还是轻量级锁,当Thread-0试图释放锁的时候(使用CAS将Mark Word的值恢复给对象头),会失败。这时会进入重量级解锁流程,即按照Monitord地址找到Monitor对象,设置Owner为null,唤醒EntryList中的阻塞线程
重量级锁
介绍
轻量级锁自旋是要有限度的,你不能一直在那里空转,所以如果锁竞争环境比较严重,当自旋次数达到某个阈值(默认10次,可自动调整)后,就是停止自旋,此时锁膨胀为重量级锁。当其膨胀为重量级锁后,其他线程就不再是等待了,而是阻塞等待。重量级锁依赖对象内部的监视器(Monitor)实现,而Monitor依赖的是操作系统的MutexLock(互斥锁)
由于是重量级锁,那么等待锁资源的线程都会被阻塞,虽然阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程都需要通过底层操作系统来实现,它会涉及到上下文切换,用户态和内核态之间的转换,这本身就是一个非常重量级、高开销的操作
Monitor的特点
- 同步:Monitor所保护的临界区代码,是互斥的执行。一个Monitor是一个运行许可,任一个线程进入临界区代码都需要获得这个许可,离开时把许可归还
- 协作:Monitor提供Signal机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒,其他拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可以重新获得许可并启动执行
流程分析
比如有两个线程t1,t2
- 锁对象会关联一个Monitor(Markword指向Monitor地址)
- t1线程找到指定Monitor并把Owner设置为t1,Monitor中只能有一个Owner
- 在t1上锁的过程中,如果t2也来执行synchronized(obj),就会进入EntryList,线程进入BLOCKED状态
- t1执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争的时候是非公平的
- 如果锁的所有线程调用了wait那么这个线程会释放锁然后进入Waitset中等待,线程进入waiting状态
6.锁升级
介绍
锁升级就是无锁->偏向锁->轻量级锁->重量级锁 的一个过程,注意,锁只能升级,不能降级。流程图如下:
全过程
下图为锁的升级全过程:
7.锁优化
介绍
在JDK1.6之前,synchronized是一个重量级、效率比较低下的锁,但是在JDK1.6后,JVM为了提高synchronized的性能,Hotspot虚拟机开发团队做了大量的优化工作,如自旋锁、自适应性自旋、锁消除、锁粗化、偏向锁、轻量级锁
自旋锁
介绍
线程的阻塞和唤醒都需要依赖底层操作系统,会涉及到用户态、内核态的切换,这种操作是非常消耗资源的
如果一个同步代码块执行的时间非常短,为了这一段很短的时间去频繁阻塞和唤醒线程其实时非常不值得的。为了解决这种很短时间的任务,Java引入自旋锁
何谓自旋锁?就是当一个线程尝试去获取某个锁对象时,如果该锁对象被其他线程持有,那么该线程不会被挂起,而是一直循环检测锁是否已被释放,通过自旋而不是挂起线程,可以减少线程上下文切换的开销
注意
自旋锁基于的条件是:任务执行时间很短,那么自旋等待的效果就会很好,反之,如果任务执行的时间比较长,那么自旋的线程就会白白浪费资源,会带来更多的性能消耗
所以,自旋等待的时间我们需要控制下,不能长时间的自旋,如果自旋的次数超过某个阈值后还没有获取到锁,就应该使用传统的方式去挂起线程。默认自旋次数为十次,我们也可以通过-XX:PreBlockSpin来自行更改
能否控制开启
Java7之后不能控制是否开启自旋功能
自适应自旋
自适应性是对自旋锁的一种优化,它的次数不再是固定的,而是根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
- 动态调整自旋次数:根据之前的锁竞争情况,动态调整自旋的次数。如果之前的自旋锁获取经常成功,则增加自旋次数;如果很少成功,则减少自旋次数,甚至可以不自旋
- 考虑锁的拥有者状态:如果锁的持有者正在运行,则自旋的机会就会增加,因为锁可能很快就会被释放。相反,如果锁的拥有者不在运行状态,自旋的次数可能就会减少
锁消除
介绍
锁消除是虚拟机的一种优化机制,Java虚拟机JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式提高性能
在有些情况下,JVM检测到某个锁对象的锁定状态是不会逃逸到方法或者线程的外部,那么这个锁就可以被认为是不必要的,可以被安全地去除。通过这种方式消除没有必要的锁,可以节省毫无意义的锁获取时间
依据
- 锁消除的依据是逃逸分析的支持
- 锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析
相关JVM参数
# 开启逃逸分析
-XX:+DoEscapeAnalysis
# 表示开启锁消除
-XX:+EliminateLocks
案例
public String concatStrings(List<String> strings) {
StringBuffer sb = new StringBuffer();
for(String s : strings){
sb.append(s);
}
return sb.toString();
}
我们知道StringBuffer是一个线程安全的类,它内部使用synchronized来保证线程安全,比如append方法里面用的就是synchronized
对于append方法来说synchronized作用在方法上,监视器锁为实例对象,锁定的是调用该方法的对象实例,上面例子中,锁定的是sb对象,但是sb对象是局部变量,它的引用不会逃逸出这个方法的。也就是说,sb是不会被多个线程共享的。因此JVM可以安全地消除掉sb.append(s)操作中的同步锁
锁粗化
介绍
原则上,我们在编写代码的时候需要尽可能地控制锁的粒度,将锁的范围控制得尽可能小。在大多数情况下,这个是没问题的。但是如果我们一个操作频繁地获取、释放同一个锁对象,那么即使是没有锁竞争,也会因为频繁的锁操作而导致性能损耗
所以,当JVM检测到一系列的连续锁操作实际上是对同一个对象的操作时,JVM会尝试将这些锁操作合并为一个更大范围的锁操作,从而减少锁的获取和释放的次数
代码示例
// 优化前
for(int i = 0; i < 100; i++){
synchronized(obj){
//doSomething
}
}
// 优化后
synchronized(obj){
for(int i = 0; i < 100; i++){
//doSomething
}
}