Java内存模型
Java内存模型 JMM
Java内存模型 Java Memory Model(JMM)是一个抽象的概念。JSR-133: Java Memory Model andThreadSpecification中描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。
Java 多线程之间通信一般有两种方式: 共享内存和消息传递 。Java 的并发采用共享内存的方式,共享内存通信方式对于程序员而言总是透明隐式进行的。
Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、有序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。
JMM 关键技术点都是围绕着多线程的可见性、有序性、原子性来讨论的。JMM解决了可见性和有序性的问题,而锁解决了原子性的问题。
一方面 Java 程序书写顺序与编译后的指令顺序不一定相同,指令存在重排序情况;另一方面每个CPU 处理器存在缓存,缓存存储了线程读写共享变量的副本。
Java 内存模型 (JMM) 定义了 JVM 如何正确访问计算机主内存。JMM指定了不同线程如何以及何时可以看到其他线程写入到共享变量的值,以及如何在必要时同步访问共享变量。早期 JDK 的 Java 内存模型不够完善,所以 Java 内存模型在 Java 1.5 中进行了修改。目前,该版本的 Java 内存模型仍然在 Java 8 中使用。
Java也在Loom项目中,孕育新的类似轻量级用户线程(Fiber)等机制。
Thread和Object的方法,听起来简单,但是实际应用中被证明非常晦涩、易错,这是为什么Java后来引入了并发包。有了并发包,大多数情况下,已经不再需要去调用wait/notify之类的方法了。
工作内存在 Java 内存中是一个抽象的概念,一般是指 CPU 高速缓存、寄存器等。在现代多核处理器系统中处理器一般会存在多层的高速缓存,因为访问内存数据的速度远落后于直接从寄存器、高速缓存获取数据。 高速缓存减少了共享内存总线的访问流量冲突,极大改善了 CPU 的访问性能。缓存使性能得到优化同时也带来的新的具有挑战性的问题。 例如,当两个线程 (处理器) 同时访问同一内存位置的共享变量,在什么条件下双方可以看到相同的值。这就是多线程并发中的内存可见性 问题。 Java 内存模型的可见性问题的底层实现是通过内存屏障 (memory barriers) 实现。
JVM 指令重排序 ( Instruction Reorder)
JVM 编译器为了提高程序的执行效率,一般会对代码进行优化。因此,不能保证程序中的代码顺序一定是按照书写顺序执行的,也就是编译器和处理器会对指令进行重排序。 指令重排序不会对单线程程序有影响,但是在多线程并发环境下就会存在很多问题。
指令重排序有一个基本前提:指令重排序需要确保串行语义一致,但不能够确保多线程间的语义也是一致的。
经典的五级流水线,也就是一条指令可以分为 5 个步骤:
- 取址 (IF,Instruction Fetch, 取指 );
- 译码 / 读寄存器 (ID,Instruction Decode, 译码);
- 执行 / 计算有效地址 (EX,Execute, 执行);
- 访问内存(读或写)(MEM,Memory Access, 内存数据读或者写);
- 结果写回寄存器 (WB,Write Back, 数据写回到通用寄存器中)
显然指令重排序对于提高 CPU 的吞吐能力是有极大的提升的。但也带来了程序运行乱序的负面问题,不过与性能相比较这点牺牲是值得的。
指令重排序还有一个非常经典的例子,就是单例模式与双重检查锁 (DCL:double-checkedlocking)问题。
public class SingleInstance {
private static volatile SingleInstance INSTANCE;
private SingleInstance() {
}
public static synchronized SingleInstance getInstance(){
if (INSTANCE == null) {
synchronized (SingleInstance.class){
if (INSTANCE == null) {
INSTANCE = new SingleInstance();
}
}
}
return INSTANCE;
}
}
Java 语言中final、synchronized、volatile、lock 等都能保证有序性,需要 JDK5 以及更高版本的支持,JDK5 开始使用 JSR-133 内存模型。volatile、synchronized(隐式锁)、显式锁、原子变量这些同步手段都可以保证可见性。
JVM 内存结构中有一个非常重要的内存区域叫做线程栈 , 每个线程的栈大小可以通过设置JVM参数-Xss。 -Xss128k 表示每个线程堆栈大小为 128K,JDK1.5 默认值为1M。线程栈内存存储了基本类型变量和对象引用,当访问了对象的某一实例变量时,通过在栈中获得对象引用再获取变量的值,然后将变量的值拷贝至线程的工作内存。每个线程 (处理器) 都有工作内存,工作内存存了该线程以读写共享变量的副本。工作内存是JMM 抽象概念 , 并不真实存在。
可见性指的是一个线程对变量的写操作对其他线程后续的读操作可见。
CPU 的操作都是基于高速缓存的,而线程通信是基于内存的,这中间有一个 Gap, 可见性的关键还是在对变量的写操作之后能够在某个时间点显示地写回到主内存,这样其他线程就能从主内存中看到最新的写的值。
可见性底层的实现是通过加内存屏障实现的:
- 写变量后加写屏障,保证 CPU 写缓冲区的值强制刷新回主内存;
- 读变量前加读屏障,使缓存失效,从而强制从主内存读取变量最新值。
总结: 在并发环境下,volatile 能够保证有序性、可见性,但原子性没办法保证。
happens-before 原则 是 Java 内存模型(JMM)中的核心概念,用于定义多线程环境下操作之间的可见性与有序性保证。它不表示实际的时间先后顺序,而是确保:如果操作 A happens-before 操作 B,则 A 的结果对 B 可见,且 A 的执行顺序在 B 之前(从 JMM 视角看)。
Jvm内存结构
五大内存区域:
1.程序计数器
程序计数器是一块很小的内存空间,它是线程私有的,可以认作为当前线程的行号指示器。
一条线程中有多个指令,为了线程切换可以恢复到正确执行位置,每个线程都需有独立的一个程序计数器,不同线程之间的程序计数器独立存储互不影响。这块内存区域是虚拟机规范中唯一没有OutOfMemoryError的区域。
2.虚拟机栈
线程私有,栈描述的是Java方法执行的内存模型。存放局部变量(线程创建的时候 被创建)
局部变量表所需要的内存空间在编译期完成分配,当进入一个方法时,这个方法在栈中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。
Java虚拟机栈可能出现两种类型的异常:
- 线程请求的栈深度大于虚拟机允许的栈深度,将抛出StackOverflowError。
- 虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时,抛出OutOfMemory异常。
3.本地方法栈
本地方法栈是与虚拟机栈发挥的作用十分相似,区别是虚拟机栈执行的是Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务。
4.堆
堆是java虚拟机管理内存最大的一块内存区域,堆存放的对象是线程共享的,多线程的时候需要同步机制。存放所有 new出来的东西(堆空间是所有线程共享,虚拟机气动的时候建立)
JVM又把堆内存分三代,新生代,老年代,持久代。
在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。
在JDK8之前的HotSpot实现中,类的元数据如方法数据、方法信息(字节码,栈和变量大小)、运行时常量池、已确定的符号引用和虚方法表等被保存在永久代中,32位默认永久代的大小为64M,64位默认为85M,可以通过参数-XX:MaxPermSize进行设置。GC不会在主程序运行期对永久区域进行清理,这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。 所以虚拟机团队在JDK8的HotSpot中,把永久代从Java堆中移除了,并把类的元数据直接保存在本地内存区域(堆外内存),称之为元空间。 元空间并不在虚拟机中,而是使用本地内存。
5.方法区
方法区同堆一样,是所有线程共享的内存区域,为了区分堆,又被称为非堆。用于存储已被虚拟机加载的类信息、常量、静态变量,如static修饰的变量加载类的时候就被加载到方法区中。
运行时常量池 是方法区的一部分,class文件除了有类的字段、接口、方法等描述信息之外,还有常量池用于存放编译期间生成的各种字面量和符号引用。被虚拟机加载的类信息、常量、静态常量等。
JVM同样以线程为最小单位运行, 其中方法区与堆属于线程共享区,而栈与程序计数器属于线程独占区。
魔数与Class文件的版本
每个Class文件的头四个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的Class文件。
很多文件存储标准中都使用魔术来进行身份识别,譬如图片格式,如gir或者jpeg等在文件头中都存在魔数。文件格式的制定者可以自由地选择魔数值,只要这个值还没有被广泛采用过同时又不会引起混淆即可。
Class文件的魔数富有“浪漫气息”,值为0xCAFEBABE,预示着Java语言的logo。 紧跟着魔数的四个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。紧接着版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据项,也是占用Class文件空间最大的数据项目之一。
Java内存结构
类加载系统:负责从文件系统或者网络加载class信息,加载的信息存放在方法区。
直接内存:JAVA nio库允许JAVA程序直接内存,从而提高性能,通常直接内存性能高于JAVA堆。读写频繁的场合可能会考虑使用。
本地方法栈:本地方法栈和JAVA栈类似,最大的不同为本地方法栈用于本地方法调用。JAVA虚拟机允许JAVA直接调用本地方法。
垃圾回收系统:是JAVA的核心。
pc寄存器:每个线程私有的空间。JAVA虚拟机为每个线程创建pc寄存器,在任意时刻一个JAVA线程总是在执行一个方法,这个方法被称为当前方法,如果当前方法不是本地方法,pc寄存器总会执行当前正在被执行的指令,如果是本地方法,则pc寄存器值为undefined,寄存器存放当前执行执行环境指针、程序技术器、操作栈指针、计算的变量指针等信息。
执行引擎:虚拟机的核心组件,负责执行虚拟机的字节码,一般先编译成机器码后执行。
Java虚拟机如何加载一个类
在类、接口和数组类中,数组类是由Java虚拟机直接生成的,其他两种则有对应的字节流。 最常见的字节流形式要属由Java编译器生成的class文件。除此之外,也可以在程序内部直接生成,或者从网络中获取(例如网页中内嵌的小程序Java applet)字节流。不同形式的字节流,都会被加载到Java虚拟机中,成为类或接口。
无论是直接生成的数组类,还是加载的类,Java虚拟机都需要对其再进行链接和初始化。
加载
加载是指查找字节流,并且据此创建类的过程。数组类没有对应的字节流,而是由Java虚拟机直接生成的。对于其他的类来说,Java虚拟机则需要借助类加载器来完成查找字节流的过程。
链接
链接是指将创建成的类合并至Java虚拟机中,使之能够执行的过程。分为验证、准备以及解析三个阶段。
初始化
在Java代码中,如果要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值。
只有当初始化完成之后,类才正式成为可执行的状态。类的初始化何时会被触发呢?JVM规范枚举了下述多种触发情况:
- 1.当虚拟机启动时,初始化用户指定的主类;
- 2.当遇到用以新建目标类实例的new指令时,初始化new指令的目标类;
- 3.当遇到调用静态方法的指令时,初始化该静态方法所在的类;
- 4.当遇到访问静态字段的指令时,初始化该静态字段所在的类;
- 5.子类的初始化会触发父类的初始化;
- 6.如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
- 7.使用反射API对某个类进行反射调用时,初始化这个类;
- 8.当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。
单例延迟初始化示例
著名的单例延迟初始化示例中,只有当调用Singleton.getInstance时,程序才会访问LazyHolder.INSTANCE(对应第4种情况),才会触发对LazyHolder的初始化,继而新建一个Singletonde的实例。 由于类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多个线程环境下有且仅有一个Singleton实例。
如何判断对象可以被回收
一、引用计数法
程序给对象添加一个引用计数器,每有一个变量引用它时,计数器加1。当引用断开时,计数器减1。但绝大数主流的虚拟机并没有采取此计数算法来管理内存,原因是此计数算法无法回收那些具有相互循环引用的对象,此类对象确实已经不再被使用,但由于互相引用着对方,导致各自的计数器都不为0,因此JVM无法回收它们。
二、可达性分析法
程序创建一系列的GC Roots作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象与GC Roots没有任何引用链相连的话,即此对象到GC Roots不可达,则证明此对象是不可用的,JVM稍后将会对此类对象进行回收。 大多数主流的JVM都采用这样的算法来管理内存,它能够解决对象之间的循环引用的问题。对象与对象之间虽然有循环引用,当他们到GC Roots没有任何引用链,系统还是判定它们为可回收对象。
垃圾回收算法
在标记出对象是否可被回收后,接下来对可回收对象进行回收。回收算法有:标记-清理、标记-整理与复制算法。
分代回收策略
分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。
堆内存分为年轻代(Young Generation)和老年代(Old Generation)。而持久代使用非堆内存,主要 用于存储一些类的元数据,常量池,java类,静态文件等信息。
有四种情况可能会导致对象晋升老年代:
大对象直接进入老年代 年龄超过阈值 动态对象年龄判定 年轻代空间不足
JVM DVM ART的区别
JVM
JVM是基于栈的虚拟机,对于基于栈的虚拟机来说,每一个运行时的线程,都有一个独立的栈。栈中记录了方法调用的历史。
Dalvik VM
DVM是基于寄存器的虚拟机,并没有JVM栈帧中的操作数栈,但是有很多虚拟寄存器。
执行的字节码不同
- 在Java中,Java类会被编译成一个或多个.class文件,打包成jar文件,而后JVM会通过相应的.class 文件和jar文件获取相应的字节码。
- DVM会用dx工具将所有的.class文件或者jar文件转换为一个.dex文件,然后DVM会从该.dex文件读 取指令和数据。
-
.jar文件在中包含多个.class文件,每个.class文件里面包含了该类的常量池、类信息、属性等等。当JVM 加载该.jar文件的时候,会加载里面的所有的.class文件,JVM的这种加载方式很慢,对于内存有限的移 动设备并不合适。
而.dex文件中也包含很多类信息,但是dex将多个类信息整合在一起了,多个类复用常量池等区域。
ART
ART(Android Runtime)是Android 4.4发布的,用来替换Dalvik虚拟,Android 4.4默认采用的还是 DVM,系统会提供一个选项来开启ART。在Android 5.0时,默认采用ART。
ART 引入了预先编译机制(Ahead Of Time),从Android N(Android 7.0)开始,ART 混合使用AOT、JIT与解释执行。
1、最初安装应用时不进行任何 AOT 编译(避免安装过慢),运行过程中解释执行,对经常执行的方法 进行JIT,经过 JIT 编译的方法将会记录到Profile配置文件中。
2、当设备闲置和充电时,编译守护进程会运行,根据Profile文件对常用代码进行 AOT 编译。待下次运 行时直接使用。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)