线程安全
线程安全常有这样的定义:「当多个线程同时访问一个对象时,不需要知道运行时的执行顺序,也不需要同步或其他机制来保证正确的结果,那么该对象即为线程安全的」。
这个定义很严谨,它要求代码本身封装了所有必要的多线程保障手段,让调用者少关心甚至不用关心多线程调用问题。
虽然听起来不难,但执行起来又何其难。在很多时候,我们说的「线程安全」,都已经是弱化之后的定义。即只要该对象所有的方法,亦即单次调用,能够保障正确性即可。完全线程安全的可变对象是很少见的。
线程安全分级
不可变
只要不可变(immutable),那么该对象绝对是安全的。这也是为什么,现代编程语言都提倡默认不可变。在
Java 中被 final
关键字修饰的基本类型一定是线程安全的。
而若一个对象是 final
的,它并不一定也是安全的。需要它的成员也为不可变的才行。 比如
java.lang.String
即为不可变类型。 调用
substring()
、repalce()
、concat()
等方法都是返回新值。
绝对线程安全
亦即之前提到的定义。这个很难达成。你可能认为
Vector
、CopyOnWriteArrayList
、ConcurrentHashMap
等并发数据结构是绝对线程安全的。然而实质上并不是这样。它们仅仅保证并发调用单个方法时的正确性。
不妨查看以下代码:
private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 10; i++) {
.add(i);
vector}
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
.remove();
vector}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public vodi run() {
for (int i = 0; i < vector.size(); i++) {
System.out.println(vector.get(i));
}
}
});
.start();
removeThread.start();
printThread
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
关键字。编译后会在块前后放置 moniterenter
和
moniterexit
指令,前者令计数器加一,后者令计数器减一。因此,synchronized
是可重入的。也就是在单条线程中反复进入同步块,不会死锁。
锁是很重量级的操作,比较耗时。因为需要让内核在用户态和内核态之间切换。因此 JVM 也会优化锁,这在后面专门介绍。
因此,synchronized
局限性也很明显。它不太灵活,对于复杂需求很难胜任。从 JDK 5 开始,Java
新增了 java.util.concurrent
包(JUC),其中的
locks.Lock
接口便成了另一种全新的互斥同步手段。
可重入锁 ReentrantLock
的基本用法和
synchronized
块很类似,但增加了三个高级选项:
- 等待可终中断:当持有锁的线程长期不释放时,正在等待的现成可以选择放弃等待,改为处理其他工作。
- 公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序依次获取。非公平锁反之亦然。
synchronized
块是非公平的,ReentrantLock
默认也是非公平的,因为开启该功能会显著降低性能。 - 锁绑定多个条件:一个锁对象可以绑定多个
Condition
。在synchronized
中,锁对象的wait()
、notify()
和notifyAll()
方法就是隐含的一个条件,如果需要多个条件关联,则需要新加一个锁。而ReentrantLock
原生支持多条件。
如果不使用特别的功能,在性能上 ReentrantLock
和
synchornized
没有太大差别。
非阻塞同步
锁的最大问题即为性能问题,因此这种同步也被称为阻塞同步(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 内部代码,外部程序不应该调用。只可调用
AtomicInt
、AtomicLong
等封装好的类。
直到 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();
.append(s1);
sb.append(s2);
sb.append(s3);
sbreturn 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 HashTable
和
Vector
等在访问时都会同步。然而时过境迁,如今用的人已经很少了,非同步集合
HashMap
和 ArrayList
已经替代了这些位置。
并且偏向锁实现困难,侵入了其他 HotSpot 组件,甚至影响了可读性。
综上,收益不高且维护成本巨大,吃力不讨好,所以现在被标记为废弃,且最后会被移除。
以上,就是本文的全部内容了。