the site subtitle

我的架构之路(二)-JVM深度剖析

2021.08.24

最近线上项目老是OOM,且分析的原因似是非是,反反复复,无法根治,就很难受,索性研究研究JVM,作为Java开发人员,我觉得深究一下JVM很有必要。

如何下手

在探索未知的领域时,可以从一个已知的领域作为切入点进行深入,那么探索JVM我们就理所应当从源码文件入手。java源码语法编码这些是咱们已经具备的知识与技能,未知的是,我们不清楚Java是如何将一个.java文件运行起来的,当然了,我们都知道是JVM的功劳。

类加载机制

首先准备一个.java源文件,通过javac Xxx.java进行编译,得到一个Xxx.class文件,这里就涉及到了Java的类加载机制。从官方文档中得知,类加载机制主要分为三个阶段,分别是Loading/Liking/Initialization
流程如下:
e73bb840b4cae2489266cbd0f4016e1f.jpeg
这里只讨论Loading/Liking/Initialization这三个阶段,其他不展开。

  1. 加载
    顾名思义,就是将编译生成的class文件装载到JVM中,但是怎么装,这里有涉及到了类加载机制中的双亲委派机制,简单的说,双亲委派机制就是把事情先委派给父亲(这里也有可能父亲交给爷爷,直到找到祖宗)去做,父亲干不了的再自己做,从下图中可以看出,加载一个类,一定是先交给BootStrap ClassLoader去加载,如果没有则交给Ext ClassLoader,依此类推,最后才是用户自定义的Custom ClassLoader。并且各个类加载器都有各自价值的目录。

    • BootStrap ClassLoader加载的是$JAVA_HOME/lib下面的class;
    • Ext ClassLoader加载的是$JAVA_HOME/lib/ext下面的class;
    • App ClassLoader加载的是classpath下面的class;
      92a07155750708aac89cfea8fcab3f74.png

    加载完成后将类的信息放到JVM中的Method Area(方法区中);
    将对象信息放到Heap(堆内存中);

  2. 链接

    • 验证
      略(主要进行验证被加载的类的正确性)
    • 准备
      为类的静态变量分配空间,且赋默认值,例如static int a = 10;这句代码中,先为a分配空间,然后为a赋默认值0;
    • 解析
      将符号引用转为直接引用
      符号引用:将java文件编译成class文件的过程中,一个Java类并不知道所引用的类的实际地址(例如:Person中引用了Student),这个时候因为Student还没有被载入内存,所以并不清楚Student的内存地址,所以这个时候需要使用一个符号来代替。
      直接引用:直接指向目标的指针或者能间接定位到目标的句柄。
  3. 初始化
    为静态变量赋值,例如为准备阶段中a赋值为a = 10;

  4. 使用

  5. 卸载

运行时数据区(Run-time Data Areas)

先来看一张图,整个Java虚拟机运行数据区如下图所示:
8301dee7b3d49693a37a1a552a59ff40.jpeg

方法区(Method Area)

JDK1.7方法区名叫永久代(Perm),设置永久代大小使用以下参数:

-XX:Permsize 设置永久代初始分配空间
-XX:MaxPermsize 设定永久代最大可分配空间

JDK1.8方法区叫元空间(MetaSpace),设置元空间大小使用以下参数:

-XX:MetaspaceSize 设置元空间初始分配空间
-XX:MaxMetaspaceSize 设置元空间最大可分配空间

方法区随JVM的启动而创建,因为是线程共享,所以属于线程非安全区域。方法区的大小决定了当前虚拟机能容纳多少个类(注意是类而不是对象),如果空间不足则会抛出OOM;在方法区中,还存在一块比较重要的空间---常量池。

运行时常量池(Constant Pool)

运行时常量池(Runtime Constant Pool),它是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到常量池中。

堆(Heap)

堆同方法区一样,随JVM的启动而创建,因为是线程共享,所以属于线程非安全区域。如果空间不足则会抛出OOM;从一张图来清楚的认识对内存:
image.png
从上图中不禁心里会有很多疑问,为什么一个堆内存中会划分了这么多区域,而他们的作用分别是什么。

  • Old区:老年代,这里主要存放分代年龄很大(默认阈值为15)的对象或者是特别大的对象.
  • Young区:Young区包含了Eden区和Survivor区。
    • Eden区:新创建的对象直接放到Eden区
    • Survivor区:Survivor区包含了S0和S1区,这两块区域一定会有一块空间是空闲的,而整个Young区的空间分配比是Eden:S0:S1=8:1:1
      • S0:垃圾回收后将还存活的对象copy到S区的某一块区域,比如S0区,然后剩下的Eden区空间就连续了,主要是用于解决空间不连续的问题。
      • S1:当GC过后,数据被copy到S0区时,S1区处于空闲状态,直到下一次GC讲数据copy到S1区,S0空闲,交替进行。

JVM中对象分配的流程

  • Minor GC:新生代
  • Major GC:老年代,值得注意的是Major GC通常伴随着Minor GC
  • Full GC:新生代+老年代+方法区

Java虚拟机栈(Java Virtual Machine Stacks)

如下图所示:
c8e660539319c64a19114da5fb27d2ae.png
Java虚拟机栈随着线程的创建而创建,每一个线程对应一个虚拟机栈,属于线程独享区域,所以是线程安全的。栈的特点是先进后出,所以,虚拟机栈很好的表达了一个线程中方法调用链路的执行。当一个线程出现递归调用时且超过Stack Frame(栈桢)深度时则会抛出StackOverflowError。
一个线程的方法,对应虚拟机栈中的一个Stack Frame(栈桢),一个栈桢里面包含如下数据

  • 局部变量表(Local Variables)
    在Stack Frame中,局部变量从0开始从形参从做到右开始将变量放入到局部变量表中,JVM通过字节码指令进行取制赋值等操作,例如:istore_0,这里的istore就是将操作数栈中的数据弹出并赋值给第0个局部变量。
  • 操作数栈(Operand Stack)
    它是一个先入后出栈,通过将需要操作的数据压入/弹出栈的操作为局部变量赋值和读取。32位数据类型占用栈容量为1,64位占用栈容量为2。
  • 动态链接(Dynamic Liking)
    每一个方法在被调用执行的时候,例如invocation #2这样的命令,需要将#2所代表的方法压入栈,通过动态链接找到运行时常量池中具体是哪个对象的方法。从上面类加载机制中了解到,在解析阶段,将一些能确定的符号引用转换为了直接引用,而一些不能确定的符号引用仍然是符号引用,只有在每一次运行期间进行转换。
  • 方法返回地址(Invocation Completion)
    方法返回地址很好理解,也就是当前执行的栈桢(方法)执行完成后所返回的地址。这里的执行完成分为两种情况,一种数正常方法执行完成(Normal Method Invocation Completion);另一种时方法运行时抛出异常结束(Abrupt Method Invocation Completion)。

本地方法栈(Native Method Stacks)

本地方法栈和Java虚拟机栈类似,只是Java虚拟机栈用于存储Java方法的调用状态,本地方法栈用于存储C语言方法的调用状态。

程序计数器(The pc Register)

在多线程的情况下,CPU会不断的切换各个线程间的执行,当返回来执行某一个线程时,需要知道上一次执行的位置,这样才能继续接着往下执行,而程序计数器的作用就是记录了线程执行的位置。

区域引用的指向

在JVM中,各个区域有各自的作用,在分工的同时,各个区域的数据也是需要联系的。那么就出现的指向或者说引用。

方法区指向堆

方法区中会存放静态变量及常量等信息,假如在某个类中存在以下代码:

static Object obj = new Object();

我们都知道对象是存放在对内存中的,而静态变量存放在方法区中,所以就出现 了方法区指向堆。

堆指向方法区

堆用于存放对象,但是各个对象需要知道自己的类的元数据,而类的元数据保存在方法区,所以就有了堆指向方法区,具体的指向逻辑是,在每一个对象中都分为三个部分,分别是对象头/实例数据/对齐填充,先看下图:
image.png

  • 对象头:对象头还包含三个部分,Mark Word/Class Pointer/Length
    • Mark Word:里面主要包含了对象的分代年龄,锁状态,hash码等等,64位系统占用8个字节。
    • Class Pointer:指向方法区中类的元数据的内存地址,64位系统占用8个字节。
    • Length:数组特有的长度信息,占用4个字节。
  • 实例数据:用于存放我们自己定义的类的信息。
  • 对齐填充:当整个类(对象头加实例数据)所占用的空间倍数不是8字节的整数倍时,对齐填充就用来补齐,例如对象头加上实例数据的大小为19字节,则对齐填充所占用空间就为5,因为19+5=24,24/8=3为整数倍。

栈指向堆

