摘抄并自查
1. JMM 的介绍
线程安全:当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象就是线程安全的。
出现线程安全的问题一般是因为主内存和工作内存数据不一致和重排序导致的,而解决线程安全的问题最重要的就是理解这两种问题是怎么来的,那么,理解他们的核心在于理解 java内存模型(JMM)。
在多线程条件下,多个线程肯定会相互协作完成一件事情,一般来说会涉及到多个线程间相互通信告知彼此的状态以及当前的执行结果等,另外,为了性能优化,还会涉及到编译器指令重排序和处理器指令重排序。
2. 内存模型抽象结构
在并发编程中主要需要解决两个问题:1.线程之间如何通信;2.线程之间如何完成同步。通信是指线程之间以何种机制来交换信息,主要有两种:共享内存和消息传递。Java内存模型是共享内存的并发面模型,线程之间主要通过读-写共享变量来完成隐式通信。如果程序员不能理解Java的共享内存模型,在编写并发程序时,一定会遇到各种各样关于可见性的问题。
1)哪些是共享变量
在 java 程序中,所有的实例域,静态域和数组元素都是放在堆内存中(所有线程均可访问,是共享的),而局部变量,方法定义参数和异常处理参数不会在线程间共享。共享数据会出现线程安全的问题,而非共享数据不会出现线程安全的问题。
2)JMM 抽象结构模型
我们知道 CPU 的处理速度和主存的读写速度不是一个量级的,为了平衡这种巨大的差距,每个 CPU 都会有缓存。因此,共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。JMM 就从抽血层次定义了这种方式,并且 JMM 决定了一个线程对共享变量的写入何时对其他线程是可见的。
两个线程,线程A 和 线程B 之间要完成通信的话,要经历如下两步:
- 线程 A 从主内存中将共享变量读入线程 A 的工作内存后进行操作,之后将数据重新写回到主内存中
- 线程 B 从主内存中读取最新的共享变量
从横向看,线程 A 和线程 B 就好像通过共享变量在进行隐式通信。但是,如果线程 A 更新后数据并没有及时写回主内存,而此时线程 B 读到的是过期的数据,这就出现了 “脏读” 现象。可以通过同步机制(控制不同线程间操作发生的相对顺序)来解决或者通过 volatile 关键字使得每次 volatile 变量都能够刷新到主存,从而对每个线程都是可见的。
3. 重排序
一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而奋斗:在不改变程序执行结果的前提下,尽可能提高并行度。JMM 对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的
这些重排序会导致线程安全的问题。
针对编译器重排序,JMM 的编译器重排序规则会禁止一些特定类型的编译器重排序,针对处理器重排序,编译器在生成指令序列的时候,会通过插入内存屏障指令来禁止某些特殊的处理器重排序。
数据依赖是什么?
如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作数就存在数据依赖性。分三种情况:读后写,写后写,写后读。这三种操作都是存在数据依赖性的,如果重排序会对最终执行结果存在影响。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。
as-if-serial 语义?
语义的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按照程序的顺序来执行的。即,as-if-serial 语义使程序员不比担心单线程中重排序问题,也无需担心内存可见性问题。
4. happens-before 规则
上面重排序原则,比较复杂难理解,所以,JMM 为程序员在上层提供了六条规则,这样我们就可以根据规则去推论跨线程的内存可见性问题,而不用再去理解底层重排序的规则。
JSR-133 使用 happens-before 的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程内,也可以是在不同线程之间。因此,JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证(如果 A 线程的写操作 a 与 B线程的读操作 b 之间存在 happens-before 关系,尽管 a 操作和 b 操作在不同的线程中执行,但 JMM 向程序员保证 a 操作将对 b 操作可见)。具体定义为:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(JMM 允许这种重排序)
上面的第一条是 JMM 对程序员的承诺。从程序员的角度来说,可以这样理解 happens-before 关系:如果 A happens-before B,那么 JMM 向程序员保证 —— A 的操作结果将对 B 可见,且 A 的执行顺序排在 B 之前。
上面的第二条是 JMM 对编译器和处理器重排序的约束原则。正如前面所说的,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,只要执行结果不改变即可。因此,happens-before 关系本质上和 as-if-serial 语义是一回事。
比较 as-if-serial 和 happens-before :
- as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变
- as-if-serial 语义 和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
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),那么 A 线程的 ThreadB.start() 操作 happens-before 于线程 B 中的任意操作
- join() 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回
- 程序中断规则:对线程 interrupted() 方法的调用先于被中断线程的代码检测到中断时间的发生
- 对象 finalize 规则:一个对象的初始化完成(构造函数执行结束)先于发生它的 finalize() 方法的开始(finalize方法是在对象要被回收的时候执行)
5. 总结
一个 happens-before 规则对应于一个或多个编译器和处理器。对于Java 程序员来说, happens-before 规则简单易懂,它避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则及这些规则的具体实现方法。