JDK天生就是多线程的,多线程大大提速了程序运行的速度,但是凡事有利就有弊,并发编程时经常会涉及到线程之间的通信
跟同步
问题,一般也说是可见性、原子性、有序性。
线程的通信是指线程之间通过什么机制来交换信息,在编程中常用的通信机制有两个,共享内存跟消息传递。
在共享内存的并发模型中线程之间共享程序的公共数据状态,线程之前通过读写内存中的公共内存区域来进行信息的传递,典型的共享内存通信方式就是通过共享对象来进行通信。
在消息传递的并发模型中,线程之间是没有共享状态的,线程之间必须通过明确的发送消息来显式的进行通信,在Java中的典型通信方式就是
wait()
跟notify()
。
在C/C++中可以同时支持
共享内存跟消息传递机制,Java中采用的是共享内存模型
。
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
了解JMM前我们先了解下现代计算机物理上的数据存储模型。
随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,我执行一个任务一共耗时10秒,结果CPU获取数据耗时8秒,CPU计算耗时2秒,大部分时间都用来获取数据上了。
怎么解决这个问题呢?就是在CPU和内存之间增加高速缓存。缓存的概念就是保存一份数据拷贝。他的特点是速度快,内存小,并且昂贵。
以后程序运行获取数据就是如下的步骤了。
并且随着CPU计算能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L2),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的,性能对比如下:
单核CPU只含有一套L1,L2,L3缓存;如果CPU含有多个核心,即多核CPU,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。
随着计算机能力不断提升,开始支持多线程。那么问题就来了。我们分别来分析下单线程、多线程在单核CPU、多核CPU中的影响。
在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致
。
缓存一致性(Cache Coherence):在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,比如共享内存的一个变量在多个CPU之间的共享。如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议
,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
不一致demo如下:
//线程A 执行如下
a = 1 // A1
x = b // A2
-----
// 线程B 执行如下
b = 2 // B1
y = a // B2
处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果。
前
执行了(A2,B2),x=b,y=a。程序就可以得到x=y=0的结果。上面提到在在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用
,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化。
除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java的JIT[1]。
可想而知,如果任由处理器优化和编译器对指令重排的话,就可能导致各种各样的问题。硬件级别跟编译器级别都会对这些问题进行解决。
前面说的都是跟硬件相关的问题,我们需要知道软件的基层是硬件,软件在这样的层面上运行就会出现原子性
、可见性
、有序性
问题。其实,原子性问题,可见性问题和有序性问题。是人们抽象定义出来的。而这个抽象的底层问题就是前面提到的缓存一致性、处理器优化、指令重排问题。
一般而言并发编程,为了保证数据的安全,需要满足以下三个特性:
你可以发现缓存一致性
问题其实就是可见性
问题。而处理器优化
是可以导致原子性
问题的。指令重排
即会导致有序性
问题。
前面提到的,缓存一致性、处理器优化、指令重排问题是硬件的不断升级导致的。那么,有没有什么机制可以很好的解决上面的这些问题呢 为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是内存模型
。
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化
和使用内存屏障
。
前面说到计算机内存模型是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。
我们知道,Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model
,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
提到Java内存模型,一般指的是JDK 5 开始使用的新的内存模型,主要由JSR-133[2]: JavaTM Memory Model and Thread Specification 描述。简单形象图如下:
JMM功能:
这是一种虚拟的规范,作用于
工作内存
和主存之间
数据同步过程。目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
PS:
这里面提到的主内存和工作内存(高速缓存,寄存器),可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与JVM内存结构中的Java堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。《深入理解Java虚拟机》中认为,如果一定要勉强对应起来的话,从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分。工作内存则对应于虚拟机栈中的部分区域。
任意的线程之间通信方式简单如下:
在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区,关于JVM具体的讲解参考以前博文[3],这里只给出大致架构图,细节部分都写过了。
可见性
A 线程读取主内存数据修改后还没来得及将修改数据同步到主内存,主内存数据就又被B线程读取了。
竞争
现象AB两个线程同时读取主内存数据,然后同时加1,再返回。
对于上面的问题无非就是变量用volatile
,加锁,CAS
等这样的操作来解决。
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。比如:
code1 // 耗时10秒
code2 // 耗时2秒
----
如果code1跟code2符合指令重拍的要求,code2不会一直等到code1执行完毕再执行。
编译的源代码可能经过如下重排加速才是最终CPU执行的指令。
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行(处理器重排)
重排序对于数据依赖性跟控制依赖性的代码不会重拍。
public void use(){
if(flag){ //A
int i = a*a;// B
....
}
}
不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念, as-if-serial语义的意思是:
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。
int a = 1; //1
int b = 2;//2
int c = a + b ;// 3
1和3之间存在数据依赖关系,同时2和3之间也存在数据依赖关系。因此在最终执行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程序的结果将会被改变)。但1和2之间没有数据依赖关系,编译器和处理器可以重排序1和2之间的执行顺序。asif-serial语义使单线程下无需担心重排序的干扰,也无需担心内存可见性问题
比如下面的类中两个经典函数,如果AB线程分别同时执行不同的函数,
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。
编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier
会告诉编译器和CPU:
Memory Barrier
指令重排序。Memory Barrier
所做的另外一件事是强制刷出各种CPU cache
,如一个Write-Barrier
(写入屏障)将刷出所有在Barrier
之前写入cache
的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。目前有4种屏障.。
序列:Load1,Loadload,Load2 读 读 大白话就是Load1一定要在Load2前执行,及时Load1执行慢Load2也要等Load1执行完。通常能执行预加载指令/支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。
序列:Store1,StoreStore,Store2 大白话就是Store1的指令任何操作都可以及时的从高速缓存区写入到共享区,确保其他线程可以读到最新数据,可以理解为确保可见性。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。
序列:Load1; LoadStore; Store2 大致作用跟第一个类似,确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。
序列: Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。StoreLoad Barriers是一个
全能型
的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。
也就是加锁,运行两个函数的时候都加上相同的锁,这样就保证了两个线程执行两个函数的有序性,在同步方法里只要负责as-if-serial
即可。
因为有指令重排的存在会导致难以理解CPU内部运行规则,JDK用happens-before
的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before
关系 。其中CPU的happens-before
无需任何同步手段就可以保证的。
happens-before
另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(对程序员来说)happens-before
关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before
关系来执行的结果一致,那么这种重排序是允许的(对编译器和处理器 来说)happens-before
具体规则Mark下,以备不时之需。
volatile
保证变量的可见性,同时还具有弱原子性。关于 volatile 以前博文写过细节不再重复,指令重排的时候对volatile
规则如下:
有点类似于重型版本的volatile
,功能如下:
编译器和处理器要遵守两个重排序规则。
class SoWhat{
final int b;
SoWhat(){
b = 1412;
}
public static void main(String[] args) {
SoWhat soWhat = new SoWhat();
// 备注1:禁止在 b = 1 这个语句执行完之前,系统将新new出来的对象地址赋值给了sowhat。
System.out.println(soWhat); //A
System.out.println(soWhat.b); //B
// 备注2:A B 两个指令不能重排序。
}
}
final为引用类型时,增加了如下规则:
在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
class SoWhat{
final Object b;
SoWhat(){
this.b = new Object(); // A
}
public static void main(String[] args) {
SoWhat soWhat = new SoWhat(); //B
// 含义是 必须A执行完毕了 才可以执行B
}
}
final语义在处理器中的实现
[1]
JIT: https://sowhat.blog.csdn.net/article/details/104864128
[2]
JSR-133: http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf
[3]
博文: https://sowhat.blog.csdn.net/article/details/104738411
[4]
计算机存储结构图: https://blog.csdn.net/qq_36894974/article/details/104750989
[5]
计算机内存模型: http://www.hollischuang.com/archives/2550