更确切的讲应该是栈桢指向堆,因为一个方法的执行可能会有对象局部变量,而对象又存放在对内存中,所以,栈桢中局部变量指向堆是很常见的。

垃圾回收机制

JVM中还有一个重要的知识点,也就是垃圾回收机制。这里重点讨论的是对内存的垃圾回收,并不是说其他区域没有垃圾回收,只是说,对内存的垃圾回收比较典型。

什么是垃圾?

首先我们怎么判断某个对象是否是垃圾,在JVM中有两种方式,这两种方式是一个互补的关系。

  • 引用计数:引用计数也就是某个对象是否被其他对象所引用,如果是则不能被认为是垃圾,所以不能被回收,但是该种方式有个问题就是,比如说多个对象互相引用,这就出现了循环引用,所以这就不能被回收掉,这是有问题的。
  • 可达性分析
    可达性分析,这个就需要有一个GCRoot,通过这个GCRoot去找,看能否找得到,能找到说明不是垃圾。那么这个GCRoot又该如何去确定,首先呢这个GCRoot应该具备如下特点:应该长时间存活,例如ClassLoader/Java虚拟机栈中的本地变量表/静态成员/常量等等。为什么虚拟机栈中的本地变量表能被认为是GCRoot,因为本地变量表的存在代表着栈桢的存在,栈桢的存在代表一个方法的执行,一个正在执行的方法中的对象可以被认为是GCRoot。

垃圾回收算法?

  • 标记-清除:该算法执行效率较低,且会造成内存不连续的问题。如下图所示:
    e61ffc85b0f8ac3d6f4d890b22e7a012.jpeg
  • 标记-整理:该算法相对来讲比标记清除更优,因为它解决了内存不连续的问题。老年代一般使用标记整理算法的较多。如下图:
    dae40982d078335a6ced4f3bccb99c57.png
  • 复制算法:复制算法很好理解,首先会划分出两个等份大小的空间,先标记出存活的对象,然后将存活的对象copy到一个空间中,下一次GC时,将存活的对象copy到另一边,该方式的好处是空间连续,但是会造成一块空间的浪费。因为需要将存活对象进行复制,而复制也是一种开销,所以该算法适合存活对象较少的区域进行垃圾回收,而Young区正好符合这一条件,也就是说,在Young区中,很多对象都是朝生夕死的。所以在Young区的垃圾回收就是复制算法的落地实现。通过下图可以形象的表示复制算法:
    90a7fb323ac2a7a20509ad58131be711.png

垃圾收集器

由于我们讨论的是Java8,所以到目前为止JVM提供了如下垃圾回收器,Java8以后的垃圾收集器暂且不去讨论
bc670f07e78c301d9fefb34b795f549a.png
使用方式:

-XX:+UseSerialGC = Serial New (DefNew) + Serial Old
小型程序。默认情况下不会是这种选项,HotSpot会根据计算及配置和JDK版本自动选择收集器
-XX:+UseParNewGC = ParNew + SerialOld
这个组合已经很少用(在某些版本中已经废弃)
-XX:+UseConc(urrent)MarkSweepGC = ParNew + CMS + Serial Old(jdk1.8的命令是UseConcMarkSweepGC,有的jdk版本是要加上括号部分的。Serial Old是替补)
-XX:+UseParallelGC = Parallel Scavenge + Parallel Old (1.8默认) 【PS + SerialOld】
-XX:+UseParallelOldGC = Parallel Scavenge + Parallel Old
-XX:+UseG1GC = G1

Serial

该收集器是一个单线程的,适用于新生代的垃圾收集器,由于是单线程,所以效率并不高,且会在垃圾收集时停顿用户线程。
image.png

ParNew

该收集器是多线程并行进行收集的垃圾收集器,好处是多线程并行,在多核CPU下效率比Serial高。
image.png

Paraller Scavenge

和ParNew收集器差不多,不过它更注重吞吐量(吞吐量=业务代码执行时间/(业务代码执行时间 + 垃圾回收执行时间),吞吐两越大,垃圾收集的时间就越短,业务代码的执行效率越高)

Serial Old

该收集器是Serial收集器的老年代版本。实现的算法为标记整理算法

Paraller Old

是Paraller Scavenge收集器的老年代版本,不同的是Paraller Old使用的是标记-整理算法。

CMS(Concurrent Mark Sweep)

