一、类加载机制
Java字节码
我们编写好的代码是 .java文件,并不能交给机器直接执行, 需要将其编译成为.class文件,这个文件即是Java字节码。它是一个以“cafe babe”打头的十六进制表示的二进制流。静态编译器如何把源码转化成字节码呢?如下图所示:
词法解析是通过空格分隔出单词、操作符、控制符等信息,将其形成 token信息流,传递给语法解析器;在语法解析时,把词法解析得到的 token信息流按照Java语法规则组装成一棵语法树;在语义分析阶段, 需要检查关键字的使用是否合理、类型是否匹配、作用域是否正确等;当语义分析完成之后,即可生成字节码。
字节码必须通过类加载过程加载到JVM环境后,才可以执行。执行有三种模式:
- 解释执行
- JIT编译执行
- JIT编译与解释混合执行(主流JVM默认执行模式)
混合执行模式的优势在于解释器在启动时先解释执行,省去编译时间。 随着时间推进,JVM 通过热点代码统计分析,识别高频的方法、循环体、公共模块等,基于JlT动态编译技术将热点代码转换成机器码,缓存起来并直接交给CPU执行。这也是为什么机器在热机状态可以承受的负载要大于冷机状态(刚启动时)的原因。
类加载过程
类加载是一个将.class字节码文件实例化成Class对象并进行相关初始化的过程。在这个过程中,JVM会初始化继承树上还没有被初始化过的所有父类,并且会执行这个链路上所有未执行过的静态代码块、静态变量赋值语句等。某些类在使用时,也可以按需由类加载器进行加载。类加载包括加载、链接、初始化三个过程。
- 加载(Load):读取类文件产生二进制流,并转化为特定的数据结构,初步校验 cafe babe 魔法数、常量池、文件长度、是否有父类等,然后创建对应类的java.Lang.Class实例
- 链接(Link):包括验证、准备、解析三个步骤。验证是更详细的校验,比如 final 是否合规、类型是否正确、静态变量是否合理等;准备阶段是为静态变量分配内存,并设定默认值;解析是解析类和方法确保类与类之间的相互引用正确性,完成内存结构布局
- 初始化(Init):执行类构造器<clinit> 方法,如果赋值运算是通过其他类的静态方法来完成的,那么会马上解析另外一个类,在虚拟机栈中执行完毕后通过返回值进行赋值
双亲委派模型
JVM在加载类时,使用的是双亲委派模型(Parents Delegation Model),如下图:
- 第一层:Bootstrap ClassLoader,它是在JVM启动时创建的,通常由C/C++实现。Bootstrap是最根基的类加载器,负责装载最核心的Java类,比如Object、System、String等
- 第二层:在JDK9 版本中,称为Platform ClassLoader,即平台类加载器,用以加载一 些扩展的系统类,比如 XML、加密、压缩相关的功能类等 。JDK9之前的加载器是Extension ClassLoader
- 第三层:应用类加载器,主要是加载用户定义的CLASSPATH路径下的类
低层次的当前类加载器,不能覆盖更高层次类加载器已经加载的类。如果低层次的类加载器想加载一个未知类,需要向上逐级询问是否已加载此类,直至Bootstrap ClassLoader;然后向下逐级尝试是否能够加载此类。如果都为否,则通知发起加载请求的当前类加载器,可以加载。
双亲委派模型的关键点:
- 每个ClassLoader都维护了一份自己的名称空间, 同一个名称空间里不能出现两个同名的类。不同加载器之间的两个类是不可见的。但只要得到每个对象的class对象的引用,还是可以访问另一类加载器中的类
- 当类被加载到内存中后,就会生成一个Class对象,在JVM中唯一标示一个Class的组成元素是:classLoader实例号+类名
- 如果同一个类被不同的classloader加载,那么他们在JVM中会被认为是不同的class,如果这两个class之间进行强制类型的转换,就会出现ClassCastException
- 为什么使用双亲委派模型?为了实现java安全沙箱模型顶层的类加载器安全机制,类库的高可重用性, java默认采用了"双亲委派的加载链"结构
查看Bootstrap所有已经加载的类库以及本地类加载器的代码:
// 查看Bootstrap所有已经加载的类库
URL[] urLs = sun.misc.Launcher.getBootstrapClassPath() .getURLs() ;
for (java.口et.URL url : urLs) {
System .out .println(url.toExternalForm());
}
// AppClassLoader
ClassLoader c = TestClass.class.getClassLoader();
// ExtClassLoader
ClassLoader cl= c.getParent();
// null (Bootstrap不存在于JVM体系内)
ClassLoader c2 = cl.getParent();
解决类冲突时,如果想在启动时观察加载了哪个jar包中的哪个类,可以增加参数:-XX:+TraceClassLoading
自定义类加载器
什么时候需要自定义类加载器?
- 隔离加载类
- 修改类加载方式
- 扩展加载源
- 防止源码泄漏
自定义类加载起的步骤:继承 ClassLoader;重写findClass()方法;调用defineClass()方法。例如:
public class CustomClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte(] result = getClassFromCustomPath(name);
if (result == null) {
throw new FileNotFoundException();
} else {
return defineClass(name, result, 0, result.length);
}
} catch (Exception e) {
e.printStackTrace();
}
throw new ClassNotFoundException(name);
}
private byte[] getClassFromCustomPath(String name) {
// 自定义加载类的路径
}
}
二、JVM内存结构
内存是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM高效稳定运行。JVM内存结构如下图所示:
Heap(堆区)
堆区存储着JVM内存中几乎所有的实例对象,被各子线程共享使用,由垃圾回收器自动回收。堆是OOM(out of memory)的主要发源地。
堆区分为两块:新生代和老年代。新生代 = 1个Eden区 + 2个Survivor区,默认情况按照8:1:1的比例分配。绝大部分对象在 Eden 区生成,当Eden 区装填满的时候,会触发 Young Garbage Collection,即YGC。垃圾回收的时候,在Eden区实现清除策略,没有被引用的对象则直接回收,依然存活的对象会被移送到 Survivor 区。Survivor区分为S0和Sl两块内存空间,送到哪块空间呢?每次YGC的时候,将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除,交换两块空间的使用状态。如果YGC要移送的对象大于Survivor区容量的上限 ,则直接移交给老年代。对象也并不是一直在新生代的 Survivor区交换来交换去,每个对象都有一个计数器,每次YGC 都会加1。计数器的值到达某个阐值的时候(-XX:MaxTenuringThreshold可以设置该值),对象从新生代晋升至老年代。如果该参数配置为1,那么从新生代的 Eden 区直接移至老年代,默认值是15。该过程如下图所示:
Metaspace(元空间)
Metaspaces是hotspot对Method Area的实现(即方法区,是JVM规范层面的)。在JDK8版本中,元空间的前身Perm区已经被淘汰。在JDK7及之前的版本中,只有Hotspot才有Perm区,译为永久代,它在启动时固定大小,很难进行调优,并且FGC时会移动类元信息。在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。
区别于永久代,元空间在本地内存中分配,而不是在虚拟机中。在JDK8里,Perm区中的所有内容中字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内。
元空间的内存管理由元空间虚拟机来完成。每一个类加载器的存储区域都称作一个元空间,所有的元空间合在一起就是我们一直说的元空间。当一个类加载器被垃圾回收器标记为不再存活,其对应的元空间会被回收。元空间虚拟机采用了组块分配的形式,同时区块的大小由类加载器类型决定。类信息并不是固定大小,因此有可能分配的空闲区块和类需要的区块大小不同,这种情况下可能导致碎片存在。
JVM Stack (虚拟机栈)
JVM中的虚拟机栈是描述Java方法执行的内存区域,它是线程私有的。每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程。在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。而 StackOverflowError表示请求的栈溢出,导致内存耗尽(通常出现在递归方法中)。
虚拟机栈通过压栈和出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上;如果执行过程中出现异常,会进行异常回溯,返回地址通过异常处理表确定。栈帧包括局部变量表、操作枝、动态连接、方法返回地址等。虚拟机栈的示意图如下:
- 局部变量表:局部变量表是存放方法参数和局部变量的区域。局部变量没有准备阶段,必须显式初始化。如果是非静态方法,则在index[0]位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量
- 操作栈:在方法执行过程中,会有各种指令往栈中写人和提取信息。字节码指令集都是基于栈类型的,栈的深度在方法元信息的 stack属性中
- 动态连接:每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接
- 方法返回地址:方法执行时有两种退出情况:正常退出、异常退出。无论哪种都将返回至方法当前被调用的位置,方法退出的过程相当于弹出当前栈帧
Native Method Stack(本地方法栈)
虚拟机栈“主内”,本地方法栈“主外”,这个“内外”是针对JVM来说的,本地方法栈为Native方法服务。本地方法可以通过JNI(Java Native Interface )来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和JVM相同的能力和权限。当大量本地方法出现时,势必会削弱JVM对系统的控制力,因为它的出错信息都比较黑盒。对于内存不足的情况,本地方法栈还是会抛出native heap OutOfMemory。
这里介绍一下JNI类本地方法,最著名的本地方法应该是System. currentTimeMillis() 。JNI使Java深度使用操作系统的特性功能,复用非Java代码。但是在项目过程中,如果大量使用其他语言来实现JNI,就会丧失跨平台特性,威胁到程序运行的稳定性。假如需要与本地代码交互,就可以用中间标准框架进行解辑,这样即使本地方法崩溃也不至于影响到JVM的稳定。当然,如果要求极高的执行效率、偏底层的跨进程操作等,可以考虑设计为JNI调用方式。
三、对象实例化
对象存储信息
- 对象头
对象头占用12个字节,存储内容包括对象标记(markOop )和类元信息( klassOop )。 对象标记存储对象本身运行时的数据,如哈希码、GC标记、锁信息、线程关联信息等,这部分数据在 64 位JVM上占用8个字节,称为"Mark World"。为了存储更多的状态信息,对象标记的存储格式是非固定的(具休与JVM的实现有关)。类元信息存储的是对象指向它的类元数据(即Klass)的首地址,占用4个字节,与引用对象开销一致。 - 实例数据
存储本类对象的实例成员变量和所有可见的父类成员变量。例如:Integer 的实例成员只有一个private int value,占用4个字节,所以加上对象头为16个字节。其他的如MAX VALUE、 MIN_VALUE等都是静态成员变量,在类加载时就分配了内存,与实例对象容量无关。此外,类定义中的方法代码不占用实例对象的任何空间。IntegerCache是Integer的静态内部类,容量占用也与实例对象无关。 - 对齐填充
对象的存储空间分配单位是8个字节。如果一个占用大小为16个字节的对象,增加一个成员变量byte类型,此时需要占用17个字节,但是也会分配24个字节进行对齐填充操作。
示例:
class Demo1 {
//对象头最小占用空间12个字节
//下方4个byte类型分配后,对象占用大小是4个字节
byte bl;
byte b2;
byte b3;
byte b4;
//下方每个引用变量占用是4个字节,共20个字节
Ob]ect obj1;
Ob]ect obj2;
Ob]ect obj3;
Ob]ect obj4;
Ob]ect obj5;
//实例占用空间并非计算在本对象内,依然只计算引用变量大小4个字节
Demo2 ol = new Demo2();
Demo2 o2 = new Demo2();
//综上,Demo1对象占用: 12B + (1B × 4) + (4B × 5) + (4B × 2) = 44字节,取8的倍数为48字节
}
class Demo2 {
//double类型占用8个字节,但此处是数组引用变量,所以只占4个字节,而不是8*1000
//这个数组引用的是double[]类型,指向实际分配的数组空间首地址。在new对象时,已经实际分配空间
double[] d = new double[1000];
}
对象创建过程
- 确认类元信息是否存在
当JVM接收到new指令时,首先在metaspace内检查需要创建的类元信息是否存在。若不存在,那么在双亲委派模型下,使用当前类加载器以 ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常;如果找到,则进行类加载,并生成对应的Class类对象。 - 分配对象内存
首先计算对象占用空间大小,如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节,接着在堆中划分一块内存给新对象。在分配内存空间时,需要进行同步操作,比如果用CAS (Compare And Swap)失败重试、区域加锁等方式保证分配操作的原子性。 - 设定默认值
成员变量值都需要设定为默认值,即各种不同形式的零值。 - 设置对象头
设置新对象的哈希码、GC信息、锁信息、对象所属的类元信息等。这个过程的具体设置方式取决于JVM的实现。 - 执行init方法
初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
四、垃圾回收
垃圾回收算法
Java会对内存进行自动分配与回收管理,其中后者是通过JVM的垃圾回收(GC)来实现的。如何判断对象是否可回收?JVM引入了GC Roots。如果一个对象与GC Roots之间没有直接或间接的引用关系,比如某个失去任何引用的对象,或者两个互相环岛状循环引用的对象等,这些对象就是可以被回收的。什么对象可以作为 GC Roots 呢?比如类静态属性中引用的对象、常量引用的对象、虚拟机栈中引用的对象、本地方法栈中引用的对象等。
常见的垃圾回收算法:
- 标记-清除
该算法分为“标记”和“清除”两个阶段,首先会从每个GC ROOTS出发,依次标记有引用关系的对象,最后统一回收掉所有被标记的对象。
标记-清除算法的缺点有两个:首先是效率问题,标记和清除效率都不高。其次,标记清除之后会产生大量的不连续的内存碎片,空间碎片太多会导致当程序需要为较大对象分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。 - 复制算法(Mark-Copy)
将可用内存按容量分成大小相等的两块,每次只使用其中一块,当这块内存使用完了,就将还存活的对象复制到另一块内存上去,然后把使用过的内存空间一次清理掉。这样使得每次都是对其中一块内存进行回收,内存分配时不用考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。复制算法的缺点显而易见,可使用的内存降为原来一半。
复制算法现在作为主流的YGC算法进行新生代的垃圾回收,例如堆内存空间分为较大的Eden和两块较小的Survivor,每次只使用Eden和Survivor 区的一块。 - 标记-整理
标记-整理算法在标记-清除算法基础上做了改进,标记阶段是相同的标记出所有需要回收的对象,在标记完成之后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,在移动过程中清理掉可回收的对象,这个过程叫做整理。
标记-整理算法相比标记-清除算法的优点是内存被整理以后不会产生大量不连续内存碎片问题。复制算法在对象存活率高的情况下就要执行较多的复制操作,效率将会变低,而在对象存活率高的情况下使用标记-整理算法效率会大大提高。
垃圾回收器
- Serial
Serial回收器采用串行单线程的方式完成GC任务,垃圾回收的某个阶段会暂停整个应用程序的执行,即“Stop The World”。Serial是一个主要应用于YGC的垃圾回收器,FGC的时间相对较长,频繁FGC会严重影响应用程序的性能。 - CMS(Concurrent Mark Sweep Collector)
CMS即并发标记扫描垃圾回收器,是回收停顿时间比较短、目前比较常用的垃圾回收器。它通过初始标记、并发标记、重新标记、并发清除四个步骤完成垃圾回收工作。第 1、3步的初始标记和重新标记阶段依然会引发 STW,而第2、4步的并发标记和并发清除两个阶段可以和应用程序并发执行,也是比较耗时的操作,但并不影响应用程序的正常执行。由于CMS采用的是 “标记一清除算法 ”,因此产生大量的空间碎片。为了解决这个问题,CMS可以通过-XX:+UseCMSCompactAtFullCollection
参数,强制JVM在FGC完成后对老年代进行压缩,执行一次空间碎片整理,但是空间碎片整理阶段也会引发STW。为了减少STW次数,CMS还可以通过配置-XX:+CMSFullGCsBeforeCompaction=n
参数,在执行了n次FGC后,JVM再在老年代执行空间碎片整理。 - G1
Hotspot在JDK7中推出了新一代G1(Garbage-First Garbage Collector)垃圾回收。和CMS相比,G1具备压缩功能,能避免碎片问题。G1的暂停时间也更加可控,性能总体还是非常不错的。
G1将Java堆空间分割成了若干相同大小的区域,即region,这些区域有Eden、Survivor、Old、Humongous四种类型,优先回收垃圾最多的区域。其中,Humongous是特殊的Old类型,专门放置大型对象。这样的划分方式意味着不需要一个连续的内存空间管理对象。G1采用的是“Mark-Copy”,有非常好的空间整合能力,不会产生大量的空间碎片。G1的一大优势在于可预测的停顿时间,能够尽可能快地在指定时间内完成垃圾回收任务。在 JDK11中,已经将G1设为默认垃圾回收器。
在YGC时S0/S1并不会交换,S0/S1的功能由G1中的Survivor region来承载。通过GC日志可以观察到完整的垃圾回收过程如下图,其中就有Survivor regions的区域从0个到1个;红色标识的为G1中的四种region, 都处于Heap中。G1执行时使用4个worker并发执行,在初始标记时,还是会触发STW,如第一步所示的Pause。
五、JVM调优
JVM问题处理
问题分类:
当应用系统运行缓慢,页面加载时间变长,后台长时间无影响时,都可以参考以下归类的解决方法。绝大部分的JAVA程序运行时异常都是Full GC、OOM、线程过多。主要分这么几大类:
- 持续发生Full GC,但是系统不抛出OOM错误
- 堆内存溢出:java.lang.OutOfMemoryError:Java heap space
- 线程过多:java.lang.OutOfMemoryError:unable to create new native thread
- JAVA进程退出
- CPU占用过高
紧急处理原则:
问题发生后,第一时间是快速保留问题现场供后面排查定位,然后尽快恢复服务。保留现场的具体操作:
- 打印堆栈信息,命令行:jstack -l 'java进程PID'
- 打印内存镜像,命令行:jmap -dump:format=b,file=hprof 'java进程PID'
- 生成core文件,命令行:gcore 'java进程PID'
- 保留gc日志文件
- 保留业务日志文件
- 查看JAVA堆内存运行分配:命令行:jstat -gcutil 'java进程PID' 1000
- 完成以上操作后,尽快重启JAVA进程或回滚,恢复服务
JVM常用参数
-Xms设置堆的最小空间大小
-Xmx设置堆的最大空间大小
-XX:NewSize设置新生代最小空间大小
-XX:MaxNewSize设置新生代最大空间大小
-XX:MetaspaceSize设置metaspace初始化空间大小
-XX:MaxMetaspaceSize设置metaspace最大空间大小
-XX:PermSize设置永久代最小空间大小(已废弃)
-XX:MaxPermSize设置永久代最大空间大小(已废弃)
-Xss设置每个线程的堆栈大小
参考文献:
- 《码出高效:Java开发手册》
- 《深入理解JVM虚拟机》
- 昊蓝:JVM问题分析处理手册
- http://www.cnblogs.com/duanxz/p/3520829.html
- https://blog.csdn.net/goldenfish1919/article/details/81216560
- https://www.cnblogs.com/huajiezh/p/5769255.html