第 16 章 Java 内存模型

 2019-12-10 16:19  阅读(1063)
文章分类:Java Core

@@@ 安全发布 、 同步策略的规范以及一致性等的安全性都来自于 JMM 。

》》什么是内存模型,为什么需要它

@@@ 在编译器中生成的指令顺序,可以与源代码中的顺序不同;

此外编译器还会把变量保存在寄存器而不是内存中;

处理器可以采用乱序或并行等方式来执行指令;

缓存可能会改变将写入变量提交到主内存的次序;

保存在处理器本地缓存中的值,对于其他处理器是不可见的。

@@@ 随着处理器变得越来越强大,编译器也在不断地改进:通过对指令重新排序来实现优化

执行,以及使用成熟的全局寄存器分配算法。

@@@ 只有当多个线程要共享数据时,才必须协调它们之间的操作,并且 JVM 依赖程序通过同步

操作来找出这些协调操作将在何时发生。

### 平台的内存模型

@@@ JMM 规定了 JVM 必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对

于其他线程可见。

@@@ JMM 在设计时就在可预测性和程序的易于开发性之间进行了权衡,从而在各种主流的处理器体系

架构上能实现高性能的 JVM 。

@@@ Java 提供了自己的内存模型,并且 JVM 通过在适当的位置上插入内存栅栏来屏蔽在 JMM 与

底层平台内存模型之间的差异。

@@@ 在现代支持共享内存的多处理器(和编译器)中,当跨线程共享数据时,会出现一些奇怪的情况,

除非通过使用内存栅栏来防止这些情况的发生。Java 程序不需要指定内存栅栏的位置,而只需要通过正

确地使用同步来找出将何时访问共享状态

### 重排序

@@@ JMM 使得不同线程看到的操作顺序是不同的,从而导致缺乏同步的情况下,要推断操作的执行

顺序将变得更加复杂。**各种操作延迟或者看似乱序执行的不同原因,都可以归为****重排序**。

@@@ 在没有正确同步的情况下,即使要推断最简单的并发程序的行为也很困难。

@@@ 内存级的重排序会使程序的行为变得不可预测。

@@@ 要确保在内存中正确地使用同步。同步将限制编译器 、 运行时 和 硬件对内存操作重排序的方式,

从而在实施重排序时不会破坏 JMM 提供的可见性保证。

### Java 内存模型简介

@@@ Java 内存模型是通过各种操作来定义的,包括对变量的读 / 写 操作监视器的加锁和释放操作

以及线程的启动和合并操作

@@@ JMM 为程序中所有的操作定义了一个偏序关系,称之为 Happens-Before

@@@ 要想保证执行操作 B 的线程看到操作 A 的结果(无论 A 和 B 是否在同一个线程中执行 ),

那么在 A 和 B 之间必须满足 Happens-Before 关系。如果两个操作之间缺乏 Happens-Before 关系,

那么 JVM 可以对它们任意地重排序。

@@@ 当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有

依照 Happens-Before 来排序,那么就会产生数据竞争问题。

在正确同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中的所有操作

都会按照一种固定和全局的顺序排序。

@@@ Happens-Before 的规则包括

——- 程序顺序规则。如果程序中操作 A 在操作 B 之前,那么在线程中 A 操作将在 B 操作之前

执行。

——- 监视器锁规则。在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。

——- volatile 变量规则。 对 volatile 变量的写入操作必须在对该变量的读操作之前执行。

——- 线程启动规则。在线程上对 Thread.start 的调用必须在该线程中执行任何操作之前执行。

——- 线程结束规则。线程中的任何操作都必须在其他线程检测该线程已经结束之前执行,或者

从 Thread.join 中成功返回,或者在调用 Thread.isAlive 时返回 false。

——- 中断规则。当一个线程在另一个线程上调用 interrupt 时,必须在被中断线程检测到 interrupt

调用之前执行(通过抛出 InterruptedException ,或者调用 isInterrupted 和 interrupted)。

——- 终结器规则。对象的构造函数必须在启动该对象的终结器之前执行完成。

——- 传递性。如果操作 A 在操作 B 之前执行,并且操作 B 在操作 C 之前执行,那么操作 A 必须

在操作 C 之前执行。

补充:上面操作只满足偏序关系

@@@ **同步操作,如锁的获取与释放等操作,以及 volatile 变量的读取和写入操作,都满足****全序关系**。

### 借助同步

@@@ 将 Happens-Before 的程序顺序规则与其他某个程序规则(通常是监视器锁规则或者 volatile 变量)

结合起来,从而对某个未被锁保护的变量的访问操作进行排序。(这项技术由于对语句的顺序非常敏感,

因此很容易出错,它是一项高级技术)

@@@ 由于 Happens-Before 的排序功能很强大,因此有时候一可以 “ 借助 ” 现有同步的可见性属性。

——— 在 FutureTask 的保护方法 AbstractQueuedSynchronizer 中说明了如何使用这种 “ 借助 ”技术。

——— 之所以将这项技术称为 “ 借助 ” , 是因为它使用了一种现有的 Happens-Before 顺序来确保

对象 X 的可见性,而不是专门为了发布 X 而创建一种 Happens-Before 顺序。

@@@ 在 FutureTask 中使用的 ” 借助 “ 技术很容易出错,因此要谨慎使用。但在某些情况下,这种

” 借助 “ 技术是非常合理的。

@@@ 在类库中提供的其他 Happens-Before 排序包括:

——– 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前

执行。

——– 在 CountDownLatch 上的倒数操作将在线程从闭锁上的 await 方法中返回之前执行。

——— 释放 Semaphore 许可的操作将在从该 Semaphore 上获得一个许可之前执行。

——— Future 表示的任务的所有操作将在从 Future.get 中返回之前执行。

——– 向 Executor 提交一个 Runnable 或 Callable 的操作将在任务开始执行之前执行。

——— 一个线程到达 CyclicBarrier 或 Exchange 的操作将在其他到达该栅栏或交换点的线程被释放

之前执行。如果 CyclicBarrier 使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,

而栅栏操作又会在线程从栅栏中释放之前执行。

》》发布

@@@ 软件安全性都来自于 JMM 提供的保证,而造成不正确发布的真正原因,就是在 “ 发布一个共享

对象 ” 与 “ 另一个线程访问该对象 ” 之间缺少一种 Happens-Before 排序。

### 不安全的发布

@@@ 当缺少 Happens-Before 关系时,就可能出现重排序问题。

@@@ 错误的延迟初始化将导致不正确的发布。

@@@ 除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作

是在使用该对象的线程开始使用之前执行

### 安全的发布

@@@ 在 BlockingQueue 的实现中有足够的内部同步确保了 put 方法在 take 方法之前执行。同样,

通过使用一个由锁来保护共享变量或使用共享的 volatile 类型变量,也可以确保对该变量的读取操作和

写入操作按照 Happens-Before 关系来排序。

@@@ Happens-Before 比安全发布提供了更强可见性与顺序保证。

@@@ Happens-Before 关系@GuardedBy 、安全发布 之间的区别:

——- 与内存写入操作的可见性相比,从转移对象的所有权以及对象发布等角度来看,@GuardedBy与

安全发布 更符合大多数程序设计。

——- Happens-Before 排序是在内存访问级别上操作的,它是一种 “ 并发级汇编语言 ” ;

安全发布的级别更接近程序设计。

### 安全初始化模式

@@@ 有时候,我们需要推迟一些高开销的对象初始化操作,并且只有当使用这些对象时才进行

初始化,但我们也看到了误用延迟初始会导致很多问题。

@@@ 静态(static)初始化器是由 JVM 在类的初始化阶段执行,即在类被加载后并且被线程使用

之前。

由于 JVM 将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类

已经加载,因此在静态初始化期间, 内存写入操作将自动对所有线程可见。

因此无论是在被构造期间还是被引用时,静态初始化对象都不需要显式的同步。然而,这个

规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步

来确保随后的修改操作是可见的,以避免数据破坏。

@@@ 提前初始化技术JVM 的延迟加载机制结合起来,可以形成一种延迟初始化技术,从而在

常见的代码路径中不需要同步。

### 双重检查加锁(DCL)

@@@ 由于早期的 JVM 在性能上存在一些有待优化的地方,因此延迟初始化经常被用来避免不必要

的高开销操作,或者降低程序的启动时间。

在编写正确的延迟初始化方法中需要使用同步。

@@@ 在同步中需要了解的概念:

——– “ 独占性 ” 的含义

——– “ 可见性 ” 的含义

》》初始化过程中的安全性

@@@ 安全性架构依赖于 String 的不可变性,如果缺少了初始化安全性,那么可能导致一个安全漏洞,

从而使恶意代码绕过安全检查。

@@@ 初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象给各个

final 域设置的正确值,而不管采用何种方式来发布对象。而且,对于可以通过被正确构造对象中某个

final 域到达的任意变量(例如某个 final 数组中的元素,或者由一个 final 域引用的 HashMap 的内容)

将同样对于其他线程是可见的

@@@ 当构造函数完成时,构造函数对 final 域的所有写入操作,以及对通过这些域可以到达的任何

变量的写入操作,都将被 “ 冻结 ” ,并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于

通过 final 域可达到的初始化变量的写入操作,将不会与构造过程后的操作一起被重排序。

@@@ 初始化安全性只能保证通过 final 域可达的值从构造过程完成时开始的可见性。对于通过非 final

域可达的值,或者在构造过程完成后可能改变的值,必须采用同步来确保可见性。

》》小结

@@@ Java 内存模型说明了某个线程的内存操作在哪些情况下对于其他线程是可见的。其中包括确保

这些操作是按照一种 Happens-Before 的偏序关系进行排序,而这种关系是基于内存操作和同步操作等

级别来定义的。

如果缺少充足的同步,那么当线程访问共享数据时,会发生一些非常奇怪的问题。

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 第 16 章 Java 内存模型

相关推荐