JVM原理剖析

2023-07-31,,

前言

本文讨论的JVM以JDK1.8为基准点,附带会横向比较,往前推到JDK1.6。JVM是任何一个学习JAVA的程序员绕不开的核心,本文就会围绕这个核心展开对它剖析,希望能给广大的程序员带来帮助。

一. 简介

Java Virtual Machine(Java虚拟机)的缩写
JVM是一个标准,一套规范,规定了.class文件在其内部运行的相关标准和规范,及其相关的内部构成。比如:所有的JVM都是基于栈结构的运行方式。那么不符合这种要求的,不算是JVM( 如Android中所使用的Dalvik 虚拟机就不能称作是JAVA 虚拟机, 因为它是基于寄存器(最新的Android系统据说已经放弃了Dalvik VM, 而是使用ART)。
JVM相关的产品有很多,现在最常用的是Oracle公司的HotSpot虚拟机(还有Oracle的JRockit、IBM的J9也是非常有名的JVM),HotSpot是JVM的具体实现。因此, 这里讨论的都是HotSpot虚拟机。
JVM是实现跨平台的关键,JVM在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

JDK: Java Development Kit

JRE: Java Runtime Environment

JVM主要由以下四部分构成:
类加载器子系统(Class Loader)、
运行时数据区/内存空间(Runtime Data Area)、
执行引擎(Execution Engine)
本地接口/本地方法接口(Native Interface)

二. 类加载器子系统(Class Loader)

2.1. 加载顺序

类加载: 是通过JVM的类加载器从JVM外部以二进制字节流的方式加载到JVM中。
JVM本身有至少三种类加载器:
1). Bootstrap ClassLoader(根类加载器)C++实现, 加载位于jre/lib/下的jar;
2). Extension ClassLoader(扩展类加载器)主要用于加载jre/lib/ext/下的jar;
3). App ClassLoader(应用类加载器)加载classpath环境变量所指定的class;
4). Custom ClassLoader(自定义的类加载器)用于实现自己的类加载器, 如Tomcat中就实现多个类加载器,用来管理不同的jar。
类加载顺序:Custom → App → Extension → Bootstrap 直到这个类被加载成功
如果一个类被不同的类加载器加载, 那么就是两个不同的类。

为了保证类加载的安全性,在Java 1.2后引入了双亲委派模型

双亲委派:
1). 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtensionClassLoader去完成。
2). 当ExtensionClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootstrapClassLoader去完成。
3). 如果BootstrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtensionClassLoader来尝试加载;
4). 若ExtensionClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
双亲委派模型有效解决了以下问题:
1). 每一个类都只会被加载一次,避免了重复加载
2). 每一个类都会被尽可能的加载(从引导类加载器往下,每个加载器都可能会根据优先次序尝试加载它)
3). 有效避免了某些恶意类的加载(比如自定义了Java。lang.Object类,一般而言在双亲委派模型下会加载系统的Object类而不是自定义的Object类)

2.2. 类加载过程

1). 加载: 首先,通过一个类的全类名来获取此类的二进制字节流;其次,将类中所代表的静态存储结构转换为运行时数据结构;最后,生成一个代表加载的类的java.lang.Class对象,作为方法区这个类的所有数据的访问入口。
加载完成之后,虚拟机外部的二进制静态数据结构就转换成了虚拟机所需要的结构存储在方法区中(至于如何转换,则由具体虚拟机自己定义实现),而所生成的Class对象,则存放在方法区中,用来作为程序访问方法区中数据的外部接口。
加载包括隐式加载(new方式创建对象)和显式加载(反射创建对象)
2). 验证: 其目的就是保证加载进来的.class文件不会危害到虚拟机本身,且内容符合当前虚拟机的规范要求。
主要验证的内容大致有:文件格式验证、元数据验证、字节码验证、符号引用验证。
文件格式验证: 主要确保符合class文件格式规范(如文本后缀为.class的文件将验证不通过),以及主次版本号,验证是否当前JVM可以处理等。
元数据验证: 主要验证编译后的字节码描述信息是否符合java语法规范。
字节码验证: 最为复杂,主要通过控制流和数据流确定语义是否合法、符合逻辑。
符号引用验证: 可以看做是除自身以外(常量池中各种引用符号)的信息匹配校验,如通过持有的引用能否找到对应的实例。
3). 准备: 包括类初始化(clinit)和对象实例化(init)。正式为类变量分配内存,并设置类变量的初始值。这些变量都会在方法区中进行分配。
4). 解析: 将常量池内的符号引用替换为直接引用的过程。主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄等。
5). 初始化: 加载的最后阶段,程序真正运行的开始。
6). 使用
7). 卸载

