第十六章:Java内存模型
本文我们将重点放在Java内存模型(JMM)的一些高层设计问题,以及JMM的底层需求和所提供的保证,还有一些高层设计原则背后的原理。
例如安全发布,同步策略的规范以及一致性等。他们的安全性都来自于JMM,并且当你理解了这些机制的工作原理后,就能更容易的使用他们。
1、什么是内存模型,为什么要使用它
假设一个线程为变量aVar赋值:
a = 3;
内存模型要解决的问题是:“在什么条件下,读取a的线程可以看到这个值为3?”。这听起来似乎是一个愚蠢的问题,但如果缺少同步,那么会有很多因素导致无法立即、甚至永远看不到一个线程的操作结果。这包括很多因素,如果没有使用正确的同步,例如:
- 编译器中生成的指令顺序与源代码中的顺序不同;
- 编译器将变量保存在寄存器而不是内存中;
- 处理器可以乱序或者并行执行指令;
- 缓存可能会改变将写入变量提交到主内存的次序;
- 处理器中也有本地缓存,对其他处理器不可见;
在单线程中,我们无法看到所有这些底层技术,他们除了提高成勋的执行速度,不会产生其他影响。Java语言规范要求JVM在线程中维护一种类似串行的语义:只要程序的最终结果与在严格环境中的执行结果相同,那么上述操作都是允许的。
这确实是一件好事情,因为在近几年中,计算性能的提升在很大的程度上要归功于:
- 重新排序措施;
- 时钟频率的提升;
- 不断提升的并行性;
- 采用流水线的超标量执行单元,动态指令调整, 猜测执行以及完备的多级缓存等;
随着处理器越来越强大,编译器也在不断的改进,通过指令重排序实现优化执行,以及使用成熟的全局寄存器分配算法。由于时钟频率越来越难以提高,因此许多处理器生产商都开始转而生产多核处理器,因为能够提高的只有硬件的并行性。
在多线程环境中,维护程序的串行性将导致很大的性能开销,并发程序中的线程,大多数时间里都执行各自的任务,因此线程之间协调操作只会降低应用程序的运行速度,不会带来任何好处。只有当多个线程要共享数据时,才必须协调他们之间的操作,并且JVM依赖程序通过同步操作找出这些协调操作将何时发生。
JMM规定了JVM必须遵循一组最小的保证,这组保证规定了对变量的写入操作在何时将对其他线程可见。
JMM在设计时就在可预测性与易于开发性之间进行了权衡,从而在各种主流的处理器体系架构上能实现高性能的JVM。如果你不了解在现代处理器和编译器中使用的程序性能提升措施,那么在刚刚接触JMM的某些方面时会感到困惑。
1.1 平台的内存模型
在共享内存的多处理器体系架构中,每个处理器拥有自己的缓存,并且定期的与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(cache coherence)。其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。操作系统、编译器以及runtime运行时(有时甚至包括应用程序)需要弥补这种硬件能力与线程安全需求之间的差异。
要确保每个处理器在任意时刻都知道其他处理器在进行的工作,这将开销巨大。多数情况下,这完全没必要,可随意放宽存储一致性,换取性能的提升。
在架构定义的内存模型中将告诉应用程序可以从内存系统中获取怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使Java开发人员无须关心不同架构上内存模型之间的差异,Java还提供了自己的内存模型JMM,并且JVM通过在适当的位置上插入内存栅栏来屏蔽JMM与底层平台内存模型之间的差异。
程序执行一种简单的假设:想象在程序中之存在唯一的操作执行顺序,而不考虑这些操作在何种处理器上执行,并且在每次读取变量时,都能获得在执行序列中最近一次写入该变量的值。这种乐观的模型被称为串行一致性。软件开发人员经常会错误地假设存在串行一致性。但是在任何一款现代多处理器架构中都不会提供这种串行一致性,JMM也是如此。冯诺依曼模型这种经典的穿行计算模型,只能近似描述现代多处理器的行为。
在现在支持共享内存的多处理和编译器中,当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存栅栏来防止这种情况的发生。幸运的是,Java程序不需要制定内存栅栏的位置,只需要通过正确地使用同步就可以。
1.2 重排序
程序清单16-1 如果没有包含足够的同步,将产生奇怪的结果 public class ReorderingDemo { static int x = 0, y = 0; static int a = 0, b = 0; public static void main(String[] args) throws Exception { x = y = a = b = 0; Thread one = new Thread() { public void run() { a = 1; x = b; } }; Thread two = new Thread() { public void run() { b = 1; y = a; } }; one.start(); two.start(); one.join(); two.join(); System.out.println(x + ", " + y); }
程序清单16-1 ReorderingDemo 说明了在没有正确的同步情况下,即使要推断最简单的并发程序的行为也很难。图16-1给出了一种可能由于不同执行顺序而输出的结果。
这种各种使操作延迟或者看似混乱执行的不同原因,都可以归为重排序。
ReorderingDemo很简单,但是要列举出他所有可能的结果却非常困难。内存级别的重排序会使程序的行为不可预测。如果没有同步,那么推断出执行顺序将是非常困难的,而要确保在程序中正确地使用同步却是非常容易的。同步将限制编译器、运行时和硬件对内存操作的重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证。
注:在大多数主流的处理器架构中,内存模型都非常强大,使得读取volatile变量的性能与读取非volatile变量的性能大致相当。
1.3 Java内存模型简介
JMM是通过各种操作来定义,包括对变量的读写操作,监视器monitor的加锁和释放操作,以及线程的启动和合并操作,JMM为程序中所有的操作定义了一个偏序关系,称为Happens-before,要想保证执行操作B的线程看到A的结果(无论A和B是否在同一个线程中执行),那么A和B之间必须满足Happens-before关系。如果没有这个关系,那么JVM可以对他们任意的重排序。
当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-before来排序,那么就会产生数据竞争的问题。在正确使用同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中的所有操作都会按照一种固定的和全局的顺序执行。
图16-2给出了当两个线程使用同一个锁进行同步时,在他们之间的Happens-before关系。在线程A内部的所有操作都按照他们在源程序中的先后顺序来排序,在线程B内部的操作也是如此。由于A释放了锁M,并且B随后获得了锁M,因此A中所有在释放锁之前的操作,也就位于B中请求锁之后的所有操作之前。如果这两个线程是在不同的锁上进行同步的,那么就不能推断他们之间的动作顺序,因为他们之间不存在Happens-before关系。
1.4 借助同步
由于Happens-Before的排序功能很强大,因此有时候可以”借助(Piggyback)”现有同步机制的可见性属性。这需要将Happens-Before的程序规则与其他某个顺序规则(通常是监视器锁规则或者volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。这项技术由于对语句的顺序非常敏感,因此很容易出错。他是一项高级技术,并且只有当需要最大限度地提升某些类(例如ReentrantLock)的性能时,才应该使用这项技术。同时,因为在使用中很容易出错,因此也要谨慎使用。
在FutureTask的保护方法AbstractQueuedSynchronizer中说明了如何使用这种“借助”技术。
AQS维护了一个标识同步器状态的整数,FutureTask用这个整数来保存任务的状态:正在运行、已完成和已取消。但FutureTask还维护了其他一些变量,例如计算的结果。当一个线程调用set方来保存结果并且另一线程调用get来获取该结果时,这两个线程最好按照Happens-Before进行排序。这可以通过将执行结果的引用声明为volatile类型来实现,但利用现在的同步机制可以更容易地实现相同的功能。
程序清单16-2 说明如何借助同步的FutureTask的内部类
FutureTask在设计时能够确保,在调用 tryAccquireShared 之前总能成功调用 tryReleaseShard 。tryReleaseShard会写入一个volatile类型的变量,而tryAccquireShard将读取这个变量。程序清单16-2给出了innerGet和innerSet等方法,在保存和获取result时将调用这些方法。由于innerSet将在调用releaseShared(这又将调用tryReleaseShard)之前写入result,并且innerGet将在调用acquireShared(这又将调用tryAccquireShared)之后读取result,因此将程序顺讯规则与volatile变量规则结合在一起,就可以确保innerSet中的写入操作在innerGer之前之前。
之所以将这项技术称为“借助”,是因为它使用了一种现有的Happens- Before顺序来确保对象X的可见性,而不是专门为了发布X而创建一种Happens-Before顺序。在类库中提供的其他Happens-Before排序包括:
- 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行
- 在CountDownLatch上的倒数操作将在线程从闭锁上的await方法返回之前执行
- 释放Semaphore许可的操作将在从该Semaphore上获得一个许可之前执行
- Future表示的任务的所有操作将在从Future.get中返回之前执行
- 向Executor提交一个Runnable或Callable的操作将在任务开始执行之前执行
- 一个线程到达CyclicBarrier或Exchange的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果CyclicBarrier使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。
2、发布
第三章介绍了如何安全的或者不正确的发布一个对象,其中介绍的各种技术都依赖JMM的保证,而造成发布不正确的原因就是在“发布一个共享对象”与“另外一个线程访问该对象”之间缺少一种happens-before关系。
2.1 不安全的发布
当缺少happens-before关系时,就可能会发生重排序,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。假入初始化一个对象时需要写入多个变量(多个域),在发布该对象时,则可能出现如下情况,导致发布了一个被部分构造的对象:
init field a init field b 发布ref init field c
错误的延迟初始化将导致不正确的发布,如下程序清单16-3。
注:除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行
程序清单16-3 不安全的延迟初始化 public class UnsafeLazyInitialization { private static Object resource; public static Object getInstance(){ if (resource == null){ resource = new Object(); //不安全的发布 } return resource; } }
2.2 安全发布
借助于类库中现在的同步容器、使用锁保护共享变量、或都使用共享的volatile类型变量,都可以保证对该变量的读取和写入是按照happens-before关系来排序。
注:happens-before事实上可以比安全发布承诺更强的可见性与排序性
2.3 安全初始化模式
方式一:加锁保证可见性与排序性
getInstance的代码路径很短,只包括一个判断预见和一个预测分支,因此如果在没有被多个线程频繁调用或者在不会出现激烈竞争的情况下,可以提供较为满意的性能。
程序清单16-4 线程安全的延迟初始化 public class SafeLazyInitialization { private static Object resource; public synchronized static Object getInstance(){ if (resource == null){ resource = new Object(); } return resource; } }
方式二:提前初始化
在初始化器中采用了特殊的方式来处理静态域(或者在静态初始化代码块中初始化的值),并提供了额外的线程安全性保证。静态初始化是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用之前。由于JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。
因此,无论是在被构造期间还是被引用时,静态初始化的对象都不需要显示的同步。
程序清单16-5 提前初始化 public class EagerInitialization { private static Object resource = new Object(); public static Object getInstance(){ return resource; } }
方式三:延迟初始化展位模式,建议
通过静态初始化和JVM的延迟加载机制结合起来可以形成一种延迟初始化的技术,从而在常见的代码路径中不需要同步。
程序清单16-6 掩藏初始化占位类模式 public class ResourceFactory { private static class ResourceHolder{ public static Object resource = new Object(); } public static Object getInstance(){ return ResourceHolder.resource; } }
方式四:DCL双重加锁机制,注意保证volatile类型,否则出现一致性问题(jdk5.0+)
DCL实际是一种糟糕的方式,是一种anti-pattern,它只在JAVA1.4时代好用,因为早期同步的性能开销较大,用来避免不必要的开销或者降低程序的启动时间,但是目前DCL已经被广泛的废弃不用,因为促使该模式出现的驱动力已经不在(无竞争同步的执行速度很慢,以及jvm启动时很慢),他不是一个高效的优化措施。
程序清单16-7 双重加锁 public class DoubleCheckedLocking { private static volatile Object resource; public static Object getInstance(){ if (resource == null){ synchronized (DoubleCheckedLocking.class){ if (resource == null){ resource = new Object(); } } } return resource; } }
3、初始化过程中的安全性
final不会被重排序。
- 程序清单16-8中的states因为是final的所以可以被安全的发布。即使没有volatile,没有锁。但是,如果除了构造函数外其他方法也能修改states。如果类中还有其他非final域,那么其他线程仍然可能看到这些域上不正确的值。也导致了构造过程中的escape。
写final的重排规则:
- JMM禁止编译器把final域的写重排序到构造函数之外。
- 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。也就是说:写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了。
读final的重排规则:
- 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。也就是说:读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
如果final域是引用类型,那么增加如下约束:
- 在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
程序清单16-8 不可变对象的初始化安全性 @ThreadSafepublic class SafeStates { private final Map<String, String> states; public SafeStates() { states = new HashMap<String, String>(); states.put("alaska", "AK"); states.put("alabama", "AL"); states.put("wyoming", "WY"); } public String getAbbreviation(String s) { return states.get(s); } }
了解更多知识,关注我。