Skip to content

Java并发编程之 volatile #23

@Jimmy2Angel

Description

@Jimmy2Angel

简介

Java 中如果一个变量被声明是 volatile 的,则一个线程对该变量的修改对其它线程是立即可见的。但是并不能保证基于该变量的操作的原子性。
所以 volatile 变量可以看成功能被削的 synchronized,但是与 synchronized 相比,使用 volatile 编码较少,且运行时开销也较少,
因此在 volatile 能满足需求的时候不需要使用 synchronized。

volatile 的使用方式很简单,只要修饰一个能被多个线程访问的变量即可,下面是一个单例的代码:

public class Singleton {
    private volatile static Singleton singleton;  
    private Singleton (){}
    public static Singleton getSingleton() {  
        if (singleton == null) {
            synchronized (Singleton.class) {  
                if (singleton == null) {
                    singleton = new Singleton();  
                }
            }  
        }
        return singleton;  
    }
}

volatile 的原理

Java 内存模型(JMM)

JMM 规定了所有的变量的都是存储在 主内存 中的,每个线程都有自己的 工作内存,线程的 工作内存 中存储的是 主内存 中变量的
副本拷贝,线程对变量的操作只能发生在 工作内存 中,不能直接读写 主内存。不同线程之间无法访问对方的 工作线程,只能通过 主内存 的同步。

但是这样的话就可能发生线程A修改了某个变量但是线程B不知道的情况。(可见性问题)

内存屏障

内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,
使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。

  • LoadLoad Barriers:该屏障确保屏障前的读操作效果先于屏障后的读操作效果
  • StoreStore Barriers:该屏障确保屏障前的写操作效果先于屏障后的写操作效果
  • LoadStore Barriers:该屏障确保屏障前的读操作效果先于屏障后的写操作效果
  • StoreLoad Barriers:该屏障确保屏障前的写操作效果先于屏障后的读操作效果

volatile 变量的内存屏障使用如下:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障,保证之前所有的普通写在 volatile 写之前刷新回 主内存
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障,避免 volatile 写与后面可能发生的 volatile 读写操作重排序
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障,避免 volatile 读与后面的普通读重排序
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障,避免 volatile 读与后面的普通写重排序

volatile 与可见性

通过汇编码可以看出 volatile 实际上是基于 lock 内存屏障指令来完成可见性的。

  • 修改 volatile 变量后立即刷新回 主内存
  • 工作内存 使用 volatile 变量时从 主内存 获取最新值

volatile 与有序性

  • StoreStore 屏障保证 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • LoadLoad、LoadStore 屏障保证 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
  • 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。

volatile 的使用场景

volatile 适用于下面两种情况:

  1. 变量的写操作不受变量的当前值影响
  2. 变量不与其它变量共同参与不变约束

状态标记

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { shutdownRequested = true; }
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

用 volatile 变量作为一个状态标记,当其它线程转换了状态,当前线程对该状态立即可见,从而进行正确的处理,较使用 synchronized 编码简单许多。

一次性安全发布

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);
        }
    }
}

与文章开头的单例代码类似,如果 theFlooble 变量不使用 volatile 修饰,通过 floobleLoader.theFlooble 引用到的对象可能是一个不完全构造的 Flooble。
因为 theFlooble = new Flooble() 可能先让变量指向一块内存,但是该内存中的 Flooble 对象还未初始化。

该模式的一个必要条件是:被发布的对象必须是线程安全的,或者是有效的不可变对象(有效不可变意味着对象的状态在发布之后永远不会被修改)。

独立观察

该模式定期"发布"观察结果供程序内部使用,示例代码如下:

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;
    }
}

该代码用 volatile 修饰变量 lastUser,然后反复使用 lastUser 来引用最新的有效的用户名。

该模式是前面模式的扩展;将某个值发布以在程序内的其他地方使用,但是与一次性事件的发布不同,这是一系列独立事件。
这个模式要求被发布的值(即每次验证的String user)是有效不可变的 —— 即值的状态在发布后不会更改。

“volatile bean” 模式

volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。

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

@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;
    }
}

开销较低的读写锁策略

如果读操作远远超过写操作,您可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。示例代码显示的线程安全的计数器使用 synchronized 确保增量操作是原子的,
并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

public class CheesyCounter {
    private volatile int value;
    public int getValue() { return value; }
    public synchronized int increment() {
        return value++;
    }
}

总结

  • volatile 基于内存屏障保证了可见性和有序性,但不保证原子性。
  • 使用条件:变量真正独立于其它变量和自己以前的值。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions