什么是volatile
volatile,
Java 中的一个关键字,用于声明变量。当一个变量被声明为 volatile
时,它将具备一些特殊的性质,这些性质与 Java 的内存模型和线程间的可见性有关。
Java内存模型(JMM)简单回忆(非详细介绍)
Java内存模型(Java Memory Model,简称JMM),主要描述了程序中各个变量(包括实例域、静态域和数组元素)之间的关系,以及这些变量在实际计算机系统内存中的存储和取出操作的底层细节。
Java平台自动集成了线程以及多处理器技术,针对多种异构平台的平台独立性而使用的多线程技术支持也是具有开拓性的一面。
在Java中,内存主要分为栈内存、堆内存和方法区。
- 栈内存:主要用于存储基本类型的变量和对象的引用变量,当数据使用完后,所占空间会自动释放。
- 堆内存:主要用于存放由new创建的对象和数组,实体中的变量都有默认初始化值,当实体不再被使用时,会在不确定的时间内被垃圾回收器回收。
- 方法区:包含所有的class和static变量,且被所有的线程共享。
变量在线程间的可见性
变量在线程间的可见性是指一个线程对共享变量值的修改能够实时地被其他线程所看到。换句话说,当一个线程修改了一个共享变量的值,这个修改应该立即对其他线程可见,而不是保持在修改它的线程的本地缓存中。
在Java中,共享变量是那些在多个线程的工作内存中都有副本的变量。每个线程都有自己的工作内存,里面保存着该线程使用到的变量的副本。这些副本是主内存中相应变量的拷贝。
主内存、工作内存:线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中,线程不能直接操作主内存,线程的工作内存私有,不同线程之间的工作内存不能互相访问(即:每个线程不能访问其他线程的工作内存)。
volatile的作用
- 可见性:当一个线程修改了一个
volatile
变量的值,新值对其他线程来说是立即可见的。这确保了多个线程之间可以共享和传递最新的变量值,而不需要额外的同步措施。 - 禁止指令重排:
volatile
还可以确保变量的读写操作不会被 JVM 或硬件优化而重排。这有助于确保多线程环境中程序行为的可预测性。
volatile的实现原理
volatile
的实现原理与 Java 内存模型(Java Memory Model, JMM)和硬件内存模型有关。
- Java 内存模型(JMM):JMM 定义了线程和主内存之间的抽象关系。线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(工作内存),线程对变量的所有操作(读取、赋值等)都必须在本地内存中进行,然后再刷新到主内存或把主内存的数据加载到本地内存。
volatile
关键字的作用就是告诉 JVM,这个变量是共享且不稳定的,每次使用它都到主存中读取,而不是使用本地内存中的变量。 - 硬件内存模型:现代的处理器通常使用缓存来提高访问速度,这可能导致一个线程在本地缓存中修改了变量的值,而其他线程在访问该变量时可能看不到最新的值。
volatile
关键字在硬件层面通常会转换为一条内存屏障指令(Memory Barrier),它可以确保对volatile
变量的读写操作不会被处理器重排,并且会强制将本地缓存中的变量值刷新到主内存中,或者从主内存中加载最新的值到本地缓存中。
关于Java内存模型(JMM),前面我们已经大概回忆过了,接下来再了解一下内存屏障。
内存屏障
什么是内存屏障?
内存屏障是一种特殊的指令,它可以防止处理器对指令进行重排序,并确保在屏障之前的所有读写操作都已经被执行并刷新到主内存中。
volatile关键字在Java中的实现原理之一就是通过插入内存屏障来确保多线程环境下共享变量的可见性。当一个线程修改了一个volatile变量的值,JVM会在这个写操作之后插入一个写屏障(Write Barrier),这个屏障会确保修改的值能够立即同步到主内存中,使得其他线程能够看到这个修改。同样地,当一个线程读取一个volatile变量的值,JVM会在这个读操作之前插入一个读屏障(Read Barrier),这个屏障会确保从主内存中读取的值是最新的,从而避免读取到本地缓存中的旧值。
内存屏障的作用是阻止处理器对指令进行重排序,确保在屏障之前的指令先执行,在屏障之后的指令后执行。这对于volatile变量的实现至关重要,因为volatile需要保证操作的顺序性和可见性。
内存屏障的类型有哪些?
内存屏障的类型主要包括以下几种:
- 读屏障(Load Barrier):确保对屏障之前的所有读操作的结果在继续执行屏障之后的任何指令前都是可见的。
- 写屏障(Store Barrier):确保对屏障之前的所有写操作的结果在继续执行屏障之后的任何指令前都是可见的。
- 全屏障(Full Barrier):是读屏障和写屏障的组合,确保屏障之前的所有操作在屏障之后的所有操作之前完成。
按照上述类型分类,我们可以进一步总结为如下四种类型:
内存屏障分类 | 场景 | 描述 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 在Load2要读取的数据被访问前,保证Load1要读取的数据已读取完毕; |
StoreStore | Store1;StoreStore;Store2 | 在Store2写入执行前,保证Store1的写入对其他处理器可见; |
LoadStore | Load1;LoadStore;Store2 | 在Store2写入执行前,保证Load1要读取的数据已读取完毕; |
StoreLoad | Store1;StoreLoad;Load2 | 在Load2要读取的数据被访问前,保证Store1写入执行对所有处理器可见; |
此外,按照实现方式的不同,内存屏障还可以分为硬件内存屏障和软件内存屏障:
- 硬件内存屏障:这些是由处理器提供的特定指令,例如x86架构的MFENCE、LFENCE和SFENCE。
- 软件内存屏障:这些是由编程语言或库提供的,例如C11和C++11提供的原子操作和顺序点。
内存屏障对并发编程的影响有哪些?
内存屏障对并发编程有重要影响,主要体现在以下几个方面:
- 控制内存操作的顺序:在并发编程中,由于多个线程可能同时访问和修改共享数据,指令的执行顺序成为关键问题。内存屏障能够阻止屏障两侧的指令重排序,确保对共享数据的操作按照预期的顺序执行。这对于维护数据的一致性和正确性至关重要。
- 保证数据的可见性:内存屏障强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。这意味着当一个线程修改了共享数据后,其他线程能够立即看到这些修改。这确保了线程间的数据同步和可见性,避免了因数据不一致而引发的问题。
- 提升性能:虽然内存屏障能够解决并发编程中的一些问题,但过度使用它们会导致性能下降。因为内存屏障会限制CPU和编译器的优化能力,使得指令的执行顺序受到更多限制。因此,在并发编程中应谨慎使用内存屏障,并在必要时进行优化。
综上所述,内存屏障是并发编程中用于解决数据一致性和可见性问题的重要工具。然而,使用时需要注意其可能带来的性能开销,并根据具体场景进行合理使用和优化。
Volatile使用代码示例
下面是一个简单的例子来说明 volatile
的用法:
public class VolatileExample {
// 使用 volatile 关键字修饰共享变量
private volatile boolean flag = false;
public void writer() {
new Thread(() -> {
try {
// 模拟一些工作
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改 flag 的值
flag = true;
System.out.println("Flag is set to true");
}).start();
}
public void reader() {
new Thread(() -> {
while (!flag) {
// 循环等待 flag 变为 true
// 由于 flag 是 volatile,这里每次循环都会从主内存中读取 flag 的最新值
System.out.println("Waiting for flag to be true...");
try {
Thread.sleep(100); // 短暂休眠以避免忙等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Flag is true, exiting loop");
}).start();
}
public static void main(String[] args) {
VolatileExample example = new VolatileExample();
example.writer(); // 启动一个线程来设置 flag
example.reader(); // 启动另一个线程来等待 flag 变为 true
}
}
在这个例子中,我们有一个 VolatileExample
类,其中包含一个 volatile
修饰的 flag
变量。writer
方法启动一个线程,在一段时间后设置 flag
为 true
。reader
方法启动另一个线程,它在一个循环中等待 flag
变为 true
。由于 flag
是 volatile
的,因此当 writer
线程修改 flag
的值时,这个修改会立即对其他线程可见,包括 reader
线程。这样,reader
线程最终会退出循环并打印消息。
如果没有使用 volatile
关键字,reader
线程可能会因为缓存一致性问题而看不到 flag
的变化,导致它永远无法退出循环。使用 volatile
确保了 flag
的可见性,使得多线程环境下的程序行为更加可预测。
使用建议
虽然 volatile
提供了可见性和禁止指令重排的特性,但它并不能解决所有并发问题。例如,它不能保证复合操作的原子性。因此,在复杂的并发场景中,通常还需要结合其他同步机制(如 synchronized
、Lock
等)来确保线程安全。