volatile详解

2023/12/18 Java多线程与并发

我们知道要保证线程安全,就要保证可见性、原子性、有序性这三个条件。volatile关键字在并发中有着重要的作用,本章我们详解一下volatile关键字。

# 1、volatile特性详解

# 1.1、防止重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。volatile可以禁止这种指令重排。

并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。源码如下:

public class Singleton {
    public static volatile Singleton singleton;
    /**
     * 构造函数私有,禁止外部实例化
     */
    private Singleton() {};
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

  • 分配内存空间。
  • 初始化对象。
  • 将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

  • 分配内存空间。
  • 将内存空间的地址赋值给对应的引用。
  • 初始化对象。

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

# 1.2、保证可见性

当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去。这个写操作会导致其他线程中的volatile变量缓存无效。

案例

public class TestVolatile {
    // 没加 volatile
    // private static boolean stop = false;
    // 加 volatile
    private static volatile boolean stop = false;

    public static void main(String[] args) {
        // Thread-A
        new Thread("Thread A") {
            @Override
            public void run() {
                while (!stop) {
                }
                System.out.println(Thread.currentThread() + " stopped");
            }
        }.start();

        // Thread-main
        try {
            TimeUnit.SECONDS.sleep(1);
            System.out.println(Thread.currentThread() + " after 1 seconds");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        stop = true;
    }
}

/**
 * 没加volatile输出结果:
 * Thread[main,5,main] after 1 seconds
 * 
 * Thread A一直在loop, 因为Thread A 由于可见性原因看不到Thread Main 已经修改stop的值
 */

/**
 * 加volatile输出结果:
 * Thread[main,5,main] after 1 seconds
 * Thread[Thread A,5,main] stopped
 * 
 * Process finished with exit code 0
 */
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
35
36
37
38
39
40
41
42

# 1.3、保证单次读/写原子性

volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。

两个问题盲区:

  • volatile只能保证单次的读或写的原子性,因此i++使用volatile不能保证原子性,因为i++操作分为三个步骤:读取i的值、i加1、将i的值写回。
  • 共享的long和double变量需要用volatile,因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。

# 2、valatile原理解析

# 2.1、有序性原理解析

volatile变量具有一种特殊的Happens-Before关系。Happens-Before关系是对程序顺序的一种约束,它关注线程之间操作的可见性和顺序性。

  • 写-读规则:对一个volatile变量的写操作Happens-Before于后续对该变量的读操作。这意味着,一个写线程对volatile变量的修改对于随后读取该变量的线程是可见的。通过这个规则,可以确保线程之间对volatile变量的修改的可见性。
  • 同一个线程内的顺序规则:在一个线程内,对volatile变量的写操作Happens-Before于后续对该变量的读操作。在同一个线程中,写操作总是在读操作之前发生,保证了线程内操作的顺序性。

# 2.2、可见性原理解析

volatile变量的内存可见性是通过内存屏障(Memory Barrier)来实现的。

  • 内存屏障是一种特殊的指令,它可以确保在它之前的所有操作都已经完成,并将结果刷新到主内存中;同时,它也可以确保在它之后的所有操作都将从主内存中重新读取最新的值。
  • 对于volatile变量,当一个线程对其进行写操作时,会在写操作前插入一个写屏障,这会使得该线程对其他线程可见的写操作在写屏障之前完成。同样地,当一个线程对volatile变量进行读操作时,会在读操作后插入一个读屏障,这会使得该线程从主内存中重新加载最新的值。
  • 通过写屏障和读屏障的插入,volatile变量能够在不同线程之间保证内存可见性。这意味着当一个线程修改了volatile变量的值时,其他线程能够立即看到这个修改后的值。
  • 需要注意的是,volatile变量的内存可见性只适用于对单个volatile变量的读写操作。对多个volatile变量之间的操作并不能保证原子性,如果需要保证多个操作的原子性,需要使用其他同步机制来保证。

# 3、volatile不适用的场景

虽然volatile关键字可以确保可见性,禁止指令重排序,并保证对volatile变量的简单读写操作具有原子性,但并不适用于所有的多线程场景。以下是一些volatile不适用的场景:

  • 高频写入场景:由于volatile变量的写操作会导致其他线程的工作内存刷新,频繁地写入volatile变量可能会造成较高的开销。如果在高频写入场景下使用volatile,可能会降低性能,此时可以考虑使用其他的同步机制。
  • 不支持复合操作:volatile变量只能保证对其单个操作的可见性,对于复合操作,如线程之间的累加操作,volatile无法提供对整个复合操作的原子性保证。对于这种情况,需要使用其他同步机制,如AtomicInteger或显式的锁。还有比如前面说的i++也是复合操作。
  • 锁的复用和条件依赖:volatile关键字不能支持复杂的锁的用法,它无法实现锁的复用和条件依赖,无法用于等待/通知机制的实现。

# 4、volatile的适用场景

# 4.1、状态标志

通常的状态转换,比如开机:true,关机:false。当设置了关机,所有程序都停止。

volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}
1
2
3
4
5
6
7
8

# 4.2、一次性安全发布(one-time safe publication)

一次性安全发布,意味着一个对象能被安全地发布到其他线程并且不会出现重排序等现象,使得其他线程始终能看到完整,正确状态的对象。

对于volatile关键字,它在Java内存模型中有一个特性:线程写一个volatile变量之后,对volatile变量的写入操作先行发生于其他线程后续对该volatile变量的读取操作。

在这样的设计下,如果一个对象引用被声明为volatile,那么当对象的状态发生改变,然后这个改变被更新到内存(即我们说的volatile变量写的过程),其他线程读取这个引用(即我们说的volatile变量读的过程)获得的就是一个完整的,最新的对象状态。

那样便可以安全地在多线程环境中发布一个对象,保证其他线程始终能获得一个正确,完整的对象状态。这就是所谓的一次性安全发布。

public class BackgroundFloobleLoader {
    public volatile Flooble theFlooble;
 
    public void initInBackground() {
        // do lots of stuff
        theFlooble = new Flooble();  // this is the only write to theFlooble
    }
}
 
public class SomeOtherClass {
    public void doWork() {
        while (true) { 
            // do some stuff...
            // use the Flooble, but only if it is ready
            if (floobleLoader.theFlooble != null) 
                doSomething(floobleLoader.theFlooble);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 4.3、独立观察(independent observation)

安全使用 volatile 的另一种简单模式是定期、发布、观察结果供程序内部使用。

例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

public class UserManager {
    public volatile String lastUser;
 
    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 4.4、volatile bean 模式

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。

@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
 
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
 
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
 
    public void setAge(int age) { 
        this.age = age;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 4.5、开销较低的读-写锁策略

volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;
 
    public int getValue() { return value; }
 
    public synchronized int increment() {
        return value++;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 4.6、双重检查(double-checked)

单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。

class Singleton {
    private volatile static Singleton instance;
    private Singleton() {
    }
    public static Singleton getInstance() {
        if (instance == null) {
            syschronized(Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    } 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15