[13] 线程安全与锁优化
[13] 线程安全与锁优化

首先要保证并发的正确性,然后再在此基础上实现高效。

1. 线程安全

1.1 Brain Goetz 提出的线程安全定义

《Java 并发编程实践》作者 Brain Goetz 提出的线程安全(Thread Safety)定义:
「当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是的线程安全的。」

1.2 Java 中线程安全的体现

Java 语言中各种操作共享的数据分为 5 类,从强至弱的线程安全程度排序:不可变绝对线程安全相对线程安全线程兼容线程对立

不可变
在 JDK 5 时通过 JSR-133 修正 JMM 后,不可变(Immutable)的对象一定是线程安全的,无论对象的方法实现还是方法的调用者,都无需再采取任何线程安全的保障措施。
final 关键字修饰的基础数据类型,就可以保证其是不变的,外部的可见状态永远不会改变,在多个线程中永远保持一致。
final 关键字修饰的对象只要正确地被构建出来(没有发生 this 引用逃逸),其可见状态也是永远不会改变,多个线程中也永远保持一致。典型的不可变对象就是 java.lang.String 类的对象,调用其任何方法都不会影响其原来的值,只会返回一个新构造的 String 对象。
final 关键字修饰的成员字段,在构造方法结束之后,它就是不可变的,例如 java.lang.Integer 的构造函数,它通过将内部变量 value 定义为 final 来保障不变。
在 Java API 中符合不可变要求的类型:
  • enum 枚举类型
  • java.lang.String 字符串类型
  • java.lang.Number 的部分子类(如 LongDouble 等包装类和 BigIntegerBigDecimal 等大数据类型),对于原子类 AtomicIntegerAtomicLong 则并非不可变的(原子类需要 CAS 保证原子性,所以要在原地修改,而包装类创建新实例的过程不是原子操作)。
「不可变」带来的安全性是最简单和最纯粹的。

绝对线程安全
「不管运行时环境如何,调用者都不需要任何额外的同步措施。」
对于 Brain Goetz 定义中的这一段,就是绝对线程安全,一个类要达到这一定义,通常需要付出很大的代价,还不一定能够达成目的。在 Java API 中标注自己是线程安全的类,大多都不是绝对线程安全的。
例如 java.util.Vector 这个标注为线程安全的容器,其 add()get()size() 这些方法都是被 synchronized 关键字修饰的,尽管效率很低,但对于单个方法来说确实是安全的,其仅保证只有拿到这个锁的线程可以执行这个方法,无法保证其他线程不执行其他的方法。
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) ;
    }
}

/*
	运行结果:阶段性抛出异常
	Exception in thread "Thread-52963" 
  java.lang.ArrayIndexOutOfBoundsException: 
  Array index out of range: 11

	因为如果另一个线程恰好在错误的时间里删除了一个元素,
  导致序号i已经不再可用的话,再用i访问数组就会抛出一个数组越界异常。
*/
在 Vector 特定顺序调用的情况下不做同步手段会发生异常
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() {
								// 同步手段
                synchronized (vector) {
                    for (int i = 0; i < vector.size(); i++) {
                        vector.remove(i);
                    }
                }
            }
        });
        Thread printThread = new Thread(new Runnable() {
            @Override
            public synchronized void run() {
								// 同步手段
                synchronized (vector) {
                    for (int i = 0; i < vector.size(); i++) {
                        System.out.println((vector.get(i)));
                    }
                }
            }
        });
        removeThread.start();
        printThread.start();
        // 不要同时产生过多的线程,否则会导致操作系统假死
        while (Thread.activeCount() > 20) ;
    }
}

/*
	运行结果:正常打印
  在两个线程中加上同步块,并锁住vector对象。
*/
在 Vector 特定顺序调用的情况下加上同步手段来保证正确性
然而绝对线程安全追求的是调用它的时候永远都不再需要任何同步手段,java.util.Vector 做不到这一点,在多线程环境下进行特定顺序调用仍然需要做额外的同步措施。

相对线程安全
相对线程安全就是我们通常意义上所说的「线程安全」,它需要保证对这个对象单独的操作是线程安全的,在调用时也不需要做额外的保障措施,但对于一些特定顺序的连续调用,仍然需要在调用端使用额外的同步手段来保证正确性。
在 Java 语言中,大部分的线程安全类都属于相对线程安全这种类型。

