Guidelines for Using Volatile Variables


 

Guidelines for Using Volatile Variables

如何正确使用 volatile 变量

0x01. 简介

synchronized锁提供了两个主要特性:互斥可见性。互斥意味着一次只能有一个线程持有给定的锁,该属性可用于实现协调对共享数据的访问的协议,以便一次只能有一个线程使用共享数据。可见性更为微妙,它与确保在释放锁之前对共享数据所做的更改对随后获取该锁的另一个线程可见有关,遵循happens-before规则,如果没有同步提供的可见性保证,线程可能会看到过时或不一致的值。

Java 语言中的volatile变量可以看作是synchronized lite,即synchronized的简化版,与synchronized块相比,它们需要更少的编码,运行时开销也更少,但是它们只能用于synchronized所能做的事情的一个子集。下面介绍一些有效使用volatile变量的模式,以及关于何时不使用它们的一些警告。

0x02. volatile 变量

volatile变量共享synchronized的可见性特性,但没有原子性特性。这意味着线程将自动看到volatile变量的最新值。它们可以用于提供线程安全,但仅在一组非常受限的情况下使用:不在多个变量之间或变量的当前值与其未来值之间施加约束的情况。因此,volatile本身不足以实现计数器、互斥锁或任何具有与多个变量相关的不变量的类(如start <= end)。

出于两个主要原因之一,你可能更喜欢使用volatile变量而不是锁:简单性或可伸缩性。当使用volatile变量而不是锁时,一些习惯用法更容易编码和阅读。此外,volatile变量与锁不同,它们不会导致线程阻塞,因此它们不太可能导致可伸缩性问题。在读操作远远多于写操作的情况下,volatile变量还可能比锁提供性能优势

1. 正确使用 volatile 的条件

只有在一组受限的情况下,才能使用volatile变量而不是锁。volatile变量必须满足以下两个条件才能提供所需的线程安全性:

  • 对变量的写操作不依赖于它的当前值
  • 变量不参与其他变量的不变条件,如start <= end,若start为volatile的,则该条件语句为原子操作,先取值,再比较,有可能取完值后,在比较前发生变化。

基本上,这些条件表明可以写入volatile变量的有效值集独立于任何其他程序状态,包括该变量的当前状态。第一个条件使volatile变量不能用作线程安全的计数器。虽然x++的自增操作看起来像一个单独的操作,但它实际上是一个复合的读-修改-写操作序列,必须以原子方式执行,而volatile不提供必要的原子性。正确的操作要求x的值在操作期间保持不变,这是使用volatile变量无法实现的。但是,如果该值只从单线程写入,那么可以忽略第一个条件。

大多数编程情况都会与第一个或第二个条件发生冲突,这使得volatile变量在实现线程安全方面不如synchronized那么常用。下面代码清单显示了一个非线程安全的数字范围类。它包含的一个不变条件是下界lower总是小于或等于上界upper

@NotThreadSafe 
public class NumberRange {
    private int lower, upper;
    public int getLower() { return lower; }
    public int getUpper() { return upper; }
 
    // 下溢出,设为最小
    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }
 
    // 上溢出,设为最大
    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}

由于范围的状态变量uooerlower参与另一个变量value的条件判断,因而受到约束,所以即使用volatile修饰它们仍不足以使该类成为线程安全的,仍需要同步。否则,如果时间不凑合,两个执行setLowersetUpper的线程的值不一致,可能会使范围(lower, upper)处于不一致的状态。例如,如果初始状态为(0,5)线程A线程B调用setUpper(3)的同时调用setLower(4),并且操作交错错了,那么这两个线程都可以通过if条件,最后将范围设为无效的(4,3),所以我们需要使setLower()setUpper()操作相对于范围上的其他操作具有原子性,而用volatile修饰范围字段不能做到这一点。

2. 性能考虑

