JAVA内存模型JMM解析
在讲JMM之前我们必须先来了解一下现代计算机的工作原理。现在的计算机的工作原理叫做冯.诺依曼计算机模型,结构如下图:
现代的计算机模型:
CPU的内部结构划分如下:
如上图所示,cpu的内部结构分为三个部分:控制单元,运算单元,存储单元。控制单元是整个cpu的指挥中心,由指令寄存器、指令译码器、操作控制器组成。根据程序依次从存储器中取出各条指令,放在指令寄存器中,然后指令译码器分析确定要进行什么操作,接着操作控制器对相应的部件发出指令进行操作。运算单元就是根据控制单元的发出来的指令进行运算,相当于执行器。存储单元包括 CPU 片内缓存Cache和寄存器组,是 CPU 中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据,CPU 访问寄存器所用的时间要比访问内存的时间短。 寄存器是CPU内部的元件,寄存器拥有非常高的读写速度,所以在寄存器之间的数据传送非常快。采用寄存器,可以减少 CPU 访问内存的次数,从而提高了 CPU 的工作速度。寄存器组可分为专用寄存器和通用寄存器。专用寄存器的作用是固定的,分别寄存相应的数据;而通用寄存器用途广泛并可由程序员规定其用途。
计算机有多个cpu的硬件结构如下:
现代计算机基本上都是多核的cpu,这是因为多核的cpu运算速度快。多cpu是因为单cpu在运行某多个程序(进程)的时候,假如只有一个CPU的话,就意味着要经常进行进程上下文切换,因为单CPU即便是多核的,也只是多个处理器核心,其他设备都是共用的,所以 多个进程就必然要经常进行进程上下文切换,这个代价是很高的。
多核是因为比如说现在我们要在一台计算机上跑一个多线程的程序,因为是一个进程里的线程,所以需要一些共享一些存储变量,如果这台计算机都是单核单线程CPU的话,就意味着这个程序的不同线程需要经常在CPU之间的外部总线上通信,同时还要处理不同CPU之间不同缓存导致数据不一致的问题,所以在这种场景下多核单CPU的架构就能发挥很大的优势,通信都在内部总线,共用同一个缓存。
CPU寄存器是内存的基础,cpu在寄存器上的操作速度远大于主内存。cpu缓存器是存在于主内存与寄存器中间的,是一种容量很小速度很快的存储器。CPU的速度远高于主内存,CPU直接从内存中存取数据要等待一定时间周期,Cache中保存着CPU刚用过或循环使用的一部分数据,当CPU再次使用该部分数据时可从Cache中直接调用,减少CPU的等待时间,提高了系统的效率。一个计算机还包含一个主存。所有的CPU都可以访问主存。主存通常比CPU中的缓存大得多。
多线程环境下存在的问题
缓存一致性问题
在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了新的问题:缓存一致性(CacheCoherence)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那同步
回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、
MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等。我们下面来看一个例子
public class MesiTest {
private static boolean iniFlag=false;
public static void testMe(){
System.out.println(Thread.currentThread().getName()+"iniFlag变更测试我开始啦");
iniFlag=true;
System.out.println(Thread.currentThread().getName()+"iniFlag变更测试我结束啦");
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"等待测试。。。。。。");
while(!iniFlag){
}
System.out.println(Thread.currentThread().getName()+"测试结束");
}
}).start();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(new Runnable() {
@Override
public void run() {
testMe();
}
}).start();
}
//结果如下:
Thread-0等待测试。。。。。。
Thread-1iniFlag变更测试我开始啦
Thread-1iniFlag变更测试我结束啦
//private static boolean iniFlag=false; 变更为private static volatile boolean iniFlag=false; 结果如下
Thread-0等待测试。。。。。。
Thread-1iniFlag变更测试我开始啦
Thread-1iniFlag变更测试我结束啦
Thread-0测试结束
}
根据以上的第一种结果来看,说明Thread-0一直在while循环中,为什么呢。根本原因就是iniFlag的值改变了,但是线程0没有感知到。这就是上面所说的共享变量缓存数据不一致的原因,以及使用缓存一致性协议的原因所在。下面我们来看一下上面那个程序的运行的过程。在看上面的程序运行的时候我们需要知道java内存模型JMM的一个原子操作。先来看看JMM的内存模型。
从上图可以看出是将主内存中的共享变量先拷贝到各个线程的工作内存中然后再进行操作的。这与硬件的CPU结构相似。
Java内存模型内存交互操作
1、lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
2、unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
3、read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
4、load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
5、use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
6、assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
7、store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
8、write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
有序性问题
在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。Java内存模型:每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
指令重排序:java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
happens-before 原则只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下:
1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
5. 传递性 A先于B ,B先于C 那么A必然先于C
6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
8. 对象终结规则 对象的构造函数执行,结束先于finalize()方法
volatile内存语义
volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
禁止指令重排序优化。
volatile的可见性
关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可
见的,对volatile变量的所有写操作总是能立刻反应到其他线程中;
Java 内存模型规定所有变量都存储在主内存中,每条线程还有自己的工作内存,工作内存保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行而不能直接读写主内存中的变量,不同线程之间无法相互直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成(具体如下图)。
知道了它的交互操作我们来看看上面的程序在第一次执行的过程,如下图所示:
从上图中可以看到,线程0,1刚开始的时候将iniFlag=false拷贝到各自的工作内存中,这个过程中涉及三步操作:第一步从主内存中read 数据,然后第二步load到工作内存中。read 和load操作一定是连续执行的,接下来就是第三步工作内存中的程序调用变量,即use,use完成之后将计算结果返回到工作内存,即第四步assign返回计算结果,第五步存储返回结果到工作内存store,接下来工作内存需要将计算结果写回到主内存中这就是第六步write.从上述步骤我们就知道为什么第一次执行的时候线程0会在while中死循环了。因为线程1和2中的变量是各自拥有的是不会相互交互的,只能通过主内存来进行交互。如上图线程2将iniFlag=true写入到主内存的时候,我的线程1其实已经开始执行了,1读的共享变量是true,你主内存的值被修改了之后线程1是不知道的,所以上述程序的第一执行结果显示Thread-0在while中死循环。这就是我们所说的缓存变量数据不一致。为了解决这个问题我们在总线上使用了缓存一致性协议(MESI)M 修改 (Modified),E 独享、互斥 (Exclusive),S 共享 (Shared),I 无效 (Invalid).
在以上程序中我们在给iniFlag前加入了volatile关键字的以后,我们就发现我们的thread-0没有进入死循环了,明显的就知道 thread-0感知到了共享变量的变更。现在我们解决了缓存数据的不一致问题。这就是volatile关键字保证了缓存数据的一致性。但是在高并发的线程中频繁的使用volatile就会导致我工作内存的数据不断的在外部总线进行数据交互,当量达到一定程度的时候就会导致我外部总线的带宽被这样的交互占用,其他的程序无法执行,这就是我们所说的总线风暴。
并发编程的三大特性是:可见性,有序性,原子性。
volatile关键字保证了我们的可见性,但是不保证程序的原子性,代码如下:
/**
*
Title: Test6.java
*
Description:
*
@datetime 2019年7月11日 上午12:43:17
*
$Revision$
*
$Date$
*
$Id$
*/package test1;import java.util.concurrent.locks.AbstractQueuedSynchronizer;import javax.annotation.Resource;/** * @author hong_liping*
*/publicclass Test6 {
privatestaticvolatileintcount=0;
publicstaticvoid main(String[] args) {
for(inti=0;i<10;i++){
Thread t1=newThread(new Runnable() {
@Override
publicvoid run() {
for(intj=0;j<1000;j++){
count++;
}
}
});
t1.start();
}
try {
Thread.sleep(20);
} catch(InterruptedException e) { e.printStackTrace(); } System.out.println(count); }}
//执行结果9999,9679
执行以上代码你会发现每次执行结果都不一样,我们的理想结果是1000*10=10000,但是没有得到我们想要的结果,这是为啥呢?因为我们每个线程执行的时候都是在自己的工作内存中进行的,各个线程的工作内存是不会相互通信的,只能通过主内存进行数据的通信。在并发执行的时候就出现了其中一部分线程执行完成以后还没有来得及写回主存,其他线程就已经读取主内存中的未更新的数据开始执行了。所以每次都会出现不同的结果,因为线程的原子性就没有得到保证。
volatile虽然没有办法保证我的原子性,但是可以保证我的有序性,即我的线程按照代码顺序进行执行。来看一下下面的代码:
publicclass OrderTest {
privatestaticintx = 0, y = 0;
privatestaticinta = 0, b =0;
staticObject object =new Object();
publicstaticvoidmain(String[] args)throws InterruptedException {
inti = 0;
for (;;){
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread t1 =newThread(new Runnable() {
publicvoid run() {
//由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.shortWait(10000);
a = 1;//是读还是写?store,volatile写
//storeload ,读写屏障,不允许volatile写与第二部volatile读发生重排x = b;// 读还是写?读写都有,先读volatile,写普通变量
//分两步进行,第一步先volatile读,第二步再普通写 }
});
Thread t2 =newThread(new Runnable() {
publicvoid run() {
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
/** * cpu或者jit对我们的代码进行了指令重排?
* 1,1
* 0,1
* 1,0
* 0,0
*/ String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
publicstaticvoidshortWait(long interval){
longstart = System.nanoTime();
long end;
do{
end = System.nanoTime();
}while(start + interval >= end);
}
}
以上当x=0,y=0的时候这样的结果是怎么出现的呢,这就是因为线程在执行的时候进行了指令重排,没有按照程序的执行结果来进行执行。先执行了x=a,y=b.加上volatile以后就可以解决这个问题。
以上就是JMM内存模型与volatile,欢迎各位留言评论,谢谢。