JavaRush /Java 博客 /Random-ZH /管理波动性
lexmirnov
第 29 级
Москва

管理波动性

已在 Random-ZH 群组中发布

使用易失性变量的指南

作者:布莱恩·戈茨 2007 年 6 月 19 日 原文:管理波动性 Java中的易失性变量可以称为“synchronized-light”;它们比同步块需要使用更少的代码,通常运行速度更快,但只能完成同步块功能的一小部分。本文介绍了有效使用 volatility 的几种模式,以及一些关于哪些地方不应该使用它的警告。锁有两个主要特性:互斥(mutex)和可见性。互斥意味着锁一次只能由一个线程持有,这一特性可用于实现共享资源的访问控制协议,以便一次只有一个线程使用它们。可见性是一个更微妙的问题,其目的是确保在释放锁之前对公共资源所做的更改对于接管该锁的下一个线程是可见的。如果同步不能保证可见性,线程可能会收到过时或不正确的公共变量值,这将导致许多严重问题。
波动变量
易失性变量具有同步变量的可见性属性,但缺乏同步变量的原子性。这意味着线程将自动使用易失性变量的最新值。它们可用于线程安全但在非常有限的情况下:那些不引入多个变量之间或变量的当前值和未来值之间的关系的情况。因此,仅使用 volatile 不足以实现计数器、互斥体或任何其不可变部分与多个变量关联的类(例如,“start <=end”)。您可以出于两个主要原因之一选择易失性锁:简单性或可扩展性。当某些语言结构使用易失性变量而不是锁时,它们更容易编写为程序代码,并且随后更容易阅读和理解。此外,与锁不同,它们不能阻塞线程,因此不太容易出现可扩展性问题。在读取次数多于写入次数的情况下,与锁相比,易失性变量可以提供性能优势。
正确使用挥发性物质的条件
在有限的情况下,您可以将锁替换为易失性锁。为了线程安全,必须满足两个条件:
  1. 写入变量的内容与其当前值无关。
  2. 该变量不参与其他变量的不变量。