验证,准备,解析合称为链接

符号引用:

符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。
例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个Java类将会编译成一个class文件。在编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

三. 运行时数据区/内存模型/内存空间(Runtime Data Area)

运行时数据区由五部分组成: 方法区、堆、虚拟机栈、本地方法栈、程序计数器

3.1. 方法区

是线程共享,线程安全的。Java虚拟机规范中定义方法区是堆的一个逻辑部分。
方法区存储三种数据: 类信息(类及父类的完全限定名,类的类型,访问修饰符, Class引用、ClassLoader引用、实现的接口的全限定名的列表)、常量、静态变量。举例来说,如果两个类同时要加载一个尚未被加载的类,那么一个类会请求它的ClassLoader去加载需要的类,另一个类只能等待而不会重复加载。
3.1.1. 特点:
1>. 线程共享: 方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的。整个虚拟机中只有一个方法区。
2>. 永久代/元空间: 方法区中的信息一般需要长期存在,而且它又是堆的逻辑分区,因此用堆的划分方法,我们把方法区称为老年代。
3>. 内存回收效率低: 方法区中的信息一般需要长期存在,回收一遍内存之后可能只有少量信息无效。对方法区的内存回收的主要目标是:对常量池的回收和对类型的卸载。
4>. Java虚拟机规范对方法区的要求比较宽松: 和堆一样,允许固定大小,也允许可扩展的大小,还允许不实现垃圾回收。
3.1.2. 常量池
常量池主要分为:Class文件常量池、运行时常量池,字符串常量池。
注:包装类型常量池不属于JVM层面,而是java层面的封装。
Class文件常量池、运行时常量池 存储在方法区中

字符串常量池:
JDK1.6: 存储在持久代中,与方法区隔离
JDK1.7: 存储在堆中
JDK1.8及以后: 存储在元空间,与方法区隔离

3.2. 堆(Heap)

堆是所有线程共享的,用于存储对象实例、数组值、指向方法表的指针。
堆分为新生代(Minor)、旧生代(Major)、永久代(Permanent Space)[1.7及之前]、元数据区/元空间(Meta Space)[1.8及之后]
新生代分为Eden Space(伊甸园区)和Survivor Space(幸存者区): 由From Space和To Space组成
默认比例: Young : Old = 1:2, Eden : Survivor From : Survivor To = 8:1:1

