锁
- Monitor
- Java对象头
Monitor enter指令
- 每个对象都有一个监视器。当该监视器被占用时即是锁定状态或是获取监视器即获得同步锁。线程执行monitorenter指令时会尝试获取监视器的所有权,过程如下:
- 若该监视器的进入次数为0,则该线程进入监视器并将进入次数设置为1,此时该线程即为该监视器的所有者
- 若线程已经占有该监视器并重入,则进入次数+1
- 若其他线程已经占有该监视器,则线程会被阻塞直到监视器的进入次数为0,之后线程间会竞争获取该监视器的所有权
- 只有首先获得锁的线程才能允许继续获取多个锁
Monitor exit指令
- 执行monitorexit指令将遵循以下步骤:
- 执行monitorexit指令的线程必须是对象实例所对于的监视器的所有者
- 指令执行时,线程会先将进入次数-1,若-1之后进入次数变成0,则线程退出监视器即释放锁
- 其他阻塞在该监视器的线程可以重新竞争该监视器的所有权
同步代码块实现原理
-
在同步代码块中,JVM通过monitorenter和monitorexist指令实现同步锁的获取和释放功能
-
monitorenter指令是在编译后插入到同步代码块的开始位置
-
monitorenter指令是插入到方法结束处和异常处
-
JVM要保证每个monitorenter必须有对应的monitorexit与之配对
-
任何对象都有一个monitor与之关联,并且一个monitor被持有后,它将处于锁定状态
-
线程执行monitorenter指令时,将会尝试获取对象所有对应的monitor的所有权,即尝试获得对象的锁
-
线程执行monitorexit指令时,将会将进入次数-1直到变成0时释放监视器
-
同一时刻只有一个线程能够成功,其它失败的线程会被阻塞,并放入到同步队列中,进入BLOCKED状态
注意:由于wait/notify等方法底层实现是基于监视器,因此只有在同步方法块中才能调用wait/notify等方法,否则会抛出IllegalMonitorStateException的异常的原因
同步方法同步原理
- 区别于同步代码块的监视器实现,同步方法通过使用ACC_SYNCHRONIZED标记符隐式的实现
- 原理是通过方法调用指令检查该方法在常量池中是否包含ACC_SYNCHRONIZED 标记符,如果有,JVM要求线程在调用之前请求锁
对象头
JVM内存中的对象
- 在JVM中,对象在内存中的布局分成三块区域:对象头、示例数据和对齐填充
- 对象头:对象头主要存储对象的hashcode、锁信息、类型指针、数组长度等信息
- 示例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组长度,这部分内存按4 字节对齐
- 填充数据:由于JVM要求对象起始地址必须是8字节的整数倍,当不满足8字节时会自动填充
对象头综述
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashcode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/32bit | Array length | 数组的长度 |
- synchronized的锁是存放在Java对象头中的
- 如果对象是数组类型,JVM用3个子宽(Word)存储对象头,否则是用2个子宽
- 在32位虚拟机中,1子宽等于4个字节,即32bit;64位的话就是8个字节,即64bit
Mark Word的存储结构
32位JVM的Mark Word的默认存储结构 无锁状态
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的hashcode | 对象分代年龄 | 1 | 01 |
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化 32位
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | ||
GC 标记 | 空 | 11 | ||
偏向锁 | 线程ID | Epoch 对象分代年龄 | 1 | 01 |
64位JVM的Mark Word的默认存储结构
锁状态 | 25bit | 31bit | 1bit cms_free | 4bit 分代年龄 | 1bit 偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|---|---|
无锁 | unused | hashcode | 0 | 01 | ||
偏向锁 | ThreadID(54bit) | Epoch(2bit) | 1 | 01 |
Monitor Record
- Monitor Record 统一简称MR是Java线程私有的数据结构,每一个线程都有一个可用MR列表,同时还有一个全局的可用列表
- 一个被锁住的对象都会和一个MR关联,对象头的MarkWord中的LockWord指向MR的起始地址
- MR中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用
结构:
Monitor Record | 描述 |
---|---|
Owner | 1、当该值为NULL是 表示没有任何线程拥有该monitor,初始值为NULL 2、当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL |
EntryQ | 关联一个系统互斥锁semaphore,阻塞所有竞争该锁monitor record失败的线程 |
RcThis | 阻塞或等待在该锁的线程个数-被阻塞/等待的线程被存入同步或等待队列中 |
Nest | 记录重入次数 |
HashCode | 保存从对象头拷贝过来的hashcode值 |
candidate | 1、用来避免不必要的阻塞或等待线程唤醒 2、只有两种可能的值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。原因:因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞->就绪->阻塞)从而导致性能严重下降 |
工作原理:
- 线程如果获得监视锁成功,将成为该监视锁对象的拥有者
- 在任意时刻,监视器对象只有一个活动的线程Owner
- 拥有者开源调用
wait
方法自动释放监视锁,进入等待状态
锁优化
1.自旋锁
-
痛点:
由于线程的阻塞/唤醒需要CPU在用户态和内核状态间切换,频繁的转换对CPU负担很重,进而对并发性能带来很大的影响。
-
现象:
通过大量分析 发现,对象锁的锁状态通常只会持续很短一段时间,没必要频繁地阻塞和唤醒线程。
-
原理:
通过执行一段无意义的空循环让线程等待一段时间,不会立即挂起,看持有锁的线程是否很快释放锁,如果锁很快被释放,那当前线程就有机会不要阻塞就能拿到锁了,从而减少切换,提高性能
-
隐患:
若锁能很快被释放,那么自旋效率就很好,真正执行的自旋次数越少效率越好,等待时间就少;若是锁被一直占用,那自旋其实没有做任何有意义的事但又白白占用和浪费了CPU资源,反而造成资源浪费
-
**注意:**自旋次数必须有个限度或者自旋时间,如果超过自旋次数或时间还没获得锁,就要被阻塞挂起
-
**使用:**JDK1.6以后默认开启-XX:+UseSpinning,自旋次数可通过-XX:PreBlockSpin调整,默认10次
2.自适应自旋锁
- **痛点:**由于自旋锁只能指定固定的自旋次数,但由于任何的差异,导致每次的最佳自旋次数有差异
- **原理:**通过引入智能学习的概念,由前一次在同一个锁上的自旋时间和锁的持有者的状态来决定自旋的次数,换句话说就是自旋的次数是固定的,而是可以通过分析上次得出下次,更加智能
- **实现:**若当前线程针对某锁自旋成功,那下次自旋此时可能增加,因为JVM认为这次成功是下次成功的基础,增加的话成功几率可能更大;相反,若自旋很少成功,那么自旋次数会减少,应减少空转浪费,甚至直接省略自旋的过程,直接阻塞,因为自旋完全没有意义,还不如直接阻塞。
- **拓展:**有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,JVM对锁的状况预测会越来越准确,JVM会变得越来越智能
3.阻塞锁
- 加锁成功:当出现锁竞争时,只有获得锁的线程能够继续执行
- 加锁失败:竞争失败的线程会由running状态进入blocking状态,并被放置到与目标锁相关的一个等待队列中
- 解锁:当持有锁的线程退出临界区,释放锁后,会将等待队列中的一个阻塞线程唤醒,让其重新参与到锁的竞争中
4.公平锁
- 公平锁就是获得锁的顺序按照先到先得的原则,从实现上说,要求当一个线程竞争某个对象锁时,只要这个锁的等待队列非空,就必须把这个线程阻塞并塞入队尾。插入队尾一般通过一个CAS操作保持插入过程中没有锁释放。
5.非公平锁
- 相对的,非公平锁场景下,每个线程都先要竞争锁,在竞争失败或当前已被加锁的前提下才会被塞入等待队列,在这种实现下,后到的线程有可能无需进入等待队列直接竞争到锁随机锁。
6.锁粗化
- 痛点:多次连接在一起的加锁、解锁操作会造成
- 原理:将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁
- 使用:将多个彼此靠近的同步块合同在一个同步块或把多个同步方法合并为一个方法
- 扩展:在JDK内置的API中,例如StringBuffer、Vector都会存在隐性加锁操作,可合并
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("I'm");
stringBuffer.append("a litter");
stringBuffer.append("sunyk");
}
StringBuffer是线程安全的字符串处理类,每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围大的加锁和解锁操作,即第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
7.锁消除
- 痛点:根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁
- 原理:JVM在编译时通过对运行上下文的描述,去除不可能存在共享资源竞争的锁,通过这种方式消除无用锁,即删除不必要的加锁操作,从而节省开销
- 使用:逃逸分析和锁消除分别可以使用参数-XX:+DoEscapeAnalysis和 -XX:+EliminateLocks 锁消除必须在
-server
模式下开启 - 扩展:在JDK内置的API中,例如StringBuffer、Vector都会存在隐性加锁操作,可消除
/**
* 执行10000次字符串的拼接
*/
public static void main(String[] args){
SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
for(int i = 0; i <10000; i++){
synchronizedDemo.append("first","two");
}
}
public void append(String str1,String str2){
//由于StringBuffer对象被封装在方法内部,不可能存在共享资源竞争的情况
//因此JVM会认为加锁是无意义的,会在编译期就删除相关的加锁操作
//还有一点特别要注明:名知道不会有线程安全问题,代码阶段就应该使用StringBuilder
//否则在没有开启锁消除的情况下,StringBuffer不会被优化,性能可能只有StringBuilder的1/3
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1).append(str2);
}
8.锁的升级
- 从JDK1.6开始,锁一共有四种状态:无锁状态,偏向锁状态,轻量锁状态,重量锁状态
- 锁的状态会随着竞争情况逐渐升级,锁允许升级但不允许降级
- 不允许降级的目的是提高获得锁和释放锁的效率
- 通过倒序的方式,即重量级锁->轻量级锁->偏向锁进行讲解,因为通常是前者的优化
9.重量级锁
- 重量级锁通过对象内部的monitor实现
- monitor的本质是依赖于底层操作系统的MutexLock(互斥锁)实现,操作系统实现线程间的切换是通过用户态与内核态的切换完成的,而切换成功很高。
- MutexLock最核心的理念就是尝试获取锁,若可得到就占有,若不能就进入睡眠等待
10.轻量级锁
- 痛点:由于线程的阻塞/唤醒需要CPU在用户态和内核态切换,频繁的转换对CPU负担很重,进而对并发性能带来很大的影响
- 主要目的:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗
- 升级时机:当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁
- 原理:在只有一个线程执行同步块时进一步提高性能
- 数据结构:包括指向栈中锁记录的指针、锁标志位
11.轻量级锁加锁
- 1.线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中Mark Word复制到锁记录中Displaced Mark Word 即被取代的Mark Word 做一份拷贝
- 2.拷贝成功后,线程尝试使用CAS将对象头的Mark Word替换为指向锁记录的指针,将对象头的Mark Word更新为指向锁记录的指针,并将锁记录里的Owner指针指向Object Mark Word
- 如果更新成功,当前线程获得锁,继续执行同步方法
- 如果更新失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁,若自旋后没有获得锁,此时轻量级锁会升级为重量级锁,当前线程会被阻塞
12.轻量级锁解锁
- 解决时会使用CAS操作将Displaced Mark Word替换回到对象头
- 如果解锁成功,则表示没有竞争发生
- 如果解锁失败,表示当前锁存在竞争,锁会膨胀成重量级锁,需要再释放锁的同时唤醒被阻塞的线程,之后线程间要根据重量级锁规则重新竞争重量级锁
轻量级锁注意:对于轻量级锁有个使用前提是没有多线程竞争环境,一旦越过这个前提,除了互斥开销开,还会增加额外的CAS操作的开销,在多线程竞争环境下,轻量级锁甚至比重量级锁还要慢
13.偏向锁
- 痛点:在大多数情况下不存在多线程竞争的情况,而是同一个线程多次获取到同一个锁,为了让线程获得锁代价更低,因此设计了偏向锁,这个跟业务使用有很大关系
- 主要目的:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径
- 原理:在只有一个线程执行同步块时通过增加标记检查而减少CAS操作进一步提高性能
- 数据结构:占用锁的线程ID,是否是偏向锁,epoch偏向锁的时间戳,对象分代年龄,锁标志位
14.偏向锁撤销锁
- 偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的线程才会释放锁
- 偏向锁的撤销需要等待全局安全点,该时间点上没有字节码正在执行
- 偏向锁的撤销需要遵循以下步骤:
首先会暂停拥有偏向锁的线程并检查该线程是否存活:
1.如果线程非活动状态,则将对象头设置为无锁状态
2.如果线程是活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,并将对栈中的锁记录和对象头的Mark Word进行重置:
- 要么重新偏向于其他线程即将偏向锁交给其他线程,相当于当前线程被释放了锁
- 要么恢复到无锁或标记锁对象不适合作为偏向锁此时锁会被升级为轻量级锁
最后唤醒暂停的线程,被阻塞在安全点的线程继续往下执行同步代码块
15.偏向锁关闭锁
- 优势:偏向锁只需要再置换ThreadID的时候依赖一次CAS原子指令,其余时刻不需要CAS指令相比其他锁
- 隐患:由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能消耗必须小于节省下来的CAS原子指令的性能消耗
- 对比:轻量级锁是为在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能
结论对比 偏向锁 轻量级锁 重量级锁
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级别的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 使用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行速度较长。 |