第十三章 线程安全与锁优化

111 阅读17分钟

1 概述

在软件发展初期,程序员会把数据和过程分别作为独立部分来考虑,数据代表问题空间的客体,而程序代码用户处理这些数据。这种思维方式直接站在计算机的角度去抽象和解决,被称为面向过程的编程思想。于此相对的,站在现实世界的角度去抽象和解决问题,它把数据和行为都看作对象的一部分,让程序员可以以符合现实世界的思维方式来编写和组织程序。面向对象的编程思想极大的提高了现代软件开发的效率和软件可以达到的规模。

2 线程安全

粗略的说,就是一个对象可以安全的同时被多个线程使用,那它就是线程安全的。从更精确的定义上来说:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方法进行其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么就称这个对象时线程安全的。即多个线程访问同一个对象时,不考虑环境、交替执行,不进行同步、不需要额外的协调,也能获得正确的结果,那这个对象就是线程安全的。

2.1 Java 语言中的线程安全

讨论线程安全的前提是:多个线程之间存在共享数据访问。如果不存在多个线程,或者不存在共享数据,那么也就没有什么线程安全问题。

为了更深入理解线程安全,我们可以把线程安全不当作一个二元问题看,而是按照安全程度由强到弱来排序。Java 语言中各种共享数据可以分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立。

  • 不可变:在 Java 中,不可变的对象一定是线程安全的。

比如被 final 修饰的对象,如果是不可变得基本类型,它的值也是不可变的。如果不可变的是对象,需要对象自己去保证它的行为不会对其他状态产生影响。比如 String,用户调用 substring()、replace()、concat() 都不会影响到它原来的值,只会返回一个新构造的字符串对象。

  • 绝对线程安全:绝对线程安全能够完全满足上面线程安全的定义。

对于 Java 的 Vecotor 容器,它的 add()、get()、size() 等方法都是被 synchronized 修饰的,它们的每个方法都保证了原子性、可见性、有序性。但是这不意味着它是绝对线程安全的,考虑以下情况,有可能出现数组越界,因为 printThread 可能读取到 remove 的 i:

private static Vector<Integer> vector = new Vector<Integer>(); 
public static void main(String[] args) { 
    while (true) { 
        for (int i = 0; i < 10; i++) { 
            vector.add(i); 
        }
        Thread removeThread = new Thread(new Runnable() {
             @Override public void run() { 
                for (int i = 0; i < vector.size(); i++) {
                     vector.remove(i); 
                } 
            } 
        }); 
        Thread printThread = new Thread(new Runnable() {
             @Override public void run() { 
                for (int i = 0; i < vector.size(); i++) {
                     System.out.println((vector.get(i))); 
                } 
            } 
        }); 
        
        removeThread.start(); 
        printThread.start(); 
        //不要同时产生过多的线程,否则会导致操作系统假死 
        while (Thread.activeCount() > 20) ; 
    } 
}
  • 相对线程安全:相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象的单次操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端额外的同步手段来保证调用的正确性,比如上面绝对线程安全中举例的 Vector,还包括 HashTable、Collections 的包装方法等集合。
  • 线程兼容:线程兼容是指对象本身不是线程安全的,但是我们可以正确的使用同步手段来保证对象在并发环境中可以安全的使用。
  • 线程对立:线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中使用代码。由于 Java 天生支持多线程,所以线程对立这种现象很少见。

其中一个例子是 Thread 类的 suspend() 和 resume() 方法。比如线程 A、B 去操作线程 C,线程 A 去 suspend() 线程 C,线程 B 去 resume() 线程 C,此时分两种情况,如果被 suspend() 的代码中存在锁,那么 A 持有锁后不会释放, B 获取步到锁执行不了 resume(),导致死锁。另一种情况是没有锁,但是 resume() 比 suspend() 先执行,也会导致 C 线程一直挂起,无法释放。

2.2 线程安全的实现方法

线程安全主要是解决线程兼容情况下的线程安全问题。

1 互斥同步

互斥同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条线程使用。互斥是实现同步的一种手段,临界区、互斥量、信号量是常见的互斥实现方式。

在 Java 里最基本的互斥同步手段就是 synchronized 关键字,它是一种块结构的同步语法,synchronized 关键字在编译后,会在同步代码块的前后插入 monitorenter 和 monitorexit 这两个字节码指令。这两个字节码指令需要一个引用类型的数据来指明要锁定和解锁的对象。如果 synchronized 中指定对象参数,就以这个引用类型对象作为持有的锁。如果没有指定,就会根据是同步的类方法还是实例方法来决定是以当前类型的 Class 对象还是当前实例对象作为线程要持有的锁。

Java 虚拟机规定,执行 monitorenter 时,首先要求获取对象锁,如果这个对象没有被锁定,或者当前对象已经持有了这个对象锁,就把锁的计数器加一,而 monitorexit 指令会把计数器值减一,一旦计数器值为零,锁随即被释放。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

由此我们可以得出:

  • synchronized 是可重入锁,这意味着同一线程反复进入同步块也不会出现把自己锁死。
  • synchronized 同步块在持有锁的线程执行并释放锁之前,会无条件阻塞后面其他线程的进入。这意味着无法强制以获取锁的线程释放锁,也无法强制正在等待锁的线程中断等待或者超时退出。
  • 从执行成本看,synchronized 是一个重量级锁,因为线程的阻塞或者唤醒都需要操作系统来完成,这需要从用户态到内核态切换,成本很高。

除了 synchronized 之外,从 JDK5 开始,Java 还提供了 JUC 包,其中的 Lock 接口成了另一种全新的互斥手段。基于 Lock 接口,用户可以以非块结构来实现互斥同步,它摆脱了语言特性的束缚(不是语法),而是在类库层面实现,为日后扩展出不同的算法提供了基础。

重入锁(ReentrantLock)是 Lock 接口最常见的一种实现,它与 synchronized 区别主要在于,它增加了等待可中断、可以实现公平锁、以及锁可以绑定多个条件。

  • 等待可中断:指当持有锁的线程长时间不释放锁,等待线程可以主动放弃等待,改为去处理其他事情。
  • 公平锁:指多个线程在等待同一个锁时,按照申请锁的时间顺序来依次获得锁。非公平锁是任意一个等待线程都有机会获取到锁,ReentrantLock 默认是非公平锁。
  • 锁绑定多个条件:是指 ReentrantLock 对象可以同时绑定多个 Conditon 对象。在 synchronized 中,锁对象的 wait()跟它的 notify() 或者 notifyAll() 方法配合可以实现一 个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而 ReentrantLock 则无须这样做,多次调用 newCondition()方法即可。

synchronized 与 ReentrantLock 使用建议:在 都满足需要时,优先使用 synchronized。理由如下:

  • synchronized 是 Java 语法层面的同步,足够清晰、简单。
  • Lock 应该确保在 final 块中释放,否则同步代码块中抛异常,有可能永远不会释放锁。这一点必须由程序员自己保证,而 synchronized 可以由 Java 虚拟机来保证发生异常后,锁能被自动释放。
  • 优化后的 synchronized 性能已经不熟 Lock 锁了。

2 非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒锁所带来的性能开销,因此互斥同步也被称为阻塞同步,从解决问题的方式上看,互斥同步属于一种悲观的并发策略。另一种是基于冲突监测的乐观并发策略,简单说就是先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果遇到了争用,再进行补偿,常用的补偿措施是重试。这种乐观的并发策略不需要阻塞和挂起线程,因此称为非阻塞同步,使用这种措施的代码常被称为无锁编程(乐观锁)。

乐观并发策略依赖于硬件指令集,其中常用的指令有:

  • 测试并设置(Test-and-Set);
  • 获取并增加(Fetch-and-Increment);
  • 交换(Swap);
  • 比较并交互(Compare-and-Swap,简称CAS);
  • 加载链接/条件储存(Load-Linked/Store-Conditional,下文称 LL/SC)。

Java 语言里最终暴露出来的是 CAS 操作,CAS 需要三个操作数:内存地址、预期的旧值、准备设置的新值。当内存地址的值与预期的旧值相同,然后用新值替换旧值,并返回旧值。这个过程是一个原子操作,不会被其他线程中断。

CAS 看起来简单高效,但是它无法涵盖互斥同步所有使用场景,并且它存在 ABA 问题。 Java 提供了 AtomicStampedReference ,带有版本标记的原子引用类开解决 ABA 问题。但是这比较鸡肋,因为 ABA 不会影响程序并发真确性。

3 无同步方案

要保证线程安全,也并非一定要进行阻塞或者非阻塞同步,同步与线程安全之间没有必然的联系。如果没有数据共享和竞争,也就不需要任何措施去保证它的正确性。有一些代码天生就是线程安全的:

  • 可重入代码:这类代码又叫纯代码。可重入代码是指代码执行在任何时刻中断它,转而执行其他代码,然后再恢复执行,原来的程序不会不会出现任何错误。可重入代码的一些特征:不依赖全局变量、不依赖堆上数据、不依赖公用系统资源,不调用非可重入的方法等。
  • 线程本地存储(Thread Local Storage):如果一些数据需要与其他代码进行共享,并且这些共享代码再同一个线程中执行。我们可以把共享数据的访问范围限制在同一个线程内,这样就保证线程之间不出现数据争用问题。比如 Java 的 ThreadLocal。

3 锁优化

3.1 自旋锁与自适应自旋

由于互斥同步对性能最大的影响是阻塞的实现,挂起和恢复线程操作都需要转入内核态中完成,这个给虚拟机并发性带来了很大的压力。同时虚拟机开发团队发现大多数应用共享数据的锁持续时间很短,为了这很短的时间去挂起和恢复线程不划算。所以可以让线程陷入忙等(自旋),等待持有锁的线程释放锁,这就是自旋锁。