3.2.1. 永久代和元数据区的区别:
元空间并不在虚拟机中,而是使用本地内存
3.2.2. 特点:
1>. 线程共享: 整个Java虚拟机只有一个堆,所有的线程都访问同一个堆。而程序计数器、Java虚拟机栈、本地方法栈都是一个线程对应一个的。
2>. 在虚拟机启动时创建
3>. 垃圾回收的主要场所。
4>. 不同的区域存放具有不同生命周期的对象。这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,更高效。
5>. 堆的大小既可以固定也可以扩展,但主流的虚拟机堆的大小是可扩展的,因此当线程请求分配内存,但堆已满,且内存已满无法再扩展时,就抛出OutOfMemoryError。
3.2.3. 新生代:
新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。
新生区又分为两部分:伊甸区(Eden space)和幸存者区(Survivor space),所有的类都是在伊甸区被new出来的。
幸存区有两个:0区(Survivor 0 space)和1区(Survivor 1 space)。
年龄参数: MaxTenuringThreshold
当对象在Survivor区躲过一次GC的话,其对象年龄便会加1
默认情况下,如果对象年龄达到15岁,就会移动到老年代中
当伊甸区的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园进行垃圾回收Minor GC(又叫Young GC),将伊甸区中的剩余对象移动到幸存0区。若幸存0区也满了,移动到1区。那如果1区也满了呢?再移动到老年代。若老年代也满了,那么这个时候将产生Major GC(又叫Old GC), 进行老年代的内存清理, Major GC后老年代仍然无法保存,则产生Full GC。若执行Full GC 之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”。
如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。原因有二:
a.Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
b.代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

分配担保(老年代为新生代作担保):
当JVM准备为一个对象分配内存空间时,发现此时Eden+Survior中空闲的区域无法装下该对象,那么就会触发MinorGC,对该区域的废弃对象进行回收。但如果MinorGC过后只有少量对象被回收,仍然无法装下新对象,那么此时需要将Eden+Survior中的所有对象都转移到老年代中,然后再将新对象存入Eden区。这个过程就是“分配担保”

3.2.4. 永久代
永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。
如果出现java.lang.OutOfMemoryError: PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。原因有二:
a. 程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用;
b. 大量动态反射生成的类不断被加载,最终导致Perm区被占满。
3.2.5. 对象创建过程
1>. 检查常量池中是否有即将要创建的这个对象所属的类的符号引用:
若常量池中没有这个类的符号引用,说明这个类还没有被定义!抛出ClassNotFoundException;
若常量池中有这个类的符号引用,则进行下一步工作
2>. 检查这个符号引用所代表的类是否已经被JVM加载:
若该类还没有被加载,就找该类的class文件,并加载进方法区;
若该类已经被JVM加载,则准备为对象分配内存
3>. 根据方法区中该类的信息确定该类所需的内存大小:
一个对象所需的内存大小是在这个对象所属类被定义完就能确定的!且一个类所生产的所有对象的内存大小是一样的!JVM在一个类被加载进方法区的时候就知道该类生产的每一个对象所需要的内存大小。
4>. 从堆中划分一块对应大小的内存空间给新的对象
5>. 为对象中的成员变量赋上初始值(默认初始化)
6>. 设置对象头中的信息: 对象头(哈希值、GC分代年龄、数据长度)
7>. 调用对象的构造函数进行初始化

3.3. 虚拟机栈/VM栈/Java栈(VM Stack)

栈是线程私有的,线程安全的。它的生命期跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致。
3.3.1. 栈运行原理
栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据的数据集,当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了C方法,于是产生栈帧F3也被压入栈…… 依次执行完毕后,先弹出后进......F3栈帧,再弹出F2栈帧,再弹出F1栈帧。遵循“先进后出”/“后进先出”(FILO)原则。
3.3.2. 栈存储(栈帧中主要存储以下数据):
栈帧数据(Frame Data): 包括类文件、方法等等。
本地变量表/局部变量表(Local Variables): 输入参数和输出参数以及方法内的变量;
栈操作/操作数栈(Operand Stack): 记录出栈、入栈的操作;
动态链接: 在运行期间将符号引用转化为直接引用,就称为动态链接
方法出口信息: 返回值
3.3.3. 特点:
1>. 局部变量表的创建是在方法被执行的时候,随着栈帧的创建而创建。而且,局部变量表的大小在编译时期就确定下来了,在创建的时候只需分配事先规定好的大小即可。此外,在方法运行的过程中局部变量表的大小是不会发生改变的。
2>. 虚拟机栈会出现两种异常:StackOverFlowError和OutOfMemoryError。
a) StackOverFlowError: 当线程请求栈的深度超过当前虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
b) OutOfMemoryError: 当线程请求栈时内存用完了,此时抛出OutOfMemoryError异常。

