以前一直在实习,博客停写了一段时间,如今秋招开始了,因此辞职回来专心看书,同时将天天的收获以博客的形式记录下来。最近在看jvm相关的书籍,下面对面试中问得最多的部分--java 内存模型进行简单总结。java
本篇博客大概由一下几个部分组成:面试
一、程序在真实物理世界的内存模型缓存
二、java的内存模型安全
三、java中的volatile与线程安全网络
四、happen-before原则与加锁。多线程
1、程序在物理世界中是怎样运行的并发
全部的程序,不管什么语言编写,最后都会变为一串机器码,而cpu的运算过程,就是将这些机器码转换为电信号给相应的运算电路进行逻辑或者算术运算,而后将结果返回。在这个过程,底层的电路如何进行运算并不是讨论范畴,咱们主要从更加宏观的角度理解程序怎么运行:程序从加载到内存到cpu得出结果返回到内存中,这个过程具体究竟是如何进行的。app
一、程序在物理中的执行过程框架
咱们之前一直知道,cpu从内存中读取数据远远快于从磁盘或者网络流中读取数据,因此为了提升响应速度,咱们不少时候会提早把读取频繁的数据预先加载到内存中以提升性能;其实相似的,因为cpu的运算速度真的太快了,从内存加载数据到cpu的时间和cpu运算速度相比也是太慢了,因此为了提升程序执行效率,在cpu和内存之间,还有一个缓存区,叫cpu高速缓存区,cpu从高速缓存中读取数据的速度远远快于从主存中读取数据(固然,高速缓存的物理制形成本也比主内存物理成本高得多)因此,为了提升速率,真实的程序是按照下面这张示意图的路径进行运行的:jvm
总的来讲:程序加载到主存以后,当cpu运算须要读或写数据的时候,它不会直接对主存进行操做,由于这样速度实在太慢了,它从位于cpu和主存之间的高速缓存读取数据/写入数据,当运算完成时,高速缓存会将结果自动更新到主存中。
二、缓存带来的问题
和全部缓存机制面临的问题同样,cpu高速缓存也面临这样的问题:多线程下数据同步问题。现代的cpu,基本都是以多核cpu为主,因此,再多核(多线程)下,不一样cpu之间的数据同步问题是高速缓存缓存须要解决的。真实物理世界中,在有必要的状况下,为了保证数据同步,能够经过对内存中的数据进行加锁操做来保证数据的一致性,这和咱们日常理解的多线程加锁操做是一致的,不过本人对硬件层面如何实现加锁不太了解,固然也不是此次的重点,因此就不展开了。
2、java的内存模型
咱们知道,java程序是运行在jvm上面的,jvm自己有它本身的一套内存模型,固然,不少时候,jvm的内存模型和真实的物理中程序运行的内存模型是一致的。
一、jvm的高速缓存
和真实的硬件内存模型相似,jvm内存模型中,每一个线程都有本身独立的工做空间,能够类比为前面cpu的高速缓存区域,它也是位于jvm主存和cpu之间,因此,当咱们在多线程的环境下对共享数据进行操做的时候,也会有真实的cpu内存模型面临的数据一致性的问题,固然,在讨论数据一致性问题以前,咱们先讨论下再java内存模型中操做原子性的问题。
二、操做指令的原子性
程序指令的原子性就是说这个指令在cpu运算中是最小的不可分割的了,它要不就执行成功,要不就没执行。而数据的同步问题,就是在多个原子操做之间出现的,当多个线程下多个原子指令对共享数据进行操做时,便会面临数据同步的问题,因此解决同步问题以前咱们要先知道在java内存模型中,哪些指令或者说操做是原子性的;
java的jvm中的原子指令主要有一下:lock(锁),unlock(解锁),read(读)和load(加载),use(使用),store(存储)和write(写), assign(赋值)。下面详细解释下各个指令;
lock 和unlock操做:对数据进行加锁/解锁操做,这组指令从直观上理解也是一致性的,由于不可能对数据lock/unlock到一半又失败了,这是不合理的;
read和load:这两个指令很容易混淆,咱们能够这样理解:load操做,是从主内存中将数据加载到告诉缓存中,而read操做是数据从高速缓存中加载到cpu中。因此,咱们能够看到,数据从主内存到cpu的这个过程,涉及到了两个原则性的动做。
use:user指令能够理解为数据被jvm进行某个运算;
strore/write:分别是read和load的反过程。
assign:赋值操做,将某个常量或者变量赋值给某个变量。
举个例子进行上面指令的理解:i=i+1,在这个程序段中,jvm首先进行load操做,将i的值加载到告诉缓存中,而后进行read操做,将i值加载到cpu的对应寄存器中,当变量read完毕以后,进行use操做,将i变量的值加1,加完1以后,执行assign操做将新的值赋值给i,而后,cpu执行store操做,将数据的运算结果写进高速缓存中,最后,cpu将i的新值刷新进内存中。注意,在这个过程当中,若是是在多线程环境下的,全部的操做,都有可能中途被打断。
另外,咱们要知道,何时会进行相应的指令操做,这对后面咱们理解数据一致性和可见性有很大帮助。read/write动做只会发生在use动做或者assign动做以前/后,对于连续的use和assign操做,只有一开始的use和assign进行read操做(这也很合理,对于一个变量的多个运算连,在续进行时,当前的运算依赖上一步的运算结果而不是在高速缓存或者内存中的值,因此,对于同一个变量,不会每次执行use指令和assign指令都进行read操做);而一样的,进行store操做时,也不是没执行use或者assign指令一次就直接将当前结果写进高速缓存的,jvm仅仅将最终运算结果写到高速缓存。
上面这段话比较抽象,举个具体的例子:i=i+1,这个操做步骤是这样的:load i值--> read i值--> use i值-->assign i值--> store i值-->write i 值;在执行这段代码的时候,i的read操做只会执行一次,jvm不会再在use和assign之间执行read或者write操做,毕竟这样作的话jvm太累了。
三、数据同步问题与volatile关键字
在进行数据同步问题讨论以前,先看如下简单的代码:
public class Demo1 implements Runnable { static volatile int i=0; public static void main(String[] args) { for(int j=0;j<10000;j++){ Runnable runnable = new Demo1(); new Thread(runnable).start(); } Thread.yield(); System.out.println(Demo1.i); } public void run() { i++; } }
读者可能觉得上面的例子打印结果必定是9999,可是事实并非,固然,将j的值设置更大一点效果会更明显。因此,在多线程环境下对变量进行操做,须要进行加锁操做,volatile并不保证变量的线程安全,至于为何,后面会对这里例子出现的状况进行详细的说明。
不少人都觉得volatile关键字的意思就是同步数据,在使用volatile关键字修饰变量以后变量会变得线程安全起来,可是,事实上volatile关键字并非保证数据同步的,相似上面这种状况,只有经过加锁进行同步操做才能保证i值的正常增长,而volatile关键字的做用仅仅是保证变量在修改以后当即可见,这就引出了java内存模型中的另一个问题:数据可见性问题。
四、java内存模型中的数据可见性问题
可能读者看到这里会有很强烈的疑问了:数据的一致性和指令原子性、可见性之间有什么关系?它和前面说的高速缓存又有什么关系?在阐述这些问题的时候,咱们先要理解java中数据可见性是什么意思:在前面介绍java内存模型的时候,咱们有提到java内存模型中的高速缓存区以及java原子操做的八个指令,数据可见性的意思能够理解为,在进行数据操做时,java程序的read和load以及store和write操做变为了一个原子动做,对于读数据,jvm再也不用高速缓存上的缓存副本数据而是直接读取内存中最新的数据,对于写数据jvm将运算结果当即写入内存,而要实现这种操做,只须要将变量修饰为volatile变量便可。
因此,可见性是保证各个线程读取到的数据是其余线程最新修改的,那么,为何可见性不能保证数据的一致性呢?
以上面的i++例子为例,i++其实是i=i+1,在jvm运算中,这个代码段包含了两个操做:将i的值加1以及将新值赋值给i,这两个动做对应两个原子指令,多线程环境下,这两个指令并不必定是连续,有可能线程1进行了i+1操做以后还没进行赋值操做,线程2也进行了一样的操做而且成功将加1后的最新值写进了内存,可是,线程1中的i值已经处于运算状态了,不可能再从新读取内存中的值,因此线程在线程1进行了赋值操做以后再将新i值刷新到内存中时,内存原来的线程2的值就被覆盖掉了。
因此,由上面的例子可见,可见性并不能保证数据的一致性,要保证数据一致性,还要有一个互斥条件:一个线程在操做数据的时候,另一条线程不能进行一样的操做,这就是为何上面的计数例子,即便计数的变量被volatile修饰也不能保证是线程安全的缘由了:volatile仅仅保证了变量的可见性,而没有保证变量操做是互斥的。在对某个变量进行操做时,对于线程之间的操做结果是相互依赖的过程,只有对变量进行加锁才能保证数据的一致性。
3、java 的volatile关键字和线程安全。
一、volatile和线程安全
由上面的讨论以后,读者可能以为volatile关键字显得有点鸡肋:它并不能保证线程安全,只能保证数据可见性。其实,volatile不少时候是用在初始化变量的时候,保证其余线程对最新赋值可见,在这点上,它比加锁的开销小,看下面的代码例子:
volatile int i = 0; volatile int j = 1; //下面这条语句时线程1执行的 j=1; //线面这条语句是线程2执行的 i = j;
volatile 能够保证变量对各个线程都是可见的,例如上面的例子,被volatile修饰以后的变量,能够保证线程之间用到的i值都是最新的,固然,其实volatile在配合java并发下的其余工具使用会能够实现并发下更多的其余功能,这里不展开讨论
二、指令重排序问题与volatile关键字
什么是指令的重排序?在cpu实际执行程序时,为了提升速度,cpu会将“容许被打乱”的指令打乱后再执行,而编译器在编译的时候,也会对指令进行重排序以进行优化,这变致使了,程序执行的顺序,并不必定和咱们编码顺序同样的,具体可看下面代码:
//指令重排序 int n = 0; int m = 1;
上面的n和m的初始化顺序并不必定是先n在m,由于上面代码有可能会被编译器以及cpu进行指令的重排序。那么,代码何时会容许被重排序呢?其实咱们能够这样理解:只要重排序以后的结果和非重排序结果在单线程环境下是同样的,代码即可以被重排序。也就是说,代码的先后再单线程环境下没有依赖关系时,即可进行重排序操做。例如上面这段代码,再单线程环境中,先初始化n或者先初始化m对程序的最终结果并无影响,那么重排序边有可能发生,可是,诸以下面的代码,重排序是不会发生的:
int k = 0; int l = k;
由于上面的代码之间,l的值依赖k的值,因此这时,重排序条件并不知足,不会进行重排序。
要注意的是,重排序只是保证在单线程的环境下和非重排序前一致,因此,在多线程环境下,重排序会带来意向不到的结果,请看下面例子:
public class Demo2 extends Thread { static int x = 0, y = 0, a = 0, b = 0; public static void main(String[] args) throws Exception { for (int i = 0; i < 1000; i++) { x = y = a = b = 0; Thread one = new Thread() { public void run() { a = 1; x = b; } }; Thread two = new Thread() { public void run() { b = 1; y = a; } }; one.start(); two.start(); one.join(); two.join(); System.out.println(x + " " + y); } } }
上面的代码,分析以后,有可能出现10,01,可是其实在现代jvm内存模型中更是有可能出现00的,由于赋值操做之间在不一样线程之间交换顺序是对在该线程下最终结果没有影响的,因此重排序有可能出现,不过因为程序复杂度问题,可能读者测试的该例子时候jvm不必定进行了指令重排序。可是,当程序复杂度提升以后,重排序效果会很明显的。
通过上面的举例子,相信读者对重排序的问题理解也更深入了,那么重排序和volatile关键字又有什么关系呢?
其实,volatile关键字还有一个做用:被它修饰以后的变量,不会进行指令重排序,更具体地说,被volatile修饰以后变量,该变量以后的代码块,不会由于重排序而出如今该变量以前,该变量以前的代码块,也不会由于重排序而出如今该变量以后。因此,咱们看到不少开源框架包括jdk源码,在设置某些阈值的时候,都会用volatile进行修饰,其目的就是防止指令重排序在多线程环境下致使意想不到的结果,好比下面的例子:
int k = 1; volatile boolean flag = false; int j = 0; int h = 2;
好比上面的例子,k=1的代码不可能在重排序以后位于flag=false以后,j=0和h=2也不可能在重排序以后出如今flag=false以前,固然,j=0和h=2之间是能够发生重排序的。总的来讲,volatile关键字就像一堵墙,墙内和墙外之间的代码不能在重排序以后互相交换位置,可是墙的同一端仍是能够进行指令重排序的。
那么,除了volatile关键字以外,jvm中,还有没有其余办法能够防止或者说有必要避免指令重排序呢?在jvm的内存模型中,有著名的happen-before原则,知足这些原则的指令都不可能进行指令重排序,下面详细讨论happen-before原则以及与之相关的多线程知识。
4、happen-before和线程安全
一、什么是happen-before?
前面说了,编译器以及cpu为了提升执行速率,会对代码编译后的指令进行从新优化排序。可是,当代码符合happen-before时,jvm规定不能进行重排序的,由于这会影响程序得出正确的结果;
原则一:对用一个lock ,unlock happen-before于lock操做。
原则二:线程启动动做必须happen-before于线程中全部动做。
原则三:一个线程对另外一个线程的interrupt动做必须happen-before 与该线程感知到它被interrupt。
原则四:对象的构造函数运行完成happen-before 于finalizer的开始
原则五:单线程环境中,动做a出如今动做b以前,则a happen-before于b以前。
原则六:传递性,a happen-before b,b happen-before c,则a happen-before c。
下面对这些原则进行简单的说明:
原则1,可能读者会感到疑惑:解锁为何会发生于加锁以前呢?不该该是先有加锁,而后才会有解锁吗?其实一开始我也有这样的疑惑,后来才知道,happen-before的意思不彻底是描述程序动做的发生前后发生动做,它表示的意思是:若是a happen-before于b,则线程1进行a操做后,线程1的整个a操做过程以及形成的影响都是当即能被线程2接下来进行的b动做看到的(更多时候,咱们仅仅理解了a和b是同一个线程的状况,没有深刻理解a动做和b动做有可能发生在不一样线程之间)。
而后,原则1就好理解了:它实际是这个意思,某个线程(线程1)对变量加锁动做必需要在另一个线程(线程2)对该变量解锁后才能发生,而且线程1的解锁前的全部操做对线程2都是当即可见的,这期间不会有指令重排序形成的影响--指令重排序不可能将线程1解锁前的动做重排序到线程2加锁的动做以后。
按照常理以及上面的思路,原则2,3,4,6很好理解,这里不累赘了,下面再简单提提原则5,原则5什么意思?a书写在b前面,则a happen-before于b前面?不是说有可能发生指令重排序吗?其实,原则5的意思是说单线程环境下,程序能够在重排序以后保证和重排序结果同样,其实就是说jvm能保证程序正常逻辑不会变,可是该重排序的仍是会重排序......(这尼玛不是在将废话吗,其实我也以为)
二、线程安全
水了那么多,其实讨论上面那些的全部东西,都是为了让咱们更好地理解线程安全。从java内存模型进行对线程安全的理解,你会发现不少模糊的东西瞬间就开窍了很多,因此,对于jvm的研究,是研究多线程必不可少的关键步骤,只有正确理解了java的内存模型,才能更好地理解java中的多线程工做机制。好了,就不说废话了,码了这么多字手好累。
学生党秋招干巴爹。