JVM是一种规范,基于这套规范的jvm平台可以通过字节码指令集及内存管理来虚构出一台计算机,任何语言符合JVM规范并编译成class文件,即可以在JVM虚拟机上运行。目前常见的JVM实现,常用的有Hotspot,也有TaobaoVM,J9,LiquidVM,Jrockit,Microsoft VM,azul zing等,通过java -version 即可查看当前的虚拟机平台。
JVM虚拟机可以加载运行应用程序,在JVM基础上,附加核心库(core lib) ,即构成JRE运行时环境,在JRE之上封装了开发包(development kit)即构成JDK,本文分析的是已发行的JDK8版本,其包容关系如下:
JVM的三个组成部分,包括:
三个部分组成结构如下图:
JVM通过这三个组件完成程序运行,三部件执行的整体流程是:类加载器加载编译的class字节码文件到运行时数据区,执行引擎执行具体的指令。更具体的JVM架构如下图:
通过官网可知,class文件结构包含了以下内容:
依次对应如下:
魔数
次版本,主版本号
常量池大小,常量池表[池大小-1]常量池中包含了以下字段、方法、接口、属性等的名称信息、索引信息、长度信息、权限信息、数据类型信息等
class的access权限,当前class索引,父类class索引
接口池大小,接口表[池大小-1]
字段池大小,字段表定义的常量[池大小-1]
方法池大小,方法表[池大小-1]
其他属性池大小,其他属性表[池大小-1],例如内部类等
不管是scala,或者java等其他语言编译后形成class文件,可通过二进制编辑工具,可直接查看编译后的class文件数据,以下是通过IDEA插件BinED进行查看的情况:
在二进制文件中按照class文件结构和步长进行16进制读取,可以获取到常量池、属性、方法、java汇编指令等信息。为了将class二进制可视化,可以使用javap命令来分析文件结构,这里使用IDEA的插件jclasslib来查看,可参考上述官网的class文件结构进行查看分析:
class二进制文件要想运行,需要加载到内存使得执行引擎可以运行。class文件加载到内存的流程如下:
这个流程大体分为3个流程,即加载、链接和初始化,其中链接流程又分为验证、准备和解析的过程,各个流程简单概括如下:
加载:类加载器加载class文件到内存
链接:元空间符号引用转换成直接引用。执行代码放在原空间常量池,具体执行的指令与元空间常量进行关联。
验证:class文件合法性
准备:静态变量赋默认值
解析:常量池引用转换为内存地址的引用
初始化:静态变量赋初始值
Launcher是java程序启动入口类,在启动java应用的时候会首先创建Launcher类,并准备应用程序运行中需要的类加载器。jvm的类加载有三个基本类型和特殊的线程上下文加载器。这三个基本的类加载器就是引导类加载器(Bootstrap)、扩展类加载器(Extension)以及系统程序类加载器(Application)。
类加载可看作是类的命名空间,同一个类由不同的类加载器加载得到对象不相同。
加载某个类的class文件时,这些类加载器有加载顺序,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,具体的流程如下图:
简单描述,就是当 JVM 接收到需要加载类的请求时,先自下而上从各自的类加载器缓存中判断该类是否已经加载,如果加载则返回,如果未加载则委托给父加载器判断,一直到引导类加载器(Boostrap)为止,如果所有 ClassLoader 都未加载,则自上向下委托加载该类,直到加载成功或报出异常。
需要留意的是,父加载器,并非是被继承的加载器,也不是加载器的加载器,而是一个引用组合的关系。最顶层的加载器是Bootstrap类加载器,由于它是用c++写的,并不继承于java.lang.ClassLoader,所以在返回该ClassLoader时就会返回null,意味着当某个类的加载器为空时,意味着该类的类加载器是引导类加载器。
双亲委派机制主要是涉及到安全稳定问题,如果类被一个加载器加载,就不用再从父类加载器加载,如果父类加载器未加载交由后续加载器加载,也能防止父类加载器的类被破坏。
自定义加载器,就是定义如何读取字节码文件和加载链接初始化的过程。在ClassLoader源码中的loadClass()方法递归实现了双亲委派机制,因此覆盖这个方法就可以破坏掉双亲委派机制,如果不想破坏双亲委派机制,则重写findClass()方法即可,最终通过defineClass()方法来实现类的链接初始化。
这里以一个加密的class文件作为示例:
首先正常编译class文件,然后通过IO读取文件字节并对每个字节异或,具体异或值即为加密的密文,然后保存为新的class文件,这样class文件即加密了,可以进行共享分发了。
为了使用加密类的方法,需要构建一个解密加载器,再重写findClass()方法时,添加解密逻辑(反加密,这里使用异或操作),然后实例化这个解密加载器并按照加密类的类名进行加载,这样就能实例化加密的类了。
下面就行简单的自定义加载操作,不做加解密操作,只有一个正常类和一个加载器类,不使用正常类的new方法实例化,而是用类加载器加载并实例化,可观察到最终实例化的加载器是应用加载器(AppClassLoader),名称上也不是custome之类的其他类加载器。
JVM加载.class字节码文件后产生数据存放的区域就是JVM内存区域,这个区域的结构如下:
可以看到运行时数据区主要分为以下几个部分:
内存区域按线程共享与否分为两部分,其中蓝绿色部分是线程共享的,浅黄色部分是线程私有的。栈、本地方法栈、程序计数器是每个线程私有的,因此不会有线程安全问题,而原空间(方法区)、堆是线程共享的,因此会有线程安全问题。其运行时内存的分配过程简述如下:
存储类的元信息,类的常量池、静态部分等。
存放new出来的对象,当然并不是所有的对象都存放在堆空间中,也可能存在于线程栈中。在对象的逃逸分析章节中有描述。
线程栈中存储的是栈帧。栈帧存储了方法的变量表、操作数栈、动态链接和方法返回等信息,方法从调用开始到执行结束的整个过程,就对应着一个栈帧的入栈出栈过程。
变量表存储了了方法参数和内部的局部变量,操作数栈存放了,动态链接指向了栈帧方法的引用,方法返地址则用于恢复调用状态。以一个springboot引用程序的入口程序为例:
该springboot启动类只有一个主线程并有一个main方法,并且只调用了run方法,因此这里有两个栈帧,分别是main和run的栈帧。可以通过javap指令来打印类的栈结构:
通过javap -v StudyApplication.class编译后的指令可知,大体流程如下:
不太准确但直观的理解:局部变量表用于对象或值的存储;操作数栈用于值的计算;动态链接用于将方法的符号引用转化为执行指令的引用(指令代码);方法返回用于指示结果存储的寄存器地址。
线程私有的,存储运行本地方法(native)产生的数据。
线程私有的,用于存储当前线程正在执行的Java方法的JVM指令地址,配合执行引擎来执行命令。
对象的创建分为以下几个步骤:
如下图所示:
为对象分配内存时,按照内存空间地址连续性与否的分配策略如下:
内存分配产生的并发问题:
JVM优先分配到Eden区,如果空间足够则申请结束;如果Eden区空间不足,会发生Minor GC去清除不活跃的对象,如果Minor GC 后空间不足,JVM会试图把Eden区对象转移一部分到Survivor区;如果Survivor区对象晋升老年代则会分配到Old老年区中,如果老年代区满了也无法分配对象,则会发生Full GC,如果Full GC后Eden和Old区都无法分配对象时,则会出现out of memory的情况。
根据对象的数据类型,为对象的属性进行属性初始化。
对象在内存中的布局如下:
可以看出一个对象内存包括:对象头、实例数据以及对齐填充,非常细致的解读请参考一文聊透对象在JVM中的内存布局,以及内存对齐和压缩指针的原理及应用。
其中,对象头信息由MarkWord和KlassPointer组成。
mark word包含了对象的锁状态、hashcode、对象年龄等信息,下表是对象在不同锁状态下其MakrWord字段的情况,java开发中synchronized就是读取对象的锁状态来判断并发性。
Klass Pointer指类型指针,指向了元空间当前类的类元信息。当调用对象的方法时,其实是读取元空间类信息中的方法。
对象头信息根据对象是否为数组类型而有所不同,如果为数组类型,对象头信息会多出4字节的数组长度信息,否则就没有;对象头的Mark部分固定长度8字节,未压缩的指针部分为8字节长度,压缩过的为4字节长度,而JVM要求对象内存占用为8的倍数,因此减少的4字节作为空闲gap进行补齐。
指针数字指向元空间的类元信息,对象头的压缩可以减小对象总的空间占用,减少GC。
JVM参数UseCompressedOops可用来设置是否开启指针压缩,jdk8已默认开启。写一段程序为例,在IDEA的VM参数中填入-XX:-UseCompressedOops或-XX:-UseCompressedOops,分别打印关闭和打开对象压缩后的对象头信息,注意关注Klass Point即(object header:class)的部分。
import org.openjdk.jol.info.ClassLayout;
public class KlassPointer {
public static void main(String[] args) {
KlassPointer klassPointer = new KlassPointer();
System.out.println(ClassLayout.parseInstance(klassPointer).toPrintable());
System.out.println("========================================");
System.out.println(ClassLayout.parseInstance(new KlassPointer[]{klassPointer}).toPrintable());
}
}
通过程序测试,开启压缩后,klass pinter部分原先占用的8字节空间变成4字节空间,为满足8的倍数关系需要对齐压缩掉的4字节。
java代码中定义的属性和值;
JVM要求java对象占用内存大小为8 bit的倍数,因此对齐填充就是把不是8bit倍数字节数的补齐为8的倍数。
默认class类实例化对象时都会执行的默认方法,这个在class字节码反编译后可查看到,例如通过jclasslib插件或者javap查看以上程序,可看到JVM中默认的init方法,不过这个方法无法被重写。
垃圾回收是JVM提供的空闲时间回收垃圾对象的机制。回收的时间有三个时机:
垃圾对象的判定算法有两个:
引用计数法:当对象被引用了,则对象计数+1,当计数为0则对象被判定为垃圾,缺点是无法解决循环依赖对象问题,Hotspot未采用这种方法;
可达性分析:
JVM运行时内存结构中,对象的引用可能在元空间,可能在线程栈的变量表中,也可能在本地方法栈中,就把这些引用作为GC Root 根节点,其他节点挂载到根节点上,垃圾回收会从 GC Roots 这个集合的引用链去寻找回收对象[深度优先bi],如下图所示:
当对象不在GC Root引用链中则该对象就应该被回收。
object类中有一个finalize方法,因此任何对象都有finalize方法,这个方法是对象被回收前的最后一根救命稻草。
以一段程序为例子,注意在JVM参数中设置NewSize参数,即-Xmn2m,然后执行:
public class Finalize {
public static void main(String[] args) {
List userList = new ArrayList<>();
int i = 0, j = 1000;
for (int k = 0; k < 500; k++) {
userList.add(new User(i++));
new User(userList, j++);
}
}
@Data
static class User {
private List userList;
private int id;
public User(List userList, int id) {
this.userList = userList;
this.id = id;
}
public User(int id) {
this.id = id;
}
@Override
protected void finalize() throws Throwable {
if (id % 10 == 0) {
userList.add(this);
System.out.println("不回收对象:" + id);
} else {
System.out.println("对象被回收:" + id);
}
}
}
}
其运行结果如图:
这段程序的userList引用存在于线程栈的局部变量表中,是在GC Root根节点中,然后循环体添加User对象到GC Root引用链中,因此这些对象都不会进行GC操作;循环体中新建的User对象没有指向其他引用,因此都是垃圾,但是在垃圾回收时执行finalize方法发现,id是10倍数的对象会添加到userList这个GC Root引用链中,因此不会被垃圾回收,非10倍数的对象依然会被垃圾回收。这些对象在JVM内存中的对象分配情况如下:
通俗地讲,逃逸分析就是确定一个变量要放堆上还是栈上。如果对象都放在堆上,则GC压力大并且会产生更多的内存碎片,因此jdk1.7以后默认增加了逃逸分析,如果JVM发现对象没有逃逸出方法则优先分配到线程栈中,以一段程序为例:
public class EscapeAnalysis {
// 堆上分配对象
public MyUser test1() {
return new MyUser();
}
// 线程栈上分配对象
public void test2() {
MyUser user = new MyUser();
}
}
通过Return可知,test1()方法中发生了对象逃逸,对象很可能会分配到堆中,test2()则很可能分配到栈中,分配到栈中的好处是对象随栈销毁而不会发生GC。
JVM逃逸分析的规则是:
垃圾回收算法常见四种:标记清除、复制算法、标记整理、分代回收。
通过GC Root可达性分析,标记可达性对象,GC时不可达对象将被清除,动图如下:
算法产生的问题:
将内存分为大小相同的两块区域,每次只是用一个区域,在任意时刻所有对象只能分配到其中一个内存区域,另一块区域是空闲的,清理步骤如下:
复制算法动图如下:
这个算法的问题:
复制算法主要用于新生代存活率低的对象。
标记整理于标记清除类似,比标记清理多了一步移动操作,具体步骤如下:
标记整理节省了空间,使对象地址连续规整,但依然需要gc root遍历标记可达(存活)对象,加上对象移动操作,因此标记整理操作具有更高的使用成本,其回收动图如下:
JVM按照对象的不同生命周期来划分不同的内存区域,不同区域采用不同的回收算法,参考下图:
JVM的堆内存分为新生代、老年代,默认占比1:2,其中新生代又划分为Eden和S1 From/S2 TO三块内存区域,Eden、S1、S2默认占比8:1:1。
分代回收的大致流程:对象分配到Eden区,当Eden区满发生一次Minor GC,存活的对象会分配到survivor区,当survivor区对象熬过15次Minor GC(15岁),对象被分配到老年区,当老年区满就发生Full GC,此时产生STW现象,线程暂停,如果Full GC后老年区依然没有空间存放对象,则会发生OOM现象。
详细的流程如下:
注意:From、To区没有必然的先后顺序,地位相同,From区可能存在来自Eden或To区的对象,同样To区可能存在来自Eden或From区的对象,不过转移复制对象时总有一个为空的。
在新生代中,采用标记复制算法,在老年代中采用标记整理算法,动图如下
这里写一个循环创建对象的案例来演示OOM,
public class GCTest {
private byte[] object = new byte[1042*1000];
public static void main(String[] args) throws InterruptedException {
ArrayList gcTests = new ArrayList<>();
while (true) {
gcTests.add(new GCTest());
Thread.sleep(3);
}
}
}
通过jvisualvm客户端工具,配置visual gc 插件来查看gc过程。
运行结果如下图所示:
实现垃圾回收,有很多实现回收的算法,包括Serial收集器、Parallel收集器、ParNew收集器、CMS收集器。
单线程垃圾收集器,在收集过程中会有较长时间的STW,GC执行完成用户线程继续执行,单线程垃圾回收比较简单直接。
多线程垃圾收集器,充分利用CPU,吞吐量高。
原理与Parallel相同,不过可以配合CMS收集器进行工作。
使得用户线程和gc线程并发执行,尽量减少STW时间,简单描述就是gc与用户线程并发标记并发清理,过程如下图所示:
初始标记:暂停一次所有线程(stw)
并发标记:gc roots遍历所有对象,同时用户线程也在并发执行;
重新标记:修正并发过程中的对象的状态,通过三色标记算法来实现;
并发清理:gc开始清理对象,同时用户线程也在并发执行;
并发重置:重置gc过程中的标记数据;
并发过程中对象的状态可能发生改变,因此使用三色标记来重新标识:
黑色:对象及其引用被gc roots遍历,重新标记为黑色,不会被回收;
灰色:对象及部分引用被gc roots遍历,重新标记为灰色;
白色:对象未被gc roots遍历,会被回收;
不同的垃圾收集器在不同的生命周期下组合使用。
年轻代 | 老年代 | 特点 |
Serial | Serial Old | 简单直接 |
Serial | CMS | |
ParNew | CMS | 推荐使用 |
ParNew | Serial Old | |
Paralle | Parallel Old | 吞吐量高,jdk8默认使用 |
Paralle | Serial Old |
JVM调优到底调的是什么?JVM出现了什么问题而需要调优呢?jvm调优调的是稳定,并不保证带来性能上的极大提升,可参考的需要调优的情况有:
通过分析GC日志,观察cpu、内存性能等可视化指标,来确定jvm优化目标,例如减少GC次数和停顿时常,gc回收规律干净,降低内存占用增加吞吐量等,然后通过jvm参数来调整对象的堆内存分配和垃圾回收算法等来综合测试调参,来让程序性能更稳定更高效。
在官方JDK1.8的JVM文档中,详细描述了GC优化的点,包括了堆大小调优和收集器gc算法调优
,当然还有其他方面的优化,实践中常常对堆和GC算法进行优化,实现方式就是向传递JVM参数,JVM参数常用三类参数如下:
参数 | 标准 | 描述 | ||
- | 标准参数 | 全部的JVM都必须实现,向后兼容 | ||
-X | 非标准参数 | 默认实现,不保证全部jvm都满足,不保证向后兼容 | ||
-XX | 非stable参数 | 不同jvm实现会不一样,未来可能会取 |
的发射点
其中通用参数-常用如下:
堆的调优参数-Xm..
线程栈调优参数为-Xs..
常用的调优参数与作用位置如下图:
-XX后面的+号表示显式增加参数,-号反之,实践中可以使用-XX:+PrintGCDetails参数来打印查看GC日志,例如:
对于打印的GC日志格式解读如下图,引用自JVM 实战 | JAVACORE
可以增加如下参数来生成GC日志文件:
实际运行情况如下:
具体的日志内容如下:
为了对GC日志进行可视化,可以通过在线GC解析服务来读取并分析gc日志并生成分析报告,如下:
通过报告内存视图可以看出当前引用堆内存分配情况和峰值,通过参数可以适当调节分配比例和大小,通过GC视图来查看程序GC频率GC时间等,通过参数设置来进行优化并进行优化前后的对比来决定最佳参数,如下:
引用及参考链接: