回到顶部 暗色模式

JVM(10):线程安全与锁优化

1. 线程安全

        按照线程安全程度由强到弱,可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1.1 不可变

        在Java语言里,不可变 ( $Immutable$ ) 对象一定是线程安全对象,因此不需要进行任何线程安全保障措施。对于基本数据类型,只需要在定义时使用 $final$ 关键字即可保证不可变。而对于对象类型,Java语言目前暂时还没有提供支持,因此只能让对象自行保证行为不会影响状态,其中最简单的一种方式就是把所有带有状态的变量都声明为 $final$ 。

1.2 绝对线程安全

        绝对线程安全指的是当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果。而实际上,要想达到绝对线程安全可能需要付出非常高昂的代价。Java API中标注线程安全的类,大多数都不是绝对线程安全的。例如 $java.util.Vector$ 是一个线程安全的容器,因为它的很多方法被 $synchronized$ 修饰,但是在以下情况下还是线程不安全的:

private static Vector<Integer> vector = new Vector<>();

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();
    }
}

        而要想做到线程安全,需要将上述代码改为如下形式:

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 void run() {
        synchronized (vector) {
            for (int i = 0; i < vector.size(); i++)
                System.out.println(vector.get(i));
        }
    }
});

        而 $Vector$ 要想做到绝对线程安全,就需要在内部维护一组一致性的快照访问,在每次对元素进行改动的时候都要产生新的快照,因此需要付出极大的空间成本和时间成本。

1.3 相对线程安全

        相对线程安全是我们通常意义上的线程安全,需要保证对对象单次的操作是线程安全的,即将上述绝对线程安全的定义中的“调用这个对象的行为”改为“单次调用”。Java API中大部分标注线程安全的类都属于相对线程安全,比如上面提到的 $java.util.Vector$ 类。

1.4 线程兼容

        线程兼容是指对象本身不是线程安全的,但是可以通过在调用端正确地使用同步手段保证对象在并发环境中安全使用,也就是我们通常所说的线程不安全类。Java API中大部分类都是线程兼容的。

1.5 线程对立

        线程对立是指不管调用端采取了什么同步措施都不能在多线程中并发使用代码。线程对立的代码极少出现,而且通常都是有害的,应当尽量避免。

2. 线程安全的实现

2.1 互斥同步

        互斥同步 ( $Mutal\ \ Exclusion\ \ \And\ \ Synchronization$ ) 是最常见也是最主要的实现手段。同步即保证共享数据在一个时刻只能被一个线程使用,互斥则是实现同步的手段。Java内最基本的互斥同步手段就是 $synchronized$ 关键字,经过Javac编译后生成 $monitorenter$ 和 $monitorexit$ 字节码指令。这两条指令都要指定一个 $reference$ 类型的参数,如果源码中指定的是对象,那么就以对象作为参数;如果没有明确指定,则根据其修饰的方法类型决定是以对象实例作为参数还是以 $Class$ 对象作为参数。执行 $monitorenter$ 时,如果对象没有被锁定,或者已经持有对象锁,就把锁计数器值加一,并在执行 $monitorexit$ 时减一。如果对象锁计数器为零,代表锁被释放。持有的对象锁只能被当前线程所释放,另一个线程无法强制释放,只能被阻塞。
        从执行成本的角度来看,持有锁是一个重量级 ( $Heavy-Weight$ ) 操作。在采用操作系统原生内核线程的情况下,阻塞或者唤醒一个线程都需要操作系统完成,因此需要进行用户态和核心态的转换,所以应当只有在必要的情况下才使用 $synchronized$ 。虚拟机也会进行一些优化,比如在阻塞之前加入一段自旋等待过程,从而避免频繁切换到核心态。
        除了 $synchronized$ 外,我们也可以使用 $java.util.concurrent$ 包,其中的 $locks.Lock$ 接口允许用户以非块结构完成互斥同步。重入锁 ( $ReentrantLock$ ) 是最常见的一种实现,它与 $synchronized$ 一样是可重入的,而且还增加了一些高级功能:等待可中断(等待线程可以放弃等待)、公平锁(按照申请顺序获得锁,但会导致性能下降)、锁绑定多个条件(一个 $ReentrantLock$ 可以绑定多个 $Condition$ 对象)。

2.2 非阻塞同步

        互斥同步在线程阻塞和线程唤醒的时候会带来额外开销,因此也被称为阻塞同步 ( $Blocking\ \ Synchronization$ ),从解决方式上来看属于悲观锁。而与之相反的基于冲突检测的乐观并发策略就是允许线程进行操作,如果在操作过程中出现了冲突则进行补偿,最常见的补偿措施是不断重试。这种方式称为非阻塞同步 ( $Non-Blocking\ \ Synchronization$ ),使用这种措施的代码也被称为无锁 ( $Lock-Free$ ) 编程。乐观并发策略要求操作和冲突检测的步骤具备原子性,因此需要依赖一定的硬件指令集。

2.3 无同步

        保证线程安全并不一定需要进行阻塞或者非阻塞同步,同步只是一个保证数据正确的手段。如果能够让一个方法本来就不涉及共享数据,那么就不需要同步。这类型的典型例子是可重入代码 ( $Reentrant\ \ Code$ ) 和线程本地存储 ( $Thread\ \ Local\ \ Storage$ )。
        可重入代码是指可以在执行的任何时刻中断,并在控制权返回后也不会出错的代码。我们可以把可重入作为线程安全的一个条件,但并不是所有的线程安全代码都要求可重入。可重入代码具有一些共同特征,如不依赖全局变量,存储在堆上的数据和公用的系统资源、状态量由参数传入、不调用非可重入方法等。如果一个方法的返回结果是可预测的,那么就可以认为是可重入代码。
        线程本地存储是指如果一段代码中所需要的数据必须与其他代码共享,并且这些共享数据的代码可以在同一个线程中执行,那么就把共享数据的可见范围限制在同一个线程中。符合线程本地存储的典型例子就是“生产者-消费者模式”。Java语言中并没有方式让一个变量被某个线程独占,但是可以通过 $java.lang.ThreadLocal$ 类实现本地存储。

3. 锁优化

3.1 自旋锁与自适应自旋

        互斥同步在阻塞和唤醒的过程中需要完成用户态和核心态的转换,为了避免频繁转换,可以让线程执行一个忙等待,这就是自旋锁。自旋等待虽然避免了线程切换的开销,但是还是会占用处理器时间。所以如果锁被占用的时间很短,那么自旋等待的效果就会很好,反之会很差。因此可以给自旋等待设置一个时间限度,在限定次数内没有获得锁的线程就使用传统方式挂起,这个值默认是十次,可以通过 $-XX:PreBlockSpin$ 设置。
        JDK 6中引入了自适应自旋,这使得自旋等待的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。如果在同一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机就认为这次自旋很有可能再次成功,从而允许自旋等待更长时间。

3.2 锁消除

        锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除,主要判定依据是逃逸分析。Java语言内部存在许多同步代码,锁消除主要是针对Java语言内部的锁。

3.3 锁粗化

        大多数情况下,我们应当把同步块的作用范围限制到尽量小,从而减少同步操作。但是如果一系列的连续操作都对同一个对象进行上锁和解锁,那么会带来额外的性能损耗。锁粗化就是针对这种情况,扩展同步块的作用范围,从而避免额外的性能损耗。
        锁的升级很容易发生,但是锁降级发生的条件比较苛刻,锁降级发生在 $Stop\ \ The\ \ World$ 期间,当JVM进入安全点时,会检查是否有闲置的锁,然后进行降级。

3.4 对象头

        每个Java对象都有对象头。如果是非数组类型,会使用 $2$ 字来存储对象头;如果是数组,会使用 $3$ 个字。在 $32$ 位虚拟机中,一个字是 $32$ 位;在 $64$ 位虚拟机中,一个字是 $64$ 位。对象头的内容如下:

