目录
前言
java内存模型的核心价值:保证变量的可见性,有序性和原子性。并发编程与java内存模型(Java Memory Model) 和jvm内存区域的划分密不可分,一定要清楚的了解它们的布局,才能写出线程安全和少垃圾的代码。事实上,JMM/JVM/JVM内存区域的划分都是逻辑上存在的一组规则规范,并不是真实物理的存在。
在JVM的工作流程中,学习到了JAVA内存区域的划分,这里主要了解一下java内存模型。本文主要从硬件内存,到java线程实现的模型,然后到JMM的实现。
1、硬件内存架构
由于CPU执行指令的速度比CPU去主存取数据的速度快很多,为了提高CPU的利用率,较少速度的差异,这里使用了CPU缓存和CPU寄存器缓存。当需要对数据进行读取或者更新操作的时候,直接在缓存里取,操作完放回缓存,然后再刷回到主存中,我们看的见摸得着硬件内存架构
只包含如下三部分:
-
CPU:一般一个计算机拥有一个或者多个cpu,每个cpu存在多核心;
-
CPU 高速缓存:一般分为三级缓存 (一级缓存:256K - 二级缓存:1M -三级缓存:6M);
-
主内存:也就是内存条;
![](https://i-blog.csdnimg.cn/blog_migrate/ea9feee5aca1525e32a57286f49c98ce.png)
CPU的读数据
CPU内部还有一个CPU寄存器,利用
程序的局部性原理,CPU会将程序需要的数据加载到内存中,用于存储CPU直接访问的临时数据;一般CPU会先从主内存取数据到高速缓存,然后从高速缓存取数据到寄存器供CPU使用;记载的时候连续地址的数据会被一起加载到缓存中,
也称CPU
缓存加速;如果缓存中的数据不存在,CPU还是会绕过寄存器缓存,直接从内存中取,命中的次数统计称为
缓存命中率;
CPU
的写数据
CPU进行写数据的时候,需要先刷新数据到寄存器,然后更新到CPU高速缓存,最后写回到主内存,解决速度不一致问题;
2、java线程实现模型
对于
Sun JDK来说,他们的
Windows和
Linux版本都是使用一对一的线程模型来实现了。一条Java线程就映射到一条轻量级进程
( Light Weight Process,LWP ) 中,它是内核使用的一种高级接口,
实际上就是通过语言级别层面上的程序去间接调用系统内核的线程模型,每一个轻量级进程都由一个内核线程区支持,因此只有先支持内核线程,然后才有轻量级进程。这种轻量级进程与内核线程1:1的关系称为一对一的线程模型;
缺点:
-
轻量级进程的创建和销毁都是基于内核进行的,都需要进行系统调用,而系统调用的代价很高,需要在用户态和内核态中来回切换;
-
每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程与内核资源的消耗是关系1:1(如内核线程的栈空间);
因此,一个系统支持轻量级进程的数量是有限的,这也是为啥使用线程池的原因之一。
java线程的实现方案-
1:1模型
![](https://i-blog.csdnimg.cn/blog_migrate/0da5880c5386e2e17a17cae84ff04761.png)
3、java内存模型-JMM
Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变
量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
线程之间通信主要通过两种方式:
-
共享内存:通过读-写内存中的公共区域进行隐式通信。
-
消息传递:线程之间发送消息进行显式通信。
问题:可共享内存的多处理器硬件内存架构和Java线程模型导致在
多任务环境下一个线程无法立即看到另一个线程对共享变量的操作结果;
解决:引入Java内存模型,从而
保证一个线程对共享变量的修改让
其他的线程可见;
JMM的具体实现:线程对变量的所有操作,包括读取/赋值等都必须在工作内存中完成,而不能直接读写主存中的变量;
![](https://i-blog.csdnimg.cn/blog_migrate/040a1eb55c5ba753760f18adbd4f8bce.png)
主内存
主要存储的是java的实例对象,所有的线程创建的实例对象都存放在主内存中,当然也包括了共享的类信息,常量,静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
工作内存
主要存储线程当前方法的所有本地变量信息(工作内存中存储着主内存变量副本拷贝),例如虚拟机栈帧中的变量,每个线程只能访问自己的工作内存,即线程中的本地变量对其他的线程是不可见的,就算两个线程操作同一段代码,因为线程间的工作内存不共享,所以不存在线程安全问题。
了解主内存和工作内存的概念后,再了解一下主内存与工作内存的数据存储类型以及操作方式,根据虚拟机规范,对于一个实例对象中的成员方法而言:
-
如果方法中包含本地变量是 基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中;
-
若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中;
-
对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区;
-
至于static变量以及类本身相关信息将会存储在主内存堆中;
需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存,且刷新的顺序是不可控的。
![](https://i-blog.csdnimg.cn/blog_migrate/34b0a274bb225363747263968ff9e85c.png)
JMM中规定了8种操作来完成工作内存和主内存的交互:
-
加锁 lock:把主内存中的一个变量标识为一条线程独占的状态。
-
解锁 unlock:把主内存中处于加锁状态的变量释放出来。
-
读取 read:把主内存的变量的值传输到工作内存中。
-
载入load:把主内存传输过来的变量值放进工作内存的变量副本中。
-
使用 use:把变量副本的值传递给执行引擎进行运算操作。
-
赋值 assign:工作内存接受执行引擎传递过来的值放进变量副本里。
-
存储 store:把工作内存的变量副本的值传输给主内存。
-
写入 write :把工作内存接收到的值放进主内存的变量中。
3.1、JMM与硬件内存架构的关系
通过对前面的硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。
对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。
![](https://i-blog.csdnimg.cn/blog_migrate/475d88bfa1ea4828ad050f40a7bce392.png)
3.2、JMM与JAVA内存区域划分的关系
JMM与JAVA内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。
3.3、JMM存在的必要性
在明白了Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系后,接着来谈谈Java内存模型存在的必要性。
由于jvm运行的实体是线程,每个线程运行时有工作内存和主存之分,线程会先将数据从主存拷贝到各自的工作内存,然后对变量进行操作,最后将变量写会到主内存。在这个过程中,如果是多个线程对同一个变量的操作就会有线程安全问题。
![](https://i-blog.csdnimg.cn/blog_migrate/3ae2b1eb17e448cac249dc8db9a4ca1c.png)
例如:线程A和线程B同时对共享变量x进行访问,线程A对变量x进行一次写操作变为2,那么线程B进行一次读操作,如果没有一组规则对其进行限制,这时候B读取的值是不确定的?
-
情况一:如果A线程写完将更新的值写回主存后B访问,这时候B读到的值则是2;
-
情况二:如果线程A还未来得及将新值2刷回主存,时间片用完,就进行了cpu的调度放弃了cpu,此时线程B读取到的值就是1;
为了解决上述问题,Java 定义了一组变量访问规则来屏蔽各个硬件平台和操作系统的内存访问差异问题,通过这组规则来决定一个线程的写入对另一个线程何时可见,让java程序在各个平台下都有一样的内存访问效果,
这组规则就是JMM,JMM主要围绕程序执行的
原子性/有序性/可见性来展开。
Java平台的优化原则:
只要和顺序一致性执行的结果一致就是允许的。 所以为了提高程序的执行效率,JMM
并没有限制引擎使用处理器的寄存器和高速缓存来提升指令速度,也没有限制
编译器的重排序,因此java中还是会存在一致性的问题。
4、Java内存模型的承诺
4.1、原子性
背景:
-
All or Nothing. 一个操作一旦开始,就不会停止,即使在多线程并发情况下,也不会有中间状态。
-
通过锁住总线和缓存行来实现。
保证:
-
基本数据类型byte/short/int/ float/char/boolean 基本类型都是原子操作, double/long是非原子操作,分高地位。
实例一:
请分析以下哪些操作是原子性操作:
x = 10; //语句1 ok 直接将10这个值写入到工作内存中;
y = x; //语句2 no 第一步:先去读取x的值,第二步:将x的值写入工作内存中;不原子
x++; //语句3 no 第一步:读取x的值;第二步:将x的值+1;第三步:将新值赋给x;
x = x + 1; //语句4 no 同语句3;
操作过程分析:以上四个语句那几个是原子操作呢,答案只有语句一。语句一是直接将10写入到工作内存中。
语句2实际上包含2个操作,(1)、先去读取x的值;(2)、再将x的值写入内存;单一的操作是原子的,但是两步合起来就不是了;
同样的,x++和 x = x+1包括3个操作:(1)、读取x的值;(2)、进行加1操作;(3)、写入新的值。
所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
另外一个很经典的例子就是银行账户转账问题:
实例二
:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
所以这2个操作必须要具备原子性才能保证不会出现数据一致性问题,但是转账问题还需要应用层面的配合才能实现。
值得注意的是:对于32位系统的来说,对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作。而long和double则是64位的存储单元,对它们的操作不是原子的。这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取,需要注意一下。
JMM提供的原子性解决方案:
JVM对基本数据类型读写操作的原子性外,对于方法级别和代码块级别的原子性操作,可以使用锁机制保证程序执行的原子性。
-
Synchronized(字节码指令monitorenter和monitorexit)
-
ReentrantLock
-
CAS机制-读多写少的情况性能高;
4.2、可见性
背景:
对于串性程序来说是不存在这个问题的。由于工作内存和主内存之间同步延迟的现象就造成了可见性问题。另外编译器重排/指令重排/内存重排也会导致可见性问题。
实例讲解:
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个简单的例子,看下面这段代码:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
分析:
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为2,那么在CPU1的高速缓存当中i的值变为2了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是2.
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题。
JMM提供的可见性解决方案:
主要是通过限制编译器和处理器/读写内存的重排序来保证可见性;
对于工作内存与主内存同步延迟现象导致的可见性问题,就可以使用如下方案解决。它们都可以使一个线程更改后的变量对另一个线程立即可见。
-
Synchronized
-
volatile (写完立即刷到主存-不保证原子性)
-
ReentrantLock
-
final(this引用逃逸-初始化一半的情况)
4.3、有序性
java程序中天然的有序性总结为一句话:如果在本线程内观察,所有的操作都是有序的(as -if -serials);如果一个线程观察另一个线程,所有的操作都是无须的。
-
前半句:线程内表现的串行语义;
-
后半句:指令重排序 + 工作内存与主内存同步延迟的现象
实例:
有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:
int i = 0; //语句1
boolean result = true;//语句2
i = 2; //语句3
result = false; //语句4
问题分析:上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它通过指令之间的依赖性来保证程序最终执行结果和代码顺序执行的结果是一致的。指令重排包括:编译期重排,指令并行的重排和内存系统的重排。编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是语句2和4之间有依赖关系,指令4依赖指令2,所以2一定在4之前执行。
程序的顺序是:1-2-3-4,但是jvm实际执行的顺序可以是:2-4-1-3,1-3-2-4,2-1-3-4。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程就不一定了?
如下实例:
//线程1:
config = getConfig(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(config);
问题分析:上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(config)方法,而此时config并没有配置完成。
造成的问题:线程1语句1和2指令重排,导致线程2在context没有被初始化的情况下作了一些操作,出错;
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
JMM提供的解决方案:
对于指令重排导致的可见性和有序性问题,可以利用如下方案来解决。
-
volatile(禁止指令重排 )
-
synchronize
-
lock
-
happen-before原则
结论:
要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
5、Happens-before原则
为什么需要happens-before?
JVM会对代码进行编译优化,会出现指令重排序情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。
除了synchronzied/lock/volatile来保证原子性/可见性/有序性意外,JMM还定义了一套
happens-before原则来保证多线程环境下的原子性/可见性及有序性。有时候写代码不考虑这些并不是不存在这些问题,而是有前辈已经帮我们解决了,我们才能大着胆子去编码。
Happens-before:
the first is visible to and ordered before the second.
-
程序顺序性: 单线程里串行语义-单线程内,虽然会重排序,但是程序执行的结果不会变;
-
volatile:一个线程先去写volatile变量,后一个线程去读这个变量,那么这个前一个线程对这个变量的写一定对后一个读线程可见;
-
锁规则:无论是单线程还是多线程,对于同一个锁而言,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程操作的结果;
-
传递性:a先行于b,b先行于c,则a先行于c;
-
线程终止规则-thread.join():在主线程a执行执行过程中,如果子线程调b用了join方法,当子线程b结束时,子线程对共享变量的修改结果对a线程可见;
-
线程启动规则-thread.start():主线程在执行过程中,启动了子线程b,那么a在启动b线程之前对共享变量的修改结果b线程一定可见;
-
线程中断规则-thread.interrupt():对线程interrupt()的调用先行于被中断线程代码检测到中断事件的发生,可以通过interrupted()来检测是否发生了中断;
-
对象的终结规则finalize() :构造函数的执行一定先于先于finalized,类似于c++中的析构函数;
happens-before规则实例详解:
注意:上述8条原则无需手动添加任何同步手段(synchronized | volatile)即可达到效果,这个是jvm在执行的时候默认遵守的原则。
1-2-3规则实例理解:
public class Demo {
int a = 0;
volatile boolean flag false;
public void write(){
a = 1; // 1
flag = true; // 2
// 1 heppens-before 2;
}
public void reader(){
if(flag){ // 3
int x = a; // 4
}
// 3 heppens-before 4; 程序顺序性:单线程里必须按照程序编写的顺序来执行;
// 2 heppens-before 3; volatile规则,内存屏障;
// 1 heppens-before 4; 传递性;
}
}
锁规则理解:
//锁规则
public class SynDemo{
public void LockDemo(){
Synchronized(this){ //threadA / threadB, threadA释放一定在threadB获取之前;
}
}
}
start规则理解:
//start规则:
public class startRule{
static int x =0;
public static void main(String[] args) {
Thread th = new Thread(()->{
System.out.println(x)//x的值一定等于100
});
x = 100; //x happen-before th.start()
th.start();
}
}
join规则实例理解:
//join规则:
static int x =0;
public class startRule{
public static void main(String[] args) {
Thread th = new Thread(()->{
x = 200;
});
x = 100;
th.start();
th.join(); //join之前的线程th操作对于主线程来说一定是可见的
system.out.println(x);//一定是200
}
}
6、小结
java内存模型的出现
屏蔽了各种硬件和操作系统的内存访问差异,以实现让java程序在各个平台上都能达到一致的并发效果,也是实现
平台无关性的另一个重要的基石,进而实现:
一次编写,到处运行。
JMM
核心作用
:
解决可见性,有序性和部分原子性。
JMM的承诺:
-
原子性 :基本数据类型 - long&double除外;非基本数据类型通过同步手段:lock/synchronized
-
可见性 :volatile/synchronized / lock/ final
-
顺序性 :volatile-内存屏障 / synchronized /lock/ happens-before / final
水滴石穿,积少成多。学习笔记,内容简单,用于复习,梳理巩固。
资料参考:
《java并发编程的艺术》 方腾飞 程晓明
《深入理解jvm虚拟机》 周志明