JVM相关及其拓展(七) — 线程安全与锁优化
线程安全与锁优化
首先需要并发的正确性,然后在此基础上实现高效。
线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
线程安全的代码必须具备一个特征:代码本身封装了所有必要的正确保障性手段,令调用者无需关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。
从这个角度看,线程安全并不是一个简单的“是/否”二元判断,更重要的是:**对象把并发正确性责任承担到了什么程度。**有的类型自己已经封装好了全部约束;有的类型只保证单次调用安全;还有的类型需要调用方在更高层额外配合同步策略。
线程安全的“安全程度”由强至弱分为以下5类:
这几种分类的关键不在于给一个类简单贴上“线程安全”或“线程不安全”的标签,而在于看:对象自身保证了多少并发语义,以及调用方是否还要继续承担复合操作层面的正确性责任。
不可变
只要一个不可变的对象被正确的构建出来,那其外部的可见状态永远不会改变
不发生 this引用逃逸 情况下成立 -- 在构造函数返回之前,其他线程已经取得了该对象的引用。实现方案:
- 如果共享数据是一个
基本数据类型,只要在定义时用final修饰 - 如果共享数据是一个
对象,最简单的就是 吧对象中带有状态的变量都声明为final
符合不可变要求的类型:
String、枚举类(Enum)、Long,Double以及BigInteger等大数据类型- 如果共享数据是一个
绝对线程安全
完全满足线程安全的定义,即达到”不管运行环境如何,调用者都不需要任何额外的同步措施。”
现实中这类对象其实非常少见。因为一旦涉及多步操作、外部回调或者组合调用,调用方往往仍然需要约束调用时机和使用方式。所以很多“线程安全类”并不能轻易归入绝对线程安全,更准确地说常常只是后面的“相对线程安全”。
相对线程安全
保证对这个对象单独的操作是线程安全的,调用时不需做额外的保障措施,但是对于一些特定顺序的连续调用,就需要在调用端使用额外的同步手段保证调用的正确性。
大部分的线程安全类都属于这种类型,例如
Vector,HashTable,synchronizedCollection()例如对
Vector执行“先判断是否为空,再取出第一个元素”这种复合操作时,即使isEmpty()和get()各自都是线程安全的,也不能保证这两步连在一起仍然线程安全。因为在两次调用之间,别的线程完全可能已经修改了容器状态。线程兼容
对象本身并非线程安全,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全的使用。
例如
ArrayList,HashMap线程对立
无论调用端是否采取了同步措施,都无法在多线程环境中并发使用。应当尽量避免
例如
Thread中的suspend()和resume()。
线程安全的实现方法
①通过代码实现线程安全 ②通过虚拟机本身实现同步与锁
互斥同步 (阻塞同步)
同步:在多个线程并发访问共享数据时,保证共享数据在同一个时刻只能被一个线程使用。
互斥:实现同步的一种手段,
临界区、互斥量和信号量都是主要的互斥实现方法
互斥是因,同步是果;互斥是方法,同步是目的
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),就会出现问题,无论共享数据是否真的会出现数据竞争,都要进行加锁。
和它相对的,是后面要提到的乐观并发策略。两者本质上是在不同场景下做取舍:
- 悲观并发更强调正确性边界清晰、冲突处理简单。
- 乐观并发更强调低竞争下减少阻塞、降低线程切换开销。
并不存在绝对更“高级”的一方,关键还是看共享数据冲突概率、临界区长度以及失败重试的成本。
实现手段:
synchronized
Java-synchronized原理及解析ReentrantLock
Java-ReentrantLock原理及解析
synchronized和ReentrantLock的异同:
两者都是可重入锁
可重入锁:当一个线程得到一个对象锁后,再次请求该对象锁时是可以再次得到该对象锁的。自己可以再次获得自己的内部锁。
synchronized依赖于JVM而ReentrantLock依赖于APIsynchronized由JVM内置的monitor机制支持,在锁膨胀为重量级锁时会涉及操作系统层面的互斥量;ReentrantLock则是Java层Lock接口的实现,底层依赖AQS维护同步状态和等待队列。synchronized的锁状态无法在代码中判断,ReentrantLock通过isLocked()判断synchronized非公平锁,另一个可以是公平也可以是非公平的synchronized不可被中断,另一个调用lockInterruptibly()即可中断ReentrantLock支持定时等待、可中断获取以及多个Condition条件队列;而当真正需要“读多写少”优化时,通常使用的是ReentrantReadWriteLock,并不是ReentrantLock本身自动提高读并发。
因此两者更准确的选择边界是:
- 语义简单、由JVM直接支持、代码更紧凑时,
synchronized已经足够。 - 需要可中断、超时等待、公平策略或多个条件队列时,
ReentrantLock更灵活。
非阻塞同步
基于冲突检测的乐观并发策略,即先进行操作,若无其他线程争用共享数据,操作成功;反之,产生了冲突再去采用其他的补偿措施(最常见自旋——不停重试,直到成功为止)。
为了保证操作和冲突检测具备原子性,需要用到硬件指令集,比如:
- 测试并设置
- 获取并增加
- 交换
- 比较并交换(CAS)
- 加载链接/条件存储
因此可以把阻塞同步与非阻塞同步放在一张图里理解:
synchronized/ReentrantLock更偏悲观并发,先拿到独占访问权再做操作。CAS/Atomic更偏乐观并发,先尝试更新,失败后再补偿或重试。
低竞争场景下,乐观并发往往能减少挂起/唤醒线程带来的开销;但如果竞争激烈,自旋失败过多,反而可能浪费更多CPU。
无同步方案
不用同步的方式保证线程安全,因为有些天生就是安全的。
有以下两类:
可重入代码/纯代码(Reentrant Code/Pure Code)
在代码执行的时候在任何时刻去中断,再去执行另外的代码,在控制权返回后,原来的程序不会出现任何的错误。
可重入性是它的基本特征,满足可重入性的代码一定是线程安全的,反之,满足线程安全的代码不一定是可重入的。
共同特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态栏都由参数中传入、不调用非可重入的方法等
判定依据:返回结果是可预测的,只要是输入了相同的数据就能返回相同的结果,就满足可重入性的要求。
线程本地存储(Thread Local Storage)
把共享数据的可见范围限制在同一个进程之内,无须同步也可以保证线程之间不出现数据争用的情况。
使用
ThreadLocal类可实现本地存储的功能。
锁优化
锁优化是为了在线程之间更高效的共享数据,以及解决竞争性问题。
锁的状态共分为4种:无锁状态、偏向锁、轻量级锁和重量级锁。锁随着竞争情况可以升级,但升级后不能降级。
无锁状态->偏向锁->轻量级锁->重量级锁
这里的“锁优化”并不是说JVM想让锁彻底消失,而是希望在无竞争或低竞争场景下,尽量减少直接使用重量级互斥量的成本。也就是说,JVM会根据竞争强度动态选择更合适的锁实现路径,把阻塞、上下文切换和系统调用的代价尽量往后推。
因此锁的升级过程也不能简单理解成“性能从好到坏的排名”。偏向锁、轻量级锁、重量级锁本质上是在不同竞争强度下采用不同成本模型:低竞争时尽量减少同步开销,竞争激烈时则接受更高成本来换取正确性与可推进性。
1. 自旋锁与适应性自旋
互斥同步对性能最大的影响就是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,影响系统的并发性能。同时,共享数据的锁定状态只会持续很短的一段时间,不值得去挂起和恢复线程。
自旋锁:若物理机器有一个以上的处理器,能使多个线程同时并行执行,让后面的请求锁线程(通过自旋——CPU忙循环执行空指令)等待,但不放弃处理器的执行时间,看看持有锁的线程是否很快释放锁。
自旋等待不能代替阻塞,虽然避开了线程切换的开销,但要占用处理器时间,因此自旋等待必须有一定的上限,若超过了次数没有成功,就需要去挂起线程。
自适应自旋锁:自旋时间不固定,由该锁上次的自旋时间及锁的拥有者状态决定。
- 对于某个锁,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,JVM就会认为这次自旋也会再次成功获得锁,进而允许等待持续相对更长的时间
- 对于某个所,自选很少成功获得锁,以后再获取这个锁时可能忽略自旋过程,以避免浪费处理器资源。
2. 锁消除
JVM即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为他们是线程私有的,同步加锁操作自然就无须进行。
换句话说,**锁消除成立的前提就是逃逸分析能够证明对象不会逃逸出当前线程。**如果一个对象只在方法内部短暂存在,没有被发布给其他线程,那么JVM就有机会把这段本来写着synchronized的代码当成线程私有逻辑来优化掉。
例如StringBuffer对象的连续append()
3. 锁粗化
JVM探测到一串零碎的操作都对同一对象进行加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外代码
它的出发点和锁消除不同:锁消除是“证明不需要锁”,而锁粗化是“既然这一串操作迟早都要对同一把锁反复进入和退出,不如把范围适当放大,减少频繁加锁解锁的成本”。例如一个循环里连续多次对同一个对象进入同步块,过于细碎的同步边界反而可能比一次更大的同步块更贵。
4. 轻量级锁
在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁并不是用来代替重量级锁的
必须先了解 HotSpot虚拟机的对象(对象头部分)的内存布局:分为两部分
- Mark Word:存储自身的运行时数据,如:
HashCode、GC分代年龄和锁信息,这部分数据的长度在32和64位中的JVM中分别为32bit和64bit。它是实现轻量级锁和偏向锁的关键。 - 存储指向方法区对象类型数据的指针,如果是数组对象的话,额外会存储数据的长度。
加锁过程
代码进入同步块时,如果同步对象未锁定(标记位为01),虚拟机会在当前线程的栈帧中建立一个锁记录(Lock Record)的空间,用于存储对象目前的Mark Word拷贝(Displaced Mark Word)。
然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。
- 更新成功,那么当前线程拥有了该对象的锁,且对象Mark Word的锁标志位为
00,处于轻量级锁定状态。 - 更新失败,JVM首先会检查对象的Mark Word是否指向当前线程的栈帧
- 已指向表明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行
- 没指向表明该对象已被其他线程抢占。
如果有两条以上的线程竞争同一个锁,轻量级锁就无法使用,需要膨胀为重量级锁,Mark Word的锁标志位变为10,存储的是指向重量级的指针,后面等待锁的也会进入阻塞状态。
因此轻量级锁更适合的是:存在同步块,但真正的线程竞争并不激烈,或者锁持有时间很短。它优化的是“避免一上来就走重量级互斥量”,而不是在高竞争场景下永远保持高效。
解锁过程
若对象的Mark Word仍然指向线程的Lock Record,那就用CAS操作把对象当前的Mark Word和Displaced Mark Word替换回来
- 替换成功,就完成了整个同步过程
- 替换失败,说明有其他线程尝试获取锁,就要在释放锁的同时,唤醒被挂起的线程
栈帧:用于支持虚拟西进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表,操作数栈,动态链接和方法返回地址信息。第一个方法从调用开始到执行完成,就是一个栈帧从入栈到出栈的过程。
5. 偏向锁
消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。提高一个对象在很长一段时间内都只被一个线程用做锁对象场景下的性能。
偏向锁可以提高带有同步但无竞争的程序性能。
偏向锁优化的核心前提是:**这个锁长期只会被同一个线程反复获取。**如果场景里线程切换频繁、锁对象经常发生争用,那么偏向锁带来的收益就会下降,甚至还要承担撤销偏向锁的额外成本。
这个锁会偏向于第一个获得它的线程,如果后续该锁没有被其他线程获取,则持有偏向锁的线程将永远不会进行同步。
加锁过程
JVM启用了偏向锁模式,当锁对象第一次被线程获取的时候,JVM会把锁标记位置为01,即偏向模式。使用CAS操作记录锁的线程ID到Mark Word中。
- CAS操作成功。持有偏向锁的线程在每次进入和退出同步块是,只要比较一下Mark Word存储的线程ID是否相同。
- 相同代表线程已经获得了锁,不需要再用CAS操作加锁和解锁
- 不同,就需要CAS操作竞争锁,竞争成功,替换Mark Word中的ThreadID为当前竞争线程的ID
解锁过程
当有另一个线程去尝试获取偏向锁时,CAS替换ThreadID失败,就要撤销偏向锁。(撤销偏向锁,需要等待原持有偏向锁的线程到达全局安全点所有线程都是暂停的,没有字节码正在执行,暂停线程,并检查状态)。判断原持有偏向锁的线程是否处于活动状态
- 无活动则置为无锁状态(锁标志为
01,是否偏向锁状态0) - 还处于活动状态,则升级为轻量锁(标志位为
00)
关闭偏向锁模式
使用JVM参数 -XX:BlasedLockingStartupDelay=0可以关闭延迟,因为偏向锁需要应用启动后过几秒激活
-XX:UseBlasedLocking=false关闭偏向锁
这里还需要注意一点:**不同JDK版本里偏向锁的默认策略和实现细节并不完全一致。**阅读旧资料时,要结合当时的JDK版本背景理解,不要把某一版本下的默认行为直接当成长期不变的结论。
三种锁的升级
三种锁的比较
| 锁 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要额外的消耗,与执行非同步方法相比仅存在纳秒级的差距 | 线程间存在锁竞争,需要带来额外锁撤销的消耗 | 只有一个线程访问同步块场景 |
| 轻量级锁 | 竞争的线程不会阻塞,提高程序的相应速度 | 始终得不到锁竞争的线程,会使用自旋消耗CPU资源 | 追求相应时间,同步块执行速度非常快 |
| 重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行时间较长 |
重量级锁:本质上是依赖操作系统的
Mutex Lock互斥量来实现同步操作。由于线程间的切换需要从用户态转向核心态,转换成本较高,耗时相对较长。
一个线程如何判断自己是否取得锁?
线程在获取锁之前会判断对象的
Mark Word中是否存放自己的threadId,存放且相同则重入;不同,则使用CAS进行切换,锁升级为轻量级锁,释放偏向锁,清空Mark Word,线程开始竞争,竞争成功的就存入自己的ThreadId,失败的开始自旋。调用
Thread.holdsLock()
其他锁类型及其概念
1.乐观锁
由于在进程挂起和恢复执行过程中需要很大的开销进行切换。所以有了乐观锁概念。
每次去拿数据的时候都认为别人不会修改,但在更新的时候会去判断在此期间是否数据发生修改,没有被修改则进行数据更新。如果因为修改过产生冲突就失败就重试到成功为止(自旋)。
实例:例如Atomic原子类
使用场景:适合读取操作比较频繁的场景
2.悲观锁
每次获取数据的时候,担心数据被修改,所以每次都要加锁,确保操作过程中数据不会发生改变,操作完成后再解锁让其他线程操作。
在某个资源不可用的时候,就将CPU让出,把当前等待的线程切换为阻塞状态。等到资源可用,将阻塞线程唤醒,进入Runnable状态等待CPU调度。
实例:例如synchronized
使用场景:比较适合写入操作频繁的场景
3.互斥锁
通过
排他性,同时只允许一个访问者对其进行访问来保证资源的有效同步,但无法限制线程对该资源的访问顺序
4.死锁
两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象,若无外力作用,他们都将无法推进下去。
死锁形成必须要求四个条件:
互斥条件:一个资源每次只能被一个线程使用
请求与保持条件:一个线程引请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:线程已获得的资源,在未使用完之前,不能强行剥夺
循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
从处理思路上,死锁问题一般可以分成几个层次来看:
- 预防:在设计阶段主动破坏死锁成立的必要条件。
- 避免:运行时判断资源分配是否会进入不安全状态。
- 检测:允许问题发生,但通过线程栈、监控或工具把死锁找出来。
- 恢复:通过中断、回滚、重启或人工介入让系统重新恢复可用。
业务开发里最常见的还是“预防 + 检测”组合:平时通过统一加锁顺序、缩小锁粒度等方式预防,出问题时再结合线程dump、日志和监控定位。
真正排查死锁时,最常见的路径通常是:
- 先导出线程dump,例如使用
jstack或线上线程栈采样工具。 - 找出处于
BLOCKED状态、长期等待锁的线程。 - 顺着“线程持有什么锁、又在等待什么锁”的关系往回看,确认是否形成循环等待。
- 再回到业务代码中检查加锁顺序是否一致、是否在持锁期间调用了外部逻辑、是否存在嵌套锁扩大了风险面。
常见死锁类型:
静态的锁顺序死锁
所有需要多个锁的线程,都要以相同的顺序获得锁1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class DeadLockTest{
private final Object lockA = new Object();
private final Object lockB = new Object();
public void a(){
synchronized(lockA){
synchronized(lockB){
System.out.println("func A")
}
}
}
public void b(){
synchronized(lockB){
synchronized(lockA){
System.out.println("func b")
}
}
}
}动态的锁顺序死锁
自定义锁的顺序,确保所有线程以相同的顺序获得锁协作对象之间发生的死锁
避免在持有锁的情况下调用外部的方法
死锁预防:
以确定的顺序获得锁
将所有的锁都按照特定顺序进行获取,防止死锁发生。
银行家算法:允许进程动态的申请资源,但在系统进行资源分配之前,先计算此次资源分配的安全性,若分配不会导致进入不安全状态,则分配;否则等待。
超时放弃
例如
synchronized只要线程没有获得锁,就会永远等待下去,Lock提供了tryLock()可以实现超时放弃