
- 外文名
- Java Memory Model [1]
- 术 语
- JMM
- 中文名
- java内存模型
-
2021-12-02 17:13:40
Java内存结构、内存模型、对象模型这几个概念经常会被混为一谈。理论知识容易忘写下来帮助记忆,方便以后查看。
1、Java内存结构
Java内存结构其实说的是JVM在运行时内存区域的划分,是根据Java虚拟机规范来实现的。说的是JVM中划分出的各块内存区域分别用来干什么。如上图分为了5大块:方法区,虚拟机栈(有的叫方法栈也有叫线程栈的)、本地方法栈、堆、程序计数器(也有叫寄存器的)。其中方法区和堆是线程共享的内存区域,而虚拟机栈、本地方法栈和程序计数器是线程私有的,或者说是线程隔离的。1.1 程序计数器:
它是当前线程执行字节码的指示器,通过改变它的值来选取下一条要执行的Java字节码指令。Java代码中的循环、跳转、异常处理、线程获得时间片后的恢复都需要依赖程序计数器。程序计数器所占内存很小,也是唯一一块不会出现内存溢出异常的内存区域。
1.2 虚拟机栈:
虚拟机栈是线程私有的,每一个线程都会分配一个虚拟机栈,它描述的是Java方法执行的内存模型,每个方法执行时都会同步创建一个栈帧,用于存储方法运行过程中需要的数据,指令,返回地址等信息。
1.3 本地方法栈
本地方法栈和虚拟机栈描述类似,区别只在于虚拟机栈描述的是Java方法,而本地方法栈描述的是Native方法(c++语言)。
1.4 方法区
它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区只是Java虚拟机规范中的一种理论思想,在jdk1.7之前这部分内容被存放于永久代中,在jdk1.8之后用元空间来代替。jdk的做法是理论的实现方式。永久代和元空间都可以通过jvm参数来调整其大小,不同的是元空间使用的是直接内存。
1.5 堆
堆是这几块内存区域中最大的一块,存放的是Java中几乎所有的对象实例。几乎所有对象也就是说不是全部,从jdk1.7之后开启了逃逸分析,感兴趣的可以查一查,它说的是当方法中的一个对象,未被外部引用并且未被方法返回,也就是说这个对象只在这个方法内有用,那么这个对象可以直接在栈上分配内存。
2、Java内存模型(JMM)
JMM可以理解为Java模拟了cpu的硬件架构所规定的一种规范。由于cpu处理速度非常块比内存的读写速度要快很多,为了不妥协内存的读写速度,cpu引入了高速缓存,cpu要想修改主内存的值必须先将主内存中的数据拷贝到高速缓存中进行读取,然后再回写入主内存。
从上节内容可知,Java堆和方法区域是多个线程共享的数据区域。也就是说,多个线程可以操作保存在堆或者方法区中的同一数据。
JMM和多线程相关,它定义了一个线程对共享变量的写入对另一个线程是可见的。Java的多线程间是通过共享内存进行通信的,在通信过程中就会存在一系列如可见性、原子性、顺序性等问题。而JMM就是围绕多线程通信以及其相关的一系列特性而建立的模型。JMM定义了一系列语法集,这些语法集映射到Java中就是volatile
、synchronized
等关键字。更多相关内容 -
Java 内存模型
2018-07-23 16:45:07深入理解 java 内存模型是 java 程序员的必修课,看看原汁原味正宗的内存模型吧 -
Java面试-讲一讲什么是Java内存模型
2020-12-22 10:03:41讲一讲什么是Java内存模型 Java内存模型虽说是一个老生常谈的问题 ,也是大厂面试中绕不过的,甚至初级面试也会问到。但是真正要理解起来,还是相当困难,主要这个东西看不见,摸不着。 这是一个比较开放的题目,... -
浅析java内存模型--JMM(JavaMemoryModel)
2021-02-24 21:50:35在说Java内存模型之前,我们先说一下Java的内存结构,也就是运行时的数据区域:Java虚拟机在执行Java程序的过程中,会把它管理的内存划分为几个不同的数据区域,这些区域都有各自的用途、创建时间、销毁时间 -
《深入理解JAVA内存模型》PDF
2017-11-27 17:10:20Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main ... -
Java内存模型与JVM运行时数据区的区别详解
2020-08-25 12:52:44主要介绍了Java内存模型与JVM运行时数据区的区别详解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 -
深入理解Java内存模型 pdf 超清版
2019-01-31 16:48:04深入理解Java内存模型 pdf 超清版 -
Java内存模型
2021-11-16 18:12:44 在JVM内存结构中,我们知道栈、程序计数器、局部变量这些是线程私有的,但是进程范围内的资源,同一个进程内的线程都会共享进程内的地址空间,那么它们会共享堆上分配的对象。当多个线程并发运行时,它们就可能会...Java内存模型
在前面阅读了Thread类的源码,总结了线程的创建、停止、生命周期,以及多线程编程的异常处理,那么接下来就要总结多线程编程下的安全问题。
事实上,并不是所用的情况用多线程就一定比单线程快。多线程如果使用不当,不仅会带来严重的安全性问题,也会造成性能问题。
在JVM内存结构中,我们知道栈、程序计数器、局部变量这些是线程私有的,但是进程范围内的资源,同一个进程内的线程都会共享进程内的地址空间,那么它们会共享堆上分配的对象。当多个线程并发运行时,它们就可能会访问或者修改其他线程正在使用的变量造成严重的后果。那么这些问题是怎么造成的呢?
Java内存模型
Java内存模型,全称Java Memory Model,JMM,目的是为了屏蔽掉各种硬件与操作系统的内存访问差异,让Java程序在各种平台下都能达到一致的访问效果。它是Java虚拟机定义的一组规范,目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
Java内存模型定义了所有的变量都存储在主内存中,每条线程也有自己的工作内存,线程的工作内存中,保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都在工作内存中进行,而不能直接读写主内存的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间的变量值的传递均需要通过主内存完成。
CPU有多级缓存,Java做为高级语言,屏蔽了CPU cache的底层细节,用JMM定义了一套读写内存的规范:主内存与工作内存就是JMM抽象出来的概念
线程、主内存、工作内存三者的交互关系如下图所示:
内存之间的交互
那么主内存与工作内存直接具体是怎么交互的呢,一个变量是怎么从主内存拷贝到工作内存的?
主内存与工作内存交互的八种操作
Java内存模型中,定义了以下这8种操作,并规定了虚拟机实现这八种操作时,每一种操作都是原子的、不可再分的:
- lock
作用于主内存的变量,它把一个变量标识为一条线程独占的状态
- unlock
作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read
作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后续的load使用
- load
作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use
作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令就会执行这个操作
- assign
作用于工作内存的变量,它把一个从执行引擎接受到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
- store
作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便后序的write操作使用
- write
作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入到主内存的变量中
个人理解,这八个指令其实就是保证了变量从主内存到工作内存的读取/写入,从工作内存到虚拟机使用的读取/写入,每个步骤的原子性。但是在read与load、store和write之间,Java内存模型只要求它们必须按顺序执行,却不要求是连续执行。也就是说读取主内存的变量a、b时,很有可能的顺序是:read a、read b、load b、load a。
八种操作需要满足的规则
此外,Java内存模型还规定了在上述八种操作时,必须满足下面的规则:
-
不允许read和load、store与write操作之一单独出现
-
不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变之后必须把该变化同步回主内存
-
不允许一个线程无原因(没发生过assign操作)把数据从线程的工作内存中同步回主内存中
-
一个新的变量只能在主内存中诞生,不允许工作内存中直接使用一个未被初始化的变量(也就是说,在变量use、store之前必须经过assign与load操作)
-
一个变量在同一时刻只允许一条线程对其进行lock操作,但是lock操作可以被同一条线程执行多次(lock多少次也必须unlock多少次,才能解锁)
-
如果对一个变量执行lock操作,那么会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或者assign操作初始化变量的值
-
如果一个变量事先没有被lock操作锁定,那么久不允许对它执行unlock操作
-
对一个变量执行unlock操作之前,必须把这个遍历同步回主内存中
上面的描述可能有点绕。
实际上,它们都是为了在并发过程中如何处理原子性、可见性、有序性这三个特征来建立的。
那么原子性、可见性、有序性这三个指的是什么呢?
我们先看下并发编程中非常出名的的两种栗子:read-modify-write和check-and-act。
read-modify-write
大名鼎鼎的read-modify-write问题:(两个线程同时对主内存的一个变量进行读-改-写问题)
测试代码:
@NotThreadSafe public class NotSafeThreadTest implements Runnable { public static NotSafeThreadTest instance = new NotSafeThreadTest(); int i = 0; public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(instance); Thread thread2 = new Thread(instance); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(instance.i); } @Override public void run() { for (int j = 0; j < 1000; j++) { i++; } } }
测试结果:
多试几次,你会发现每次的测试结果都不一样哈。这里就随便截了几次:
结果分析:
为什么会出现这种结果呢?
这就涉及到可见性、一致性这两个问题:
可见性
我们现在有线程1,线程2两个线程同时对主内存中的变量i进行修改。假设它们非常正好的同时执行完了i++,想要把各自工作内存中的i=1写回主内存,因为没有同步手段的保护,这个时候线程1先把i改成1,然后线程2过来,也是把i改成了1。
而我们期望的则是线程2可以在修改时,可以看到线程1把主内存中的i改成了1,然后再重新进行计算,得出i=2。
事实上这就是可见性造成的问题。
可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
Happens-Before原则
Happens-Before原则指的是前面一个操作的结果对后续操作是可见的。
Java内存模型下的Happens-Before:
程序次序规则:
在一个线程内,按照程序代码顺序,写在前面的操作先行发生在后面的操作
管程锁定:
一个unlock操作先行发生与后面同一个锁的lock操作
volatile变量规则:
对一个volatile变量的写操作先行发生于后面对这个变量的读操作
线程启动规则:
Thread对象的start方法先行发生于此线程的每一个动作
线程终止规则:
线程中的所有操作都先行发生于此线程的终止检测
线程中断规则
对线程interrupt方法的调用先行发生于被中断线程的代码先测到中断事件的发生,可以通过Thread.interrupted方法检测到是否有中断发生
对象终结规则:
一个对象的初始化完成先行发生于它的finalize方法的开始
传递性:
如果操作A先行发生于B,操作B先行发生于C,那么就可以得出操作A先行发生于C
这些规则有些抽象,但是它其实是用来约束编译器的优化行为的。允许编译器优化,但是要求编译器优化后需要遵守这些Happends-Before规则,所以我们才可以利用各种同步手段来保证并发编程下,我们代码的安全性与正确性。
保证可见性的措施
根据Happends-Before,我们能看出volatile关键字、synchronize关键字、Thread.join、Thread.start、Lock、并发集合都能保证一定的可见性。
原子性
我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性
关于我们程序中的i++,看起来我们只写了一行代码。那么对应到虚拟机与操作系统做了什么呢,我们反编译看一下:
i++的虚拟机字节码指令
测试代码:
public static void main(String[] args) { int i=0; i++; }
反编译后的class:
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.invokevirtual 可以在oracle官网查看字节码指令含义
public class com.designpattern.demo.thread.safe.IncreasingClass { public com.designpattern.demo.thread.safe.IncreasingClass(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: iconst_0 //常量0加载到操作数栈 1: istore_1 //将栈顶的0存储到局部变量表(后1表示存入第几个本地变量表,此处第2个,从0开始) 2: iinc 1, 1 //变量i直接在局部变量表的slot上进行运算,并不影响操作数栈已经存在的值 5: return }
i++的操作系统执行指令
CPU至少执行了三条指令:
-
将变量i从主内存加载到CPU寄存器
-
在寄存器中执行+1
-
将结果写入主内存
CPU执行这几行命令的时候,并不像我们想的那样会一口气执行完。实际上,如果我们没有加任何同步措施,操作系统可以在CPU执行完这三条的任意一条指令后做任务切换。
任务切换与时间片
我们知道IO的速度是远远小于CPU的运算速度的。为了提高CPU使用率,操作系统允许某个进程执行一小段时间,比如50ms,过了50ms操作系统就会重新选择一个进程来执行,而这50ms就称为时间片。
在一个时间片内,如果一个进程进行一个非常耗时的I/O操作,这个时候进程就可以把自己标记为休眠状态,并让出CPU的使用权,这样就可以让CPU在等待的时间去做别的事情。等文件读进内存,操作系统就会把这个休眠的进程唤醒,唤醒之后的进程就有机会重新获得CPU的使用权。
那么可以看出,在没加任何同步措施的情况下,任务切换其实也是并发编程问题的原因之一。
CPU保证的原子操作是在CPU的指令级别(也就是说上面的八种操作每种是原子的),而不是我们的Java语句。所以我们需要解决的问题就是在使用Java开发的过程中,合理的利用同步手段保证并发编程下的程序安全。
小扩展:
Q:原子性操作+原子性操作=原子性操作吗?
A:并不,比如取钱是原子操作,但是两次取钱中间你完全可以做别的事情把钱花掉呀。
Q:long和double的特殊性
A:long和double是64位的,它们有特殊的规定:允许虚拟机将没被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。但是目前商用Java虚拟机并不会出现当多个线程共享一个没有声明volatile的long或者double类型的变量,某个线程读取到一个修改一半的情况,目前各种平台下的虚拟机都会选择将64位数据的读写操作作为原子操作。
check-and-act
这个就是所谓的先检查,在执行某个操作,最著名的就是单例模式的double check:
@NotThreadSafe public class DoubleCheckNotSafeSingleton { private static DoubleCheckNotSafeSingleton singleton; private DoubleCheckNotSafeSingleton() { } public static DoubleCheckNotSafeSingleton newInstance() { if (singleton == null) { //1 synchronized (DoubleCheckNotSafeSingleton.class) { //2 if (singleton == null) { singleton = new DoubleCheckNotSafeSingleton(); } } } return singleton;//3 } }
这个单例模式看上去加了锁保证了安全也加了双重检查,那么实际上真的是安全的吗?
问题就出现在singleton = new DoubleCheckNotSafeSingleton();这一行上:
public class com.designpattern.demo.thread.safe.DoubleCheckNotSafeSingleton { public static com.designpattern.demo.thread.safe.DoubleCheckNotSafeSingleton newInstance(); Code: 0: getstatic #2 // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton; 3: ifnonnull 37 6: ldc #3 // class com/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton 8: dup 9: astore_0 10: monitorenter 11: getstatic #2 // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton; 14: ifnonnull 27 17: new #3 // class com/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton 20: dup 21: invokespecial #4 // Method "<init>":()V 24: putstatic #2 // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton; 27: aload_0 28: monitorexit 29: goto 37 32: astore_1 33: aload_0 34: monitorexit 35: aload_1 36: athrow 37: getstatic #2 // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton; 40: areturn Exception table: from to target type 11 29 32 any 32 35 32 any }
可以看到这一行对应到虚拟机字节码指令是:
17: new #3 // class com/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton 20: dup 21: invokespecial #4 // Method "<init>":()V 24: putstatic #2 // Field singleton:Lcom/designpattern/demo/thread/safe/DoubleCheckNotSafeSingleton; 27: aload_0
实际上,我们以为的new操作是:
- 分配一块内存M
- 在内存M上初始化DoubleCheckNotSafeSingleton对象
- 将M的地址赋值给singleton变量
但是实际上JVM的编译器为了优化性能,有时候会改变程序中语句的执行顺序,(但并不会影响程序获得正确的结果)比如:
int x=0; int y=0; x=x+5;
很有可能会优化成:
int x=0; x=x+5; int y=0;
那么上面的new操作很有可能会优化成:
-
分配一块内存M
-
将M的地址赋值给singleton变量
-
在内存M上初始化DoubleCheckNotSafeSingleton对象
那这时,假设线程A先执行到了newInstance方法,执行完了将M的地址赋值给singleton变量指令后,恰巧切换到了线程B,线程B执行newInstance的第一个判断就会发现singleton!=null,会直接返回,但是这个时候的singleton还没有初始化,如果这个时候访问singleton的成员变量就会出发空指针异常。
有序性
有序性指的是程序按照代码的先后顺序执行
可见性、有序性、原子性的总结:
经过上面的描述,可以看出可见性其实是因为CPU的多级缓存造成的,原子性则是线程切换带来的,而编译优化则是有序性的原因。而多级缓存、线程切换、编译优化的目的也是为了提高程序的性能。这其实就说明了技术在解决一个问题的同时必然会带来另外一个问题,我们要根据实际情况与需求去做取舍。
在实际开发中,Java提供了volatile与synchronized两个关键字来保证线程之间操作的有序性,大部分都并发场景加锁也确实都能保证安全。但是并不是所有的场景都需要加锁,否则操作系统、JVM为了提升性能做的优化也就没了意义。锁也有很多种,可以根据需求选择出最适合自己业务的锁,而除了加锁,有的时候也有不加锁也能保证安全性的更好的方案。一切都要根据需求而定。
-
深入理解 Java 内存模型.pdf
2021-02-09 09:19:03java线程之间的通信对程序员完全透明,内存可见性问题很容易困扰java程序员,本文试图揭开java内存模型神秘的面纱。本文大致分三部分:重排序与顺序一致性;三个同步原语(lock,volatile,final)的内存语义,重... -
Java内存模型详解JMM.docx
2021-05-03 20:27:32Java内存模型详解JMM.docx -
java内存模型.pdf
2021-10-07 11:44:31java内存模型.pdf -
JVM内存模型和java内存模型
2022-01-20 11:29:411. JVM内存模型 Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。JDK1.8 和之前的版本略有不同,下⾯会介绍到。 首先对于一个进程来说,它包含多个线程,每个线程都有其独立的...文章目录
1. JVM内存模型
Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。JDK1.8 和之前的版本略有不同,下⾯会介绍到。
首先对于一个进程来说,它包含多个线程,每个线程都有其独立的内存区域,包括:虚拟机栈,本地方法栈和程序计数器。程序计数器:线程私有
记录当前线程所执行到的字节码的行号。
每个线程都有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存区域。
它是唯一没有OutOfMemoryError情况的内存区域。
它的⽣命周期随着线程的创建⽽创建,随着线程的结束⽽死亡。程序计数器在哪些地方用到了?
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复登基础功能都需要依赖这个计数器来完成。Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
Java虚拟机栈:线程私有
描述的是 Java ⽅法执⾏的内存模型,每个Java方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、⽅法出⼝等信息。
每个Java方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
Java 虚拟机栈也是线程私有的,每个线程都有各⾃的Java虚拟机栈,⽽且随着线程的创建⽽创建,随着线程的死亡⽽死亡。
局部变量表
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引⽤(reference类型,它不同于对象本身,可能是⼀个指向对象起始地址的引⽤指针,也可能是指向⼀个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间是以局部变量槽来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。
局部变量表所需的内存空间在编译期间完成分配。当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
StackOverFlowError:若Java虚拟机栈的内存⼤⼩不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最⼤深度的时候,就抛出StackOverFlowError异常。(典型的场景有:递归调用和死循环)
OutOfMemoryError:若 Java 虚拟机栈的内存⼤⼩允许动态扩展,且当线程请求栈时内存⽤完了,⽆法再动态扩展了,此时抛出OutOfMemoryError异常。在栈上分配对象
大多数对象都在堆上分配内存空间,但是由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配,标量替换优化手段已经导致对象在堆上分配不是那么绝对了。本地方法栈:线程私有
本地方法栈和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合⼆为⼀。本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、出⼝信息。
⽅法执⾏完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和OutOfMemoryError 两种异常。
看完了线程私有的内存区域,在来看看线程共享的内存区域:
堆:线程共享
Java 虚拟机所管理的内存中最⼤的⼀块,Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创建。
此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存。
堆分区
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的⻆度,由于现在收集器基本都采⽤分代垃圾收集算法,所以Java堆还可以细分为:新⽣代和⽼年代。
新生代再细致⼀点有:Eden空间、From Survivor、To Survivor空间等。
进⼀步划分的⽬的是更好地回收内存,或者更快地分配内存。
上图所示的 eden区、s0区、s1区都属于新⽣代,tentired 区属于⽼年代。⼤部分情况,对象都会⾸先在 Eden 区域分配,在⼀次新⽣代垃圾回收后,如果对象还存活,则会进⼊ s0 或者 s1,并且对象的年龄还会加 1(Eden区i>Survivor 区后对象的初始年龄变为1),当它的年龄增加到⼀定程度(默认为15岁),就会被晋升到⽼年代中。对象晋升到⽼年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。方法区:线程共享
它⽤于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。⽅法区也被称为永久代。很多⼈都会分不清⽅法区和永久代的关系,为此我也查阅了⽂献。永久代是对方法区的一种实现方式。方法区是一种定义,永久代是一种实现。
运⾏时常量池
运⾏时常量池是⽅法区的⼀部分。Class ⽂件中除了有类的版本、字段、⽅法、接⼝等描述信息外,还有常量池信息(⽤于存放编译期⽣成的各种字⾯量和符号引⽤)既然运⾏时常量池时⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛出 OutOfMemoryError 异常。
JDK1.7及之后版本的 JVM 已经将运⾏时常量池从⽅法区中移了出来,在 Java 堆(Heap)中开辟了⼀块区域存放运⾏时常量池。字符串常量池1.7以后也是放在堆中
JDK1.7和1.8 内存模型的区别
JDK1.7JVM内存模型:
线程私有:Java虚拟机栈、本地方法栈、程序计数器
线程共享:方法区、堆
JDK1.8JVM内存模型:JDK1.8与1.7最大的区别是1.8将永久代(方法区)取消,取而代之的是元空间。
JDK1.7方法区是由永久代实现的,JDK1.8方法区是由元空间实现的,元空间属于本地内存,所以元空间的大小受本地内存的限制。
直接内存
直接内存并不是虚拟机运⾏时数据区的⼀部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使⽤。⽽且也可能导致 OutOfMemoryError 异常出现。2. Java内存模型(JMM)
Java内存模型规定所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本的拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
主内存:主要对应Java堆中的对象实例数据部分。(寄存器,高速缓存)
工作内存:对应于虚拟机栈中的部分区域。(硬件的内存)3 . Java对象的创建过程
①类加载检查: 虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的类参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。(详细的类加载机制会新出一篇博客)
②分配内存: 在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存。对象所需的内存⼤⼩在类加载完成后便可确定,为对象分配空间的任务等同于把⼀块确定⼤⼩的内存从 Java 堆中划分出来。分配⽅式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。
③初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。④设置对象头: 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。
⑤执⾏ init ⽅法: 在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看,对象创建才刚开始, ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏ ⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。
[注意]:执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
-
读深入理解JAVA内存模型整理的思维导图
2015-07-28 12:16:35读深入理解JAVA内存模型整理的思维导图 本人记忆差,整理这个思维导图,相当于较认真的看了一遍,整个思维导图,基本上就是对原文的拷贝。 有了层级关系。和本人自己的一些理解。由于思维导图弄出来的图片大于100M了... -
深入理解Java内存模型.程晓明(带书签文字版).pdf
2019-03-18 00:17:29Java 内存模型的抽象 4 重排序 6 处理器重排序与内存屏障指令 7 happens-before 10 重排序 13 数据依赖性 13 as-if-serial 语义 13 程序顺序规则 15 重排序对多线程的影响 15 顺序一致性 19 数据竞争与顺序... -
深入理解 Java 内存模型
2019-05-06 15:04:18深入理解 Java 内存模型,由程晓明编著,深入理解java内存模型JMM -
深度剖析java内存模型
2019-06-03 11:35:21深度剖析java内存模型深度剖析java内存模型深度剖析java内存模型 -
深入Java 内存模型
2018-08-15 14:57:57深入Java 内存模型本书介绍了,如何深入学习了解JAVA 内存模型!更好的了解java 虚拟机! -
【深入Java虚拟机】Java内存模型探讨一.pdf
2021-09-27 13:23:42【深入Java虚拟机】Java内存模型探讨一.pdf -
Java内存模型分析与其在编程中的应用.pdf
2021-07-02 18:32:07Java内存模型分析与其在编程中的应用.pdf -
jvm内存结构以及Java内存模型JMM的模型图
2021-10-03 11:23:30jvm和jmm的模型图,注意在浏览器中搜索draw.io进行文件的打开。 -
深入理解java内存模型
2018-10-10 13:26:00理解java内存模型的一本不错的书。 -
java内存模型与并发技术
2019-03-13 19:58:45阿里巴巴专家讲座——java内存模型与并发技术。 主要内容: 学习java并发理论基础:Java Memory Model 学习java并发技术基础:理解同步是如何工作 分析程序什么时候需要同步 几个典型的并发设计策略 -
全面理解Java内存模型(JMM)及volatile关键字
2017-06-12 11:25:05【版权申明】未经博主同意,谢绝转载!(请尊重原创,博主保留追究权) ... 出自【zejian的博客】...深入理解Java类型信息(Class对象)与反射机制 深入理解Java枚举类型(enum) 深入理解Java注解类型(@Annotation) 深...【版权申明】未经博主同意,谢绝转载!(请尊重原创,博主保留追究权)
http://blog.csdn.net/javazejian/article/details/72772461
出自【zejian的博客】关联文章:
Java并发编程-无锁CAS与Unsafe类及其并发包Atomic
剖析基于并发AQS的重入锁(ReetrantLock)及其Condition实现原理
剖析基于并发AQS的共享锁的实现(基于信号量Semaphore)
并发之阻塞队列LinkedBlockingQueue与ArrayBlockingQueue
本篇主要结合博主个人对Java内存模型的理解以及相关书籍内容的分析作为前提,对JMM进行较为全面的分析,本篇的写作思路是先阐明Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系,在弄明白它们间的关系后,进一步分析Java内存模型作用以及一些必要的实现手段,以下是本篇主要内容(如有错误,欢迎留言,谢谢!)
理解Java内存区域与Java内存模型
Java内存区域
Java虚拟机在运行程序时会把其自动管理的内存划分为以上几个区域,每个区域都有的用途以及创建销毁的时机,其中蓝色部分代表的是所有线程共享的数据区域,而绿色部分代表的是每个线程的私有数据区域。
方法区(Method Area):
方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。
JVM堆(Java Heap):
Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
程序计数器(Program Counter Register):
属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
虚拟机栈(Java Virtual Machine Stacks):
属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程,如下(图有误,应该为栈桢):
本地方法栈(Native Method Stacks):
本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域。
这里之所以简要说明这部分内容,注意是为了区别Java内存模型与Java内存区域的划分,毕竟这两种划分是属于不同层次的概念。
Java内存模型概述
Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图
需要注意的是,JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开的(稍后会分析)。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工作内存说明如下
主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
工作内存
主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
弄清楚主内存和工作内存后,接了解一下主内存与工作内存的数据存储类型以及操作方式,根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存,简单示意图如下所示:
硬件内存架构与Java内存模型
硬件内存架构
正如上图所示,经过简化CPU与内存操作的简易图,实际上没有这么简单,这里为了理解方便,我们省去了南北桥并将三级缓存统一为CPU缓存(有些CPU只有二级缓存,有些CPU有三级缓存)。就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。
Java线程与硬件处理器
了解完硬件的内存架构后,接着了解JVM中线程的实现原理,理解线程的实现原理,有助于我们了解Java内存模型与硬件内存架构的关系,在Window系统和Linux系统上,Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。如下图
如图所示,每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么一个CPU将可以并行执行多个线程任务。
Java内存模型与硬件内存架构的关系
通过对前面的硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)
JMM存在的必要性
在明白了Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系后,接着来谈谈Java内存模型存在的必要性。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。如下图,主内存中存在一个共享变量x,现在有A和B两条线程分别对该变量x=1进行操作,A/B线程各自的工作内存中存在共享变量副本x。假设现在A线程想要修改x的值为2,而B线程却想要读取x的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是,不确定,即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每个线程私有的数据区域,而线程A变量x时,首先是将变量从主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主内,而对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将x=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2,但到底是哪种情况先发生呢?这是不确定的,这也就是所谓的线程安全问题。
为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性展开的,下面我们看看这三个特性。
Java内存模型的承诺
这里我们先来了解几个概念,即原子性?可见性?有序性?最后再阐明JMM是如何保证这3个特性。
原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。
理解指令重排
计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种
编译器优化的重排
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令并行的重排
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序
内存系统的重排
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题
编译器重排
下面我们简单看一个编译器重排的例子:
线程 1 线程 2 1: x2 = a ; 3: x1 = b ; 2: b = 1; 4: a = 2 ;
两个线程同时执行,分别有1、2、3、4四段执行代码,其中1、2属于线程1 , 3、4属于线程2 ,从程序的执行顺序上看,似乎不太可能出现x1 = 1 和x2 = 2 的情况,但实际上这种情况是有可能发现的,因为如果编译器对这段程序代码执行重排优化后,可能出现下列情况
线程 1 线程 2 2: b = 1; 4: a = 2 ; 1:x2 = a ; 3: x1 = b ;
这种执行顺序下就有可能出现x1 = 1 和x2 = 2 的情况,这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。
处理器指令重排
先了解一下指令重排的概念,处理器指令重排是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下
- 取指 IF
- 译码和取寄存器操作数 ID
- 执行或者有效地址计算 EX
- 存储器访问 MEM
- 写回 WB
CPU在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一个步会使用到不同的硬件操作,比如取指时会只有PC寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU指令是按流水线技术来执行的,如下:
从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的
a = b + c ; d = e + f ;
下面通过汇编指令展示了上述代码在CPU执行的处理过程
- LW指令 表示 load,其中LW R1,b表示把b的值加载到寄存器R1中
- LW R2,c 表示把c的值加载到寄存器R2中
- ADD 指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。
- SW 表示 store 即将 R3寄存器的值保持到变量a中
- LW R4,e 表示把e的值加载到寄存器R4中
- LW R5,f 表示把f的值加载到寄存器R5中
- SUB 指令表示减法,把R4 、R5的值相减,并存入R6寄存器中。
- SW d,R6 表示将R6寄存器的值保持到变量d中
上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是只要有X的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,也就因此而停顿了,其他指令也是类似的情况。前面阐述过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把
LW R4,e
和LW R5,f
移动到前面执行,毕竟LW R4,e
和LW R5,f
执行并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4
指令在R4,R5加载完成后才执行的,没有影响,过程如下:正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。关于编译器重排以及指令重排(这两种重排我们后面统一称为指令重排)相关内容已阐述清晰了,我们必须意识到对于单线程而已指令重排几乎不会带来任何影响,比竟重排的前提是保证串行语义执行的一致性,但对于多线程环境而已,指令重排就可能导致严重的程序轮序执行问题,如下
class MixedOrder{ int a = 0; boolean flag = false; public void writer(){ a = 1; flag = true; } public void read(){ if(flag){ int i = a + 1; } } }
如上述代码,同时存在线程A和线程B对该实例对象进行操作,其中A线程调用写入方法,而B线程调用读取方法,由于指令重排等原因,可能导致程序执行顺序变为如下:
线程A 线程B writer: read: 1:flag = true; 1:flag = true; 2:a = 1; 2: a = 0 ; //误读 3: i = 1 ;
由于指令重排的原因,线程A的flag置为true被提前执行了,而a赋值为1的程序还未执行完,此时线程B,恰好读取flag的值为true,直接获取a的值(此时B线程并不知道a为0)并执行i赋值操作,结果i的值为1,而不是预期的2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。因此,请记住,指令重排只会保证单线程中串行语义的执行的一致性,但并不会关心多线程间的语义一致性。
可见性
理解了指令重排现象后,可见性容易了,可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。
有序性
有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。
JMM提供的解决方案
在理解了原子性,可见性以及有序性问题后,看看JMM是如何保证的,在Java内存模型中都提供一套解决方案供Java工程师在开发过程使用,如原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性,关于synchronized的详解,看博主另外一篇文章( 深入理解Java并发之synchronized实现原理)。而工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化,关于volatile稍后会进一步分析。除了靠sychronized和volatile关键字来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。
理解JMM中的happens-before 原则
倘若在程序开发中,仅靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,在Java内存模型中,还提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下
程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
传递性 A先于B ,B先于C 那么A必然先于C
线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
对象终结规则 对象的构造函数执行,结束先于finalize()方法
上述8条原则无需手动添加任何同步手段(synchronized|volatile)即可达到效果,下面我们结合前面的案例演示这8条原则如何判断线程是否安全,如下:
class MixedOrder{ int a = 0; boolean flag = false; public void writer(){ a = 1; flag = true; } public void read(){ if(flag){ int i = a + 1; } } }
同样的道理,存在两条线程A和B,线程A调用实例对象的writer()方法,而线程B调用实例对象的read()方法,线程A先启动而线程B后启动,那么线程B读取到的i值是多少呢?现在依据8条原则,由于存在两条线程同时调用,因此程序次序原则不合适。writer()方法和read()方法都没有使用同步手段,锁规则也不合适。没有使用volatile关键字,volatile变量原则不适应。线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性和本次测试案例也不合适。线程A和线程B的启动时间虽然有先后,但线程B执行结果却是不确定,也是说上述代码没有适合8条原则中的任意一条,也没有使用任何同步手段,所以上述的操作是线程不安全的,因此线程B读取的值自然也是不确定的。修复这个问题的方式很简单,要么给writer()方法和read()方法添加同步手段,如synchronized或者给变量flag添加volatile关键字,确保线程A修改的值对线程B总是可见。
volatile内存语义
volatile在并发编程中很常见,但也容易被滥用,现在我们就进一步分析volatile关键字的语义。volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用
保证被volatile修饰的共享gong’x变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知。
禁止指令重排序优化。
volatile的可见性
关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中,但是对于volatile变量运算操作在多线程环境并不保证安全性,如下
public class VolatileVisibility { public static volatile int i =0; public static void increase(){ i++; } }
正如上述代码所示,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时调用increase()方法的话,就会出现线程安全问题,毕竟
i++;
操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。public class VolatileVisibility { public static int i =0; public synchronized static void increase(){ i++; } }
现在来看另外一种场景,可以使用volatile修饰变量达到线程安全的目的,如下
public class VolatileSafe { volatile boolean close; public void close(){ close=true; } public void doWork(){ while (!close){ System.out.println("safe...."); } } }
由于对于boolean变量close值的修改属于原子性操作,因此可以通过使用volatile修饰变量close,使用该变量对其他线程立即可见,从而达到线程安全的目的。那么JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障,稍后会说明)。
volatile禁止重排优化
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子DCL,如下:/** * Created by zejian on 2017/6/11. * Blog : http://blog.csdn.net/javazejian [原文地址,请尊重原创] */ 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的引用对象可能没有完成初始化。因为
instance = new DoubleCheckLock();
可以分为以下3步完成(伪代码)memory = allocate(); //1.分配对象内存空间 instance(memory); //2.初始化对象 instance = memory; //3.设置instance指向刚分配的内存地址,此时instance!=null
由于步骤1和步骤2间可能会重排序,如下:
memory = allocate(); //1.分配对象内存空间 instance = memory; //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成! instance(memory); //2.初始化对象
由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。
//禁止指令重排优化 private volatile static DoubleCheckLock instance;
ok~,到此相信我们对Java内存模型和volatile应该都有了比较全面的认识,总而言之,我们应该清楚知道,JMM就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。
如有错误,欢迎留言,谢谢!
参考资料:
http://tutorials.jenkov.com/java-concurrency/java-memory-model.html
http://blog.csdn.net/iter_zc/article/details/41843595
http://ifeve.com/wp-content/uploads/2014/03/JSR133%E4%B8%AD%E6%96%87%E7%89%881.pdf《深入理解JVM虚拟机》
《Java高并发程序设计》 -
Java内存模型的历史变迁
2018-04-21 22:07:42一篇关于java新旧内存模型的文章---文章摘自互联网,感兴趣的可以读一读。 -
Java内存模型JMM详解
2020-08-28 18:04:37主要介绍了Java内存模型JMM详解,涉及volatile和监视器锁,final字段,内存屏障等相关内容,具有一定参考价值,需要的朋友可以了解下。 -
Java内存模型与volatile关键字
2020-12-22 23:10:45Java内存模型(Java Memory Model) Java内存模型(JMM),不同于Java运行时数据区,JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中读取数据这样的底层细节。JMM规定了... -
Java内存模型知识详解
2020-08-18 17:03:49主要介绍了Java内存模型知识详解,文中通过对内存访问时的交互关系图解介绍的十分详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