说到并发编程,其实有时候觉得,开发中真遇到这些所谓的并发编程,场景多吗,这应该是很多互联网的在职人员,一直在考虑的事情,也一直很想问,但是又不敢问,想学习的同时,网上这些讲的又是乱七八糟,那么本章开始,带你走进并发编程专题在讲专题之前,我想多说两句,可能市面上的开发,对操作系统或者多线程了解的还不是特别深入,也就会经常写一些代码质量不是很高,那么在讲并发之前,我希望可以看看这节,绝对精彩!!
1、什么是JMM模型?
我先用官方的话来描述下,请仔细看:
Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段 和构成数组对象的元素)的访问方式。JVM运行程序的实体是线程,而每个线
程创建时 JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问, 但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要
将变量从主内存拷贝的自 己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作 主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个 线程的私有数据区域,因此不同的线程间无法访
问对方 的工作内存,线程间的通信(传值)必 须通过主内存来完成。
从上面一段文字中我们提出几个关键的点:
- JMM叫做Java内存模型,描述的是一组规则,或者规范,定义程序中各种不同变量的访问方式,比如new关键字创建字段,static修饰的静态字段,以及数组对象等等。
- JVM和JMM不是一个东西,两个完全是没有关联。JVM针对的是内存的管理。JMM是一组规范。
- JMM规定,所有变量都存储在主内存,所有线程都可以去访问。但是线程对变量的操作必须在工作内存中进行。
- 工作内存,和主内存不是一回事。工作内存是每个线程私有的数据区域。
所以面试中经常会问到JVM和JMM之间的关系和区别,记住要回答,没有什么关联,JMM是定义程序中变量访问的规范,主要指各个变量在共享和私有数据区域的访问方式,JVM是虚拟机对内存的管理。
既然JMM是描述变量访问规范,那么就有了三大特性:
原子性
有序性
可见性
JMM与Java内存区域唯一相似点就是,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法 区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈 以及本地方法
栈。
主内存======共享数据区域(堆+方法区)
工作内存======线程私有数据区域(程序计数器,虚拟机栈,本地方法栈等)
再结合我的灵魂画师的图理解下:
这样,我想大家应该很好的掌握到了JMM的特点和概念性的东西。
什么是主内存?
主内存,主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可
能会发生线程安全问题。
什么是工作内存?
主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝), 每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线
程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
根据JVM虚拟机规范,主内存与工作内存的数据存储类型以及操作方式,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型 (boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本
地变量是引用类型,那么该变量的引用会存储在工作内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量而言,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static
变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存。
这些文字看起来枯燥无味,但是我还是希望很多基础理念的东西,一定要去静下心去看,理解下。
附上一张图理解下;
其实Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽
象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算 机硬件内存架构是一个相互交叉的关
系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)
我写个类来描述下上面的文字,
initFlag表示共享变量,存在主内存中,不要把这个和JVM的堆搞混淆了,我讲的还是JMM约定,规范,而不同的虚拟机会根据这个规范自己去实现内存的管理策略
此时我开了两个线程,严格来说是3个线程,其中包括main,当然main是来驱动这两个线程执行的。
线程1和线程2,由于都会拿到这个initFlag共享变量,对于JMM规范来说,这两个线程不是直接去操作initFlag这个变量,而是先报保存一份这个变量的副本到自己的工作内存中
线程1这个while循环,判断的是自己的工作内存中的initFlag这个变量的值
线程2这个refresh,是向initFlag中写一个新的值进去,为true,也是一样,先修改自己的工作内存initFlag的值,再写回去主内存,将这个值修改。
线程1休眠500毫秒后执行线程2。
li
与此同时,对于JMM中一个变量如何从主内存拷贝到工作 内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
2、数据同步八大原子操作
(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后 的变量才可以被其他线程锁定
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用
(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工 作内存的变量副本中
(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内 存的变量
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存 中,以便随后的write的操作
(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值 传送到主内存的变量中
前两个我们先不看,后面讲到Synchronized关键字再详细说,先从2开始
3、可见性案例描述
我给个图可以看下这个上述过程。
那么我运行程序看看结果,你会发现线程1会一直卡在当前的状态出不来,因为啥?因为线程1读到的是工作区域的副本,主内存这个时候的值已经是被刷新成initFlag为true了,不信我再开个线程看看?
开个t3,你会发现,确实被修改了,但是t1线程,一直在循环中,一直没退出来。
原因就是上面规范说的,每个线程只能看到自己私有的东西,别的线程里的东西他是无法得知的;
所以我上面引出了并发编程中的,三个特性,可见性,原子性,有序性。
原子性,其实我们很早就熟悉过,比如synchronized关键字,事务的原子性,一系列的原子操作,都会对这个概念比较熟悉,这个特性我先不说。
来看看可见性,既然每个线程只能看到自己私有的东西,看不到其他线程对共享资源操作的变化,更不可能去跨线程去访问别的线程的资源,除非有一种机制,在某个线程修改了某个变量或者共享资源的同时,你就可以主动通知到我,让我重新去读取主内存
中最新的数据,那么,这个时候,就叫做可见性。
就引出了一个关键字,volatile,这想必应该都听过,我来介绍下:
Volatile——>java的并发轻量级锁机制,是对Synchronized关键字的有意补充,先看看怎么使用
如果我对initFlag这个共享变量加上这个关键字,那么线程2在修改initFlag后,会主动通知线程1,我已经改了这个变量,你自己的丢掉把,重新读取主内存最新的值!!!
对于Volatile底层原理,我会单独起一个章节讲,先用起来再说。
那我加这个关键字和不加这个关键字,在字节码上有什么区别呢?
右键——External Tool——>show byte code
如果不知道idea怎么看反编译后的class文件,参考我的博客:https://blog.csdn.net/qq_31821733/article/details/117198235
解析后的字节码是这样的:加了volatile关键字,多了个ACC_VOLATILE;
所以加了Volatile关键字后,线程1马上就可以看到线程2的更改并及时通知!
那这个时候我会思考一个问题,如果我不加这个关键字,我在线程1中写行代码,count++;
线程1会不会看到线程2的修改呢?
比如这样,运行下,结果是看不到。。很明显你没有通知到线程1
但是我这个时候int改成Integer呢?我靠这是什么现象??int不行,Integer可以,Integer是int的包装类,仅此而已。。。
那我一旦给这个count修饰Volatile后呢?很奇怪,为什么我给counter加了这个关键字,线程2会通知线程1,initFlag的这个改变呢?
这些都是很玄学的情况,而我为此去寻求了答案,甚至去StackOverFlow问了下,众说纷纭,但是我最终理解了一个东西,是Volatile的本质!!
叫做及时可见,意思是说,如果你不加Volatile关键字,其他线程不会立马看到,但是,也一定会在最终看到,而Volatile,解决了及时可见性!!!!
而不加的时候,线程能看到的时机,你确定不了。
因为规范是死的,实现是活的,阿里有虚拟机,ibm有自己实现的虚拟机,华为也有,谷歌也有,而这些虚拟机都是实现了这些规范,内部很多不一样的细节,肯定是灵活的。
这些语义,不会去描述多线程的程序该如何执行,而是描述多线程程序允许表现出的行为。任何执行策略,只要产生的是允许的行为,那他就是一个可接受的执行策略。——来自JSR133中文规范
所以你不写Volatile,线程1,有一天也能看到initFlag的变化。
可是 为什么while空循环中,会必须要加Volatile才可以生效呢?因为空循环,在底层硬件中,执行具有最高的优先级,拿到时间片后基本不太可能释放了,内部没有任何代码,根本不需要停下来,而你要在while循环体内写点东西,
就会很大概率出现线程的中断,上下文切换。
所以上面的一些奇怪的现象,很有可能是这些变量,在同一个缓存行中,比如counter的变量和initFlag变量,在一个缓存行中,而其中只要一个变量失效了,会通知重新读,那么这个时候会顺带把缓存行中其他的变量最新值,也从主内存中读过来
但是这个没法证明!!
看下一个问题:
4、Volatile可以保证我们的原子性吗?
来看个案例,我计数,10*1000结果应该是10000次
但是结果并不是,结合上面的说法,每个线程在往主内存写的时候,是没有告诉别的线程嘛,循环10个线程,每个线程拿到的值修改后,都没有通知其他线程,所以导致每个线程拿到的值都不是主内存的最新的值
那么我加个volatile关键字试试?
加了Volatile后,哎,为什么还不是10000?难道上面讲错了?原则上任意一方改了东西后,会通知另一方啊,这读者讲错了吗??你说count++这就一行代码,难道不是原子的吗?
实际上,并发的三大特性中,原子性,volatile解决不了;
count++你看起来是一行代码,实际上在底层中,是执行了3步,
第一步:线程读取count=0
第二步:线程count = count + 1
第三步:写回主内存,并通知其他的线程。
而每个线程是基于时间片去轮询的,如果刚刚好,假设线程1拿到cpu,执行到count=0之后,此时中断了,这个时候线程1失去了cpu的使用权,然后当拿回来cpu的时候,时间片此时用完了。
此时cpu被线程2拿走去执行了,而线程2此时假如开始读也是count=0,正常走完,然后通知线程1,count刷新了你去读把,而此时线程1要循环1000次,本来要加到1000的,但是因为第一次刚好读到了count=0,于是中断,等恢复现场的时候,发现时间片用
完了,没办法,本次循环就被浪费了,再加上有人通知我要拿新的值,那我线程1接下来的count = count+1也要丢弃了。于是没能把count+1写回内存。
所以就出现了上述的情况。不要觉得没有这么巧的事情,如果你的服务器是面向toC的,服务器可以并发好几千个线程,每一步都会发现线程去抢夺cpu资源的情况。
所以count++中三步,每一步是原子的,但是在整体中,他不是原子的,所以volatile是没法保证原子性的。
如果你想要保证原子性,就加synchronized关键字,这个比较简单我就不多说了,关于原子性我后面会单独讲的!!!
而且Volatile只能修饰成员变量,开发中你不可能说原子性的代码只有一行count++,肯定是很多行代码,所以这也就论证了synchronized可以保证原子,而volatile不可以
5、有序性案例
有序性是相对理解比较复杂的,我这里也说下
在我们很早的一个认知里,cpu在执行代码过程中,是有序执行,按照顺序的执行的。
时间上具有优先级,原则上写在上面的代码比写在下面的代码具有优先级。
但是在程序世界里,他不一定是这么执行的,很多情况,会对你的指令进行重排!!!!
cpu会认为,你的程序在指令重排后,执行的效率会更好更快!!而cpu会把你的指令重排,但是不会影响你的结果,要是影响你的结果,那就基本GG了。
来看一个案例,有四个变量a b x y;
我开了一个循环,两个线程,分别对a=1,x=b; b=1,y=a
如果我们不考虑指令重排我们看看x和y有哪些可能!!一共只有3种结果,你不管怎么倒腾。
但是一旦出现了指令重排,就会是这样的情况
指令已经颠倒!!然后我们执行下程序,看运气,运气好我们很快可以看到。
代码贴出来:
public class MemoryBarrier { private static int x = 0, y = 0, a = 0, b = 0; public static void main(String[] args) throws InterruptedException { int i = 0; for (; ; ) { i++; x = 0; y = 0; a = 0; b = 0; Thread t1 = new Thread(() -> { shortWait(10000); a = 1; x = b; }); Thread t2 = new Thread(() -> { b = 1; y = a; }); t1.start(); t2.start(); t1.join(); t2.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if (x == 0 && y == 0) { System.out.println(result); break; } else { System.out.println(result); } } } /** * 等待一段时间,时间单位纳秒 * * @param interval */ public static void shortWait(long interval) { long start = System.nanoTime(); long end; do { end = System.nanoTime(); } while (start + interval >= end); } }
的确发生了指令重排,那么指令重排,volatile是可以解决的
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱 序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如 何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
所以jvm最终底层会调用硬件方面的内存屏障策略。
在JVM中提供了4种内存屏障的指令
第一个LoadLoad,表示Load1和Load2两个读指令之间,加了个LoadLoad指令,表示这两个读不可以进行重排,不可以把load2放在load1的前面去执行
其他的我就不多解释,和第一个一样,所以你的变量加了volatile关键字后,cpu在底层执行你的指令中,就会禁止将你的程序读,写指令,进行优化重排!!!因为会找到你的读写指令,在其中加上内存屏障指令。
用官方的话来说:内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。
如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障,就是上面说的4中指令,禁止在内存屏障前后的指令执行重排序优化。
Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。 总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
下面看一个非常典型的禁止重排优化的例子DCL,如下:
public class DoubleCheckLock { private static DoubleCheckLock instance; private DoubleCheckLock() { } public static DoubleCheckLock getInstance() { //第一次检测 if (instance == null) { //同步 synchronized (DoubleCheckLock.class) { if (instance == null) { //多线程环境下可能会出现问题的地方 instance = new DoubleCheckLock(); } } } return instance; } }
这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题,你可能觉得我在和你吹牛逼,说你不是加了锁吗,怎么还会有问题吗?原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可
能没有完成初始化!!!
我们知道new创建对象,你看起来是一行代码intance = new DoubleCheckLock();
这一步对象的创建其实分为几步去做的。
- memory = allocate()://分配对象内存空间
- init(memory)://初始化对象
- instance = memory://设置instance指向刚分配的地址,此时instance!=null
但是一旦发生了指令重排!
- memory = allocate()://分配对象内存空间
- instance = memory://设置instance指向刚分配的地址,此时instance!=null,但是对象还没初始化完成!
- init(memory)://初始化对象
由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,都是创建对象的,结果都是拿到一个对象;
因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已经初始化完成,如果你此时代码里要调用实例里面的方法,
那无疑是有问题的,因为你还没初始化对象,你只是给了个引用指向,对象里啥都没有;
也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。就中规中矩来对象创建。
这样的情况会在用户量大的时候,以及比如你的服务挂了然后重启的一瞬间,所有流量打上来,这个时候懒汉式DCL的方式,就会出现极大的问题!!!
所以加了volatile关键字,就可以防止对象创建过程中指令重排!!!
本章我就讲了这些关键点,和面试中会问到的一些知识点,下一章我会说关于——JMM-CPU缓存一致性协议MESI,欢迎提出宝贵意见!!