长度 内容 说明
$32$ / $64$ $Mark\ \ Word$ 存储对象的 $hashCode$ 或锁信息等
$32$ / $64$ $Class\ \ Metadata\ \ Address$ 存储指向对象类型元数据的指针
$32$ / $64$ $Array\ \ Length$ 如果是数组,会在此存储数组长度

        $32$ 位虚拟机的 $Mark\ \ Word$ 状态如下:

|------------------------------------------------------------------|--------------|
|                     Mark Word (32 bits)                          |     状态     |
|------------------------------------------------------------------|--------------|
|          哈希码(25)        | 年龄(4) | 偏向锁标记(1) | 锁标记(2) | 普通 ( 无锁 )|
|------------------------------------------------------------------|--------------|
| 线程ID(23) | 偏向时间戳(2) | 年龄(4) | 偏向锁标记(1) | 锁标记(2) |    偏向锁    |
|------------------------------------------------------------------|--------------|
|                  栈中锁记录指针(30)                  | 锁标记(2) |   轻量级锁   |
|------------------------------------------------------------------|--------------|
|                    互斥量指针(30)                    | 锁标记(2) |   重量级锁   |
|------------------------------------------------------------------|--------------|
|                                                      | 锁标记(2) |    GC标记    |
|------------------------------------------------------------------|--------------|

        $64$ 位虚拟机的 $Mark\ \ Word$ 状态如下:

|----------------------------------------------------------------------------======------|---------------|
|                                      Mark Word (64 bits)                               |      状态     |
|----------------------------------------------------------------------------------------|---------------|
|    未使用(25)    |     哈希码(31)    | 未使用(1) | 年龄(4) | 偏向锁标记(1) | 锁标记(2) | 普通 ( 无锁 ) |
|----------------------------------------------------------------------------------------|---------------|
|      线程ID(54)      | 偏向时间戳(2) | 未使用(1) | 年龄(4) | 偏向锁标记(1) | 锁标记(2) |     偏向锁    |
|----------------------------------------------------------------------------------------|---------------|
|                          栈中锁记录指针(62)                                | 锁标记(2) |    轻量级锁   |
|----------------------------------------------------------------------------------------|---------------|
|                            互斥量指针(62)                                  | 锁标记(2) |    重量级锁   |
|----------------------------------------------------------------------------------------|---------------|
|                                                                            | 锁标记(2) |     GC标记    |
|----------------------------------------------------------------------------------------|---------------|

        其中两个锁标记位的状态为:

锁状态 偏向锁标记 锁标记
无锁 $0$ $01$
偏向锁 $1$ $01$
轻量级锁 无偏向锁标记 $00$
重量级锁 无偏向锁标记 $10$
GC标记 无偏向锁标记 $11$

3.5 轻量级锁

        轻量级锁是JDK 6中加入的新型锁机制,轻量是相对于使用操作系统实现的传统锁而言的。轻量级锁的目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量带来的性能损耗。$Mark\ \ Word$ 被设计为非固定的动态数据结构,可以根据对象状态复用存储空间,这是轻量级锁实现的关键。JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,称为 $Displaced\ \ Mark\ \ Word$ 。

  1. 线程进入同步块时,会创建 $Displaced\ \ Mark\ \ Word$ ,并将锁的 $Mark\ \ Word$ 复制到自己的 $Displaced\ \ Mark\ \ Word$ 中;
  2. 线程尝试通过CAS将 $Mark\ \ Word$ 替换为指向线程栈中锁记录的指针,如果成功则获取锁;
  3. 如果失败,表示锁被其他线程获取,当前线程尝试自旋获取锁,这是会通过自适应自旋调整自旋次数;
  4. 如果自旋到一定程度 ( 与JVM和操作系统有关 ) 还没有获取到锁,锁就会升级成重量级锁;
  5. 在释放锁的时候,会将 $Mark\ \ Word$ 从 $Displaced\ \ Mark\ \ Word$ 中复制回对象头,如果没有竞争就会成功;如果锁已经升级为重量级锁,那么会失败,此时会释放锁并唤醒阻塞线程。

        在不存在竞争的情况下,轻量级锁因为避免了使用互斥量的开销,所以性能方面要比重量级锁高;反过来如果存在竞争,性能就会更低。

