Synchronization和java内存模型

2022-02-28 11:38:13 浏览数 (1)

写在前面

这是一篇主要是讲java的同步和内存模型相关的知识点。作者是java大神人物 Doug Lea,文章的质量肯定有保证。

我把英文原文翻译过来整理成这篇文章。目的一个是自己学习,一个是方便不习惯看英文资料的同学进行学习。

英文原文地址:

http://gee.cs.oswego.edu/dl/cpj/jmm.html

正文开始

看下面一小段代码,没有用synchronization:

代码语言:javascript复制
final class SetCheck {
  private int  a = 0;
  private long b = 0;

  void set() {
    a =  1;
    b = -1;
  }

  boolean check() {
    return ((b ==  0) ||
            (b == -1 && a == 1)); 
  }
}

在一个完全顺序的开发语言中,方法check永远不会返回 false。即使编译器、运行时系统和硬件可能会以和你的直觉不通的方式处理此代码,这仍然成立。例如,以下任何一项都可能发生在方法执行的过程中:

  • 编译器可能会重新排列语句的顺序,因此 b 可能会在 a 之前被赋值。如果该方法是内联的,编译器可能会进一步重新排列关于其他语句的顺序。
  • 处理器可能会重新排列语句对应的机器指令的执行顺序,甚至可以并行的执行。
  • 内存(由缓存控制单元控制)可能会根据变量的不同重新排列写入内存单元的顺序。这些写入可能与其他计算和内存操作重叠。
  • 在不同的位数的机器上,编译器、处理器或内存系统处理两个赋值语句的情况可能也不同。例如,在 32 位机器上,可以先写入 b 的高位字,然后写入 a,然后再写入 b 的低位字。
  • 编译器、处理器或内存系统可能会导致表示变量的内存单元在调用后续检查(如果有的话)之后的某个时间才更新,而是在别的地方保持最新的值(例如在 CPU 寄存器中),这样代码仍然具有预期的效果。

在顺序开发语言中,只要程序执行遵循串行语义,这些都无关紧要。顺序程序不能依赖于简单代码块中语句的内部处理细节,因此它们可以自由地以所有这些方式进行操作。这为编译器和机器提供了必要的灵活性。这种机制(通过流水线超标量 CPU、多级缓存、负载/存储平衡、进程间寄存器分配等)是过去十年计算中执行速度大幅提高的原因。这些操作的 as-if-serial 属性使得只编写顺序程序的程序员无需知道底层优化的细节,因为如果你不创建多线程的话,就几乎不会受到这些问题的影响。

但并发编程中的情况就不一样了。当 set方法 在一个线程中执行时,完全有可能同时另一个线程在调用 check,在这种情况下,check方法可能就会“看到” set方法的底层优化细节。如果发生这种情况,则 check 可能返回 false。例如,check 可以读取 long b 的值,该值既不是 0 也不是 -1,而是半写入的中间值。此外,set 中语句的乱序执行可能会导致 check 将 b 读取为 -1,但读取的a却还是0。

换句话说,并发执行会以可能和源代码看起来没有任何相似之处的操作执行。随着编译器和运行时技术的成熟和多处理器变得越来越普遍,这种现象变得越来越普遍。对于具有顺序编程背景的程序员(包含了大部分程序员),他们可能会带来令人惊讶的结果,他们从未接触过所谓的顺序代码的底层执行属性。这是并发编程经常出现问题的根源。

在几乎所有情况下,有一种明显、简单的方法可以避免考虑由于优化的执行机制而在并发程序中出现的复杂性:使用同步机制。例如,如果 SetCheck 类中的两个方法都声明synchronized,那么就可以确定没有任何内部处理细节会影响此代码的预期结果。

但有时你不能或不想使用同步语义。或者你必须假设其他人的代码不使用它。在这些情况下,你就必须依赖 Java 内存模型对结果语义的最低限度保证。该模型允许上面列出的各种操作,但限制了它们对执行语义的潜在影响,并且还指出了程序员可以用来控制这些语义的某些方面的一些技术(其中大部分在第 2.4 节中讨论)。

Java 内存模型是 JavaTM 语言规范的一部分,主要在JLS第17章中进行了描述。在这里,我们只讨论模型的基本动机、属性和编程结果。这部分反映了 JLS 第一版中缺少的一些声明和更新。

该模型的假设可以被视为 §1.2.4 中描述的那种标准 SMP 机器的理想化:

java内存模型的目的是,每个线程都可以被认为是在与任何其他线程不同的CPU上运行。即使在多处理器上,这在实践中也很少见,但这种 CPU-per-thread 映射是实现线程的合理的方式之一,这一事实解释了该模型最初令人困惑的一些特性。例如,由于 CPU 拥有其他 CPU 无法直接访问的寄存器,因此模型必须允许一个线程不知道另一个线程正在操作的值的信息。然而该模型的影响绝不限于多处理器。即使在单CPU系统上,编译器和处理器的操作也会导致相同的问题。