使用volatile变量的主要动机是简单性:在某些情况下,使用volatile变量比使用相应的锁要简单。使用volatile变量的第二个动机是性能:在某些情况下,volatile变量可能是比锁定性能更好的同步机制。

要做出“X总是比Y快”这种形式的准确、一般的声明是极其困难的,特别是在涉及到JVM内部操作时,例如,VM可能能够在某些情况下完全删除锁操作,这使得在抽象中很难讨论volatilesynchronized的相对成本,也就是说,在当前的大多数处理器架构中,volatile读操作的成本很低,几乎和非volatile读操作的成本一样低。volatile写操作比非volatile写操作的开销要大得多,因为需要内存配置来保证可见性,但通常仍然比获取锁操作消耗低。

与锁不同,volatile操作永远不会阻塞,因此在可以安全使用volatile的情况下,volatile比锁提供了一些可伸缩性优势。在读操作远远多于写操作的情况下,与锁相比,volatile变量通常可以降低同步的性能成本

0x03. 正确使用 volatile 的模式

许多并发专家倾向于指导用户完全不要使用volatile变量,因为它们比锁更难正确使用。但是,存在一些定义良好的模式,如果你仔细地遵循它们,就可以在各种情况下安全地使用它们。需要牢记的一个使用volatile的规则是只对真正独立于程序中其他所有内容的状态使用volatile,最直接的理解是当一个变量独立于其他变量及自己原先的值时,才将其设置为volatile

1. 状态标记

volatile变量的典型用法可能是用作简单的布尔型状态标志,用于指示发生了一个重要的一次性生命周期事件,例如初始化已经完成或请求关闭。很多应用程序包含表单的控制结构,“当我们还没有准备好关闭时,执行更多的工作”,如下面代码清单所示

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

很可能会从循环之外的某个地方(在另一个线程中,如JMX侦听器、GUI事件线程中的操作监听器、通过RMI、通过Web服务等)调用shutdown()方法,因此需要某种形式的同步来确保shutdownrequired变量的适当可见性。然而,使用同步块对循环进行编码要比使用volatile状态标志编码麻烦得多,如上面的代码清单所示。因为volatile简化了编码,并且状态标志不依赖于任何其他标记,这是很好地使用volatile变量的一个情况。

这类状态标志的一个常见特征是通常只有一个状态转换,如关闭请求标志从false变为true,然后程序关闭。可以将此模式扩展到可以来回更改的状态标志,但只有在类似ABA问题false -> true -> false转换周期未被检测到是可接受的情况下。否则,需要某种原子状态转换机制,例如原子变量。

2. 一次性安全发布

当写入对象引用而不是原始值时,在没有同步的情况下会更难判断可能出现的可见性故障。在没有同步的情况下,可以看到由另一个线程编写的对象引用的最新值,但仍然可以看到该对象状态的陈旧值。这种危险是臭名昭著的双签锁习惯用法问题的根源,在该用法中,不同步地读取对象引用,而风险是你可以看到最新的引用,但仍然可以通过该引用观察部分构造的对象。

安全地发布对象的一种技术是用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);
        }
    }
}

如果theFlooble引用不是用·volatile修饰的,doWork()中的代码在使用theFlooble引用时,就有可能看到部分构造的Flooble对象。

此模式的一个关键要求是,要发布的对象必须是线程安全的或有效不可变的。有效不可变意味着在发布之后永远不会修改其状态。volatile引用可以保证对象在发布时的可见性,但是如果对象的状态在发布后要改变,则需要额外的同步

3. 独立观测

安全地使用volatile的另一个简单模式是,定期发布观察结果以便在程序中使用。例如,有一个环境传感器可以感知当前的温度。后台线程可能每隔几秒钟读取此传感器,并更新包含当前温度的volatile变量。然后,其他线程可以读取这个变量,因为它们知道它们将始终看到最新的值。

此模式的另一个应用程序是收集有关程序的统计信息。下面代码清单显示了身份验证机制如何记住最后登录的用户的名称。lastUser引用将被反复用于发布一个供程序其余部分使用的值。

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

这个模式是上一个模式的扩展:一个值被发布以便在程序的其他地方使用,但发布不是一次性事件,而是一系列独立事件。此模式要求所发布的值实际上是不可变的,即其状态在发布后不会更改。使用该值的代码应该知道它可能在任何时候发生变化。

4. volatile bean

该模式适用于使用javabean作为美化结构的框架。在volatile bean模式中,JavaBean被用作一组具有getter和/或setter的独立属性的容器。volatile bean模式的基本原理是,许多框架为可变数据持有者(例如HttpSession)提供容器,但是放置在这些容器中的对象必须是线程安全的。

volatile bean模式中,JavaBean的所有数据成员都是volatile修饰的,gettersetter必须很简单,它们必须不包含任何逻辑,只能获取或设置适当的属性。此外,对于对象引用的数据成员,引用到的对象必须是有效不可变的,这禁止拥有数组值属性,因为当数组引用声明为volatile时,只有引用而不是元素本身具有volatile语义。与任何volatile变量一样,这里可能没有涉及JavaBean属性的不变量条件或约束。下面代码清单显示了一个遵循volatile bean模式的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;
    }
}

0x04. 高级模式

使用volatile的更高级的模式可能非常脆弱。仔细记录你的假设并对这些模式进行严格封装是非常重要的,因为很小的更改就可能破坏你的代码。此外,考虑到更高级的volatile用例的主要动机是性能,在开始应用它们之前,请确保你实际已经证明了对所谓的性能增益的需求。这些模式是牺牲可读性或可维护性以换取可能的性能提升的权衡。如果不需要性能提升或者无法通过严格的度量程序证明需要它,那么这可能是一笔糟糕的交易,因为你放弃了一些有价值的东西,而获得了一些没有价值的东西。

廉价读写锁技巧

到目前为止,应该已经知道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++;
    }
}

该技术被称为廉价读写锁的原因是,你正在使用不同的同步机制进行读写。因为这种情况下的写操作违反了使用volatile的第一个条件,所以不能使用volatile安全地实现计数器而必须用锁。但是,你可以用volatile来确保在读取时当前值的可见性,因此对所有可变操作使用锁,对只读操作使用volatile,如其中的自增操作为可变操作,使用synchronized确保原子性,而值的读取操作getValue,并没有用synchronized修饰,所以它对value的读取并不遵循锁获取即及释放的happens-before规则,这里则是通过将变量设置为volatile保障其他线程对该变量的修改及时的刷新,从而为当前线程可见。

锁只允许一个线程访问一个值,volatile·读允许不止一个,因此,当你使用volatile来保护读取变量的代码路径(此处为getValue方法)时,与对所有代码路径使用锁定相比,你可以获得更高程度的共享,其他想要读取value·值的线程并不需获取锁就能看到任何线程对变量修改后的最新值,这就像是一个读写锁。但是,需要注意此模式的脆弱性是对于两个相互竞争的同步机制,如果你超出此模式的最基本应用程序的范围,这可能会变得非常棘手。

0x05. 总结

volatile变量是一种比锁更简单但更弱的同步形式,在某些情况下,比固有锁提供更好的性能或可伸缩性。如果你遵循安全地使用volatile的条件,即该变量确实独立于其他变量和它自己的先前值,那么有时可以通过使用volatile而不是synchronized来简化代码。

然而,使用volatile的代码通常比使用锁的代码更脆弱。这里提供的模式涵盖了最常见的情况,在这些情况下volatile是同步的一种合理的替代方案。遵循这些模式,注意不要超出它们的极限,这样应该可以帮助你安全地覆盖大多数情况,在这些情况下,volatile变量是毋容置疑的更佳选择。

0x06. 参考

0x07. 相关文章