最开始自旋的时间都是固定,但是对于不同的锁需要等待的时间不一定相同,所以 JDK6 引入了自适应锁。自适应锁的自旋时间并不固定,而是由前一次在同一个锁上自旋时间及锁的拥有者的状态来决定的,如果同一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行,那么虚拟机会认为这次自旋也很可能成功,进而允许自旋等待持续相对更久的时间。如果某个锁自旋很少成功获得锁,那在以后要获取这个锁时有可能直接省略掉自旋过程。随着程序运行时间增长及性能监控信息不断完善,虚拟机对程序锁的状况预测也会越来越精准。

3.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析数据支持。如果判断一段代码中, 在堆上的所有数据都不会逃逸出去被其他线程访问到, 那就可以当作栈上数据对待。 认为它们是线程私有的, 同步加锁就无需进行。 锁消除大部分来自于框架中的锁, 比如不会有线程竞争的字符串拼接 sb.append(s1);

3.3 锁粗化

原则上,我们编写代码时需要尽可能讲同步块作用与很小的范围, 但是如果一系列操作堆同一个对象反复加锁和解锁, 甚至在循环体中出现加锁解锁, 这种会造成很多不必要的性能开销, 可以将锁的范围扩大粗化。

3.4 轻量级锁

轻量级锁时相对应使用操作系统互斥量来实现的传统锁而言的, 因此传统的锁机制就被称为重量级锁。 轻量级锁不是用来替代重量级锁的, 它的设计初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

轻量级锁和后面的偏向锁,都依赖于 HotSpot 虚拟机对象的内存布局,主要依赖于 HotSpot 虚拟机对象头的布局。对象头分为两部分,一部分存储对象自身的运行时数据,比如哈希码、GC 分代年龄等。这部分在 32 位或者 64 位虚拟机种会分别占用 32 个或者 64 个比特。官方称为 Mark Word。这部分是 轻量级锁和偏向锁的关键。另一部分用于存储指向方法区对象类型数据的指针,如果是数组,还会有一个额外的部分用于存储数组长度。

由于对象头信息是与对象自身的定义数据无关的额外存储成本,并且考虑到虚拟机空间的使用效率, Mark Word 被设计成一个非固定的动态数据结构,以便在极小的空间存储更多的信息。以 32 位的 HotSpot 虚拟机为例:在对象未被锁定的状态下,Mark Word 的 32 个比特空间中,25 个被用于存储对象的哈希码,4 个被用于存储分代年龄,2 个用于存储锁标志位,还有 1 个固定为 0 表示没有进入偏向模式。 image.png

轻量级锁的加锁过程:在代码进入同步块时,如果同步对象没有被锁,此时锁标志位为 01,虚拟机首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,然后把同步对象的 Mark Word 拷贝到锁记录里,这个拷贝叫做 Displaced Mark Word。 image.png

然后虚拟机用 CAS 尝试把对象的 Mark Word 更新为指向 Lock Record 的指针。如果操作成功表示该线程拥有了这个锁对象,并且Mark Word 的锁对象标志更新为 00,表示此对象处于轻量级锁状态。

如果更新失败,意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机会首先检查 Mark Word 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有这个对象的锁,那直接进入同步块继续执行即可。否则说明这个对象锁已经被其他线程抢占了。如果出现两条以上的线程竞争同一个锁的情况,那么轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志位为 10,此时 Mark Word 中存储的就是指向重量级锁的指针,后面等待的线程也必须进入阻塞状态。

解锁过程:解锁也是通过 CAS 来操作,如果 Mark Word 依然指向当前线程的锁记录,就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来。如果替换成功,那整个同步过程就完成了,如果替换失败了,则说明有其他线程尝试获取该锁,就需要在释放锁的同时,唤醒被挂起的线程。

轻量级锁提升同步性能的依据是,在整个同步周期内都不存在竞争的经验前提下,轻量级锁是避免了使用互斥的开销。如果确实存在竞争,除了互斥的开销,还会发生 CAS 操作开销,轻量级锁的开销反而会比重量级锁更慢。

3.4 偏向锁

轻量级锁是在无竞争情况下使用 CAS 操作消除同步使用的互斥量,偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 都不做了。

当虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志位设置为 01, 把偏向模式设置为 1, 表示进入偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 中,如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

一旦出现另外一个线程尝试去获取这个锁的情况,偏向模式马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为 0),撤销后标志位恢复到未锁定或者轻量级锁定的状态,后续的同步操作按照轻量级锁去执行。

当对象进入偏向状态时,Mark Word 大部分空间用于存储持有锁的线程 ID 了,那哈希码存在哪儿呢?当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;当一个对象正处在偏向锁状态,又收到需要计算它的一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁,在重量级锁里,对象头指向了重量级锁的位置,代表重量级锁的 ObjectMonitor 类里有字段可以记录非加锁状态(标志位为“01”)下的 Mark Word,其中 自然可以存储原来的哈希码。

偏向锁的收益主要来自锁被同一个线程访问,如果程序中的锁总是被多个不同的线程访问,那偏向模式就是多余的。