线程兼容
线程兼容是指对象本身并不是线程安全的,但可以通过在调用端正确地使用同步手段来保证其在并发环境中可以安全使用,我们通常意义上说一个类不是线程安全的,绝大多数时候指的就是这种情况。
在 Java 语言中绝大部分的类都是线程兼容的,就如 VectorHashTable 相对应的 ArrayListHashMap 等集合类。

线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排除多线程的代码是很少见的,而且通常都是有害的,应当尽量避免。
线程对立例子:
  • Thread 类的 suspend() 和 resume() 方法:如果有两个线程同时持有一个线程对象,一个尝试去中断,一个尝试去恢复,并发进行的时候无论是否在调用时进行同步,目标线程都是存在死锁风险的,正因如此 JDK 已经声明废弃(@Deprecated)这两个方法了。
  • System.setIn()System.setOut() System.runFinalizersOnExit()

2. 线程安全的实现方法

2.1 互斥同步(Mutual Exclusion & Synchronization)

互斥同步(Mutual Exclusion & Synchronization)是最常见的一种并发正确性保障手段,提供了以「排它」的方式阻止共享数据被并发修改的方法。其最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)
  • 同步(Synchronization):指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条线程(使用信号量的时候是「一些」)使用。
  • 互斥(Mutual Exclusion):是实现同步的一种手段,临界区(Critical Section)互斥量(Mutex)信号量(Semaphore)都是实现互斥的主要方式。
    • 临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。
    • 互斥量:提供了以排它方式阻止共享数据被并发修改的方法。
    • 信号量:是一个计数器,可以用来控制多个进程对共享资源的访问,它常作为进程之间(有名信号量)以及同一进程内不同线程之间(无名信号量)的同步手段。
互斥是因,同步是果,互斥是方法,同步是目的。
互斥同步实则是一种悲观锁策略,它总是认为只要不去加锁,那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要调用系统函数(Linux 系统中为 mutex)来进行加锁(这里只是悲观锁的概念,实际上 JVM 会帮我们优化掉很大一部分不必要的加锁)。

synchronized 实现互斥同步
Java 中最基本的互斥同步手段就是 synchronized 关键字,synchronized 关键字经过编译后会在同步块的前后分别形成 monitorentermonitorexit 两个字节码指令,这两个指令都需要一个引用类型的参数来指明要锁定和解锁的对象,如果没有明确指定,就根据 synchronized 修饰的是实例方法还是类方法来获取对应的对象实例或 Class 对象来作为锁对象。
由于 Java 线程都是映射到操作系统的原生线程上的,对线程的唤醒、阻塞操作都需要由用户态转换到内核态中,对于简单的代码(如被 synchronized 修饰的 getter/setter),状态转换消耗的时间可能比用户代码执行的时间还要长,所以 synchronized 是 Java 语言中一个重量级(Heavyweight)的操作。

ReentrantLock 实现互斥同步
在 Java 中还可以通过 java.util.concurrent(J.U.C)包中的 ReentrantLock(可重入锁)来实现同步。与 synchronized 的区别:
  • synchronized 是原生语法层面的互斥锁,ReentrantLock 是 API 层面的互斥锁。
  • ReentrantLock 相较于 synchronized 多了一些高级功能:
    • 等待可中断:当持有锁的线程长期不释放锁时,正在等待的线程可以放弃等待,处理其他工作。
    • 公平锁(Fair Lock):多个线程在等待锁时,必须按照申请锁的时间顺序来依次获得锁,而非公平锁(默认)在锁释放时,任何正则等待的线程都有可能抢到锁。可以通过 new ReentrantLock(true) 来要求使用公平锁。
    • 锁绑定多个条件:一个 ReentrantLock 对象可以同时绑定多个 Condition 对象,只需多次调用 new Condition() 方法即可。而 synchronized 通过锁对象的 wait()notify()notifyAll() 方法最多再绑定一个条件。
  • 在 JDK 6 之前,ReentrantLock 的性能要比 synchronized 好很多,而在 JDK 6 时 synchronized 进行了优化,两者性能基本上已经完全持平。
