java中的happens-before是什么?

《Java 内存模型: 深入探索 JavaHappens-Before 的奥秘》

作为一名资深的 Java 架构师,我经常被问到关于 Java 内存模型 (JMM) 的各种问题。其中最核心的概念就是 happens-before 关系。这个看似简单的概念,却是理解并掌握 Java 并发编程的关键所在。在本文中,我将深入探讨 happens-before 规则,并通过生动的实例为大家展示它的实际应用场景。

什么是 JavaHappens-Before?

happens-before 是 Java 内存模型中最重要的概念之一。它定义了一种偏序关系,用来描述一个操作 A 是否可以”看到”另一个操作 B 的执行结果。

准确地说,如果一个操作 A happens-before 于另一个操作 B,那么 A 执行的结果对 B 来说就是可见的。换句话说,B 就可以看到 A 的执行结果。反之,如果 A 不 happens-before B,那么 B 能否看到 A 的结果就是不确定的。

这个概念可能看起来很抽象,不过一旦理解了它的本质,你就会发现它无处不在,贯穿于 Java 并发编程的方方面面。接下来,让我们通过几个生动的例子来感受一下 happens-before 在实际应用中的威力。

示例 1: 单线程中的 happens-before

在单线程环境中,happens-before 规则非常简单:

  1. 程序顺序规则: 在一个线程中,按照代码的顺序执行,前面的操作happens-before后面的操作。

下面的代码片段就是一个典型的例子:

1
2
int a = 1;
int b = 2;

根据程序顺序规则,a = 1 happens-before b = 2。也就是说,在这个线程中,b = 2 一定能看到 a = 1 的结果。

  1. volatile 变量规则: 对一个 volatile 变量的写操作,happens-before于后面对这个变量的读操作。

假设我们有一个 volatile 变量 flag:

1
2
3
4
5
6
7
volatile boolean flag = false;
// ...
flag = true;
// ...
if (flag) {
// do something
}

在这里,flag = true happens-before if (flag)。也就是说,当执行 if (flag) 时,一定能看到 flag = true 的结果。

  1. 监视器规则: 一个线程中,unlock 一个监视器happens-before于后面对这个监视器的 lock 操作。
1
2
3
synchronized (obj) {
// ...
}

在这个代码块中,退出 synchronized 块 (unlock) happens-before于后续再次进入 synchronized 块 (lock)。

总的来说,在单线程环境下,happens-before 规则是非常直观和容易理解的。但是,当涉及到多线程环境时,情况就变得复杂多了。

示例 2: 多线程中的 happens-before

在多线程环境下,happens-before 规则就变得更加复杂和重要了。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class HappensBeforeExample {
private static int x = 0, y = 0;
private static volatile int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});

t1.start();
t2.start();
t1.join();
t2.join();

System.out.println("x = " + x + ", y = " + y);
}
}

在这个例子中,我们有两个线程 t1t2。每个线程都会修改一个 volatile 变量,然后读取另一个线程修改的变量。

我们来仔细分析一下这个程序的执行流程:

  1. 线程 t1 首先执行 a = 1。根据 volatile 变量规则,这个写操作 happens-before 于后面的 x = b
  2. 线程 t2 执行 b = 1。同样根据 volatile 变量规则,这个写操作 happens-before 于后面的 y = a
  3. 现在问题来了,x = by = a 谁先执行呢?这就不确定了。

根据 happens-before 规则,如果 t1a = 1 happens-before t2y = a,那么 y 一定能看到 a = 1的结果,即 y = 1

同理,如果 t2b = 1 happens-before t1x = b,那么 x 一定能看到 b = 1 的结果,即 x = 1

但是,如果 t1a = 1 不 happens-before t2y = a,那么 y 可能看到 a = 0(初始值),即 y = 0。同样,如果 t2b = 1 不 happens-before t1x = b,那么 x 可能看到 b = 0(初始值),即 x = 0

所以,最终的打印结果可能是:

1
2
3
4
x = 0, y = 0
x = 0, y = 1
x = 1, y = 0
x = 1, y = 1

这就是 happens-before 在多线程环境下的复杂性。它决定了一个线程能否看到另一个线程的执行结果,从而影响程序的正确性。掌握 happens-before 规则对于编写正确的并发程序至关重要。

