菜鸟的JVM学习总结
说明
更新时间:2020/10/23 14:04,未完待续
本文jvm的一些学习总结,本文会持续更新,不断地扩充
注意:本文仅为记录学习轨迹,如有侵权,联系删除
一、JDK、JRE、JVM
从大到小依次是JDK、JRE、JVM,它们三者可以简单看为包含关系,下面简单说一下这之间的关系。
JDK
简单来说就是Java的开发工具包,既然是开发工具包,肯定是给开发人员用的,既然是开发人员用的,那肯定基本JRE、JVM全包在里面了,而且JDK除了这两个之外,还包括了开发中的调试工具,例如将java文件编译成class文件的编辑器javac,文档生成器javadoc,还有注解appletviewer等开发用到的工具。
下面使用javac演示java文件编译成class文件的过程
//注意文件名是Hello.java,这个不能变
public class Hello {
public static void main(String[] args) {
System.out.println("hello");
}
}
利用cmd进入该java的所在目录,执行:javac Hello.java
,没报错的情况下,目录下会生成Hello.class文件,编译成功
注意:上图中java Hello是运行该类文件,下面会讲到
JRE
简单来说就是java运行环境,里面包含了很多java程序运行时所需的类库,这个可以单独拿出来给非开发人员用,我们知道java程序都是运行在java虚拟机JVM里面的,这也是java可以跨平台的原因;打开jre文件夹里面bin目录下应该有java.exe,这个就是用来运行java的类文件的,到这里就可以明白了所谓的java跨平台原理,只要是.class文件就可以在jvm里面执行,而不同的操作系统只需要下载不同的版本的jvm即可,这样就可以实现了跨平台。
下面使用java.exe执行上面的Hello.class文件
JVM
java虚拟机,简单一句话就是用来运行java的类文件,即.class文件,当然这个运行过程就有很多东西在里面,包括类的装载,初始化等,这里简单了解一下,下面会展开细讲。
注意:像jdk11或jdk12这样的里面不再有独立的jre,jre已经整合在jdk里面了
二、JVM简述
这里简单画出一张图,用来表示JVM与操作系统的关系,从这里也可以看出java跨平台的原理
下面给出JVM整个的结构图,接下来的学习都是基于这个结构图进行的学习
ClassLoader:类加载器
Execution Engine:执行引擎
注意:类加载器只负责加载类文件,执行会不会执行则取决于执行引擎
三、类加载器(ClassLoader)
类的加载器用于加载类文件(.class),class文件在文件开头有特定的文件标识,将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法内,然后在堆区创建一个 java.lang.Class对象
,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的 Class对象, Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。
注意:类加载器只负责加载类文件,至于改文件是否可以执行,这个是有执行引擎Execution Engine决定的
这里给出一张图
以上图为例:
Car.class文件
经过加载并初始化之后,会生成一个Class对象(Car Class)
,并且将该对象放入内存中的方法区中作为一个模板,之后需要new实例对象的时候,都是基于该模板,如上图的car1,car2,car3
等,如果需要获取该实例的对象模板(Car Class),可以反射的方式进行获取得到Car Class。
注意:在将.class文件加载并初始化成Class对象的时候,会先检测该文件的后缀名是否是class,并且检测该文件的特定的文件标识,打开class文件会发现里面会有cafe babe标识,后面跟一堆字符串,这就是特定的标识,只有满足这样的标识才能被初始化并加载。
(1)类加载器种类
类加载器种类在jdk1.8之前和1.8之后的版本有些些区别,这里简单介绍一下它们之间的区别,这个可以参考这篇博客和这篇博客
JDK1.8之前
详细讲共有4种类加载器
启动类加载器(Bootstrap ClassLoader) | C++编写,也叫根加载器,加载%JAVAHOME%/jre/lib/rt.jar。 |
扩展类加载器(Extension ClassLoader) | 加载%JAVAHOME%/jre/lib/ext目录下的jar包 |
应用程序类加载器(App ClassLoader) | 也叫系统类加载器,加载%CLASSPATH%的所有类。 |
用户自定义的类加载器 | 通过继承Java.lang.ClassLoader 类,自定义类的加载器。 |
这里补充一下应用程序类加载器的加载路径,加载%CLASSPATH%的所有类,具体可以通过
System.getProperty("java.class.path")
来查看对应的路径,一般是自己的java程序编译后的路径,例如maven项目的路径如下
JDK1.8之后
在JDK1.8之后,拓展类加载器Extension被平台类加载器PlatFrom所取代,其他的类加载器基本一样,但是它们加载的模块变了,这个很重要!!!
注意:这个很重要!!!在JDK1.8之后引入的Java平台模块化系统。原来的 rt.jar 和 tools.jar 被拆分成数十个 JMOD文件,分成了一个个的模块,然后不同的类加载器分别加载对应的模块,还有其他的模块也是,这个跟JDK1.8之前的类加载器加载的模块差了很多
在 Java 模块化系统明确规定了三个类加载器负责各自加载的模块:
启动类加载器负责加载的模块
java.base java.security.sasl
java.datatransfer java.xml
java.desktop jdk.httpserver
java.instrument jdk.internal.vm.ci
java.logging jdk.management
java.management jdk.management.agent
java.management.rmi jdk.naming.rmi
java.naming jdk.net
java.prefs jdk.sctp
java.rmi jdk.unsupported
平台类加载器负责加载的模块
java.activation* jdk.accessibility
java.compiler* jdk.charsets
java.corba* jdk.crypto.cryptoki
java.scripting jdk.crypto.ec
java.se jdk.dynalink
java.se.ee jdk.incubator.httpclient
java.security.jgss jdk.internal.vm.compiler*
java.smartcardio jdk.jsobject
java.sql jdk.localedata
java.sql.rowset jdk.naming.dns
java.transaction* jdk.scripting.nashorn
java.xml.bind* jdk.security.auth
java.xml.crypto jdk.security.jgss
java.xml.ws* jdk.xml.dom
java.xml.ws.annotation* jdk.zipfs
应用程序类加载器负责加载的模块
jdk.aot jdk.jdeps
jdk.attach jdk.jdi
jdk.compiler jdk.jdwp.agent
jdk.editpad jdk.jlink
jdk.hotspot.agent jdk.jshell
jdk.internal.ed jdk.jstatd
jdk.internal.jvmstat jdk.pack
jdk.internal.le jdk.policytool
jdk.internal.opt jdk.rmic
jdk.jartool jdk.scripting.nashorn.shell
jdk.javadoc jdk.xml.bind*
jdk.jcmd jdk.xml.ws*
jdk.jconsole
(2)双亲委派
JDK1.8之前
先来说双亲委派,通过这个可以整体了解整个类的加载的大致过程,首先来看一张图
上图对应上面讲过的4种类加载器,它们之间加载类是有顺序的,整个的加载过程如下:
- 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
- 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
JDK1.8之后
JDK1.8之后保持三级分层类加载器架构以实现向后兼容。但是,从模块系统加载类的方式有一些变化。且新增Platform ClassLoader:平台类加载器,用于加载一些平台相关的模块,例如: java.activation 、 java.se 、 jdk.desktop 、 java.compiler 等,双亲是BootClassLoader。 具体类加载器层次结构如下图所示。
整个加载过程就变成了这样
无论应用程序类加载器(AppClassLoader)或者是平台类加载器(PlatFromClassLoader)在接收到类加载请求的时候,会先判断判断该类是否能够归属到某一个系统模块中,这些系统模块就是上面说到的JDK1.8之后的各个类加载器所负责的模块,如果可以找到这样的归属关系,就将优先委派给负责那个模块的加载器完成加载,所以上面的图的箭头会指向各个加载器,如果没有的话就会委托给对应的父类,依次类推,最后都没有的话,就会返回类找不到的异常。
这里看到一个博主写的,里面讲的挺细的,引用一下里面的类加载机制
代码实战
下面会给出两个例子加深这部分的理解,这两个例子很重要,尤其是第二个例子,如果看懂了第二个例子,基本就懂了双亲委派机制了
例子1
public class TheClassLoads {
public static void main(String[] args) {
//System.out.println(System.getProperty("java.class.path"));
System.out.println("JDK1.8之后的类加载器如下:");
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println("\t应用程序类加载器: " + systemClassLoader);
// 获取系统类加载器的父类加载器 --> 平台类加载器
ClassLoader parent1 = systemClassLoader.getParent();
System.out.println("\t平台类加载器: " + parent1);
// 获取扩展类加载器的父类加载器 --> 根加载器(C/C++)
ClassLoader parent2 = parent1.getParent();
System.out.println("\t根加载器(C/C++): " + parent2);
System.out.println("\n\n=======================================================");
System.out.println("通过对象实例的反射获取类加载器:");
TheClassLoads theClassLoads = new TheClassLoads();
System.out.println("\t应用程序类加载器:"+theClassLoads.getClass().getClassLoader());
System.out.println("\t平台类加载器:"+theClassLoads.getClass().getClassLoader().getParent());
System.out.println("\t根加载器(C/C++):"+theClassLoads.getClass().getClassLoader().getParent().getParent());
}
}
注意:启动类加载器底层是C/C++编写的,尽管JDK1.8之后有了改变,但还是一致返回null
例子2
这个例子比较简单,新建一个java.lang.String类,然后在该类里面执行打印hello world,虽然例子简单,但是却有点复杂
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("hello world");
}
}
两种情况
JDK1.8之前
用JDK1.8之前的双亲委派模式分析,在加载类的时候它会通过应用类加载器App ClassLoads
一层一层地向上委托,然后在启动类加载器BootStrap ClassLoads
加载的时候找到了该类位于rt.jar里面(我们知道java.lang.String是内部已经封装好的类),然后就加载了rt.jar里面的String类,而该String里面是没有main方法的,所以报了错误:找不到main方法
一句话总结就是,它加载的是jre内部的rt.jar里面的String类,不是我们自定义的String类
JDK1.8之后
这里采用jdk11
这个时候就会报错,程序包已存在于另一个模块中,结合JDK1.8之后的双亲委派机制,可以想到,在加载这个类的时候,类加载器会先判断判断该类是否能够归属到某一个系统模块中,如果可以就由该模块负责的类来加载,结果发现可以归属到java.base这个模块中,而且发现里面已经有了java.lang这个包了,这个包里面也已经有了String类了,所以就返回了程序包已存在于另一个模块中。
一句话总结就是我们自定义的这个java.lang.String类,包括包名都已经存在于模块java.base中了,所以返回程序包已存在于另一个模块java.base中了
(3)沙箱隔离机制
官方的话这里就不说了,可以去网上找,简单理解就是基于双亲委派机制上采取的一种JVM的自我保护机制,具体可以看以下分析,还是以上面的代码为例
我们要新建一个java.lang.String类,基于双亲委派机制,它加载了rt.jar里面的String类,不会加载我们自定义的String类,使得jdk里面的代码和我们的代码互不干扰,这就是沙箱隔离机制;如果没有这样的机制的话,我们新建的String类可能会对jdk里面的String类造成影响,污染了jdk里面的代码,保证了java的运行机制不会被破坏。
注意:沙箱隔离机制不论是JDK1.8之前或是之后,基本都是一样的,基于双亲机制保护了jdk里面的代码,保证了java的运行机制不会被破坏。
四、本地方法栈和本地方法接口(私有)
这个直接下面的代码进行学习
在Thread类里面有一个方法private native void start0();
方法上有native修饰,表示这个一个本地方法接口
,它没有java的任何实现方法,因为它表示的是调用其他非java的底层的接口,例如C的底层接口,所以这里只有声明,注意关键字native,而用native修饰的方法是放在jvm里面的本地方法栈
里面的。
五、程序计数器(私有)
程序计数器(Program Counter Register),也叫PC寄存器。每个线程启动的时候,都会创建一个PC寄存器。PC寄存器里保存当前正在执行的JVM指令的地址。 每一个线程都有它自己的PC寄存器,也是该线程启动时创建的。
简单理解就是,线程启动时创建创建一个PC寄存器,它用来存储指向下一条指令的地址,也就是即将要执行的指令代码,类似指针的东西,执行引擎Execution Engine会根据它读取下一条指令并且执行代码。PC寄存器一般用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。不会发生内存溢出(OutOfMemory,OOM)错误。
这个了解一下即可
六、方法区(共享)
方法区用于存放类的模板信息(类原型),该原型包括成员变量信息、成员方法、静态变量和常量池等
例如
这个图是上面学习类加载器时用到的图,这里通过类加载器加载后的会生成一个Car Class
对象,或者叫类模板,它就存放在方法区,之后需要实例化对象的时候,都是基于该类模板进行实例化,它的独有的一份,所以实例化出来的对象都是一致的。
七、栈(私有)
栈注意存放成员变量以及类实例对象的引用,注意,类的实例对象是存放在堆中的,而它的引用是存放在栈中的。
八、堆(共享)
存放的数据
堆存放类实例对象的具体数据,注意,这个要跟方法区的类原型区分开,这里的是类的实例对象,一个类可以有多个实例对象,而类原型只能有一个
注意:这里要跟方法区里面的类原型区分开,方法区那里存的成员变量和成员方法可以理解为存的是它对应的定义,而堆中存的是实例化出来的一个对象,里面包括对应的具体的值
堆结构
(java 1.7 是永久代 java1.8之后是元空间)
GC过程
GC即垃圾回收机制,一般存在堆中,像栈这些空间比较小的一般没有GC机制,下面学习一下整个过程
(1)new出来的对象实例都是一开始存放在新生代中的伊甸园区Eden中,当对象太多,伊甸园放不了的情况下,会触发伊甸园的YGC机制,即新生代(young)的 GC回收,也叫轻GC,会回收该区的所有对象,如果该对象还有用,没被回收会被复制到新生代中的幸存者0区,此时这些幸存下来的对象会被标记+1,注意:此时:幸存者0区是from区,幸存者1区是to区,谁是空数据谁是to区 |
(2)之后新new出来的对象同样会放到伊甸园区中,同样当放不下的时候会触发YGC,这个时候清空回收伊甸园区和幸存者0区,那些没有被回收的会被复制到幸存者1区,同时这些幸存下来的对象会被标记+1,注意:此时:幸存者0区是to区,幸存者1区是from区,谁是空数据谁是to区 |
(3)之后新new出来的对象同样会放到伊甸园区中,同样当放不下的时候会触发YGC,这个时候同样清空回收伊甸园区和幸存者1区,,那些没有被回收的会被复制到幸存者0区,同时这些幸存下来的对象会被标记+1,注意:此时:幸存者0区是from区,幸存者1区是to区,谁是空数据谁是to区 |
(4)之后的就一直这样,当伊甸园区满了,触发YGC,清空对应有数据的幸存者0或1区,将幸存下来的对象复制到没有数据的幸存者0或1区,对象标记+1 |
(5)在上面这个过程中,如果发现幸存下来的对象,标记数大于15的时候,会被移到老年代 |
(6)如果老年代也满了,会触发FGC,即重GC,如果触发多次后,老年代,也就是养老区也满了,就会触发OutOfMemoryError,即堆内存溢出 |
注意,幸存者0区和幸存者1区,一个是from区,一个是toq区,谁是空数据谁是to区,触发YGC后,因为没被回收的对象会有一个复制过程,所以from和to区会有一个交换
堆参数调优
堆参数
-Xms | 设置初始分配大小,默认是物理内存的“1/64” |
-Xmx | 设置最大的分配内存,默认是物理内存的“1/4” |
-XX:+PrintGCDetails | 输出详细的GC处理日志 |
注意,堆内存的分配是基于电脑的物理内存来分配的
下面用代码演示,输出堆的内存分配情况
public static void main(String[] args) {
long maxMemory = Runtime.getRuntime().maxMemory();//返回java虚拟机试图使用的最大内存容量
long totalMemory = Runtime.getRuntime().totalMemory();//返回java虚拟机中的内存总量
System.out.println("MAX_MEMORY = " + maxMemory + "(字节)、也就是"+(maxMemory/(double)1024/1024)+"(MB),也就是"+(maxMemory/(double)1024/1024/1024)+"(GB)");
System.out.println("TOTAL_MEMORY = " + totalMemory + "(字节)、也就是"+(totalMemory/(double)1024/1024)+"(MB),也就是"+(totalMemory/(double)1024/1024/1024)+"(GB)");
}
堆参数调整
下面通过调整堆参数的大小,把堆的内存大小调低,然后new对象,让其报内存溢出错误OOM
先将堆内存的初始值和最大值调整为4M,之后再进行new了超过4M的堆内存的数组,使其堆内存溢出
打印并查看GC日志
同样的先调整堆参数,让其打印GC日志-Xms4m -Xmx4m -XX:+PrintGCDetails
执行下面的代码
public static void main(String[] args) {
int[] a = new int[1024];
}
九、方法区、栈、堆之间的关系
关系直接用代码和图直接进行展示