第13章 线程安全与锁优化
第一节:线程安全
- 概述:代码本身封装了所有的正确性保障手段。另调用者无须关心多线程的问题,也无须采取任何措施来保证多线程的正确调用。
一、java中的线程安全
概述:
- 线程安全由强到弱排序,共享数据分为五类
- 不可变
- 绝对线程安全
- 相对线程安全
- 线程兼容性
- 线程对立
1、不可变
- 不可变对象一定是线程安全的,无论方法的实现和调用者,都不需要在采取任何的安全措施。
- 只要一个不可变对象被正确构建出来,那么它的状态就永远不可变,永远不会出现多线程不一致的情况
2、绝对线程安全
- 不管运行环境如何,调用者都不需要任何额外的同步措施。成本非常高
- java API中标注线程安全类大部分都不是绝对线程安全的
3、相对线程安全(通常意义上的线程安全)
- 需要保证这个对象单独操作是线程安全的,调用时不用做额外的保障措施。对一些特定顺序的连续调用,可能在调用端使用额外的同步措施。
4、线程兼容性
- 对象本身不是线程安全的。但是调用端正确的使用同步手段,保证对象在并发情况下安全。
- 通常说这个类不是线程安全的,指的就是这种情况
5、线程对立(有害死锁)
- 无论在调用端是否采用了同步措施,都无法在多线程情况下并发使用代码
- 两个线程同时持有一个对象,一个尝试中断线程,另一个尝试修复线程,造成死锁
二、线程安全的实现方法
1、互斥同步
- 概述:悲观并发策略,不做正确同步就会出问题。
- 无论共享数据是否真的存在竞争,都要加锁(目前实际有优化)
- 同步:多线程并发访问共享数据,保证该数据同一时间只被一个线程使用。
- 互斥:实现同步的手段,临界区、互斥量、信号量都是实现同步的手段
synchronized重入锁,会被拆分成monitorenter和monitorexit两个字节码指令
- 这两个字节码都需要一个reference类型参数指定要锁定和解锁的对象。
- 执行
monitorenter时,线程尝试获取锁。
- 获取成功或已持有锁,执行
monitorenter时,锁计数器+1。执行monitorexit时,锁计数器-1。当锁计数器为0时,释放锁。
- 获取失败就要线程等待,锁被释放
synchronized对同一个锁是可以重入的,不会造成自己锁死自己
- 同步块进入线程,在执行完成之前,阻塞其他线程(自旋锁或直接挂起)
synchronized阻塞或唤醒线程,是在用户态和内核态之间切换,重量级锁开销大(虽然可能会采用自旋等待)
ReentrantLock重入锁
- 等待中断:持有锁的线程长期不释放,其他等待线程放弃等待,改为处理其他事情
- 公平锁:多个线程在同时等待锁时,必须按照时间上的申请前后依次获取。非公平锁随机获取(两个关键字均是非公平锁,但
ReentrantLock可以设置为公平锁)
- 锁绑定多个条件,一个
ReentrantLock可以绑定多个Condition对象。
ReentrantLock和synchronized在性能上差距不大
2、非阻塞同步
- 概述:基于冲突检测的乐观并发策略
- 先操作,如果没有其他线程争用共享数据,那操作就成功了
- 如果共享数据有争用,发生冲突,那就采用补偿措施(不断重试直至成功),乐观策略大多不会挂起线程
- 操作和冲突检测需要原子性
- 测试并设置
- 获取并增加
- 交换
- 比较并交换(compare-and-sweep,CAS)
- 加载连接/存储条件
- CAS指令操作(内存位置V,旧的预期值A,新值B)
- 当且仅当V符合旧预期值A时,处理器用B更新V,否则不更新。无论更新与否都要返回V的旧值,这是个原子操作
- 在New对象时,主内存中采用的就是CAS乐观并发+重试机制
- CAS无法解决ABA问题
- 假如初次读取变量V的值为A,准备赋值时发现V的值仍为A,CAS认为没有变化
- 实际,并不能确定这两次读取间隙,有没有其他线程修改了V值,然后又改回了A。
- 不影响并发
3、无同步方案
- 概述:同步是保证多线程争用共享数据时正确性手段,如果不涉及共享数据那么本身就是安全的。、
- 可重入代码(纯代码)
- 可以在代码执行的任意时刻中断,转而去执行其他代码,而在控制权返回后代码不会出现任何错误
- 不依赖堆上的数据和公用的系统资源、用到的状态量都由参数传入、不调用非可重入的方法等
- 如果一个方法的结果是可预测的,输入相同的数据,就能返回相同的结果,线程安全的
- 本地线程存储
- 把共享数据的可见范围控制在一个线程内
java.lang.ThreadLocal实现本地线程存储功能
- 在Eden空间分配新建obj空间的时候,也可以采用本地线程存储
第二节:锁优化
一、自旋锁和自适应锁
概述
- 挂起和恢复线程都需要在内核态和用户态之间切换,但共享状态数据锁定时间又很短,不值来回切换。
- 如果系统又多个物理核心,那么并发情况下,让请求锁的
线程B不放弃处理器执行时间,查看能否很快获取到锁,让线程B执行忙循环(自旋),被称之为自旋锁
- 自旋锁不能取代阻塞,等待一定限度,超过限度仍没有获取到锁,那就采用传统方式挂起(浪费了更多的资源)
JDK1.6自适应的自旋锁
- 自旋时间不固定,由前一次在同一个锁上的自旋时间及锁的拥有者状态决定的。
- 如果上一次在同一个锁对象上,自旋等待获取成功,且持有锁的线程在运行。那么jvm会认为本次仍然能成功,进而允许自旋锁等待更长的时间
- 如果对于某个锁,自旋很少获得成功。那么以后获取该锁时,可能会放弃自旋锁
二、锁消除
概述:
- jvm即时编译器在运行时,对一些代码上要求同步,但被检测到不存在共享数据竞争的锁进行消除。
- 如果判断一段代码,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那么就把它们当做栈上数据,认为线程私有
- 方法内部创建锁对象,这个对象外部不能访问,且方法结束,对象结束
三、锁粗化
概述:
- 尽量让同步代码块小,这样等待线程可以尽快拿到锁。
- 但是频繁的加锁、解锁,造成资源浪费。jvm探测到有这样连续对同一个对象加锁,会把加锁的同步范围扩展到整个操作的外部。
四、轻量级锁
1、对象头(Object Header)
- 一部分,对象存储自身的运行时数据(Mark-world,数组)
- 哈希码(HashCode)、GC分代年龄、偏向线程ID、偏向时间戳、指向锁记录的指针、指向重量级锁的指针
- 实现偏向锁和轻量级锁的关键
- 另一部分,存储指向方法区对象类型数据的指针,
- 如果是数组还有额外的部分存储数组长度
2、CAS轻量级加锁
- 代码进入同步块时,对象没有被锁定,jvm现在当前线程的栈帧中建立一个名为
锁记录(Lock Record)的空间,用于存储锁对象目前Mark-World的拷贝
- jvm使用CAS尝试将对象头中的
Mark-World的指针,指向栈帧中的锁记录(Lock Record)
- 更新成功,并且将
Mark-World的锁标志位修改为00,表示轻量级锁定。那么这个obj也就和对象栈帧中的 锁记录(Lock Record)建立了联系
- 更新失败,jvm先检查该对象的
Mard-World是不是指向本线程的栈帧内的锁记录(Lock Record)。如果是,那就继续执行。如果不是,就转化为重量级锁,线程阻塞
3、CAS轻量级解锁
- 如果对象的
mark-world指向栈帧中的锁记录(Lock Record),那就用CAS把当前对象的Mark-World和栈帧中锁记录(Lock Record)内容替换回来
- 替换成功,同步完成
- 替换失败,说明有其他线程尝试获取该锁,那就释放锁的同时,唤醒被挂起的线程。
总结:轻量级锁提升性能靠的是,对于绝大部分的锁,在整个存在周期中是不存在竞争的
- 如果没有竞争CAS回避了互斥量的开销
- 如果有竞争,会比重量级锁开销更大
五、偏向锁
概述:
- 这个锁会偏向于第一个获取它的线程,如果接下来的执行过程,该锁没有被其他的线程获取,则持有该偏向锁的线程无需在进行同步。
1、jvm启用偏向锁
- 锁对象第一次被线程获取的时候,会把对象头中的标志位设置为“01”,即偏向模式
- CAS操作会把获取到锁的线程ID记录在对象的
Mark-World
- 如果操作成功,持有偏向锁的线程以后每次进入这个锁同步块的时候,都不用进行任何同步操作
- 当有另一个线程尝试获取该锁的时候,偏向模式结束
2、偏向锁
- 偏向锁可以提高带有同步,但无竞争的程序性能。但不一定总是有利,如果锁总是被多个不同线程访问那么性能就不高