在并发编程中,需要处理两个关键问题:线程之间如何通信以及线程之间如何同步。通信是指线程之间以何种机制来交换信息。同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在命令式编程中,线程之间的通信有两种:共享内存和消息传递。
Java的并发采用的是共享内存模型,在共享内存模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。而同步是显示进行的,为了正确的使用同步,防止内存可见性问题,我们需要了解线程间的隐式通信机制,因此我们需要了解Java内存模型。
Java内存模型
Java虚拟机规范定义了一种抽象的Java内存模型(本文简称为JMM),JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节,这里的变量是指线程间共享变量,包括实例域、静态域和数组元素。局部变量、方法定义参数和异常处理器参数不会在线程间共享,因此也就不会有内存可见性问题。
从抽象角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(也可称为工作空间),本地内存存储了该线程以读/写共享变量的副本。下图展示了Java内存模型的抽象结构示意图:
工作方式:
线程修改私有数据直接在工作空间中修改
线程修改共享数据,需要先将共享数据复制到工作空间中,然后在工作空间修改,修改完成后再刷新回主内存。
JMM通过控制主内存与每个线程的私有工作空间的交互,来为Java程序员提供内存可见性保证。
JMM与重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。分为三种:
1)编译器优化: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序: 现代处理器采用了指令级并行技术(Instrution-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
as-if-serial语义
不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
JMM与顺序一致性
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特征:
一个线程中的所有操作必须按照程序的顺序来执行
所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须是原子执行且立刻对所有线程可见。
JMM和处理器内存模型在设计时通常会以顺序一致性模型为参照,但是会做一些放松,也就是说相比顺序一致性内存模型会弱些,这样做是为了给处理器和和编译器留下足够的优化空间。下面来看下JMM对内存可见性的保证:
单线程程序: 编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
正确同步的多线程程序: 如果程序是正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。JMM通过限制编译器和处理器的重排序来保证。
未同步/为正确同步的多线程程序: JMM提供最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。
JMM对并发特征的保证
JMM是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的:
原子性
原子性是指一个操作是不可中断的,要么全部执行成功,要么全部执行失败。在Java中基本数据类型的访问读写都是具备原子性的,而多个原子性的操作合并在一起是没有原子性的。
解决方式: synchronized、JUC中的lock
可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
解决方式: volatile、synchronized、final、JUC中的lock
有序性
有序性是指程序执行的顺序按照代码的先后顺序执行
解决方式: volatile、synchronized
JMM与happens-before规则
happens-before的概念最初由Leslie Lamport在一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed system》)中提出。Leslie Lamport使用happens-before来定义分布式系统中事件之间的偏序关系。
从JDK5开始,Java使用新的JSR-133内存模型。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。这两个操作可以在一个线程之内,也可以在不同线程之间。JMM通过happens-before关系向程序员提供跨线程的内存可见性保证。
《JSR-133: Java Memory Model and Thread Specification》对happens-before关系的定义如下:
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,并不意味着java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序后的执行结果,与按happens-before关系来执行的结果一致,那么JMM允许这种重排序。
上面的1)是JMM对程序员的承诺,上面的2)是JMM对编译器和处理器重排序的约束规则。
JMM的设计意图
JSR-133专家在设计JMM时的核心目标就是找到一个平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地发送。下图展示了JMM的设计示意图:
从上图中我们可以看出两点:
JMM向程序员提供了足够强的内存可见性保证
JMM所遵循的一个基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都行。
happens-before规则
程序顺序规则: 一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则: 对一个锁的的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则: 对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
传递性: 如果A happens-before B,且B happens-before C,那么A happens-before C。
start()规则: 如果线程A执行操作ThreadB.start()(启动线程B)并成功返回,那么线程B中的任意操作happens-before于线程A中的任意操作。
join()规则: 如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
happens-before规则是判断数据是否存在竞争,线程是否安全的主要依据。 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。如果两个操作之间的关系不在上面的所列,并且无法从规则中推导出来的话,那么它们就没有顺序性保障,虚拟机可以对它们进行随意的重排序。
参考资料
Java并发编程的艺术 方腾飞 魏鹏 程晓明 著
Java并发编程实战 童云兰 译
------------本文结束感谢您的阅读------------