CMS收集器适用与老年代,注重停顿时间,是并发标记-清除算法的落地,注意,它是并发的进行垃圾收集,和之前并行的收集不是一个概念,并发是指用户线程和垃圾回收线程一起执行,并行指的是同一时间要么用户线程要么垃圾回收线程一起执行。
image.png
从上图中看,似乎停顿的时间更长了,其实只是图示而已,真正停顿的时间远比Serial等算法要短。

G1(Garbage First)

从上图(垃圾收集器汇总)中我们发现,为什么G1能横跨Young区和Old区,难道它实现了两种垃圾回收算法,其实这里的G1垃圾收集器将对内存重新进行了布局,将整个堆划分成了多个Regin,Regin也分为三种类型,分别是Eden/Survivor/Old,但是物理上已经不是连续的了。如下图所示:
b3e34ea162a425107c277d8ffbdb16b3.png
G1的特点是追求更短的停顿时间,我们可以设置我们期望的停顿时间,G1的垃圾回收如下图所示:
image.png

垃圾回收日志

我们有时候需要堆GC日志进行分析,那么一个Java进程如何将gc日志导出呢,其实在jvm参数中就有导出gc日志的配置,只需要几个简单的配置项就能将其导出了。具体参数如下:

java -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:gc.log -jar xxx.jar

GC日志常用参数:

#必备
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintTenuringDistribution
-XX:+PrintHeapAtGC
-XX:+PrintReferenceGC
-XX:+PrintGCApplicationStoppedTime

# GC日志输出的文件路径
-Xloggc:/path/to/gc-%t.log
# 开启日志文件分割
-XX:+UseGCLogFileRotation
# 最多分割几个文件,超过之后从头文件开始写
-XX:NumberOfGCLogFiles=14
# 每个文件上限大小,超过就触发分割
-XX:GCLogFileSize=100M

JVM参数

参数分类

JVM参数分为三大类,分别是,标准参数(-)/非标准参数(-X)/非稳定参数(-XX)

  • 标准参数:该种类型的参数表示所有版本的JVM都必须实现这些参数,例如:java -version/-help等等,且向后兼容。
  • 非标准参数:默认实现这些参数,但不保证所有jvm都满足,且不保证向后兼容。例如:-Xms100M
  • 非Stable参数:各个JVM实现会有所不同,将来可能会随时取消,且不会发布通知,非Stable参数类型分为两种,分别是bool参数和非bool参数。
    • boolean参数:该种类型的参数使用+/-的方式来表示true或者false;例如-XX:+UseG1Gc
    • 非boolean参数:该种方式的参数采用K=V键值对的方式表示,例如-XX:MaxMetaspaceSize=10m

参数的查看及设置方式

首先需要设置某个参数可能需要先查看,查看之前的参数值是多少,进而通过分析才能明确知道我们需要设置成多少来达到调优的目的。查看Java进程参数的方式大致分为以下两种:

  • 通过设置JVM参数的方式:java –XX:PrintFlagsFinal -jar xxx.jar,该种方式并不常用。
  • 通过jinfo的方式:jinfo -flag MaxPermSize pid

设置JVM参数的方式有很多种,可以在IDEA或者Eclipse等开发工具种设置VM Options,或者在启动某个java程序时使用java -XX:MaxNewSize=512M -jar xxx.jar(线上环境一般使用这种方式),再或者可以在某些Java进程中的配置文件进行设置;当然了,以上几种方式都需要重启Java进程,有的时候我们可能不希望或者不允许进程停止(因为进程停止意味了业务中断),我们也可以使用jinfo(JDK自带工具)来进行设置,具体设置语法如下:

布尔类型: jinfo -flag +-参数 pid
非布尔类型: jinfo -flag 参数名=参数值 pid

需要注意的是,并不是所有的jvm参数都能实时设置,只有当参数是manageable时才能实时修改。

JDK自带工具

  • jps:查看当前服务器上的所有Java进程
  • jinfo:查看及设置某个Java进程的jvm参数
  • jstat:查看某个Java进程的状态
  • jstack:查看某个Java进程的线程情况,该工具可用于排除死锁问题
  • jmap:查看某个Java进程的对内存情况,也可以将当前进程的对内存情况dump出一个文件进行分析,但用处不大,真正的用处在于,在Java进程出现OOM的时候自动的dump出日志文件,设置方式为:java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=dump.hprof -jar xxx.jar
  • jconsole:用于监控本地或者远程的Java进程,能查看一些内存及线程信息
  • jvirtualvm:用于监控本地或者远程Java进程,并且能分析一些日志文件,比如说内存溢出日志hprof文件等等。

