第13章 线程安全与锁优化

103 阅读8分钟

13.1 概述

在一项工作进行期间,对象会被不停地中断和切换,对象的属性(数据)可能会在中断期间被修改和变脏,在计算机中这是再正常不过的事。所以我们必须让程序在计算机中正确无误地运行,然后再考虑如何将代码组织得更好,让程序运行得更快。要保证程序正确运行,首先需要确保并发的正确性。

13.2 线程安全

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

13.2.1 Java 语言中的线程安全

我们可以将 Java 语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

  1. 不可变:不可变(Immutable)的对象一定是线程安全的。
  2. 绝对线程安全:满足上面线程安全的定义。
  3. 相对线程安全:需要保证对这个对象单次的操作是线程安全的,调用时不需要进行额外的保障措施。Java语言中大部分声称线程安全的类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronziedCollection() 方法包装的集合等。
  4. 线程兼容:指对象本身并不是线程安全的,但可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。如 ArrayList 和 HashMap 等。
  5. 线程对立:指不管调用端是否采取了采取了同步措施,都无法在多线程环境中并发使用。

13.2.2 线程安全的实现方法

1. 互斥同步

互斥同步(Mutual Exclusion & Synchronization)指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量时)线程使用。常见的互斥实现方式有临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)。

临界区:导致竞态条件发生的代码区称作临界区。在临界区中使用适当的同步就可以避免竞态条件。

互斥量:使用 sleep 和 wake up 原语,保证同一时刻只有一个线程进入临界区的锁叫作互斥量。

信号量:把互斥量推广到 N 的空间,同时允许有 N 个线程进入临界区的锁叫作信号量。

synchronized

  • synchronized 关键字经过 Javac 编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 两个字节码指令
  • 这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象(指定对象/代码所在的对象实例/类型对应的 Class 对象)。
  • 执行 monitorenter 指令时,首先要去尝试获取对象的锁。如果取到就把锁的计数器值加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦计数器为零时,锁随即就被释放了。

ReentrantLock

  • 等待可中断:正在等待的线程可以选择放弃等待;
  • 公平锁:默认非公平,可以通过入参要求使用公平锁。但公平锁性能较低;
  • 锁绑定多个条件:可以同时绑定多个 Condition 对象。

当 synchronized 和 ReentrantLock 都可满足需要时优先使用 synchronized,理由如下:

  • synchronized 是在 Java 语法层的同步,清晰简单;
  • ReentrantLock 需要确保在 finally 块中释放锁,才能保证锁会正常释放;而 synchronized 则可以由 JVM 来确保即使出现异常,也能自动释放锁。
2. 非阻塞同步

基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的就是不断地重试,直到出现没有竞争的共享数据为止。

CAS

CAS(Compare-and-Swap) 指令需要有三个操作数,分别是内存位置(在 Java 中可以简单地理解为变量的内存地址,用 V 表示)、旧的预期值(用 A 表示)和准备设置的新值。CAS 指令执行时,当且仅当 V 符合 A 时,处理器才会用 B 更新 V 的值,否则它就不执行更新。

ABA 问题 -> 带版本号的原子引用类 AtomicStampedReference

3. 无同步方案

可重入代码

可重入代码(Reentrant Code),又称纯代码(Pure Code),是指可以在代码执行的任何时刻去中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。特征:例如不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可用重入的方法。

线程本地存储

线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一具线程中执行。如果可以,我们就把共享数据的可见范围限制在同一具线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

实现:ThreadLocal

13.3 锁优化

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给 Java 虚拟机的并发性能带来了很大的压力。

13.3.1 自旋锁与自适应自旋

自旋锁: 物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等”一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这就是所谓的自旋锁。

自适应自旋:如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能会再次成功,进而允许自旋等待持续相对更长的时间;反之,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

13.3.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。

13.3.3 锁粗化

如果虚拟机探测到一串零碎的操作都对同一个对象加锁,交付把加锁同步的范围扩展(粗化)到整个操作序列的外部。

13.3.4 轻量级锁

对象头(Object Head)

对象头.png

轻量级锁工作过程:

  1. 在代码即将进入同步块的时候,此时对象没有被锁定,锁标志位为 01 表示无锁状态
  2. 虚拟机使用 CAS 尝试获取锁,如果成功了,则把标志位转变为 00 表示轻量级锁状态;否则,说明至少存在一条线程与当前线程竞争获取该对象的锁,轻量级锁膨胀为重量级锁,锁标志变为 00。

轻量级锁在没有竞争的情况下,通过 CAS 操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 CAS 操作的开销,因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

13.3.5 偏向锁

偏向锁的工作过程:

  1. 当锁对象第一次被线程获取的时候,虚拟机将会把锁标志设置为 01,把偏向模式设置为 1,表示进行偏向模式;同时记录持有锁的线程 ID,之后该线程每次进入这个锁相关的同步块时,虚拟机都不再进行任何同步操作。
  2. 一旦出现另外一个线程去尝试获取这个锁,偏向模式就马上宣告结束,撤销偏向,横向模式变为 0。如果对象未锁定,锁标志变为 01 无锁;反之如果对象已锁定,锁标志变为 00 轻量级锁。

由于记录持有锁的 ID 会占用 哈希码的位置,因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。