J.U.C 中的各种 Lock 基本上都依赖于其核心队列式同步器( AQS) ,可以说 AQS 是一个对锁极大的实现、抽象和封装,将 CLH、CAS 以及更 native 层面的 Unsafe 类结合使用,堪称完美。Java 程序员不识 Doug Lea,写尽代码也枉然,不无道理。
JVM 在未来的性能优化中肯定也会更加偏向于原生的 synchronized,如果 synchronized 的功能足够实现需求的情况下,应优先考虑使用 synchronized 来进行同步,synchronized 的功能无法满足需求的情况下再去考虑使用 ReentrantLock

2.2 非阻塞同步(Non-Blocking Synchronization)

非阻塞同步(Non-Blocking Synchronization)是一种基于冲突检测的乐观锁策略,它总是认为不会出现问题,先去进行操作,如果没有其他线程争用共享数据,那就操作成功了。如果共享数据有竞争,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略许多实现都不需要把线程挂起,所以这种同步操作是非阻塞(Non-Blocking)的。
非阻塞同步的原子性实现基于处理器指令集,从硬件层面上来保证看起来需要多次操作的行为其实只通过一条处理器指令就能完成,这类常用的指令有:
  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-Swap,CAS)
  • 加载链接 / 条件存储(Load-Linked/Store-Conditional,LL/SC)
在 IA64、x86 指令集中有 cmpxchg 指令完成 CAS 功能,在 sparc-TSO 也有 casa 指令实现,而在 ARM 和 PowerPC 架构下,则需要使用一对 ldrex/strex 指令来完成 LL/SC 的功能。

CAS
CAS 指令有 3 个操作数,分别为:
  • 内存位置(V,在 Java 中可以简单理解为变量的内存地址)
  • 旧的预期值(A)
  • 新的预期值(B)
CAS 指令执行时,当且仅当 V 符合 A 时,处理器才用 B 更新 V 的值,否则就不执行更新,但无论是否更新了 V 的值,都会返回 V 的旧值,这一整个过程都是一个原子操作
在 JDK 5 时 Java 程序才可以使用 CAS 操作,该操作是由 sum.misc.Unsafe 类里面的 compareAndSwap*() 开头的几个方法包装提供,JVM 在内部对这些方法做了特殊处理(这种被 JVM 特殊处理的方法称为固有方法(Intrinsics),类似的固有方法有 Math.sin() 等),即时编译出来的结果就是一条平台相关的处理器 CAS 指令,没有方法调用过程,无条件内联到代码中。Unsafe 类并不是提供给用户程序调用的(Unsafe.getUnsafe() 的代码中限制了只有启动类加载器加载的 Class 才能访问它),因此只能通过反射手段,或者通过 J.U.C 包中的原子类中的包装方法来使用。

CAS 操作的 ABA 问题
CAS 的语义存在一个逻辑漏洞:如果一个变量 V 初次读取到的是 A 值,并不能说明没有被其他线程修改过,存在被其他线程被改成 B 值,后来又被改回 A 值的情况,而 CAS 操作就会误认为它从来没有被改变过,这就是 CAS 操作的 ABA 问题。
J.U.C 包为了解决这个问题,提供了一个带有标记的原子引用类 AtomicStampedReference,其通过控制变量的版本号来保证 CAS 的正确性。
不过这个类目前比较「鸡肋」,大部分情况下 ABA 问题也不会影响并发的正确性,如果确实需要解决 ABA 问题,改用传统的互斥同步可能会比使用原子类更高效。

2.3 无同步方案

要保证线程安全并不一定就需要进行同步,同步只是保证共享数据在竞争时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施来保证正确性,因此会有一些代码天生就是线程安全的。

可重入代码(Reentrant Code)
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
所有可重入的代码都是线程安全的,但并非所有线程安全的代码都是可重入的。
可重入代码的一些共同特征:
  • 不依赖存储在堆上的数据和公用的系统资源
  • 用到的状态都由参数传入
  • 不调用非可重入的方法
  • ...
可以通过简单的原则来判断代码是否具备可重入性:如果一个方法的放回结果是可预测的,只要输入了相同的数据,都会返回相同的结果,那么久满足可重入性的要求,也就是线程安全的。