以上工具可能功能上和jconsole有一部分的重叠,但是各个工具有各自的好处,就看我们开发者自己习惯使用哪一个。

如何监控远程Java进程?

监控远程Java进程我们需要在远程的Java进程中开启JMX,一样说通过设置JVM参数的方式,需要设置的参数如下:

-Dcom.sun.management.jmxremote=true 
-Dcom.sun.management.jmxremote.port=8996 
-Dcom.sun.management.jmxremote.authenticate=true
-Dcom.sun.management.jmxremote.password.file=jmx.password
-Dcom.sun.management.jmxremote.access.file=jmx.access
-Dcom.sun.management.jmxremote.ssl=false 
-Djava.rmi.server.hostname=xxx.xxx.xxx.xxx

jmx.access文件内容如下:
monitor readonly
control readwrite

jmx.password文件内容如下:
monitor 123456
control 123456

通过以上jvm参数就成功开启了jmx,并且是以安全认证的方式,我们也可以使用非安全的方式(不推荐),需要开启不安全的jmx只需要将-Dcom.sun.management.jmxremote.authenticate=false即可。

内存分析

工具:MAT,PerfMa

垃圾收集日志分析

工具:gceasy.io

JVM调优指南

调优这个词从我刚入行都听过了,到现在六七年了,仍然只是面试中会被问到,但实际中并没有涉及。知道前段时间线上项目出现了OOM,CPU飙高等问题,才渐渐的深入。首先我们要明白,调优调优,就是调整一些参数使程序能达到最优的性能。那么我们评判一个系统是否最优应该通过那几个指标来衡量,其实并没有什么最优解,只是根据业务场景来进行取舍,其实JVM调优主要看两个指标,分别是吞吐量以及停顿时间(或者说响应时间,停顿时间和响应时间是成正比的,停顿时间越长,响应时间越长,反之亦然)。

  • 停顿时间:停顿时间也就是垃圾回收时导致的用户线程被暂停的时间,也就是我们经常说的Stop The World
  • 吞吐量:吞吐量 = 用户代码执行时间 / (用户代码执行时间 + GC执行时间),吞吐量越高则表明GC执行的时间越短,GC时间短了垃圾回收不全面,所以就会导致将垃圾放到下一次GC去回收,而因为上一次GC没有回收全面,所以GC的频率就会升高。

综合来看,在相同的内存情况下,吞吐量越大就会导致GC次数越多,单次GC的停顿时间越短;停顿时间调的越短可能会导致吞吐量的下降(这里或许不那么好理解,说为什么停顿时间短了,吞吐量反而可能会下降,有这个疑问的同学一定是将GC执行时间和停顿时间画上了等号,这其实不是一个东西,GC执行时间大于等于停顿时间,也就是说GC的过程中不一定会中断用户线程的执行,那为什么有可能等于呢,在某些垃圾收集器中,进行垃圾回收时就会停止用户线程,比如Serial),但GC次数一定会增多。

如何衡量应用所需服务器资源

运行一个程序所需要的服务器硬件资源或者说所需要占用的内存空间,我们可以从应用时间的并发量和各请求的对象数量及大小来大概的推导。

计算一个对象的大小

我们应该怎么计算一个对象所占用的内存空间,从前面所学的知识可以找到,一个对象分为三个部分组成,分别是对象头/实际数据/对齐填充等。举个例子,以下对象占用多少空间

public class Person {
  private String name;
  private int age;
  // ...
}

这里我按照自己的理解来结算该对象所占用的内存空间,不对之处望指正,我们知道,一个对象(非数组)的对象头包含了Mark Word/Class Pointer,这两块分别占用8个字节,也就是16字节,int类型占用4个字节,String为引用类型(这里其实自己有点模凌两可,到底应该是算引用类型还是结算String中的数据占用)占用8个字节,对齐填充大小为4字节(16+4+8=28,而28 mod 8 = 3 余 4,因为必须一个对象的占用内存空间必须是8的倍数,不够的通过对齐填充来凑,所以8-4=4,对齐填充的大小为4字节),综上所说,该Person对象的大小为16+4+8+4=32字节。

参考

https://blog.csdn.net/a772304419/article/details/104019079
https://www.jianshu.com/p/c6d75f4c0b71
https://www.cnblogs.com/xhj928675426/p/14455406.html
https://www.jianshu.com/p/e832aa3b8b70