3.4. 程序计数器(Program Counter Register)/PC寄存器(PC Register)

程序计数器是线程私有,线程安全的。程序计数器是一块较小的内存空间,可以把它看作当前线程正在执行的字节码的行号指示器。也就是说,程序计数器里面记录的是当前线程正在执行的那一条字节码指令的地址。
3.4.1. 程序计数器的作用:
1>. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
2>. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了
3.4.2. 特点:
1>. 是一块较小的存储空间
2>. 线程私有。每条线程都有一个程序计数器。
3>. 是唯一一个不会出现OutOfMemoryError的内存区域。
4>. 生命周期随着线程的创建而创建,随着线程的结束而死亡。

3.5. 本地方法栈(Native Method Stack)

本地方法栈类似于VM栈,主要存储了本地方法调用的状态。在Sun JDK中,本地方法栈和VM栈是同一个。

四. 执行引擎

执行引擎是JVM执行Java字节码的核心,执行方式主要分为解释执行、编译执行、自适应优化执行、硬件芯片执行方式。
解释执行: 有三种优化方式(1: 栈顶缓存; 2: 部分栈帧共享; 3: 执行机器指令)
编译执行: 主要利用了JIT(Just-In-Time)编译器在运行时进行编译,它会在第一次执行时编译字节码为机器码并缓存,之后就可以重复利用
自适应优化执行: 自适应优化执行的思想是程序中10%~20%的代码占据了80%~90%的执行时间,所以通过将那少部分代码编译为优化过的机器码就可以大大提升执行效率。

五. 垃圾回收

5.1. 垃圾回收标志

1>. 引用计数法: 每个对象都有一个计数器,当这个对象被一个变量或另一个对象引用一次,该计数器加一;若该引用失效则计数器减一。当计数器为0时,就认为该对象是无效对象。
2>. 可达性分析法: 所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象。

Java中可作为根集合(GC Root)的对象有:
1). 虚拟机栈中引用的对象(本地变量表)
2). 方法区中静态属性引用的对象
3). 方法区中常量引用的对象
4). 本地方法栈中引用的对象(Native对象)
Java中的四种引用: 1.强引用;2:软引用;3:弱引用;4:虚引用(幽灵/幻影引用)
引用计数法虽然简单,但存在一个严重的问题,它无法解决循环引用的问题。 因此,目前主流语言均使用可达性分析方法来判断对象是否有效。

5.2. 垃圾回收算法

1). 引用计数器(Java1.2之前)
2). 标记-清除算法: 采用从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。
3). 复制算法: 采用从根集合扫描,并将存活对象复制到一块新的,没有使用过的空间中,这种算法当控件存活的对象比较少时,极为高效,但是带来的成本是需要一块内存交换空间用于进行对象的移动。
4). 标记-整理算法: 标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但解决了内存碎片的问题。

六. 垃圾回收器

新生代的GC: 串行GC(Serial GC)、并行GC(ParNew GC)、并行回收GC(Parallel Scavenge GC)
旧生代的GC: 串行GC(Serial MSC)、并行GC(parallel MSC)、并发GC(CMS)
Serial: 串行收集器并不是只能使用一个CPU进行收集,而是当JVM需要进行垃圾回收的时候,需要中断所有的用户线程,直到它回收结束为止,因此又号称“Stop The World” 的垃圾回收器。
ParNew: 多线程版本的Serial收集器
Parallel Scavenge: 又称为是吞吐量优先的收集器,假设程序运行了100分钟,JVM的垃圾回收占用1分钟,那么吞吐量就是99%。

