线程的安全性分析
Java内存模型
Java 内存模型是一种抽象结构,它提供了合理的禁用缓存以及禁止重排序的方法来解决可见性、有序性问题。
JMM 的抽象模型
JMM和硬件模型的对应简图
同步关键字synchronized
synchronized锁的范围
- 对于普通同步方法,锁是当前的实例对象(锁家里的门)
public class Main {
// 对象锁 同一个对象访问同步方法才有效
public synchronized void demo() {}
public static void main(String[] args) {
Main main1 = new Main();
Main main2 = new Main();
Main main3 = new Main();
// 无法实现两个线程的互斥 均可以执行
new Thread(main1::demo).start(); // 锁的是对象main1
new Thread(main2::demo).start(); // 锁的是对象main2
// 下面这种情况才会发生互斥 使第二个方法发生线程阻塞
new Thread(main3::demo).start(); // 锁的是对象main3
new Thread(main3::demo).start(); // 发生阻塞
}
}
- 对于静态同步方法,锁是当前类的 class 对象(锁小区的门)
public class Main {
// 类级别锁 Main.class
public static synchronized void demo() {}
public static void main(String[] args) {
Main main1 = new Main();
Main main2 = new Main();
// 访问静态同步方法会发生线程互斥
new Thread(() -> main1.demo()).start();
new Thread(() -> main2.demo()).start();
}
}
- 对于同步方法块,锁是 synchronized 括号里配置的对象(锁的范围是可控的)
public class Main {
// 方法内加同步代码块
public void demo() {
// TODO
synchronized (Main.class) {
// 等价于 static synchronized void demo()
}
// TODO
synchronized (this) {
// 等价于 synchronized void demo()
}
// TODO
}
public static void main(String[] args) {
Main main = new Main();
// 调用方法时只会对方法内的同步代码块进行加锁同步
new Thread(main::demo).start();
}
}
synchronized的本质
public void demo();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: ldc #7 // class Main
2: dup
3: astore_1
4: monitorenter // 获得锁
5: aload_1
6: monitorexit // 释放锁
7: goto 15
10: astore_2
11: aload_1
12: monitorexit // 异常情况下释放锁
......
synchronized的优化
在 jdk1.6 以前 synchronized 是一种重量锁对性能开销比较大,1.6 后对 synchronized 进行了如下优化措施:
- 引入自适应自旋锁
- 引入偏向锁、轻量级锁
- 引入锁消除、锁粗化概念
并发编程问题的源头:原子性、可见性、有序性
如何理解线程安全
当多个线程访问某个对象时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为(符合我们的预期和结果),那么就称这个类是线程安全的。
原子性
public class Main {
static int count = 0;
static void incr() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
/**
* getstatic
* iadd
* putstatic
*/
count++;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(Main::incr).start();
}
Thread.sleep(4000);
System.out.println("result : " + count);
}
}
输出的结果一定是一个 <= 1000 的值,原因就是在于原子性问题,解决方案为使用原子类
static void incr();
descriptor: ()V
flags: (0x0008) ACC_STATIC
Code:
stack=3, locals=1, args_size=0
0: lconst_1
1: invokestatic #7 // Method java/lang/Thread.sleep:(J)V
4: goto 17
7: astore_0
8: new #15 // class java/lang/RuntimeException
11: dup
12: aload_0
13: invokespecial #17 // Method java/lang/RuntimeException."<init>":(Ljava/lang/Throwable;)V
16: athrow
17: getstatic #20 // Field count:I
20: iconst_1
21: iadd
22: putstatic #20 // Field count:I
25: return
......
原子性问题的本质是缓存的不一致性和线程竞争
可见性
传统计算机架构优化成果:
- CPU 增加了高速缓存,均衡与内存的速度差异
- 操作系统增加进程、线程、以及分时复用 CPU,均衡 CPU 与 I/O 设备的速度差异
- 编译程序优化指令的执行顺序,使得能够更加合理地利用缓存
问题的源头:在多核 CPU 时代,每个 CPU 都有自己的高速缓存,那么此时 CPU 的缓存与内存之间的速度差异导致数据一致性难以解决。当多个线程在不同 CPU 上执行的时候,线程操作的是不同的 CPU 缓存。如下图,线程 A 操作 CPU-1 内的变量 X 对于线程 B 操作 CPU-2 内的变量 X 就不具备可见性。
public class Main {
static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
int i = 0;
while (!stop) {
i++;
}
}).start();
System.out.println("begin start thread!");
Thread.sleep(1000);
stop = true; // 目的是主线程中修改stop的值使得线程停止
} // 结果是无法修改 原因是对子线程不可见
} // 解决方法 使用 volatile 修饰变量
有序性
其中 1 是编译器级别的重排序,2 和 3 是处理器级别的重排序,会导致多线程程序出现执行顺序问题
volatile关键字分析
volatile 可以用来解决可见性和有序性问题,通过汇编指令分析可以发现 volatile 会给操作加上 Lock 指令(0x000000000372caf3: lock add dword ptr [rsp], 0h),而 Lock 指令的作用包括:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存内存的操作会使在其他 CPU 里缓存了该内存地址的数据失效
本质是使用禁用缓存的机制来解决可见性问题,从软件层面调用硬件层面的指令。但是 volatile 不能保证原子性
CPU 层面的内存屏障
- Store Barrier:强制所有在 store 屏障指令之前的 store 指令,都在该 store 屏障指令执行之前被执行,并把 store 缓冲区的数据都刷到 CPU 缓存
- Load Barrier:强制所有在 load 屏障指令之后的 load 指令,都在该 load 屏障指令执行之后被执行,并且一直等到 load 缓冲区被该 CPU 读完才能执行之后的 load 指令
- Full Barrier:全屏障,复合了 load 和 store 屏障的功能
value = 3;
void exec2CPU0() {
value = 10;
storeBarrier(); // 写屏障 之前的指令一定在之后执行 禁止了指令重排序
isFinish = true; // 此时 value = 10 一定成立
}
void exec2CPU1() {
if (isFinish) {
loadBarrier(); // 读屏障
assert value == 10;
}
}
volatile总结
本质上来说,volatile 实际上就是通过内存屏障来防止指令重排序以及禁止 CPU 高速缓存来解决可见性问题。
而 #Lock 指令,它本意上是禁止高速缓存解决可见性问题,但实际上在这里,它表示的是一种内存屏障的功能。也就是说针对当前的硬件环境,JMM 层面采用 Lock 指令作为内存屏障来解决可见性问题。
final域
概念
final 在 Java 中是一个保留的关键字,可以声明成员变量、方法、类以及本地变量。一旦你将引用声明为 final,你将不能再改变这个引用。
final域和线程安全
对于 final 域,编译器和处理器要遵守两个重排序规则:
- 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
- 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序
public class FinalExample {
int i; /* 普通变量 */
final int j; /* final变量 */
static FinalExample obj;
public FinalExample() { /* 构造函数 */
i = 1; /* 写普通域 */
j = 2; /* 写final域 */
}
public static void writer() { /* 写线程A执行 */
obj = new FinalExample();
}
public static void reader() { /* 读线程B执行 */
FinalExample object = obj; /* 读对象引用 */
int a = object.i; /* 读普通域 */
int b = object.j; /* 读final域 */
}
}
写 final 域的重排序规则:
- JMM 禁止编译器把 final 域的写重排序到构造函数之外
- 编译器会在 final 与的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外
读 final 域的重排序规则:
- 在一个县城中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作,编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障
逃逸带来的重排序问题
public class Main {
final int i;
static Main obj;
public Main() {
i = 1; // 1. 写final域
obj = this; // 2. this 引用逃逸
}
public static void writer() {
new Main();
}
public static void reader() {
if (obj != null) { // 3.
int temp = obj.i; // 4.
}
}
}
Happens-Before规则
Happens-Before 是一种可见性规则,它表达的含义是前面一个操作的结果对后续操作是可见的。
六大规则和性质:
- 程序顺序规则(单线程规则 as-if-serial)
- 解释:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 同一个线程中前面的所有写操作对后面的操作可见。
class VolatileExample { int x = 0; volatile boolean v = false; public void writer() { x = 42; v = true; } public void reader() { if (v == true) { } } }
- 监视器锁规则(synchronized, Lock等)
- 解释:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- 如果线程1解锁了 monitor a,接着线程2锁定了 monitor a,那么,线程1解锁a之前的写操作对线程2都可见(线程1和线程2可以是同一个线程)。
synchronized (this) { // 此处自动加锁 // x 是共享变量,初始值是 10 if (this.x < 12) { this.x = 12; } } // 此处自动解锁
- volatile 变量规则
- 解释:堆一个 volatile 域的写操作,happens-before 于任意后续对这个 volatile 域的读操作。
- 如果线程1写入了 volatile 变量 v(临界资源),接着线程2读取了 v,那么,线程1写入 v 及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)。
- start() 规则
- 解释:如果线程 A 执行启动线程 B 的操作 ThreadB.start(),那么 A 线程的 ThreadB.start() 操作 happens-before 于线程 B 中的任意操作。
- 假定线程 A 在执行过程中,通过执行 ThreadB.start() 来启东线程 B,那么线程 A 对共享变量的修改在接下来线程 B 开始执行前对线程 B 可见。主意:线程 B 启动之后,线程 A 再对变量进行修改的操作对线程 B 未必可见。
Thread B = new Thread(() -> { // 主线程调用 B.start() 之前 // 所有对共享变量的修改,此处皆可见 // 此案例中 var == 77 }); // 此处对共享变量 var 进行修改 var = 77; // 主线程启动子线程 B.start();
- join() 规则
- 解释:如果线程A执行操作 ThreadB.join() 并成功返回,那么线程B中的任意操作都 happens-before 于线程A从 ThreadB.join() 操作成功返回。
- 线程A写入的所有变量,在任意其他线程B调用 A.join(),或者 A.isAlive() 成功返回后,都对 B 可见。
Thread B = new Thread(() -> { // 此处对共享变量 var 进行修改 var = 66; }); // 例如此处对共享变量修改 // 则这个修改结果对线程 B 可见 // 主线程启动子线程 B.start(); B.join(); // 子线程所有对共享变量的修改 // 在主线程调用 B.join() 之后皆可见 // 此案例中 var == 66
- 传递性
- 解释:如果
A happens-before B
,且B happens-before C
,那么A happens-before C
,即(A h-b B) + (B h-b C) = (A h-b C)
class VolatileExample { int x = 0; volatile boolean v = false; public void writer() { x = 42; // happens-before v = true v = true; // happens-before v == true } public void reader() { if (v == true) { // 由传递性规则 x 一定等于 42 } } }
- 解释:如果
原子类Atomic
原子性问题的解决的方案:
- synchronized、Lock
- JUC 包下的 Atomic 类
Atomic的实现原理
- Unsafe 类:增强了 Java 底层操作的能力
private static final Unsafe U = Unsafe.getUnsafe();
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
private volatile int value;
@IntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
- CAS:compareAndSwapInt(object, offset, expect, update) 本质是乐观锁,如果相等就执行修改,不相等就不执行修改。也是不断去内存中取值,再不断地去内存中进行旧值的比较,修改失败就进入下一次循环。
Atomic分类
- 原子更新基本类型
- 原子更新数组
- 原子更新引用类型
- 原子更新字段类
评论区