线程本地存储(Thread Local Storage)
如果一段代码中所需需要的数据必须与其他代码共享,那就看这些共享数据的代码能否保证在同一个线程中执行,如果能保证,则可以把共享数据的可见范围限制在一个线程之内,这样无需同步也能保证线程间不出现数据竞争问题。
大部分使用消费队列的架构模式(如生产者-消费者模式)都会将产品的消费过程尽量在一个线程中消费完毕,其中最重要的一个应用实例就是经典 Web 交互模型中的「一个请求对应一个服务器线程(Thread-per-Request)」的处理方式,这种方式广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

ThreadLocal 实现线程本地存储
上文提到,在 Java 语言中,如果一个变量需要被多线程访问,可以使用 volatile 关键字保证它的可见性。
而如果一个变量需要被某个线程独享,则可以通过 java.lang.ThreadLocal 类来实现线程本地存储的功能:每一个线程的 Thread 对象中都有 ThreadLocalMap 对象,其中存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的键值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程键值对中找回对应的本地线程变量。

JVM 实现线程本地存储
JVM 也使用了线程本地存储的形式来保证局部变量的线程安全,也就是常说的栈封闭:多线程访问同一个方法的局部变量时,由于局部变量是存储在虚拟机栈中,而虚拟机栈是线程私有的,每个线程都有着自己所操作的那个虚拟机栈,每个线程的虚拟机栈又相互隔离,所以必然不会存在线程安全问题。

3. 锁优化

高效并发是从 JDK 5 到 JDK 6 的一个重要改进,HotSpot VM 开发团队在这个版本上花费了大量精力去实现各种锁优化技术。

3.1 自旋锁与自适应自旋(Adaptive Spinning)

互斥同步对性能最大的影响是阻塞的实现,挂起、恢复线程都需要切换到内核态中进行。HotSpot VM 开发团队注意到许多应用的共享数据的锁定状态只会持续很短一段时间,为了这段时间去挂起、恢复线程的开销并不值得,所以就有了自旋锁。
自旋锁在 JDK 1.4.2 中就已经引入,只不过是默认关闭的,可以使用 -XX+UseSpinning 参数开启,在 JDK 6 中就已经改为默认开启了。
自旋锁通过一个忙循环(自旋)使一个线程等待(等待其他线程释放锁),来替代挂起、恢复线程的操作。但自旋等待并不能代替阻塞,自旋本身还是会占用处理器时间,如果锁被占用的时间很短,那自旋等待的效果就非常好,反之则会白白浪费处理器资源。
所以自旋等待的时间一定要有限度,如果超过限定自旋次数仍然没有获得锁,则应当使用传统方式挂起线程。自旋次数默认值时 10 次,可以通过参数 -XX:PreBlockSpin 更改。
在 JDK 6 中引入了自适应的自旋锁(Adaptive Spinning),自旋的时间不再固定:
  • 如果在同一个锁对象上,该自旋在上一次刚刚获得过锁且目前持有锁的线程正在运行中,那么 JVM 就会认为这次自旋也很有可能再次成功,进而允许其自旋等待更长的时间。
  • 如果对于某个锁,自旋等待很少成功获得过,那么以后要获取这个锁时可能不会再进行自旋等待,而是直接挂起线程,避免浪费处理器资源。
随着程序运行和性能监控信息的不断完善,JVM 对程序锁的状况预测就会越来越准确。

3.2 锁消除(Lock Elimination)

锁消除(Lock Elimination)优化是指 JVM 的 JIT 编译器在运行时,会对一些在代码上要求同步(如 synchronized 同步块),但被检测到不可能存在共享数据竞争情况的锁进行消除。
锁消除的主要判定来源于逃逸分析的数据支持,如果判定出在一段代码中,堆上的所有数据都不存在线程逃逸(都不会被其他线程访问到)的话,那就可以把它们当做栈上数据对待,认为它们是线程私有的,自然就无需加锁同步。

3.3 锁粗化(Lock Coarsening)

如果一系列的连续操作都需要对一个对象反复加锁和解锁,甚至有些加锁操作是出现在循环体中的,那即时没有线程竞争,频繁地进行互斥同步操作,也会导致不必要的性能损耗。
如果 JVM 检测到有一串零碎操作都对同一个对象加锁,就会把加锁同步的范围扩展到整个序列的外部,只需加一次锁就可以了,这个过程就是锁粗化(Lock Coarsening)

