并发编程的难题和挑战
在并发编程的技术领域中,对于我们而言的难题主要有两个:
- 多线程之间如何进行通信和线程之间如何同步,通信是指线程之间以何种机制来交换信息。
多线程的线程通信机制
在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
- 共享内存的方式,多线程之间共享公共的状态(变量),那么线程之间通过写/读内存中的公共状态(变量)来隐式进行通信。在此模式下,同步实现是隐式进行的,由于消息的发送必须在消息的接收之前。
- 消息传递的方式,多线程之间没有公共的状态(变量),那么线程之间必须通过明确的传递状态(变量)来显式进行通信。在此模式下,同步实现是显式进行的,必须显式指定某个方法或某段代码需要在线程之间互斥执行。
Java中的同步模式是什么?
同步机制是指程序用于控制不同线程之间操作发生相对顺序的机制。
Java生态中的并发编程模型采用的是共享内存模型,因此在Java线程之间的通信总是隐式进行, 整个通信过程对开发者是黑盒的,如果编写多线程程序的开发者不深入理解这种隐式模式下的线程之间通信机制,就会会出现内存可见性和一致性的问题,我们统称为线程不安全问题。
存在内存可见问题
Java应用程序中, 所有实例域、静态域和数组元素存储在堆内存中, 堆内存在线程之间共享。会存在这内存可见性问题。
不存在内存可见问题
局部变量(Local variables) , 方法定义参数(java语言规范称之为formal method parameters) 和异常处理器参数(exception handler parameters) 不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
所以,我们在开发多线程场景下的程序的时候主要需要关注的就是内存可见问题变量,包含:实例域、静态域和数组元素。
而为了降低并发编程的难度和门槛,这些线程之间的数据同步和通信控制就交由一个特定的数据模型进行控制和管理,我们称之为Java内存模型(JMM)。
Java内存模型(JMM)
JMM决定在程序运行中,一个线程对共享变量的写入何时对另一个线程可见。
JMM定义了线程和主内存之间的抽象关系
线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存 , 本地内存中存储了该线程以读/写共享变量的副本。
本地内存是JMM的一个抽象概念, 并不真实存在。它涵盖了缓存, 写缓冲区, 寄存器以及其他的硬件和编译器优化。
Java 内存模型的抽象示意图如下:
由上图可见,线程A与线程B之间如要数据通信,需要有以下两个步骤:
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 线程B到主内存中去读取线程A之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:
如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。
- 线程A在执行时,把更新后的x值,临时存放在自己的本地内存A中。
- 线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变了。
- 线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变了。
总结一下就是,这两个步骤数据角度而言是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互, 来为程序提供内存可见性保证。
线程不安全因素之一(指令重排序问题)
基于上述所说的场景之下,JVM为了在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。在此我们将按照重排序的执行时间前后分为重排序分三种类型,如下图所示。
- 第一步属于编译器重排序:编译器优化的重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 第二步属于处理器重排序:指令级并行的重排序,现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP) 来将多条指令重叠执行。如果不存在数据依赖性, 处理器可以改变语句对应机器指令的执行顺序。
- 第三步属于处理器重排序:内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行,此处特别是针对与本地内存和共享主存之间的更新操作的一致性和可见性
这些重排序都可能会导致多线程程序出现内存可见性问题。
JMM解决重排序的线程不安全问题
解决编译器级别重排序
- JMM的编译器重排序规则会禁止特定类型的编译器重排序,此处注意:不是所有的编译器重排序都要禁止。
解决处理器级别重排序
- JMM的处理器重排序规则会要求java编译器在生成指令序列时, 插入特定类型的内存屏障(memory barriers, 也可以称之为memory fence)指令, 通过 内存屏障 指令来禁止特定类型的处理器重排序,此处注意:不是所有的处理器重排序都要禁止)。
总结一下,针对于JMM属于语言级的内存模型, 它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,从而实现了内存的可见性以及一致性。
处理器重排序与内存屏障指令
上面说了其实是通过插入了内存屏障指令,从而控制住了对应的处理器级别的指令重排。
线程不安全因素之一(写缓存处理模式)
- 现代的处理器使用写缓冲区来临时保存向内存写入的数据,写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。
- 通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。
这个特性会对内存操作的执行顺序产生重要的影响,处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。
- 处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1)
- 从内存中读取另一个共享变量(A2,B2)
- 最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器A的内存操作顺序被重排序了(处理器B的情况和处理器A一样)。
由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作重排序。常见的处理器都允许Store-Load重排序,常见的处理器都不允许对存在数据依赖的操作做重排序。
内存屏障指令
为了保证内存可见性, java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:
内存屏障类型 | 指令示例 | 备注 |
LoadLoad Barries | Load1\LoadLoad\Load2 | 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载 |
StoreStore Barries | Store1\StoreStore\Store2 | 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。 |
LoadStore Barriers | Load1\ LoadStore\Store2 | 确保Load1数据装载, 之前于Store2及所有后续的存储指令刷新到内存 |
StoreLoad Barriers | Store1\StoreLoad\Load2 | 确保Storel数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 |
**StoreLoad Barriers是一个“全能型”的屏障, 它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush) **。