happens-before 的应用场景

既然 happens-before 如此重要,那么它在实际应用中扮演着什么样的角色呢?让我们来看几个典型的应用场景。

1. 线程安全的发布

在多线程环境下,如何安全地发布一个共享对象是一个常见的问题。happens-before 规则可以帮助我们解决这个问题。

假设我们有一个 MyClass 对象,我们希望在一个线程中初始化它,然后在其他线程中使用它。我们可以利用 happens-before 规则来实现这个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MyClass {
private static MyClass instance;

private MyClass() {
// 初始化 MyClass
}

public static MyClass getInstance() {
if (instance == null) {
synchronized (MyClass.class) {
if (instance == null) {
instance = new MyClass();
}
}
}
return instance;
}
}

在这个例子中,我们使用了双重检查锁定 (Double-Checked Locking) 来实现线程安全的单例模式。关键在于, instance = new MyClass() 操作不仅仅是一个简单的赋值,它实际上包含了多个步骤:

  1. 分配内存空间
  2. 初始化 MyClass 对象
  3. instance 指向分配的内存空间

如果这三个步骤的执行顺序被重排,那么其他线程可能会观察到一个未初始化的 MyClass 对象。

幸运的是,happens-before 规则可以帮助我们解决这个问题。在 synchronized 块中,unlock 操作 happens-before 于后续的 lock 操作。这意味着,当一个线程成功获取到锁并执行 instance = new MyClass() 时,其他线程在 getInstance() 中的 instance == null 判断一定能看到已经正确初始化的 MyClass 对象。

2. 并发容器的实现

在 Java 并发编程中,并发容器是一个非常重要的概念。而 happens-before 规则在并发容器的实现中起着关键作用。

让我们以 ConcurrentHashMap 为例,看看 happens-before 是如何影响它的实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ConcurrentHashMap<K, V> {
private final Segment<K, V>[] segments;

// ...

public V put(K key, V value) {
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
return segmentFor(j).put(key, hash, value, false);
}

private Segment<K, V> segmentFor(int j) {
return segments[j];
}

static final class Segment<K, V> extends ReentrantLock {
volatile HashEntry<K, V>[] table;

V put(K key, int hash, V value, boolean onlyIfAbsent) {
lock();
try {
int c = count;
if (c++ > threshold) // 扩容
rehash();
HashEntry<K, V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K, V> first = tab[index];
// ...
} finally {
unlock();
}
}
}
}

ConcurrentHashMap 的实现中,每个 Segment 都是一个独立的锁段,用来保证并发安全。关键点在于:

  1. 当一个线程获取到 Segment 的锁后,它对 Segment 内部数据结构的修改对其他线程是可见的。这是因为 lock()unlock() 操作满足 happens-before 规则。
  2. 当一个线程扩容 Segment 的时候,新的 HashEntry 数组的发布对其他线程是可见的。这是因为扩容操作的 happens-before 关系。

通过 happens-before 规则的保证,ConcurrentHashMap 能够在多线程环境下安全地工作,避免出现数据不一致的问题。

3. 异步任务的协调

在实际项目中,我们经常需要编写一些异步任务,比如异步计算、异步通知等。这些任务之间通常存在着复杂的依赖关系,需要通过 happens-before 规则来协调它们的执行顺序。

举个例子,假设我们有一个异步计算任务,需要等待另一个异步任务的结果作为输入。我们可以利用 CompletableFuture 来实现这个需求:

1
2
3
4
5
6
7
8
9
10
11
CompletableFuture<Integer> taskA = CompletableFuture.supplyAsync(() -> {
// 执行任务 A
return 42;
});

CompletableFuture<String> taskB = taskA.thenApplyAsync(result -> {
// 使用任务 A 的结果执行任务 B
return "The answer is " + result;
});

String finalResult = taskB.join();

在这个例子中,taskB 的执行 happens-before 于 finalResult 的获取。这是因为 thenApplyAsync 方法保证了其回调函数的执行会在 taskA 完成之后。

通过 happens-before 规则,我们可以轻松地协调各个异步任务之间的依赖关系,确保程序的正确性。

{% if post.top %} 置顶 | {% endif %}