第 13 章:线程安全与锁优化

本文最后更新于:1 年前

面向过程的编程思想:以算法为核心,把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据。
面向对象的编程思想:站在现实世界的角度去抽象和解决问题,把数据和行为都看作对象的一部分。

线程安全:

  • 定义:当多个线程同时访问一个对象时,(1)不用考虑这些线程在运行时环境下的调度交替执行(2)不用进行额外的同步(3)不用在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
  • 特征:代码本身封装了所有必要的正确性保障手段,令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用。
  • 根据线程安全程度由强到弱排序:
    • 不可变:
      • 基本数据类型:定义时使用 final 关键字修饰。
      • 对象Java 语言目前没有支持值类型:需要对象自行保证其行为不会对其状态产生任何影响。
        • 把对象里面带有状态的变量都声明为 final,这样在构造函数结束之后,它就是不可变的。
        • java.lang.String 是典型的不可变对象,它的方法只会返回一个新构造的字符串对象。
    • 绝对线程安全:
      • 完全满足线程安全的定义,但是需要付出非常高昂的,甚至不切实际的代价在 Java API 中标注自己是线程安全的类,大多数都不是绝对的线程安全
    • 相对线程安全:
      • 弱化了线程安全的定义,只需要保证对这个对象单次的操作是线程安全的,对于连续调用可能需要在调用端使用额外的同步手段来保证调用的正确性Java API 中大部分声称线程安全的类都属于这种类型
    • 线程兼容:
      • 对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用Java API 中大部分的类都是线程兼容的
    • 线程对立:
      • 不管调用端是否采取了同步手段,都无法在多线程环境中并发使用代码。
  • 同步手段:
    • 互斥同步也叫阻塞同步
      • 互斥是实现同步的一种手段,临界区互斥量信号量都是常见的互斥实现方式。
      • 属于悲观的并发策略:其总是认为只要不去做正确的同步措施,就肯定会出现问题,所以无论共享的数据是否真的会出现竞争,都会进行加锁增加了处理成本,虚拟机会优化掉很大一部分不必要的加锁
      • synchronized:
        • 一种块结构的同步语法,经过 javac 编译后,会在同步块的前后分别形成 monitorenter 和 monitorexit 字节码指令。
          • 这两个字节码指令都需要一个 reference 类型的参数来指定要锁定和解锁的对象。如果指定了对象参数,就以这个对象的引用作为 reference;如果没有指定对象参数,将根据 synchronized 修饰的方法类型,确定是取对象实例对应实例方法或类型对应的 Class 对象对应类方法来作为线程要持有的锁。
          • 执行 monitorenter 指令:首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁推论:被 synchronized 修饰的同步块对同一条线程来说是可重入的,就把锁的计数器的值增加一。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止推论:被 synchronized 修饰的同步块会无条件地阻塞后面其他线程的进入
          • 执行 monitorexit 指令:把锁的计数器的值减少一。一旦计数器的值为零,锁随即就被释放了。
        • synchronized 是 Java 语言中一个重量级的操作Java 线程是映射到操作系统的原生内核线程之上的,用户态与核心态的转换代价高,状态转换消耗的时间甚至会比用户代码本身执行的时间还要长
      • java.util.concurrent.locks.Lock 接口:
        • 非块结构,在类库层面实现同步。
        • 重入锁ReentrantLock:Lock 接口的实现。相比 synchronized 增加了功能:
          • 等待可中断:正在等待的线程可以选择放弃等待,改为处理其他事情。
          • 公平锁使用带布尔值的构造函数,非常影响性能:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
          • 锁可以绑定多个条件:可以同时绑定多个对象。
        • Lock 应该确保在 finally 块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放持有的锁。
    • 非阻塞同步:
      • 基于冲突检测的乐观并发策略:不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施比如不断地重试
      • 要求操作和冲突检测这两个步骤具备原子性,靠硬件保证某些看起来需要多次操作的行为可以只通过一条处理器指令就能完成:
        • 测试并设置。
        • 获取并增加。
        • 交换。
        • 比较并交换CAS
        • 加载链接/条件储存。
      • 【引入问题】CAS 的 ABA 问题。
        【解决问题】引入变量作为版本可能不比阻塞同步高效
    • 无同步方案有一些代码天生就是线程安全的
      • 可重入代码:不依赖全局变量、存储在堆上的数据、公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法。
      • 线程本地存储:如果能保证共享数据的代码在同一个线程中执行,就把共享数据的可见范围限制在同一个线程之内。
        * java.lang.ThreadLocal:每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的数据。
  • 锁优化:
    • 自旋锁:
      • 如果物理机器有两个或以上的处理器或者处理器核心,就可以让两个或以上的线程同时并行执行,就可以让后面请求锁的那个线程等待让线程执行一个忙循环(自旋),看看持有锁的线程是否很快就会释放锁。
      • 【引入问题】如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源。
        【解决问题】设置自旋的最大次数。如果自旋超过了限定次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。
        • 【引入问题】自旋限定次数值的确定。
          【解决问题】自适应自旋:由前一次在同一个锁上的自旋时间及锁的拥有者的状态来确定。
    • 锁消除:
      • 如果虚拟机即时编译器检测判定依据来源于逃逸分析的数据支持到要求同步的代码在运行中不可能存在共享数据竞争,这种锁就会被消除。
    • 锁粗化:
      • 如果一系列的连续操作都对同一个对象反复加锁和解锁甚至加锁操作是出现在循环体之中的,会导致不必要的性能损耗,虚拟机会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
    • 轻量级锁和偏向锁:

      • 轻量级锁:
        • 如果没有竞争,轻量级锁便通过 CAS 操作成功避免了使用互斥量重量级锁的开销但如果确实存在锁竞争,轻量级锁反而会比重量级锁更慢
        • 加锁过程:
        1. 在代码即将进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先在当前线程的栈帧中建立一个名为锁记录Lock Record的空间用于存储锁对象目前的 Mark Word 的拷贝——Displaced Mark Word,解锁时还得替换回来
        2. 虚拟机使用 CAS 操作尝试把对象的 Mark Word 更新为指向锁记录的指针
          如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象的 Mark Word 的锁标志位将变为 00表示此对象处于轻量级锁定状态
          如果这个更新动作失败了意味着至少存在一条线程与当前线程竞争获取该对象的锁,虚拟机会首先检查对象的 Mark Word 是否指向当前线程的栈帧,如果是说明当前线程已经拥有了这个对象的锁,就直接进入同步块继续执行,否则说明这个锁对象已经被其他对象抢占了轻量级锁必须膨胀为重量级锁锁标志的状态变为 10,此时 Mark Word 中存储的就是指向重量级锁的指针,后面等待锁的线程也必须进入阻塞状态。
        • 解锁过程:
          使用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来。
          如果能够替换成功,那整个同步过程就顺利完成了。
          如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
      • 偏向锁:
        • 在无竞争状态的情况下把整个同步都消除掉:这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
        • 加锁过程:
          当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为 01、把偏向模式设置为 1,表示进入偏向模式。并使用 CAS 操作把获取到这个锁的线程 ID 记录在对象的 Mark Word 之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
        • 解锁过程:
          一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向偏向模式设置为 0,撤销后标志位恢复到未锁定标志位为 01轻量级锁定标志位为 00,后续的同步操作按照轻量级锁执行
        • 【引入问题】偏向锁的 Mark Word 不存储哈希码。
          【解决问题】当一个对象已经计算过一致性哈希码后,就无法进入偏向锁定状态;当一个对象正处于偏向锁定状态,又需要计算其一致性哈希码时,偏向状态会被撤销,并且锁会膨胀为重量级锁