synchronized深入
设计同步器的意义
多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;这种资源可能是: 对象、变量、文件等。
共享:资源可以由多个线程同时访问
可变:资源可以在其生命周期内被修改
引出的问题: 由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问!
如何解决线程并发安全问题
实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。即在同一时刻,只能有一个线程访问临 界资源,也称作同步互斥访问。
Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock
同步器的本质就是加锁
加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问)
synchronized介绍
- synchronized俗称对象锁
- 它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。
- 这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
- 说白了就是保证一个线程进入临界区的时候其他线程不会进入临界区。
- synchronized实际是用了对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
synchronized的原理
1.原理介绍
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。在JDK1.5之后做了优化(大于等于1.6),例如锁粗化(Lock Coarsening),锁消除(Lock Elimination),轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,,内置锁的并发性能已经基本与 Lock持平。
2.字节码
synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit两条指令分别在同步块逻辑代码的起始位置 与结束位置。
3.对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充 (Padding)。
- 对象头: 比如hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程id),偏向时间,数组长度(数组对象)等。Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是如果 对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
- 实例数据: 存放类的属性数据信息,包括父类的属性信息。
- 对齐填充: 由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
4.对象头
对象头介绍
- HotSpot虚拟机的对象头包括两部分信息,第一部分是Mark Word,第二部分是Klass Word
- 普通对象的对象头在32位虚拟机下对象头是64个bits,也就是8字节
- 数组对象的对象头在32位虚拟机下对象头是96个bits,也就是12字节
- Klass Word指向了这个类的类对象(Class类对象)
- 下图展示了32位虚拟机的对象头结构(包括不同对象和数组对象)
Mark Word
- Mark Word 用于存储对象自身的运行时数据, 如哈希码 (HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等
- 它是实现轻量级锁和偏向锁的关键。
- 这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为Mark Word。
- Mark Word为了能记载更多数据他在不同状态的锁(偏向锁,轻量级锁,重量级锁)会记载不同的东西
- hashcode是懒加载的只有调用了才有
对象头压缩
现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头 的。
手动设置‐XX:+UseCompressedOops
被压缩的信息如下:
- 对象的全局静态变量(即类属性)
- 对象头信息:64位平台下,原生对象头大小位16字节,压缩后位12字节
- 对象的引用类型:64位平台下,引用类型本身大小位8字节,压缩后为4字节
- 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
5.Monitor
Monitor介绍
- Monitor被翻译位监视器或管程
- 每个Java对象都可以关联一个Monitor对象
- 如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置向Monitor对象的指针
- 在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的
ObjectMonitor.hpp
ObjectMonitor(){
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
Monitor重要属性
- _owner ——表示那个线程是这把锁的主人
- _EntryList ——当锁有主人的时候,其他线程无法成为这个锁的主人会进入到EntryList(阻塞队列)进行等待
- _WaitSet ——调用wait方法进入这个等待
Monitor工作过流程
- 当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1
- 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet 集合中等待被唤醒
- 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁)
锁的膨胀升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重 量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK 1.6 中默认是开启偏向锁和轻量级锁 的,可以通过-XX:-UseBiasedLocking来禁用偏向锁
下图为锁的升级全过程:
轻量级锁
轻量级锁的使用场景
- 如果一个对象虽然有多个线程访问,但多个线程访问的时间是错开的(也就是没有竞争关系),那么可以使用轻量级锁来优化。
- 轻量级锁对使用者是透明的,即语法还是synchronized
加锁流程分析所用的代码
static final Object obj=new Object();
public static void method1(){
synchronized(obj){
method2();
}
}
public static void mehtod2(){
synchronized(obj){
}
}
栈帧中的锁记录对象的属性
- 第一部分存储要加锁对象的markword
- 第二部分是加锁对象的指针
轻量级锁的加锁流程分析
- 当线程Thread-0遇到synchronized代码块的时候开始加锁,首先会在线程的栈帧中创建一个锁记录对象(Lock Record)对象(每个栈帧都会包含一个锁记录的结构)。
- 让锁记录中的Object reference指向锁对象,并尝试用CAS替换Object的Mark Word,将Mark Word的值存入锁记录,CAS交换这一步骤表示的就是加锁。
- 如果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){
}
}
锁膨胀流程分析
- 发生竞争
- 在竞争锁的时候Thread-1会加轻量级锁失败,进入锁膨胀流程
- 为Object对象申请Monitor锁,让Object指向重量级锁地址
- 然后Thread-1线程进入Monitor的EntryList线程进入阻塞状态
3. 虽然锁升级为了重量级锁但是Thread-0持有的还是轻量级锁,当Thread-0试图释放锁的时候(使用CAS将Mark Word的值恢复给对象头),会失败。这时会进入重量级解锁流程,即按照Monitord地址找到Monitor对象,设置Owner为null,唤醒EntryList中的阻塞线程
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(持有锁的线程已经释放锁),这时当前线程就可以避免阻塞。
- 在Java6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性h会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能
- 自旋锁会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势
- Java7之后不能控制是否开启自旋功能
重量级锁
什么是重量级锁
当线程存在竞争情况(一个synchronized(obj){}有多个线程范围跟并且有竞争),那么就是重量级锁,重量级锁使用monitor来实现(性能较低)。
重量级锁的流程分析
比如有两个线程t1,t2
- 锁对象会关联一个monitor(markword指向monitor地址)
- t1线程找到指定monitor并把Owner设置为t1,Monitor中只能有一个Owner
- 在t1上锁的过程中,如果t2也来执行synchronized(obj),就会进入EntryList,线程进入BLOCKED状态
- t1执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争的时候是非公平的
- 如果锁的所有线程调用了wait那么这个线程会释放锁然后进入waitset中等待,线程进入waiting状态
偏向锁
偏向锁介绍
Java6中引入了偏向锁来做进一步优化(这个对象上只有自己这个线程);只有第一次使用CAS将线程ID设置到对象的Mark Word头(Mark Word存储线程ID),之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。
偏向锁的特点
- 如果开启了偏向锁(默认开启),那么对象创建后,markword值为0x05即最后3位为101,这时它的thread,epoch,age都为0
- 偏向锁是默认是延迟的(大概5秒),不会在程序启动时立即生效,如果想避免延迟,可以加VM参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,markword值为0x01即最后3位位001,这时它的hashcode,age都为0,第一次用到hashcode时才会赋值
- 偏向锁解锁之后对象头的后三位还是101,并不会变为001(无锁状态)
- 可以添加VM参数-XX:-UseBiasedLocking禁用偏向锁
- 如果禁用了偏向锁并且无竞争那就会加轻量级锁
- 调用对象的hashCode()方法会禁用偏向锁(因为存储空间原因,hashcode是31位而偏向锁光是线程id就占了54位)
偏向锁撤销的情况
- 调用该对象的hashCode()方法
- 有多个线程访问,但没有竞争,这时候会导致偏向锁撤销然后升级位轻量级锁
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,冲偏向会重置对象的Thread Id
当撤销偏向锁阈值超过20次后,JVM会这样觉得,我是不是偏向错了,于是会在给这些对象加锁时重新偏向至加锁线程。
批量撤销
当撤销偏向锁阈值超过40次后,JVM会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。
锁消除
锁消除是虚拟机的一种优化机制,Java虚拟机JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式提高性能。
锁消除的依据是逃逸分析的 数据支持。
锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析
-XX:+DoEscapeAnalysis 开启逃逸分析
-XX:+EliminateLocks 表示开启锁消除。
锁粗化
以后再写
逃逸分析
如果一个对象只能被一个线程访问到就说明这个对象是线程安全的,那么这个对象将不会被分配在堆上而是把对象肢解为标量(基本数据类型)直接分配在栈上。
在Java代码运行时,通过JVM参数可指定是否开启逃逸分析, -XX:+DoEscapeAnalysis : 表示开启逃逸分析 -XX:- DoEscapeAnalysis : 表示关闭逃逸分析。从jdk 1.7开始已经默认开启逃逸分析,如需关闭,需要指定 -XX:-DoEscapeAnalysis
对象头分析工具
运行时对象头锁状态分析工具JOL,他是OpenJDK开源工具包,引入下方maven依赖
<dependency>
<groupId>org.openjdk.jolgroupId>
<artifactId>jol‐coreartifactId>
<version>0.10version>
dependency>
打印markword
System.out.println(ClassLayout.parseInstance(object).toPrintable());