目录
线程之间的通信
线程之间的同步
主内存和工作内存
缓存一致性协议
总线锁
缓存锁
缓存一致性协议
CPU的优化执行
并发编程的问题
硬件架构
三大特征
原子性(Atomicity)
可见性
有序性
线程的通信是指线程之间以何种机制来交换信息。
在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java的并发采用的是共享内存模型。
共享内存模型指的就是Java内存模型(简称JMM)。
主内存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中。
工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝)
JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。
线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。
当一个CPU 对其缓存中的数据进行操作的时候,往总线中发送一个 Lock 信号。其他处理器的请求将会被阻塞,那么该处理器可以独占共享内存。总线锁 相当于把CPU 和内存之间的通信锁住了,所以这种方式会导致 CPU 的性能下 降,所以P6 系列以后的处理器,出现了另外一种方式,就是缓存锁。
如果缓存在处理器缓存行中的内存区域在 LOCK 操作期间被锁定,当它执行锁操作回写内存时,处理不在总线上声明 LOCK 信号,而是修改内部的缓存地 址,然后通过缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻 止同时修改被两个以上处理器缓存的内存区域的数据,当其他处理器回写已经 被锁定的缓存行的数据时会导致该缓存行无效。
所以如果声明了CPU 的锁机制,会生成一个 LOCK 指令,会产生两个作用
1. Lock 前缀指令会引起引起处理器缓存回写到内存,在 P6 以后的处理器中, LOCK 信号一般不锁总线,而是锁缓存
2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
处理器上有一套完整的协议,来保证 Cache 的一致性,比较经典的应该就是 MESI 协议了,它的方法是在 CPU 缓存中保存一个标记位,这个标记为有四种 状态
Ø M(Modified) 修改缓存,当前 CPU 缓存已经被修改,表示已经和内存中的 数据不一致了
Ø I(Invalid) 失效缓存,说明 CPU 的缓存已经不能使用了
Ø E(Exclusive) 独占缓存,当前 cpu 的缓存和内存中数据保持一直,而且其他 处理器没有缓存该数据
Ø S(Shared) 共享缓存,数据和内存中数据一致,并且该数据存在多个 cpu 缓存中
每个Core 的 Cache 控制器不仅知道自己的读写操作,也监听其它 Cache 的读 写操作,嗅探(snooping)"协议
CPU 的读取会遵循几个原则:
1. 如果缓存的状态是 I,那么就从内存中读取,否则直接从缓存读取
2. 如果缓存处于M 或者 E 的 CPU 嗅探到其他 CPU 有读的操作,就把自己的缓 存写入到内存,并把自己的状态设置为 S
3. 只有缓存状态是M 或 E 的时候,CPU 才可以修改缓存中的数据,修改后,缓 存状态变为MC
除了增加高速缓存以为,为了更充分利用处理器内内部的运算单元,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果 充足,保证该结果与顺序执行的结果一直,但并不保证程序中各个语句计算的 先后顺序与输入代码中的顺序一致,这个是处理器的优化执行;还有一个就是 编程语言的编译器也会有类似的优化,比如做指令重排来提升性能。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障
所以,总的来说,JMM 是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会 对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性 和有序性
当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。
现代计算机一般都有2个以上CPU,而且每个CPU还有可能包含多个核心。因此,如果我们的应用是多线程的话,这些线程可能会在各个CPU核心中并行运行。
在CPU内部有一组CPU寄存器,也就是CPU的储存器。CPU操作寄存器的速度要比操作计算机主存快的多。在主存和CPU寄存器之间还存在一个CPU缓存,CPU操作CPU缓存的速度快于主存但慢于CPU寄存器。某些CPU可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作RAM,所有的CPU都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。
当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存,进而在读取CPU缓存到寄存器。当CPU需要写数据到主存时,同样会先flush寄存器到CPU缓存,然后再在某些节点把缓存数据flush到主存。
Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:
当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:
- 共享对象对各个线程的可见性
- 共享对象的竞争现象
Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的,那我们依次看一下这三个特征:
一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。
一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)。
Java内存模型是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。
无论是普通变量还是volatile变量都是如此,区别在于:volatile的特殊规则保证了volatile变量值修改后的新值立刻同步到主内存,每次使用volatile变量前立即从主内存中刷新(原理是基于CPU内存屏障指令实现的),因此volatile保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。
除了volatile关键字能实现可见性之外,还有synchronized,Lock,final也是可以的。
使用synchronized关键字,在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。
使用Lock接口的最常用的实现ReentrantLock(重入锁)来实现可见性:当我们在方法的开始位置执行lock.lock()方法,这和synchronized开始位置(Monitor Enter)有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在方法的最后finally块里执行lock.unlock()方法,和synchronized结束位置(Monitor Exit)有相同的语义,即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。
final关键字的可见性是指:被final修饰的变量,在构造函数数一旦初始化完成,并且在构造函数中并没有把“this”的引用传递出去(“this”引用逃逸是很危险的,其他的线程很可能通过该引用访问到只“初始化一半”的对象),那么其他线程就可以看到final变量的值。
对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。
Java提供了两个关键字volatile和synchronized来保证多线程之间操作的有序性,
- volatile关键字本身通过加入内存屏障来禁止指令的重排序,
- 而synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现,
在单线程程序中,不会发生“指令重排”和“工作内存和主内存同步延迟”现象,只在多线程程序中出现。
>> 指令重排序、内存屏障和happens-before原则