《Java并发编程实战》 Java内存模型
words: 4.3k views: time: 15min假设一个线程为变量赋值var = 3
,那么内存模型需要解决一个问题:在什么条件下,读取var
的线程能看到这个值为3
。
这看上去理所当然,但如果缺少内存同步,那么将有许多因素使得线程无法立即看到,甚至永远看不到另一个线程的操作结果:
- 在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会将变量保存在寄存器而不是内存中;
- 处理器可以采用乱序或并行等方式来执行指令;
- 缓存可能会改变将写入变量提交到内存的次序;
- 而且保存在处理器本地缓存中的值,对于其他处理器是不可见的;
这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作看起来似乎在乱序执行。
Jvm规定了一组最小保证,这组保证规定了对变量的写入操作将在何时对于其他线程可见。Jvm在设计时就在可预测性和程序的易于开发性之间进行了权衡,从而在各种主流的处理器体系架构上都能实现高性能的Jvm。Java语言规范要求Jvm在线程中维护一种类似串行的语义:只要程序的最终结果与严格串行环境中执行的结果相同,那么上面所有的操作都是允许的。
这确实是一件好事,因为计算机在性能上的提升很大程度要归功于这些重排序措施。在单线程环境中,我们无法看到所有这些底层技术,它们除了提高程序的执行速度外,不会产生其他影响。而在多线程环境中,要维护程序的串行性将导致很大的性能开销。对于并发应用程序中的线程来说,它们在大部分时间里都执行各自的任务,因此,在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。只有当多个线程要共享数据时,才必须协调它们之间的操作,并且Jvm依赖程序也是通过同步操作来找出这些协调操作将在何时发生。
1. 平台的内存模型
在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性,其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。要想确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息是不必要的,因此处理器会适当放宽存储一致性保证,以换取性能的提升。
操作系统、编译器以及运行时,甚至有时是应用程序,都需要弥合这种在硬件能力与线程安全需求之间的差异。在架构定义的内存模型中将告诉应用程序可以从内存系统中获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏),当需要共享数据时,这些指令能实现额外的存储协调保证。
为了使Java开发人员无须关心不同架构上内存模型之间的差异,Java还提供了自己的内存模型,并且Jvm通过在适当的位置插入内存栅栏来屏蔽在Jvm与底层平台内存模型之间的差异。
如果在程序中只存在唯一的操作执行顺序,不考虑这些操作在何种处理器上执行,并且在每次读取变量时,都能获得在执行序列中最近一次写入该变量的值。这种乐观的模型被称为串行一致性,开发人员经常会犯的错误就是假设存在串行一致性,但在任何一款现代多处理器架构中都不会提供这种串行一致性,Jvm也如此。在支持共享内存的多处理器中,当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存栅栏来防止这些情况的发生。不过在Java程序中不需要指定内存栅栏的位置,只需要通过正确地使用同步来找出何时将访问共享状态。
2. 重排序
在没有正确同步的情况下,即使要推断最简单的并发程序的行为也很困难。例如:
1 | public class PossibleReordering{ |
PossibleReordering
中很容易想象如何输出(1,0),(0,1)或(1,1),T1可以在T2开始之前完成,T2也可以在T1开始之前完成,或者二者交替执行。但还可以输出(0,0),由于每个线程中的各个操作之间不存在数据流依赖性,因此这些操作可以乱序执行,即使这些操作按照顺序执行,但在将缓存刷新到主内存的不同时序中也可能出现这种情况。
站在T2的角度,T1的赋值操作可能以相反的次序执行,也就是在T2看来的执行顺序为[ x=b, b=1, y=a, a=1 ]
。因此,如果没有同步,内存级的重排序会使程序行为的推断变得非常困难。相对的,如果要确保在程序中正确地使用同步则比较容易,同步会限制编译器、运行时和硬件对内存操作重排序的方式,从而在重排序时不会破坏Jvm提供的可见性保证。
3. Java内存模型(JMM)
java内存模型是通过各种操作来定义的,包括对变量的读/写,监视器的加锁和释放,以及线程的启动和合并等操作。Jvm为程序中所有的操作定义了一个偏序关系,称为Happens-Before。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果缺乏这个关系,那么Jvm可以对它们任意的重排序。
当一个变量被多个线程读取并至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争问题。在正确同步的程序中不存在数据竞争,并会表现出串行一致性,就是说程序中的所有操作都会按照一种固定的和全局的顺序执行。
Happens-Before规则:
- 程序顺序规则:如果程序中操作
A
在操作B
之前,那么线程中操作A
将在操作B
之前执行; - 监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行;
- volatile变量规则:对
volatile
变量的写入操作必须在对该变量的读操作之前执行; - 线程启动规则:在线程上对
Thread.Start
的调用必须在该线程中执行任何操作之前执行; - 线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者
Thread.join
中成功返回,或者在调用Thread.isAlive
时返回false
; - 中断规则:当一个线程在另一个线程上调用
interrupt
时,必须在被中断线程检测到interrupt
调用之前执行; - 终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成;
- 传递性:如果操作
A
在操作B
之前执行,并且操作B
在操作C
之前执行,那么操作A
必须在操作C
之前执行;
虽然以上这些操作只满足偏序关系,但同步操作,如锁的获取与释放,以及volatile
变量的读取与写入操作,都满足全序关系。因此,在描述Happens-Before关系时,就可以使用“后续的锁获取操作”和“后续的volatile变量读取操作”等表达术语。
当两个线程使用同一个锁进行同步时,它们之间就存在Happens-Before关系。在线程A
内部的所有操作都按照它们在源程序中的先后顺序来执行,在线程B
内部的操作也是如此。如果A
释放了锁M
,并且B
随后获取了锁M
,那么A
中所有在释放锁之前的操作,就位于B
中请求锁之后的所有操作之前。而如果两个线程是在不同的锁上进行同步的,那么就不能推断它们之间的动作顺序,因为两个线程之间并不存在Happens-Before关系。
4. 借助同步
可以将Happens-Before的顺序规则与其他某个顺序规则(通常是监视器锁规则或volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。
在FutureTask
的AQS实现中使用了这种技巧,其AQS维护了一个表示同步器状态的整数,FutureTask
用这个整数来保存任务的状态,但FutureTask
还维护了其他一些变量,比如计算结果。
当一个线程调用set
来保存结果并且另一个线程调用get
来获取结果时,这两个线程最好按照Happens-Before进行排序,这可以将执行结果的引用声明为volatile
类型来实现,但利用现有的同步机制可以更容易地实现相同的功能。
1 | private final class Sync extends AbstractQueuedSynchronizer{ |
FutureTask
在设计时能够保证,在调用tryAcquireShared
之前总能成功地调用tryReleaseShared
,tryReleaseShared
会写入一个volatile类型的变量,而tryAcquireShared
将读取这个变量。
在保存和获取result
时将调用innerSet
和innerGet
方法,而innerSet
将在调用releaseShared
之前写入result
,并且innerGet
将在调用acquireShared
之后读取result
,因此,就可以保证innerSet
的写入操作在innerGet
中的读取操作之前执行。
很多情况下,都会使用类似的借助技巧,就是使用一种现有的Happens-Before顺序来确保对象的可见性。在类库中提供了很多现有的Happens-Before排序,如:
- 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行;
- 在
CountDownLatch
上的倒数操作将在线程从闭锁上的await
方法中返回之前执行; - 在释放
Semaphore
许可操作将在从该Semaphore
上获得一个许可之前执行; Future
表示的任务的所有操作将在从Future.get
中返回之前执行;- 向
Exceutor
提交一个Runnable
或Callable
的操作将在任务开始之前执行; - 一个线程到达
CyclicBarrier
或Exchanger
的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果CyclicBarrier
使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。
5. 对象发布问题
造成不正确发布的真正原因,就是在“发布一个共享对象”与“另一个线程访问该对象”之间缺少一种Happens-Before规则。
在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。同样,在发布一个引用时也需要写入一个变量,即新对象的引用。如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,就是说另一个线程在对象各个域初始化完成之前就看到了对象的引用,那么将会看到一个不完全构造的对象。
5.1. 示例:单例
1 | public class Resource{ |
Resource
中存在先判断后执行的竞态条件问题,如果所有发布的Resource
实例都相同,那么这个问题可以忽略。但除此之外,还有一个问题,另一个线程可能看到部分构造的Resource
实例的引用。
假设线程T1
首先调用getInstance
,它将看到resource
为null
,然后初始化一个新的Resource
,并将resource
设置为这个新实例。随后T2调用getInstance
,它可能看到resource
为非空,然后直接返回这个Resource
,但在T1写入resource
与T2读取resource
之间并不存在Happens-Before关系,那么,T2看到的可能是一个还没构造完成的实例。
可以使用同步来修复问题,假设getInstance
不会被频繁调用,即不存在激烈的竞争,那么性能问题也是可以接受的
1 | public class Resource{ |
考虑到锁的性能开销,会想到双重检查加锁机制,保证在大部分情况下避免同步开销。但这其实只解决了竞态条件的问题,没有真正理解可见性的含义,问题在于在没有同步的情况下读取一个变量时,其他线程很可能看到一个不为空但是还没有构造完成的实例引用。虽然在Java 5.0以后,将resource
声明为volatile
类型,可以解决,但仍不建议使用,因为有更好的方式。
1 | public class Resource{ |
Jvm在初始化器中采用了特殊的方式来处理静态域,并提供了额外的线程安全性保证。静态初始化器是由Jvm在类的初始化阶段执行,即在类被加载后并且被线程使用之前。由于Jvm将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存的写入操作将自动对所有线程可见。
1 | public class Resource{ |
如果与Jvm的延迟加载机制结合(使用时才加载),那么可以设计一个延迟初始化技巧
1 | public class Resource{ |
5.2. 初始化安全性
初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象给各个final
域设置的正确值,而不管采用何种方式来发布对象。而且,对于可以通过被正确构造对象中某个final
域到达的任意变量,例如final
数组或集合中的元素,将同样对于其他线程是可见的。
1 | public class Resource{ |
对于final
域可达的初始变量的写入操作,将不会与构造过程后的操作一起重排序,因此,只要线程看到了对象的引用,也就看到了对象的final
域的值。但是,初始化安全性只能保证通过final
域可达的值在构造完成时的可见性,对于其他通过非final
域可达的值仍然需要通过同步来保证可见性。
参考:
- Copyright ©《java并发编程实战》