G1 GC,全称Garbage-First Garbage Collector,通过-XX:+UseG1GC参数来启用,G1垃圾收集器没有新生代和老年代的概念了,而是将堆划分为一块块独立的Region。当要进行垃圾收集时,首先估计每个Region中的垃圾数量,每次都从垃圾回收价值最大的Region开始回收,因此可以获得最大的回收效率
产生背景: 作为体验版随着JDK 6u14版本面世,在JDK 7u4版本发行时被正式推出,相信熟悉JVM的同学们都不会对它感到陌生。在JDK 9中,G1被提议设置为默认垃圾收集器(JEP 248)。
G1收集器的设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色:
1). G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。
2). G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

七. JVM内存调优

对JVM内存调优的主要目的是减少GC频率和Full GC的次数,过多的GC和Full GC是会占用很多的系统资源(主要是CPU),影响系统的吞吐量。特别要关注Full GC,因为它会对整个堆进行整理
导致Full GC一般由于以下几种情况:
1). 旧生代空间不足: 调优时尽量让对象在新生代GC时被回收、让对象在新生代多存活一段时间和不要创建过大的对象及数组避免直接在旧生代创建对象
2). Permanet Generation空间不足: 增大Perm Gen空间,避免太多静态对象,控制好新生代和旧生代的比例
3). System.gc()被显示调用: 垃圾回收不要手动触发,尽量依靠JVM自身的机制
调优手段主要是通过控制堆内存的各个部分的比例和GC策略来实现,下面来看看各部分比例不良设置会导致什么后果
1). 新生代设置过小: 一是新生代GC次数非常频繁,增大系统消耗;二是导致大对象直接进入旧生代,占据了旧生代剩余空间,诱发Full GC
2). 新生代设置过大: 一是新生代设置过大会导致旧生代过小(堆总量一定),从而诱发Full GC;二是新生代GC耗时大幅度增加,一般说来新生代占整个堆1/3比较合适
3). Survivor设置过小: 导致对象从eden直接到达旧生代,降低了在新生代的存活时间
4). Survivor设置过大: 导致eden过小,增加了GC频率
JVM提供两种较为简单的GC策略:
1). 吞吐量优先: JVM以吞吐量为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,来达到吞吐量指标。这个值可由-XX:GCTimeRatio=n来设置
2). 暂停时间优先: JVM以暂停时间为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,尽量保证每次GC造成的应用停止时间都在指定的数值范围内完成。这个值可由-XX:MaxGCPauseRatio=n来设置
JVM常见配置:
1). 堆设置
-Xmn: 新生代内存大小的最大值,包括E区和两个S区的总和
-Xms: 初始堆大小
-Xmx: 最大堆大小
-Xss: 设置每个线程的栈内存,默认1M
-XX:NewSize=n: 设置年轻代大小
-XX:NewRatio=n: 设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
-XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5
-XX:PermSize=n:设置持久代大小
-XX:MaxPermSize=n:设置持久代最大值
JDK1.8
-XX:MetaspaceSize=n:初始元空间大小
-XX:MaxMetaspaceSize=n:元空间最大值,默认是没有限制的
2). 收集器设置
-XX:+UseSerialGC:设置串行收集器
-XX:+UseParallelGC:设置并行收集器
-XX:+UseParNewGC: 设置年轻代为并行收集
-XX:+UseParalledlOldGC:设置并行年老代收集器
-XX:+UseConcMarkSweepGC:设置并发收集器
-XX:+UseCompressedOops: 压缩堆大小,JVM 会使用 32 位的 OOP,而不是 64 位的 OOP
3). 并行收集器设置
-XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。
-XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间
-XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)
4). 并发收集器设置
-XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。
-XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。
5). CMS相关参数
-XX:+UseConcMarkSweepGC 使用CMS内存收集
6). 垃圾回收统计信息
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:filename

八. 垃圾回收监控

1). jstat命令行工具监控JVM内存和垃圾回收
2). Java VisualVM及Visual GC插件
3). JConsole

JVM原理剖析的相关教程结束。

《JVM原理剖析.doc》

下载本文的Word格式文档,以方便收藏与打印。