Java多线程-java内存模型
1.JMM
java内存模型:JMM是一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序,如果没有这样的一个JMM内存模型来规范,那么很可能经过了不同JVM的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样,那是很大的问题。
JMM最重要的3点内容是:原子性,可见性,有序性。
jvm内存结构:堆、虚拟机栈、方法区、本地方法栈、程序计数器
java对象模型:java对象自身的存储模型,是一个逻辑抽象。
JMM的抽象:主内存和工作内存(这仅仅是JMM中的概念)
主存和工作内存的关系
所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝。
线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中。
主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成。
所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。
volatile、synchronized、Lock等的原理都是JMM
2.原子性
保证指令不会收到线程上下文切换的影响。最明白的体现就是synchronized,线程拿着锁,其他线程不能访问临界区的代码。
3.可见性
保证指令不会受到CPU缓存的影响。
如下代码中子线程不会停止运行,因为main函数对run值的修改对子线程不可见。为什么会不可见呢?是因为每当子线程执行的时候都会去主内存中读取run的值,即时编译器觉得太麻烦了,就将run的值缓存到线程的工作内存中,每次从这里读取,因此主线程修改了主内存中run的值,并不会改变工作内存中run的值。
1 |
|
如何解决?
在变量前面加上volatile关键字(易变),这样子线程就不会将run缓存到工作内存中了,而是每次都需要去主内存中读取。
volatile是一种同步机制,比synchronized或者Lock相关类更轻量,因为使用volatile并不会发生上下文切换等开销很大的行为。
volatile
它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量就是直接操作主存。
volatile最好用在一个线程写,多个线程读的情况,因为它无法保证原子性。
synchronized语句块既可以保证代码块的原子性,也可以保证代码块内变量的可见性,但全店是synchronized属于重量级操作,性能低。
4.有序性
保证指令不会受到CPU指令并行优化的影响。
指令重排序是因为CPU底层会将指令并行处理,以达到最大效率。流水线模式
JVM 在不影响正确性的前提下,会调整语句的执行顺序,进行指令重排。多线程下『指令重排』会影响正确性。
volatile 修饰的变量,可以禁用指令重排,保证有序性。
5.volatile原理
5.1 volatile可见性底层如何实现
不同的物理CPU硬件所提供的内存屏障指令差异很大, JMM定义了一套相对独立的内存屏障指令,用于屏蔽不同硬件的差异,然后在volatile关键字中包含这些指令。然后在不同的硬件平台上,JMM内存屏障指令会要求jvm为不同平台生成相应的硬件层的内存屏障指令。
比如在X86处理器上,volatile被JVM编译过之后,它的汇编代码中会被插入一条lock addl 前缀指令,来实现全屏障的目的。
lock addl指令的作用:
将当前CPU缓存行(在JMM中是工作内存)的数据立即写回系统内存(在JMM中是主存)
lock指令会引起在其他CPU中缓存了该内存地址的数据无效
lock前缀指令禁止指令重排(作为内存屏障)
5.2 重排序
主要是CPU重排序导致的数据不一致问题。
指令级重排序
在不影响执行结果的情况下,CPU内核采用ILP指令级并行运算技术来将多条指令重叠执行,前提是指令之间不存在数据依赖性。
编译器和CPU都遵循As-if-Serial规则,即可以保证在单核CPU执行下他们不会对有数据依赖的指令进行重排序,但是多核并发的情况下,CPU的一个内核无法分辨其他内核上指令序列的数据依赖关系,因此可能会乱序执行。
内存系统重排序
内存重排序实际上并不是真的相关操作被排序了,而是因为CPU引入缓存还没来得及刷新导致。
每个CPU都有自己的缓存,为了提高共享变量的写操作,CPU把整个操作变成异步的了,如果写入操作还没来的及同步到其它CPU,就有可能发生其它CPU读取到的是旧的值,因此看起来这条指令还没执行一样。
内存重排序这里引用https://cloud.tencent.com/developer/article/18571747
所以要加入内存屏障来禁止多线程环境下的指令重排
硬件层面的内存屏障是如何实现的?
读屏障:在指令前插入读屏障,可以让高速缓存中的数据失效,强制重新从主存加载数据,并且,读屏障会告诉CPU和编译器,先于这个屏障的指令必须先执行。
写屏障:在指令后插入写屏障指令能让能让高速缓存中的最新数据更新到主存,让其他线程可见,并且写屏障会告诉CPU和编译器,后于这个屏障的指令必须后执行。
全屏障:读屏障+写屏障。
先于这个屏障的指令必须先执行,后于这个屏障的指令必须后执行,即禁止屏障两侧的指令重排。
高速缓存的最新数据写回到主存,其他核心的高速缓存中的数据会失效,强制从主存中加载数据。
volatile关键字修饰的变量,它的读屏障加在这个变量所在行的前面,写屏障加在这个变量所在行的后面。
在X86处理器上,lock前缀指令、mfence指令具有全屏障功能。
volatile的底层实现原理是内存屏障
保证可见性
- 在读取用volatile修饰的变量时,在这一行前面加上读屏障,则jvm对共享变量的读取,加载的是主存中的最新数据。
- 在给用volatile修饰的变量赋值时,在这一行后面加上写屏障,则对共享变量的改动赋值等都会同步到主存中。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
保证有序性
- 在读取用volatile修饰的变量时,在这一行前面加上读屏障,则读屏障会确保指令重排时,不会将读屏障之后的代码排在读屏障之前。
- 在给用volatile修饰的变量赋值时,在这一行后面加上写屏障,则写屏障会保证指令重排时,不会将写屏障之前的代码排在写屏障之后。
另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中;并禁止上面的普通写和volatile写进行重排序。
StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序
LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序
Load是读屏障,Store是写屏障
还有一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。
5.3 为什么volatile不保证原子性?
现在有两个线程,都对value自增1,最终结果应该是2,但是实际上并不是。
他们都将value=0读取到了自己的工作内存,然后线程A将value改为1,但还没有进行写入主存,此时线程B拿到时间片,将value改为1并写入主存,再到线程A,它将脏数据1写入到了主存,最终value的值是1
5.4 Happens-Before规则
Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下happens-before原则(先行发生原则):
- 程序次序规则(As-if-serial规则):同一个线程内,按照代码顺序,对于有依赖关系的语句,书写在前面的操作先行发生于书写在后面的操作,保证了单线程下的有序性。
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,如果线程A执行了线程B的Thread.join并成功,那线程B的任意操作都先行于线程A执行的Thread.join。
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始