3.6 偏向锁

        偏向锁也是JDK 6中引入的,目的是消除数据在无竞争情况下的同步原语。与轻量级锁的不同之处在于轻量级锁是为了消除同步过程中使用的互斥量,而偏向锁是为了消除整个同步过程。偏向锁会偏向于第一个获得它的线程,并且在接下来的执行过程中,如果锁一直没有被其他线程获取,那么持有偏向锁的线程将永远不需要同步。

  1. 进入锁,尝试使用CAS操作替换 $Mark\ \ Word$ 中的线程ID
  2. 替换成功进入偏向模式,替换失败进入轻量级锁模式;
  3. 在偏向模式下当线程释放锁时会通过CAS操作恢复线程ID为空。

        偏向锁升级为轻量级锁的过程开销还是很大的:

  1. 在一个安全点 ( 没有字节码正在执行的时间点 ) 停止拥有锁的线程;
  2. 遍历线程栈,如果存在锁记录,修复 $Mark\ \ Word$ 变为无锁状态;
  3. 唤醒线程,升级为轻量级锁。

        偏向锁的实现与轻量级锁类似,不同之处在于 $Mark\ \ Word$ 中会记录获得偏向锁的线程ID,并在之后同一个线程进入同步块时不再进行任何同步操作。一旦出现另一个线程尝试获取锁,偏向模式立即结束,根据当前对象的锁定状态决定是否撤销偏向。偏向锁可以提高带有同步但是无竞争的程序性能,同样也并非总是有利,对于大多数总是被多个线程访问的情况,偏向模式就是多余的。
        偏向锁的实现需要占用 $Mark\ \ Word$ 中存储哈希码的区域。哈希码依赖于 $Object::hashCode(\ )$ 方法,会在第一次调用后存储在对象头中,从而保证哈希码的一致性。因此如果一个对象计算过一次哈希码之后,就不能再使用偏向锁,一个处于偏向锁状态的对象需要计算哈希码时,它的偏向锁状态会被立即撤销,并膨胀为重量级锁。

3.7 重量级锁

        重量级锁依赖于操作系统的互斥量 ( $mutex$ ) 实现,而操作系统中线程间状态的转换需要较长时间,所以重量级锁效率低,但被阻塞的线程不会消耗CPU。当对象锁是重量级锁时,$Mark\ \ Word$ 中会记录一个指向 $ObjectMonitor$ 的指针,后者是线程共享并且持有更多信息。当多个线程同时请求某个对象锁时,对象锁会设置几种状态来区分请求线程:

  1. 当线程尝试获取锁但是锁已经被其他线程持有时,线程会被封装成 $ObjectWaiter$ 对象,插入 $Contention\ \ List$ 队列的队头,然后通过 $LockSupport.park(\ )$ 方法挂起;
  2. 当线程释放锁时,会在 $Entry\ \ List$ 中从前往后选择一个线程唤醒,如果 $Entry\ \ List$ 为空,将 $Contention\ \ List$ 设置为 $Entry\ \ List$ ,再从 $Entry\ \ List$ 中选择。被选中的线程称为 $Heir\ \ presumptive$ ( 假定继承人 );
  3. 假定继承人被唤醒后会尝试获取锁,但由于 $synchronized$ 非公平,线程会先尝试自旋获取锁,自旋不成功再进入等待队列,所以假定继承人不一定能获取锁;
  4. 如果线程获取锁后调用 $Object.wait(\ )$ 方法,线程会加入到 $Wait\ \ Set$ 中,在被唤醒后重新加入 $Contention\ \ List$ 中。当调用一个锁对象的 $wait(\ )$ 或者 $notify(\ )$ 方法时,会先将锁膨胀为重量级锁。

JVM(10):线程安全与锁优化