第13章 线程安全与锁优化

287 阅读9分钟

第一节:线程安全

  • 概述:代码本身封装了所有的正确性保障手段。另调用者无须关心多线程的问题,也无须采取任何措施来保证多线程的正确调用。

一、java中的线程安全

概述:

  • 线程安全由强到弱排序,共享数据分为五类
    • 不可变
    • 绝对线程安全
    • 相对线程安全
    • 线程兼容性
    • 线程对立

1、不可变

  • 不可变对象一定是线程安全的,无论方法的实现和调用者,都不需要在采取任何的安全措施。
  • 只要一个不可变对象被正确构建出来,那么它的状态就永远不可变,永远不会出现多线程不一致的情况

2、绝对线程安全

  • 不管运行环境如何,调用者都不需要任何额外的同步措施。成本非常高
  • java API中标注线程安全类大部分都不是绝对线程安全的

3、相对线程安全(通常意义上的线程安全)

  • 需要保证这个对象单独操作是线程安全的,调用时不用做额外的保障措施。对一些特定顺序的连续调用,可能在调用端使用额外的同步措施。

4、线程兼容性

  • 对象本身不是线程安全的。但是调用端正确的使用同步手段,保证对象在并发情况下安全。
  • 通常说这个类不是线程安全的,指的就是这种情况

5、线程对立(有害死锁)

  • 无论在调用端是否采用了同步措施,都无法在多线程情况下并发使用代码
  • 两个线程同时持有一个对象,一个尝试中断线程,另一个尝试修复线程,造成死锁

二、线程安全的实现方法

1、互斥同步

  • 概述:悲观并发策略,不做正确同步就会出问题。
    • 无论共享数据是否真的存在竞争,都要加锁(目前实际有优化)
  • 同步:多线程并发访问共享数据,保证该数据同一时间只被一个线程使用。
  • 互斥:实现同步的手段,临界区、互斥量、信号量都是实现同步的手段
  • synchronized重入锁,会被拆分成monitorentermonitorexit两个字节码指令
    • 这两个字节码都需要一个reference类型参数指定要锁定和解锁的对象。
    • 执行monitorenter时,线程尝试获取锁。
      • 获取成功或已持有锁,执行monitorenter时,锁计数器+1。执行monitorexit时,锁计数器-1。当锁计数器为0时,释放锁。
      • 获取失败就要线程等待,锁被释放
      • synchronized对同一个锁是可以重入的,不会造成自己锁死自己
      • 同步块进入线程,在执行完成之前,阻塞其他线程(自旋锁或直接挂起)
      • synchronized阻塞或唤醒线程,是在用户态和内核态之间切换,重量级锁开销大(虽然可能会采用自旋等待)
  • ReentrantLock重入锁
    • 等待中断:持有锁的线程长期不释放,其他等待线程放弃等待,改为处理其他事情
    • 公平锁:多个线程在同时等待锁时,必须按照时间上的申请前后依次获取。非公平锁随机获取(两个关键字均是非公平锁,但ReentrantLock可以设置为公平锁)
    • 锁绑定多个条件,一个ReentrantLock可以绑定多个Condition对象。
    • ReentrantLocksynchronized在性能上差距不大

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、偏向锁

  • 偏向锁可以提高带有同步,但无竞争的程序性能。但不一定总是有利,如果锁总是被多个不同线程访问那么性能就不高