3.4 轻量级锁(Lightweight Locking)

轻量级锁(Lightweight Locking)的「轻量级」是相对于传统的重量级锁而言的。
  • 重量级锁:在发生了多线程竞争,就会挂起线程使未获取到锁的线程进入阻塞状态。
  • 轻量级锁:在没有多线程竞争,但有多个线程交替执行的情况下,会避免调用挂起、恢复线程的操作。
轻量级锁和偏向锁都是依托于对象头中的 Mark Word 来实现的,所以 Mark Word 被设计成一个非固定的数据结构,锁状态的改变也会导致 Mark Word 的值和其结构上的改变。
64 位虚拟机的 Mark Word 布局
64 位虚拟机的 Mark Word 布局

加锁过程
  1. 在代码进入同步块时,如果此同步对象没有被锁定(锁标志位为 01),JVM 会先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间。
  1. JVM 接着会拷贝该对象目前的 Mark Word 到锁记录中(在锁记录中的 Mark Word 被官方称为 Displaced Mark Word)。
轻量级锁 CAS 操作之前堆栈与对象的状态
轻量级锁 CAS 操作之前堆栈与对象的状态
  1. 拷贝成功后 JVM 会使用 CAS 操作尝试将该对象的 Mark Word 更新为指向锁记录的指针,并将锁记录里的 owner 指针指向该对象的 Mark Word。
  1. 如果步骤(3)这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位(最后 2 bit)会被设置为 00
轻量级锁 CAS 操作之后堆栈与对象的状态
轻量级锁 CAS 操作之后堆栈与对象的状态
  1. 如果步骤(3)这个更新动作失败了,JVM 会先检查该对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经持有了这个对象的锁,可以继续执行同步块里的操作了。否则说明有两条以上的线程在竞争锁,轻量级锁就要膨胀为重量级锁,锁标志位的值也将被设置为 10,该对象的 Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
在只有两条以下的线程竞争时,轻量级锁使用 CAS 操作避免了使用互斥量的开销,但如果存在两条以上的线程进行锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,所以此时的轻量级锁会比传统的重量级锁更慢,因此需要膨胀为重量级锁,直接进行传统同步流程。

解锁过程
  1. JVM 使用 CAS 操作尝试将当前线程的栈帧中复制的 Displaced Mark Word 替换回此同步对象的 Mark Word。
  1. 如果这个替换动作(1)成功了,就说明解锁完成,该对象 Mark Word 的锁标志位会被设置回 01,整个同步过程就完成了。
  1. 如果这个替换动作(1)失败了,说明有其他线程尝试获取过该对象的锁(此时锁已然膨胀),那就要在释放锁的同时将该对象 Mark Word 的锁标志位设置回 01 并唤醒被挂起的线程。

3.5 偏向锁(Biased Locking)

引入偏向锁(Biased Locking)是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有再被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步,相当于无锁。
64 位虚拟机的 Mark Word 布局
64 位虚拟机的 Mark Word 布局

加锁过程
  1. 在代码进入同步块时,如果此同步对象没有被锁定(锁标志位为 01),且为可偏向(偏向模式为 1)状态。
  1. 如果步骤(1)为可偏向状态,则判断线程 ID 是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
  1. 如果线程 ID 并未指向当前线程,则通过 CAS 操作竞争锁。如果竞争成功,则将该对象 Mark Word 中的线程 ID 设置为当前线程 ID,然后执行(5);如果竞争失败,执行(4)。
  1. 如果使用 CAS 操作获取偏向锁失败,则表示有竞争,应当撤销偏向(Revoke Bias)。当到达全局安全点(Safe Point)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  1. 执行同步代码。

解锁过程
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(锁标志位设置为 01)或轻量级锁(锁标志位设置为 00)的状态。

偏向锁和轻量级锁的状态转化及对象 Mark Word 的关系
偏向锁和轻量级锁的状态转化及对象 Mark Word 的关系
偏向锁可以提高有同步但无竞争的程序性能,但它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那么偏向模式就是多余的。
在具体问题具体分析的前提下,有时候使用参数 -XX:-UseBiasedLocking 来禁止(在新的 JDK 15 中已被默认禁止)偏向锁优化反而可以提升性能。