理论基础
为什么需要多线程
众所周知,CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU 增加了缓存,以均衡与内存的速度差异;// 导致
可见性问题 - 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;// 导致
原子性问题 - 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。// 导致
有序性问题
可见性: CPU缓存引起
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
原子性: 分时复用引起
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
int i = 1;
// 线程1执行
i += 1;
// 线程2执行
i += 1;
这里需要注意的是:i += 1需要三条 CPU 指令
- 将变量 i 从内存读取到 CPU寄存器;
- 在CPU寄存器中执行 i + 1 操作;
- 将最后的结果i写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。
有序性: 重排序引起
有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗? 不一定,为什么呢? 这里可能会发生指令重排序(Instruction Reorder)。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
具体可以参看:Java 内存模型详解的重排序章节。
JAVA是怎么解决并发问题的: JMM(Java内存模型)
理解的第一个维度:核心知识点
JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
- volatile、synchronized 和 final 三个关键字
- Happens-Before 规则
理解的第二个维度:可见性,有序性,原子性
- 原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。 请分析以下哪些操作是原子性操作:
- 可见性 Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
- 有序性
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。
Happens-Before 规则
上面提到了可以用 volatile 和 synchronized 来保证有序性。除此之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。
1. 单一线程原则
Single Thread rule
在一个线程内,在程序前面的操作先行发生于后面的操作。
2. 管程锁定规则
Monitor Lock Rule
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
3. volatile 变量规则
Volatile Variable Rule
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
配合单一线程原则,可以实现如下代码
int a = 0;
volatile boolean ready = false;
// 线程一
a = 1;
ready = true;
// 线程二
while (!ready) {
// 空转等待(示例)
// 此处可以休眠或指数退避,仅演示 volatile的能力,不展开
}
System.out.println(a); // 期望输出 1
4. 线程启动规则
Thread Start Rule
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
for (int i = 0; i < 100; i++) {
Thread t1 = new Thread(() -> {
System.out.println("t1 running");
});
System.out.println("start t1");
t1.start();
t1.join();
System.out.println("t1 stop");
}
// 无论循环多少次
// 打印的顺序永远是
// start t1
// t1 running
// t1 stop
5. 线程加入规则
Thread Join Rule Thread 对象的结束先行发生于 join() 方法返回。
6. 线程中断规则
Thread Interruption Rule
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
public class InterruptHappensBeforeExample1 {
static int data = 0;
public static void main(String[] args) throws Exception {
Thread worker = new Thread(() -> {
// 一直干活,直到检测到中断
while (!Thread.currentThread().isInterrupted()) {
}
// 到这里说明“检测到中断事件发生”
// 根据中断规则:主线程在 interrupt() 之前对 data 的写入,在这里应当可见
System.out.println(data);
});
worker.start();
// 主线程先写共享数据
data = 42;
// 再发出中断信号
worker.interrupt();
// 模拟等待,不使用worker.join()单独测试 interrupt()规则
// data = 43; 如果此处继续写data=43,则可能出现问题,请务必使用join()来保证可见性。
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
这个例子里data不是volatile,也没有用synchronized。但当worker线程“检测到中断”为真之后再读取data,按照“线程中断规则”,主线程在interrupt()之前写入的data = 42应当对它可见,所以预期打印42。
7. 对象终结规则
Finalizer Rule
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
@Deprecated(since="9")
protected void finalize() throws Throwable { }
注意,在jdk9以后
finalize()标记为过时。
1)try-with-resources(推荐:显式关闭资源)
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class TryWithResourcesExample {
public static void main(String[] args) throws IOException {
String path = "test.txt";
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
String line = reader.readLine();
System.out.println(line);
} // 自动调用 reader.close()
}
}
适用:文件、套接字、输入输出流、数据库连接等实现了AutoCloseable或Closeable的资源。
2)Cleaner(兜底清理:对象不可达后执行清理动作)
import java.lang.ref.Cleaner;
public final class CleanerExample {
private static final Cleaner cleaner = Cleaner.create();
private static final class State implements Runnable {
private long nativeHandle;
State(long nativeHandle) {
this.nativeHandle = nativeHandle;
}
@Override
public void run() {
if (nativeHandle != 0) {
System.out.println("清理本地句柄: " + nativeHandle);
// releaseNative(nativeHandle);
nativeHandle = 0;
}
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
private boolean closed;
public CleanerExample(long nativeHandle) {
this.state = new State(nativeHandle);
this.cleanable = cleaner.register(this, state);
}
public void doWork() {
if (closed) {
throw new IllegalStateException("资源已关闭");
}
System.out.println("使用本地句柄: " + state.nativeHandle);
}
public void close() {
if (!closed) {
closed = true;
cleanable.clean(); // 显式清理
}
}
public static void main(String[] args) {
CleanerExample example = new CleanerExample(10001L);
try {
example.doWork();
} finally {
example.close();
}
}
}
适用:本地资源(本地内存、文件描述符封装、直接缓冲区包装等)的“忘记关闭时的兜底”,但仍建议提供显式close()。
3)PhantomReference + ReferenceQueue(更底层:可控的回收通知)
import java.lang.ref.PhantomReference;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class PhantomReferenceExample {
/**
* 模拟“持有本地资源的对象”。
* 例如:这个对象内部关联了一个本地内存地址、文件描述符、系统句柄等。
*
* 重点:这里的 handle 用 long 表示“本地资源标识”,仅用于演示。
*/
static final class ResourceOwner {
private final long handle;
ResourceOwner(long handle) {
this.handle = handle;
}
long handle() {
return handle;
}
}
public static void main(String[] args) throws Exception {
/*
* ReferenceQueue 的作用:
* 当某个对象“即将被回收”,并且它对应的 PhantomReference 被虚拟机入队时,
* 我们就能从这个队列里拿到那条 PhantomReference,从而得知“对象已经不可达了”。
*
* 注意:队列里放的不是原对象,而是“引用对象”(PhantomReference)。
*/
ReferenceQueue<ResourceOwner> referenceQueue = new ReferenceQueue<>();
/*
* 为什么需要一个 Map 来保存 handle?
* 因为 PhantomReference.get() 永远返回 null,
* 你无法通过 PhantomReference 再拿回 ResourceOwner 对象,
* 所以必须提前把“清理所需的信息”单独保存起来。
*
* 这里用 Map 的 key 保存 PhantomReference,
* value 保存该对象对应的本地资源句柄 handle。
*/
Map<PhantomReference<ResourceOwner>, Long> handles = new ConcurrentHashMap<>();
/*
* 回收线程(也叫清理线程、收割线程):
* 它一直阻塞等待 referenceQueue 中出现“被入队的 PhantomReference”。
* 一旦出现,说明对应的 ResourceOwner 已经不可达,可以做兜底清理动作。
*/
Thread reaper = new Thread(() -> {
while (true) {
try {
/*
* remove() 会阻塞,直到有引用对象进入队列。
* 这比不停轮询更省中央处理器。
*/
Reference<? extends ResourceOwner> ref = referenceQueue.remove();
/*
* 通过这个 PhantomReference 找到之前保存的 handle,
* 然后执行清理(释放本地资源)。
*/
Long handle = handles.remove(ref);
if (handle != null) {
System.out.println("检测到对象已不可达,开始释放本地句柄: " + handle);
// 这里放真正的清理逻辑(演示中只打印)
// 例如:releaseNative(handle);
}
/*
* clear() 用于清除引用对象内部保存的引用信息(虽然 PhantomReference 本来 get() 就是 null)。
* 这是一个良好习惯,帮助引用对象更快变成“无用”状态。
*/
ref.clear();
} catch (InterruptedException exception) {
/*
* 如果回收线程被中断,就退出线程。
* 这里是为了让示例更干净,并且体现线程可停止。
*/
return;
}
}
});
// 设置为守护线程:主线程结束后,守护线程不会阻止进程退出
reaper.setDaemon(true);
reaper.start();
/*
* 创建一个对象,它“拥有”一个本地资源句柄。
* 假设句柄 20002 代表某个本地资源。
*/
ResourceOwner owner = new ResourceOwner(20002L);
/*
* 创建 PhantomReference:
* - 第一个参数 owner:被监控的目标对象
* - 第二个参数 referenceQueue:当 owner 不可达时,把这条 PhantomReference 放入该队列
*
* 重要特性:phantom.get() 永远为 null
*/
PhantomReference<ResourceOwner> phantom = new PhantomReference<>(owner, referenceQueue);
/*
* 把清理所需信息(handle)保存到 Map。
* 将来回收线程从队列里取到 phantom 后,就能用它当 key 找回 handle。
*/
handles.put(phantom, owner.handle());
/*
* 断开强引用:
* owner 置为 null 后,如果没有其他地方再强引用这个对象,
* 那么它就“有机会”在将来的某个时刻被垃圾回收。
*/
owner = null;
/*
* 主动建议虚拟机进行垃圾回收(不保证立刻发生,只是建议)。
* 示例中为了更容易触发演示效果。
*/
System.gc();
/*
* 稍微等待一会儿:
* 给垃圾回收和回收线程一点时间,让它们有机会完成入队与清理打印。
*/
Thread.sleep(200);
System.out.println("主线程结束(不代表清理一定已经发生)");
}
}
适用:需要更可控的“对象已不可达”的通知机制(例如框架管理大量本地资源),但实现复杂度更高,一般业务代码不建议直接使用。
8. 传递性
Transitivity
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
线程安全: 不是一个非真即假的命题
一个类在可以被多个线程安全调用时就是线程安全的。 线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类: 不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
不可变
不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。
多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
不可变的类型:
- final 关键字修饰的基本数据类型
- String
- 枚举类型
- Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。
绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施。
相对线程安全
相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
但不代表一定不出问题
对于下面的代码,如果删除元素的线程删除了 Vector 的一个元素,而获取元素的线程试图访问一个已经被删除的元素,那么就会抛出 ArrayIndexOutOfBoundsException。
public class VectorUnsafeExample {
private static Vector<Integer> vector = new Vector<>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 100; i++) {
vector.add(i);
}
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
});
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
});
executorService.shutdown();
}
}
}
Exception in thread "Thread-159738" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3
at java.util.Vector.remove(Vector.java:831)
at VectorUnsafeExample.lambda$main$0(VectorUnsafeExample.java:14)
at VectorUnsafeExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
如果要保证上面的代码能正确执行下去,就需要对删除元素和获取元素的代码进行同步。
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
}
});
即:即使线程安全,但由于现实情况下,部分操作不是原子性,可能会出现对同一个对象的不可能操作,如:无法获取一个已经删除的对象,或者数组越界等。
线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。
线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
线程安全的实现方法
1. 互斥同步
在java中一般指: synchronized 和 ReentrantLock。
2. 非阻塞同步
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
(一)CAS
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
(二)AtomicInteger
J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。
以下代码使用了 AtomicInteger 执行了自增的操作。
private AtomicInteger cnt = new AtomicInteger();
public void add() {
cnt.incrementAndGet();
}
以下代码是 incrementAndGet() 的源码,它调用了 unsafe 的 getAndAddInt() 。
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
以下代码是 getAndAddInt() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
可以看到 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;
}
注意,这里的 this.compareAndSwapInt(var1, var2, var5, var5 + var4)等价于如下的原子操作
// 原子地执行下面这段逻辑(不可被打断)
// memory指宿主机的内存 var1+var2 -> 计算出实际机器中的内存地址,即: 把内存当前值与 expected(也就是刚刚读出来的 var5)做相等比较,如果相等,则把内存位置写成 update
if (memory[var1 + var2] == expected /* var5 */) {
memory[var1 + var2] = update /* var5 + var4 */;
return true;
} else {
return false;
}
这一步通常由底层的原子指令完成(例如 CPU 的原子读改写指令),因此不会出现“比较到一半被别的线程插队”的情况。即:这里的比较是原子性的。
核心目的是: int a = 1;a=a+1;中a+1是三步,即:拿出a,计算a+1的值,赋值给a;
(三)ABA
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
CAS, Unsafe和原子类详细分析请看:
JUC原子类: CAS, Unsafe和原子类详解
3. 无同步方案
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
(一)栈封闭
一个方法内部的局部变量,在jvm存在栈中,而栈是线程私有的。
(二)线程本地存储(Thread Local Storage)
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。
对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。
(三)可重入代码(Reentrant Code)
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
同一段代码在同一线程递归调用、或在多个线程并发调用时,都不会因为共享可变状态而出错 可重入代码的关键点通常是:不读写共享的可变全局状态,只使用局部变量或不可变对象;如果必须访问共享资源,则用可重入锁等机制把共享访问保护起来。
public final class ReentrantExamples {
// 可重入:没有共享可变状态
public static long factorial(int n) {
if (n < 0) {
throw new IllegalArgumentException("n must be >= 0");
}
long result = 1L;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
}
public static void main(String[] args) {
System.out.println(factorial(5)); // 120
System.out.println(factorial(10)); // 3628800
}
}
内存屏障
什么是内存屏障?
内存屏障(Memory Barrier),也叫内存栅栏,是一种用于屏蔽处理器或编译器对指令执行进行重排序的同步原语。
它的核心作用是保证某些特定操作的执行顺序,确保多线程并发或者多核处理器下的数据一致性和可见性。
为什么需要内存屏障?
- 现代处理器和编译器为了提升性能,会对汇编指令进行优化和重排序;
- 在单线程下没有问题,但在多线程/多核环境下,为了防止出现“你看到的不是我刚写的”这种问题,就必须让某些关键指令“必须按顺序执行”。
- 内存屏障就是用来让这些关键操作“不能被重排序”,保证并发下的正确性。
内存屏障的分类
通常细分为四种:
1. LoadLoad屏障(读-读屏障)
保证在屏障前面的所有读取操作,都必须在屏障后面的读取操作之前完成。
- 作用:防止前面的读被重排序到后面的读之后。
- 示例:
r1 = A; // 读A // LoadLoad屏障 r2 = B; // 读B // 保证:读A一定发生在读B之前。
2. StoreStore屏障(写-写屏障)
保证在屏障前面的所有写操作,都必须在屏障后面的写操作之前完成。
- 作用:防止前面的写被重排序到后面的写之后。
- 示例:
A = 1; // 写A // StoreStore屏障 B = 2; // 写B // 保证:写A一定发生在写B之前。
3. LoadStore屏障(读-写屏障)
保证在屏障前面的所有读操作,都必须在后面的写操作之前完成。
- 很少单独出现,常用于特殊硬件场景。
4. StoreLoad屏障(写-读屏障)
保证在屏障前面的所有写操作,都必须在屏障后面的读操作之前完成。
这是最强的屏障,通常用来实现volatile等强语义。
示例:
A = 1; // 写A // StoreLoad屏障 r1 = B; // 读B // 保证:写A一定发生在读B之前。
内存屏障的实际应用场景
Java中的final域安全发布
- 构造方法return之前JVM会插入StoreStore屏障,保证final字段写入在对象发布之前完成。
volatile关键字实现
- Java的volatile变量读写,分别会插入特定的内存屏障,保证可见性与禁止重排序。
多线程同步原语的底层实现
- 如锁(synchronized)、原子变量(AtomicXXX)、CAS等,底层常用内存屏障配合实现线程间“看得见”的同步。
图示(伪代码理解,各色线代表时间线顺序)
// 没有屏障时可能指令乱序
A = 1; B = 2; // 实际执行顺序:B=2, A=1
// StoreStore 屏障
A = 1;
[StoreStore 屏障]
B = 2; // 保证A=1在线程可见B=2之前完成
小结
- 内存屏障就是CPU级别/虚拟机指令级别的“一道门”,禁止关键数据操作的乱序,保证并发一致性。
- LoadLoad/StoreStore/LoadStore/StoreLoad四种屏障,各自对应不同的“顺序保障”。
- Java底层的final、volatile、安全发布、锁等,命令JVM插入相应内存屏障,使并发数据安全。