目录
volatile简介
volatile实现原理
volatile的happens-before关系
volatile的内存语义
volatile的内存语义实现
synchronized和volatile的区别和联系
示例
我们之前了解到synchronized是阻塞式同步,在线程竞争激烈的情况下会升级为重量级锁。而volatile是Java虚拟机提供的最轻量级的同步机制之一。但它同时不容易被正确理解,也至于在并发编程中很多程序员遇到线程安全的问题就会使用synchronized。它可以保证线程之间对被volatile修饰变量的可见性,即修改操作会被立刻传播到主内存中并通知其他线程,从而避免数据脏读问题。但它不能保证原子性,如果需要保证原子操作,可以使用synchronized或者java.util.concurrent.atomic包下的类。需要注意的是,在并发编程中,使用volatile需要谨慎,必须熟悉它的特性和使用场景,否则可能会产生意外的结果。
现在我们有了一个大概的印象就是:volatile 是 Java 中的一个关键字,用于修饰变量。它的作用是确保多个线程之间对该变量的读写操作都能够正确地进行同步,从而避免出现数据脏读的现象。
在 Java 中,volatile 关键字的实现原理可以从两个方面解释:一是编译器层面的语义约束,二是运行时层面的内存模型保证。
编译器层面的语义约束:
运行时层面的内存模型保证:
总结起来,volatile 关键字通过编译器层面的语义约束和运行时层面的内存模型保证,确保了对 volatile 变量的读写操作具备可见性和顺序性。这使得其在多线程并发编程中成为一种简单有效的同步手段,但需要注意的是,volatile 并不能保证原子性,如果需要原子操作,仍需配合其他机制,如锁或原子类。
在 Java 内存模型中,happens-before 关系可以确保多线程并发执行时,某个操作的结果对另一个操作可见。volatile 变量的读写操作就具有 happens-before 关系,即:
这个特性可以帮助程序员避免由于指令重排序、缓存一致性等导致的数据竞争和线程间通信问题。
具体来说,考虑以下代码示例:
class VolatileExample {
private volatile int counter = 0;
public void increase() {
counter++;
}
public int getCounter() {
return counter;
}
}
在上述代码中,counter 作为一个 volatile 变量,可以保证对其的读写操作具有 happens-before 关系,从而避免数据不一致的问题。例如,在一个线程上执行如下代码:
VolatileExample example = new VolatileExample();
example.increase(); // 对 volatile 变量的写操作
int count = example.getCounter(); // 对 volatile 变量的读操作
由于写操作先行发生于读操作,所以当线程执行 getCounter() 方法时,可以确保读取到的值是最新的,不会发生数据不一致问题。
需要注意的是,在并发程序中,volatile 变量虽然能够帮助开发者解决一些通信问题,但其并不能保证原子性。如果需要保证原子性操作,应该使用其他同步机制,如锁或原子类。
public class VolatileExample {
private int a = 0; // 非volatile变量
private volatile boolean flag = false; // volatile变量
public void writer() {
a = 1; //1 将a设置为1
flag = true; //2 将flag设置为true
}
public void reader() {
if (flag) { //3 检查flag的值,如果为true,则进入if语句块
int i = a; //4 读取a的值
}
}
}
对于线程A先执行writer方法,线程B随后执行reader方法的情况,假设flag是一个volatile变量。当线程A执行volatile写操作时,它会将flag的最新值写入主内存,并且会使得线程B的本地内存中flag的值无效。这意味着线程B需要从主内存中读取flag的最新值。
当线程B执行volatile读操作时,它会从主内存中获取flag的最新值,并且会使得线程B的本地内存中flag的值有效。这样,线程B就能够获取到线程A写入的最新值。
可以将这个过程类比为线程A向线程B发送了一个消息,告诉线程B当前的值已经失效,需要更新。而线程B在读取这个volatile变量时,就像是接收到了线程A刚刚发送的消息。由于值已经失效了,线程B只能去主内存获取最新的值。
总结一下我们之前的核心概念:happens-before和volatile的内存语义。这些概念的理解不仅能帮助我们更深入地掌握知识,还可以避免迷茫和困惑。当我们发现自己对学习如此热爱时,也许会感到特别欣喜。既然如此,让我们来分享一些关于volatile内存语义实现方面的干货吧!
为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障。
JMM内存屏障分为四类见下图,
通过内存屏障的插入,可以有效地阻止编译器和处理器对指令序列的重排序,从而实现volatile的可见性和有序性。这样保证了volatile写操作之前的所有写操作都对其他线程可见,同时保证了volatile读操作之后的所有读操作也对其他线程可见。内存屏障的插入遵循JMM制定的volatile重排序规则表,确保了正确的内存语义:
"NO"表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:
需要注意的是:对于volatile写操作,会在写操作前后分别插入StoreStore屏障和StoreLoad屏障。对于volatile读操作,会在读操作后插入LoadLoad屏障和LoadStore屏障。
具体作用如下:
通过插入这些屏障,可以保证volatile的可见性和有序性,防止编译器和处理器对指令进行重排序,从而确保程序在多线程环境下的正确执行。需要注意的是,插入内存屏障会带来一定的性能开销,因此JMM采取了保守策略来最大程度地保证内存语义的正确性。
volatile和synchronized是Java中用于处理多线程并发的关键字,它们有一些区别和联系。
1. 可见性:
2. 原子性:
3. 顺序性:
4. 适用场景:
5. 锁粒度:
需要注意的是,虽然volatile和synchronized都可以用于实现线程间通信,但在应用场景和使用方式上存在差异。volatile适用于变量的状态标记、开关等简单的场景,而synchronized适用于复杂的临界区代码,提供了更强大的同步机制。在实际使用中,根据具体需求选择合适的关键字来确保线程安全和正确的并发行为。
该示例演示了如何使用volatile关键字实现线程间通信。
public class Main {
private volatile boolean flag = false;
public static void main(String[] args) {
Main example = new Main();
// 创建并启动线程1
Thread thread1 = new Thread(() -> {
System.out.println("线程1正在运行...");
while (!example.flag) {
// 在循环中等待,直到flag为true
}
System.out.println("线程1已完成.");
});
thread1.start();
// 创建并启动线程2
Thread thread2 = new Thread(() -> {
System.out.println("线程2正在运行...");
try {
Thread.sleep(1000); // 休眠1秒钟
} catch (InterruptedException e) {
e.printStackTrace();
}
example.flag = true; // 设置flag为true
System.out.println("线程2已完成.");
});
thread2.start();
}
}
在这个示例中,我们定义了一个布尔类型的flag变量,并将其声明为volatile。该变量用于在线程1和线程2之间进行通信。线程1在一个循环中等待直到flag变量的值为true,而线程2在1秒钟后设置flag变量的值为true。由于volatile关键字保证变量的可见性,因此线程1能够立即看到flag变量的值的变化,从而退出循环。
注意,使用volatile关键字并不能保证线程安全,因此在多线程环境下仍需要考虑同步和竞争条件。