java内存模型没有具体说明上述执行策略是否由编译器、CPU、缓存控制器或任何其他机制执行。它甚至没有根据程序员熟悉的类、对象和方法来解释它们。相反,该模型定义了线程和主内存之间的抽象关系。每个线程都被定义为有一个工作内存(缓存和寄存器的抽象)来存储值。该模型保证了围绕与方法相对应的指令序列和与字段相对应的存储单元的交互的一些属性。大多数规则的表述,是围绕何时必须在主内存和每线程工作内存之间传输值。这些规则解决了三个相互关联的问题:

原子性

哪些指令必须具有不可分割的效果。出于模型的目的,这些规则只需要对表示字段的内存单元的简单读写进行说明 - 实例和静态变量,也包括数组元素,但不包括方法内的局部变量。

可见性

在什么条件下,一个线程的执行效果对另一个线程可见。这里感兴趣的效果是对字段的写入,正如通过读取这些字段所看到的那样。

顺序

在什么情况下,操作的效果对于任何给定的线程可能会出现乱序。主要的排序问题围绕着与赋值语句序列相关的读取和写入。

当使用了同步机制时,这些属性中的每一个都有一个简单的特征:在一个同步方法或块中所做的所有更改都是原子的,并且相对于使用相同锁的其他同步方法和块以及在任何同步方法或块中的处理是可见的,并且是按程序指定的顺序。即使同步块内语句的处理可能是无序的,但这对于使用同步的其他线程来说无关紧要。

当不使用或不一致地使用同步机制时,答案会变得更加复杂。内存模型所做的保证比大多数程序员预期的要弱,也比任何给定 JVM 实现通常提供的保证要弱。这对试图确保核心对象一致性关系的程序员增加了额外的义务:对象必须维护依赖于它们的所有线程所看到的不变量,而不仅仅是执行状态修改操作的那些线程。

模型提出的最重要的规则和属性如下:

原子性

对与除 long 或 double 之外的任何类型的字段对应的内存单元的访问和更新保证是原子的。这包括用作对其他对象的引用的字段。此外,原子性扩展到 volatile long 和 double是可以保证原子性的。

译者注:java的内存模型只保证了基本变量的读取操作和写入操作都必须是原子操作的,但是对于64位存储的long和double类型来说,JVM读操作和写操作是分开的,分解为2个32位的操作。

原子性保证确保当在表达式中使用不是long或者double类型的字段时,你会得到其初始值或由某个线程写入的某个值,而不是由两个或多个线程都试图 同时写入值。但是,如下所示,原子性本身并不能保证您将获得任何线程写入的最新值。出于这个原因,原子性保证本身通常对并发程序设计几乎没有影响。

可见性

只有在以下情况下,才能保证一个线程对字段所做的更改对其他线程可见:

  • 写入线程释放同步锁,读取线程随后获取相同的同步锁。

从本质上讲,释放锁会强制从线程使用的工作内存中刷新所有写入,并且获取锁会强制(重新)加载可访问字段的值。虽然锁定操作仅对同步方法或块中执行的操作提供排他性,但这些内存刷新机制被定义为影响执行操作的线程使用的所有字段。

请注意同步的双重含义:它处理允许更高级别同步协议的锁,同时处理内存系统(有时通过低级内存屏障机器指令)以保持值表示在线程之间同步。这反映了并发编程与分布式编程比顺序编程更相似的一种方式。后一种同步的含义可以被视为一种机制,通过该机制,在一个线程中运行的方法表明它愿意向运行在其他线程中的方法发送和/或接收对变量的更改。从这个角度来看,使用锁和传递消息可能仅仅被视为彼此的语法变体。

  • 如果一个字段被声明为 volatile,则写入它的任何值都会在写入线程执行任何进一步的内存操作之前被写入线程刷新并使其可见(即它会立即刷新)。读线程必须在每次访问时重新加载 volatile 字段的值。
  • 线程第一次访问对象的字段时,它会看到该字段的初始值或自其他线程写入以来的值。

其他情况,提供对未完全构造对象的引用是一种不好的做法(请参阅第 2.1.2 节)。在构造函数中启动新线程也是有风险的,尤其是在可能被子类化的类中。Thread.start 与调用 start 的线程释放锁,然后由启动的线程获取锁具有相同的记忆效果。如果 Runnable 超类在子类构造函数执行之前调用 new Thread(this).start(),那么当 run 方法执行时对象可能没有完全初始化。类似地,如果您创建并启动一个新线程 T,然后创建一个由线程 T 使用的对象 X,您不能确定 X 的字段对 T 是可见的,除非您对对象 X 的所有引用使用同步。或者,当适用,您可以在启动 T 之前创建 X。

  • 当线程终止时,所有写入的变量都被刷新到主内存。例如,如果一个线程在另一个线程终止时使用 Thread.join 进行同步,则可以保证看到该线程产生的效果(参见第 4.3.2 节)。