简单地说,这些条件意味着可以写入 volatile 变量的有效值独立于程序的任何其他状态,包括变量的当前状态。第一个条件排除使用易失性变量作为线程安全计数器。虽然增量(x++)看起来像一个单一的操作,但它实际上是必须以原子方式执行的整个读取-修改-写入操作序列,而挥发性不提供这一点。有效的操作要求 x 的值在整个操作过程中保持不变,而使用 volatile 无法实现这一点。(但是,如果可以确保仅从一个线程写入该值,则可以省略第一个条件。)在大多数情况下,第一个或第二个条件都会被违反,这使得 volatile 变量成为比同步变量更不常用的实现线程安全的方法。清单 1 显示了一个具有一系列数字的非线程安全类。它包含一个不变量 - 下限始终小于或等于上限。 @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; } } 由于范围状态变量以这种方式受到限制,因此仅使下域和上域字段为 volatile 不足以确保类是线程安全的;仍然需要同步。否则,迟早你会不幸,两个线程使用不适当的值执行 setLower() 和 setUpper() 可能会导致范围达到不一致的状态。例如,如果初始值为(0, 5),线程A调用setLower(4),同时线程B调用setUpper(3),这些交错的操作将导致错误,尽管两者都会通过检查这应该是为了保护不变量。因此,范围将为 (4, 3) - 不正确的值。我们需要使 setLower() 和 setUpper() 对于其他范围操作来说是原子的 - 而使字段成为 volatile 则无法做到这一点。
性能考虑因素
使用 volatile 的第一个原因是简单。在某些情况下,使用这样的变量比使用与其关联的锁更容易。第二个原因是性能,有时易失性会比锁更快。做出像“X 总是比 Y 快”这样精确、包罗万象的陈述是极其困难的,特别是当涉及到 Java 虚拟机的内部操作时。(例如,在某些情况下,JVM 可能会完全释放锁,这使得以抽象方式讨论 易失性与同步的成本变得困难)。然而,在大多数现代处理器架构上,读取易失性变量的成本与读取常规变量的成本没有太大区别。由于可见性所需的内存防护,写入易失性的成本明显高于写入常规变量,但通常比设置锁便宜。
正确使用易失性的模式
许多并发专家倾向于完全避免使用易失性变量,因为它们比锁更难正确使用。然而,有一些明确定义的模式,如果仔细遵循,可以在各种情况下安全地使用。始终尊重易失性的限制 - 只使用独立于程序中其他任何内容的易失性,这应该可以防止您进入这些模式的危险领域。
模式#1:状态标志
也许可变变量的规范使用是简单的布尔状态标志,指示已发生重要的一次性生命周期事件,例如初始化完成或关闭请求。许多应用程序都包含以下形式的控制结构:“直到我们准备好关闭,继续运行”,如清单 2 所示: volatile boolean shutdownRequested; ... public void shutdown() { shutdownRequested = true; } public void doWork() { while (!shutdownRequested) { // do stuff } } shutdown() 方法很可能会从循环之外的某个地方(在另一个线程上)调用。因此需要同步以确保正确的变量可见性 shutdownRequested。(它可以从 JMX 侦听器、GUI 事件线程中的操作侦听器、通过 RMI、通过 Web 服务等调用)。然而,具有同步块的循环将比具有易失性状态标志的循环麻烦得多,如清单 2 所示。因为易失性使编写代码更容易,并且状态标志不依赖于任何其他程序状态,所以这是一个善用挥发性。此类状态标志的特点是通常只有一次状态转换;shutdownRequested 标志从 false 变为 true,然后程序关闭。这种模式可以扩展到可以来回改变的状态标志,但前提是在没有外部干预的情况下发生转换周期(从假到真到假)。否则需要某种原子转换机制,例如原子变量。
模式#2:一次性安全发布
当编写对象引用而不是原始值时,没有同步时可能出现的可见性错误可能会成为更加困难的问题。如果没有同步,您可以看到另一个线程写入的对象引用的当前值,并且仍然可以看到该对象的陈旧状态值。(这种威胁是臭名昭著的双重检查锁问题的根源,在这种情况下,在没有同步的情况下读取对象引用,并且您可能会看到实际引用,但通过它获取部分构造的对象。)一种安全发布对象的方法object 是对易失性对象的引用。清单 3 显示了一个示例,其中在启动期间,后台线程从数据库加载一些数据。其他可能尝试使用此数据的代码会在尝试使用它之前检查它是否已发布。 public class BackgroundFloobleLoader { public volatile Flooble theFlooble; public void initInBackground() { // делаем много всякого theFlooble = new Flooble(); // единственная запись в theFlooble } } public class SomeOtherClass { public void doWork() { while (true) { // чё-то там делаем... // используем theFolooble, но только если она готова if (floobleLoader.theFlooble != null) doSomething(floobleLoader.theFlooble); } } } 如果对 theFlooble 的引用不是易失性的,则 doWork() 中的代码在尝试引用 theFlooble 时将面临看到部分构造的 Flooble 的风险。此模式的关键要求是发布的对象必须是线程安全的或有效不可变的(有效不可变意味着其状态在发布后永远不会改变)。易失性链接可以确保对象以其发布形式可见,但如果对象的状态在发布后发生变化,则需要额外的同步。
模式#3:独立观察
安全使用 volatility 的另一个简单例子是定期“发布”观察结果以在程序中使用。例如,有一个环境传感器可以检测当前的温度。后台线程可以每隔几秒读取该传感器并更新包含当前温度的易失性变量。然后其他线程可以读取该变量,因为知道其中的值始终是最新的。此模式的另一个用途是收集有关程序的统计信息。清单 4 显示了身份验证机制如何记住最后登录用户的名称。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:“挥发性豆”模式
“易失性 bean”模式适用于使用 JavaBean 作为“美化结构”的框架。“易失性 bean”模式使用 JavaBean 作为一组具有 getter 和/或 setter 的独立属性的容器。需要“易失性 bean”模式的基本原理是,许多框架为可变数据持有者(例如 HttpSession)提供容器,但放置在这些容器中的对象必须是线程安全的。在易失性 bean 模式中,所有 JavaBean 数据元素都是易失性的,并且 getter 和 setter 应该很简单 - 除了获取或设置相应的属性之外,它们不应包含任何逻辑。此外,对于作为对象引用的数据成员,所述对象必须是有效不可变的。(这不允许数组引用字段,因为当数组引用被声明为易失性时,只有该引用而不是元素本身具有易失性属性。)与任何易失性变量一样,不能有与 JavaBean 属性相关的不变量或限制。清单 5 显示了使用“易失性 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; } }
更复杂的波动模式
上一节中的模式涵盖了大多数常见情况,其中使用 volatile 是合理且明显的。本节着眼于更复杂的模式,其中 volatile 可以提供性能或可扩展性优势。更高级的波动模式可能极其脆弱。仔细记录您的假设并且严格封装这些模式至关重要,因为即使是最小的更改也可能会破坏您的代码!此外,考虑到更复杂的易失性用例的主要原因是性能,请确保在使用它们之前您确实对预期的性能增益有明确的需求。这些模式是为了可能的性能提升而牺牲可读性或易于维护性的妥协 - 如果您不需要性能改进(或者无法通过严格的测量程序证明您需要它),那么这可能是一个糟糕的交易,因为你放弃了一些有价值的东西,却得到了一些较少的回报。
模式#5:廉价的读写锁
现在您应该清楚地意识到,挥发性太弱,无法实现计数器。由于 ++x 本质上是三个操作(读取、追加、存储)的减少,因此如果出现问题,如果多个线程尝试同时递增易失性计数器,您将丢失更新的值。但是,如果读取次数明显多于更改次数,您可以结合内部锁定和易失性变量来减少总体代码路径开销。清单 6 显示了一个线程安全的计数器,它使用 synchronized 来确保增量操作是原子的,并使用 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 替换同步来简化代码。然而,使用易失性的代码通常比使用锁定的代码更脆弱。这里建议的模式涵盖了最常见的情况,其中波动性是同步的合理替代方案。通过遵循这些模式 - 并注意不要将它们超出其自身限制 - 您可以在它们提供好处的情况下安全地使用 volatile。
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION