Back
Featured image of post JVM 学习笔记 14 - 线程安全与锁优化

JVM 学习笔记 14 - 线程安全与锁优化

导航页

线程安全

线程安全常有这样的定义:「当多个线程同时访问一个对象时,不需要知道运行时的执行顺序,也不需要同步或其他机制来保证正确的结果,那么该对象即为线程安全的」。

这个定义很严谨,它要求代码本身封装了所有必要的多线程保障手段,让调用者少关心甚至不用关心多线程调用问题。

虽然听起来不难,但执行起来又何其难。在很多时候,我们说的「线程安全」,都已经是弱化之后的定义。即只要该对象所有的方法,亦即单次调用,能够保障正确性即可。完全线程安全的可变对象是很少见的。

线程安全分级

不可变

只要不可变(immutable),那么该对象绝对是安全的。这也是为什么,现代编程语言都提倡默认不可变。在 Java 中被 final 关键字修饰的基本类型一定是线程安全的。

而若一个对象是 final 的,它并不一定也是安全的。需要它的成员也为不可变的才行。 比如 java.lang.String 即为不可变类型。 调用 substring()repalce()concat() 等方法都是返回新值。

绝对线程安全

亦即之前提到的定义。这个很难达成。你可能认为 VectorCopyOnWriteArrayListConcurrentHashMap 等并发数据结构是绝对线程安全的。然而实质上并不是这样。它们仅仅保证并发调用单个方法时的正确性。

不妨查看以下代码:

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();
        }
      }
    });
    Thread printThread = new Thread(new Runnable() {
      @Override 
      public vodi 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-132" java.lang.ArrayIndexOutOfBoundsException:
Array index out of range: 17
  at java.util.Vector.remove(Vector.java:777)
  at org.fenixsoft.mulithread.VectorTest$1.run(VectorTest.java:21) at java.lang.Thread.run(Thread.java:662)

虽然用到的方法都是同步的,但在多线程下如果调用者不做额外的措施,使用这段代码是不安全的。可能会同时删除和尝试打印一个元素,导致序号 i 不可用,抛出异常。

相对线程安全

Java 中大多数声称线程安全的类,都属于这种。单次调用安全,但对于一些特定顺序的连续调用,可能需要在调用端有额外的同步手段。

线程兼容

即虽然对象本身没有线程安全性,但可以让调用者通过额外的同步手段保证线程安全。大部分类都是线程兼容的。

线程对立

即调用者无论采取何种措施,均无法保证在多线程环境中的正确性。

比如 Thread 类的 suspend()resume 方法。如果两个线程,同时对一个线程分别执行这两个操作,目标线程都存在死锁风险。因此这两个方法也被废弃。常见的线程对立操作还有 System.setIn()System.setOut()System.runFinalizersOnExit() 等。

实现方法

互斥同步

互斥同步(mutual exclusion & synchronization)是最常见的线程安全手段。同步是指多个线程并发访问共享数据室,保证共享数据在同一时刻只被一条(或一些)线程使用。互斥是实现同步的手段,临界区(critical section)、互斥量(mutex)、信号量(semaphore)是常见的互斥实现方式。

Java 中最常见的同步手段就是使用 synchronized 关键字。编译后会在块前后放置 moniterentermoniterexit 指令,前者令计数器加一,后者令计数器减一。因此,synchronized 是可重入的。也就是在单条线程中反复进入同步块,不会死锁。

锁是很重量级的操作,比较耗时。因为需要让内核在用户态和内核态之间切换。因此 JVM 也会优化锁,这在后面专门介绍。

因此,synchronized 局限性也很明显。它不太灵活,对于复杂需求很难胜任。从 JDK 5 开始,Java 新增了 java.util.concurrent 包(JUC),其中的 locks.Lock 接口便成了另一种全新的互斥同步手段。

可重入锁 ReentrantLock 的基本用法和 synchronized 块很类似,但增加了三个高级选项:

  • 等待可终中断:当持有锁的线程长期不释放时,正在等待的现成可以选择放弃等待,改为处理其他工作。
  • 公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序依次获取。非公平锁反之亦然。synchronized 块是非公平的,ReentrantLock 默认也是非公平的,因为开启该功能会显著降低性能。
  • 锁绑定多个条件:一个锁对象可以绑定多个 Condition。在 synchronized 中,锁对象的 wait()notify()notifyAll() 方法就是隐含的一个条件,如果需要多个条件关联,则需要新加一个锁。而 ReentrantLock 原生支持多条件。

如果不使用特别的功能,在性能上 ReentrantLocksynchornized 没有太大差别。

非阻塞同步

锁的最大问题即为性能问题,因此这种同步也被称为阻塞同步(Blocking Synchronization)。锁是一种悲观额并发策略,即无论是否出现数据竞争,都会加锁,因此性能不高。而随着硬件的发展,可以用非阻塞同步(Non-Blocking Synchronization)降低性能的影响,达到无锁(lock free)的目的。最常用的非阻塞同步就是操作和冲突检测。即,如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。

操作和冲突检测需要为原子的,因此需要硬件支持一些指令:

  • test-and-set
  • fetch-and-increment
  • swap
  • compare-and-swap, CAS
  • load-linked / store-condictional, LL/SC

前三条指令非常普遍,大多数指令集都支持。后面两条是现代处理器新增的,且功能类似,目的一致。在 Java 语言层面,表现的就是 CAS 的语义,因此之后以 CAS 为例讲解。

CAS 操作有三个参数:内存位置(V)、旧的预期值(A)、准备设置的新值(B)。CAS 执行时,仅当 V 符合 A 时,处理器才会用 B 更新 V 的值,否则它就不更新。但是,不管是否更新了 V 的值,都会返回 V 的旧值。以上操作是一个原子操作,不会被中断。

但 CAS 也有一个问题即 ABA 问题。

可以看 Java 对 AtomicInt 的实现:

public final int incrementAndGet() { 
  for (;;) {
    int current = get();
    int next = current + 1;
    if (compareAndSet(current, next))
      return next;
  } 
}

该方法在一个无限循环中,不断尝试将 current + 1 赋给自己。如果失败了,说明旧值已经发生改变,于是再次循环进行下一次操作,直到成功。

问题在于:如果变量 V 初次读取为 A,在赋值检查时也为 A 值,并不能证明它没有被修改。可能是其他线程将其改为过 B,再改回 A。但 CAS 会认为它从来没有被改变过。

JDK 5 开始,Java 内部开始使用 CAS,由 sun.misc.Unsafe 类中的几个方法包装提供,并由 JVM 特殊处理,直接内联代码。但 sun 开头的代码是 JDK 内部代码,外部程序不应该调用。只可调用 AtomicIntAtomicLong 等封装好的类。

直到 JDK 9 之后,Java 提供了 VarHandle 类,这才开放了面向用户的 CAS 操作。

而对于 ABA 问题,Java 提供了 AtomicStampedReference 类,通过控制变量值的版本来保证 CAS 的正确性。不过这个类的实际作用相当低,大部分情况下 ABA 问题不影响正确性,而在需要避免该问题的场景,传统的互斥同步反而可能会更好。

无同步方案

在函数式编程世界这种做法屡见不鲜。只要一个函数不修改实参,且不产生副作用,那么就可称其为纯函数(pure function),也叫纯代码(pure code)、可重入代码(reentrant code)。也就是无论何时调用,只要入参一致,结果一定相同。

Java 中的 ThreadLocalStorage 也属于此范畴,因为只在单线程中共享,不可能同时访问。

近些年 thread per request 的反应式流也很流行,对于每个请求都单开一个线程或协程,就能很好地避免线程安全问题。这也属于无同步方案。

锁优化

自旋锁和自适应自旋

之前提到过,互斥同步最大的瓶颈来自于内核态切换。JVM 开发团队也注意到,在很多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间而反复挂起和恢复线程并不值得。多线程时,可以让后面请求锁的线程稍微等待一会儿,但不放弃处理器的执行时间,等待持有锁的线程一会儿。为了线程等待,可以让线程执行一个忙循环,即自旋(spin),这种锁即为自旋锁(spin lock)。

自旋锁在 JDK 1.4.2 中引入,但默认关闭,可通过 -XX:+UseSpinning 开启,JDK 6 中默认开启。自旋等待不能代替阻塞,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的。因此如果锁占用的时间短,那么自旋锁的效果就好,反之如果锁占用的时间长,就会带来性能浪费。可以使用 -XX:PreBlockSpin 来自行更改自旋次数(JDK 7 后移除)。

同时 JVM 还支持自适应自旋,可以根据过往等待的时间优化自旋次数。

锁消除

比如这样一段代码:

public String concatString(String s1, String s2, String s3) {
  StringBuffer sb = new StringBuffer();
  sb.append(s1);
  sb.append(s2);
  sb.append(s3);
  return sb.toString();
}

这段代码显然写的有问题,StringBuffer 用于多线程场景,而在单线程中使用会造成不必要的内核态切换开销。正确的写法应是使用 StringBuilder

但别担心,JVM 可以通过逃逸分析消除掉其中的锁。因为 sb 是局部变量,且所有引用都没有逃逸到 concatString() 方法之外,因此可以安全地消除锁。

锁粗化

JVM 会在运行时检查相邻的同步块是否使用的是一个锁实例,此时即可将几个同步合并为一个大的同步块,避免一个线程反复申请、释放同一个锁,比如在循环中加锁的情况。

轻量级锁

轻量级锁基于这样一种假设:绝大部分锁,在整个同步周期内不存在竞争。如果没有竞争,轻量级锁通过 CAS 操作将锁信息存在 Java 对象头避免了操作系统 Mutex 开销;但如果确实存在锁竞争,轻量级锁反而会比重量级锁慢,因为除了 Mutex 开销之外,还有一次 CAS 操作。

偏向锁

偏向锁在 JDK 6 中引入,JDK 15 中被标记废弃。它的目的是消除数据在无竞争情况下的开销,这比轻量级锁更为激进,连 CAS 操作都不用做了。

偏向锁之所以「偏」,是因为它倾向于第一个获得它的线程,如果之后的执行中,该锁一致没有被其他线程获取,则持有偏向锁的线程将永远不需要同步。

然而偏向锁过于激进,是带有 trade off 性质的,需要 +XX:+UseBiasedLocking 参数手动开启。

而 JDK 15 中合并了 JEP 374,所有偏向锁有关选项现在都被弃用。

在 JDK 6 时代之所以提升巨大,是因为遗留 API HashTableVector 等在访问时都会同步。然而时过境迁,如今用的人已经很少了,非同步集合 HashMapArrayList 已经替代了这些位置。

并且偏向锁实现困难,侵入了其他 HotSpot 组件,甚至影响了可读性。

综上,收益不高且维护成本巨大,吃力不讨好,所以现在被标记为废弃,且最后会被移除。


以上,就是本文的全部内容了。

comments powered by Disqus