Java并发编程02-线程安全性
一、线程安全
1. 线程安全
可以简单的理解为:一个方法或者一个实例可以在多线程环境中使用而不会出现问题。
2. 线程不安全的原因
多个线程使用了相同的资源,如同一内存区(变量、数组或对象)、系统(数据库、web服务等)或文件等。更准确的说,是多个线程对同一资源进行了写操作。多个线程只读取相同的资源,是没有线程安全问题的。
3. 如何保证线程安全
保证共享内存的原子性、可见性和有序性。
二、原子性
对共享内存的操作必须是要么全部执行直到执行结束,且中间过程不能被任何外部因素打断,要么就不执行。
1. Java 如何实现原子操作
在 Java 中可以通过锁和循环 CAS 的方式来实现原子操作。
使用锁很好理解,下面重点说一下循环 CAS 实现的思路。
(1)Atomic包(使用循环 CAS 实现原子操作)
Jdk1.5 开始提供了以 Atomic 开头的类,例如 AtomicBoolean(用原子方式更新的 boolean 值)、AtomicInteger(用原子的方式更新的 int 值)等。
使用 AtomicInteger 实现的线程安全的计数器程序示例:
public class N18_CAS_AtomicInteger {
private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
private void safeCount() {
atomicI.incrementAndGet();
}
private void count() {
i++;
}
public static void main(String[] args) throws InterruptedException {
N18_CAS_AtomicInteger counter = new N18_CAS_AtomicInteger();
ArrayList<Thread> ts = new ArrayList<>(600);
for (int i = 0; i < 100; ++i) {
Thread t = new Thread(() -> {
for (int j = 0; j < 10000; ++j) {
counter.count();
counter.safeCount();
}
});
t.start();
ts.add(t);
}
// 等待所有线程执行完成
for (Thread t: ts)
t.join();
System.out.println(counter.i);
System.out.println(counter.atomicI);
}
}
运行结果:
992034
1000000
AtomicInteger 中的 incrementAndGet 方法就是乐观锁的一个实现,使用自旋 CAS(循环检测更新)的方式来更新内存中的值并通过底层CPU执行来保证是更新操作是原子操作。
getAndIncrement() 方法的内部:
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
getAndAddInt() 方法:
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
此时可以看到 compareAndSwapInt 方法,就是 CAS 缩写的由来。
其中 var5 是更新后要返回的值。var1 由前面的 this 参数可以看出是 AtomicInteger 实例,var2 是偏移量(AtomicInteger 内部通过改变偏移量记录值)。
compareAndSwapInt(var1, var2, var5, var5 + var4)
其实换成compareAndSwapInt(obj, offset, expect, update)
比较清楚,意思就是如果 obj 内的 value 和 expect 相等,就证明没有其他线程改变过这个变量,那么就更新它为 update,如果这一步的 CAS 没有成功,那就采用自旋的方式继续进行 CAS 操作。取出乍一看这也是两个步骤了啊,其实在 JNI 里是借助于一个 CPU 指令完成的。所以还是原子操作。
(2)CAS 实现原子操作的问题
-
1)ABA 问题
- 因为 CAS 需要在操作值的时候,检查值有没有发生变化,如果没有变化则更新值。但是如果一个值原来是 A,变成了 B,有变成了 A,那么使用 CAS 进行检查的时候会发现它的值没有发生变化,但实际上发生了变化。
- 解决思路就是使用版本号。Atomic 包中提供了 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
-
2)循环时间开销大
如果 CAS 不成功,则会原地自旋,如果长时间自旋会给 CPU 带来非常大的执行开销。
-
3)只能保证一个共享变量的原子操作
(3)synchronize、 lock、 Atomic 原子性对比
-
synchronize:不可中断锁,适合竞争不激烈,可读性好
-
lock:可中断锁,多样化同步,竞争激烈时能维持常态
-
Atomic:竞争激烈时能维持常态,比 lock 性能好;只能同步一个值
三、可见性
多线程操作共享内存时,执行结果能够及时的同步到共享内存,确保其他线程对此结果及时可见。
1. 共享变量在线程间不可见的原因
共享变量更新后的值没有在工作内存与主内存间及时更新
2. synchronized
JMM 的规范中提供了 synchronized 具备的可见性:
- 线程解锁前,必须把共享变量的最新值刷新到主内存
- 线程加锁时,将清空工作内存中共享变量的值,从主内存中读取最新的值
3. volatile
使用 volatile关键字,保证变量可见性(直接从主内存读,而不是从线程cache读)
注:volatile 变量具有 synchronized 的可见性特性,但是不具备原子性
四、有序性
程序的执行顺序按照代码顺序执行,在单线程环境下,程序的执行都是有序的,但是在多线程环境下,JMM 为了性能优化,编译器和处理器会对指令进行重排,程序的执行会变成无序。
1. volatile/synchronized/lock 可保证有序性
2. happens-before
JMM 通过 happens-before 关系向程序员提供跨线程的内存可见性保证。(如果 A 线程的写操作 a 与 B 线程的读操作 b 之间存在 happens-before 关系,尽管 a 操作和 b 操作在不同的线程中执行,但 JMM 向程序员保证 a 操作将对 b 操作可见)
happens-before 规则:
- 1)程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 2)监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- 3)volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 4)传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- 5)start() 规则:如果线程A执行操作 ThreadB.start() (启动线程B),那么 A 线程的 ThreadB.start() 操作 happens-before 于线程 B 中的任意操作。
- 6)join() 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回。
参考:
- Java CAS 原理剖析
- Java多线程与高并发(二):线程安全性
- 《Java并发编程的艺术》