需要强调的是,在同一线程中跨方法传递对象的引用时,永远不会出现可见性问题。

内存模型保证,给定上述操作的最终发生,一个线程对特定字段进行的特定更新最终将对另一个线程可见。但最终可以是任意长的时间。不使用同步的线程中的长代码段在字段取值方面可能与其他线程不同步,这是无法避免的。特别是,编写循环等待其他线程写入的值总是错误的,除非字段是volatile修饰的或通过同步访问(参见第 3.2.6 节)。

该模型还允许在没有同步的情况下不一致的可见性。例如,可以为对象的一个字段获取新值,但为另一个字段获取旧值。类似地,可以读取引用变量的新的值,但是现在被引用对象的字段之一却是旧值。

然而,这些规则不需要跨线程的可见性故障,它们只是允许这些故障发生。这是在多线程代码中不使用同步并不能保证安全违规的事实的一个方面,它只是允许它们。在大多数当前的JVM实现和平台上,即使是使用多个处理器的平台,也很少发生可检测到的可见性故障。跨共享CPU的线程使用公共缓存、缺乏基于编译器的优化以及强缓存一致性硬件的存在通常会导致值的行为就像它们立即在线程之间传播一样。这使得测试免于基于可见性的错误变得不切实际,因为此类错误可能极少发生,或者仅在无法访问的平台上发生,或者仅在尚未构建的平台上发生。相同的观点更普遍地适用于多线程安全故障。不使用同步的并发程序失败的原因有很多,包括内存一致性问题。

排序

排序规则分为两种情况,线程内和线程间:

  • 从在方法中执行操作的线程的角度来看,指令以适用于顺序编程语言的类似串行的方式进行。
  • 从其他线程的角度来看,这些线程可能通过并发运行来“监视”运行非同步方法的线程,几乎任何事情都可能发生。唯一有用的约束是同步方法和块的相对顺序,以及对volatile字段的操作不会出现非预期的优化。

再次强调,这些只是最低保证属性。在任何给定的程序或平台中,可能会发现更严格的排序。但是你不能依赖这些保证,并且你可能会发现很难测试会失败的代码,这些代码运行在具有不同属性但仍符合规则的 JVM 实现上。

需要注意的是,在 JLS 中所有其他语义讨论中都隐含地采用了线程内的角度。例如,算术表达式求值按从左到右的顺序执行(JLS 第 15.6 节),这是从从执行操作的线程角度来看,对其他线程来说就不一定了。

由于同步、结构性的排他或随机情况下,线程内的 as-if-serial 属性仅在一次只有一个线程正在操作变量时才有用。当多个线程都在运行读取和写入公共字段的非同步代码时,任意顺序、原子性失败、竞争条件和可见性失败可能会导致 as-if-serial 的概念对于任何给定线程都无效了。

尽管 JLS 解决了可能发生的一些特定的合法和非法重新排序,但与其他问题结合来看降低了实际保证,即结果可能反映了几乎任何可能的重新排序。因此,尝试推理此类代码的排序属性是没有意义的。

关于Volatile

在原子性、可见性和排序方面,将字段声明为 volatile 与使用synchronized的类通过 get/set 方法保护该字段的效果几乎相同,如下所示:

代码语言:javascript复制
final class VFloat {
  private float value;

  final synchronized void  set(float f) { value = f; }
  final synchronized float get()        { return value; }
}

将字段声明为 volatile 的不同之处仅在于不涉及锁。有种特别的情况需要注意,对 volatile 变量的“ ”操作,不是原子执行的。

此外,排序和可见性效果仅围绕对 volatile 字段本身的单一访问或更新。将引用字段声明为 volatile 并不能确保通过此引用访问的non-volatile字段的可见性。同样,将数组字段声明为 volatile并不能确保其内部元素的可见性。不能为数组手动指定volatile,因为数组元素本身不能声明为volatile。

因为不涉及锁,所以将字段声明为volatile可能比使用同步的开销更小,或者至少不会更大。但是如果在方法中频繁访问 volatile 字段,则可能会导致比锁定整个方法更差的性能。

当出于任何其他原因不需要锁时,将字段声明为volatile可能很有用,但值必须可以跨多个线程准确访问。这可能在以下情况下发生:

  • 该字段不需要遵守任何其他的不变量。
  • 对该字段的写入不依赖于其当前值。
  • 没有线程会写入与预期语义相关的非法值。
  • 读操作不依赖non-volatile字段的值。

如果你知道只有一个线程可以更改一个字段,但许多其他线程可以随时读取它时,使用volatile字段是有意义的。例如,温度计类可能将其温度字段声明为volatile。如第 3.4.2 节所述,volatile 可用作完成标志。其他示例在第 4.4 节中进行了说明,其中使用轻量级可执行框架使同步的某些方面自动化,但需要volatile声明以确保结果字段值在任务之间可见。

Doug Lea 最后一次修改时间:2000年7月29号 13:21:07

0 人点赞