JVM夺命连环10问

以下文章来源于科技缪缪,作者科技缪缪
https://mp.weixin.qq.com/s/tmMpFmmCx2sWqpB_KibdQw

JVM夺命连环10问
JVM夺命连环10问
Java虚拟机主要包含几个区域:

:Java虚拟机中最大的一块内存,是线程共享的内存区域,基本上所有的对象实例数组都是在堆上分配空间,堆区细分为Yound区年轻代和Old区老年代,其中年轻代又分为Eden,s0,s1三个部分,他们默认的比例大小是8:1:1的大小。

:栈是线程私有的内存区域,每一个方法执行的时候都会在栈创建一个栈帧,方法的调用过程就对应着栈的入栈和出栈过程。每个栈帧的结构又包含局部变量表,操作数栈,动态链接和方法返回地址。

局部变量表:用于存储方法参数和局部变量。当第一个方法被调用的时候,它的参数会被传递至从0开始的连续的局部变量表中。

操作数栈:用于一些字节码指令从局部变量表中传递至操作数栈,也用来准备方法调用的参数以及接收方法返回结果。

动态链接:用于将符号引用表示的方法转换为实际方法的直接引用。

元数据:在Java1.7之前,包含方法区的概念,常量池就存在于方法区(永久代)中,而方法区本身是一个逻辑上的概念,在1.7之后则是吧常量池移到了堆内,1.8之后移除了永久代的概念(方法区的概念仍然保留),实现方式则是现在的元数据。它包含类的元信息和运行时常量池。

Class文件就是类和接口的定义信息。

运行时常量池就是类和接口的常量池运行时的表现形式。

本地方法栈:主要用于执行本地native方法的区域。

程序计数器:也是线程私有的区域。用于标记当前线程下虚拟机正在执行的字节码的指令地址。

JVM夺命连环10问
JVM夺命连环10问
当虚拟机遇到 new 关键字的时候,先判断当前类是否已经加载,如果类没有加载,首先执行类的加载机制,加载完成后再为对象分配空间,初始化等。

  1. 校验:当前类是否加载, 没有,执行类加载机制
  2. 加载:就是从字节码加载成二进制流的过程
  3. 验证:校验Class文件是否符合虚拟机规范,
  4. 准备:为静态变量,常量赋默认值
  5. 解析:把常量池中符号引用替换为直接引用(将引用变为指向目标的指针或句柄)
  6. 初始化:执行static(cinit)代码块进行初始化,如果存在父类,先对父类进行初始化

⚠️注:静态代码块是绝对线程安全的,只能隐式被java虚拟机在类加载过程中初始化调用!

类加载完成后,就是对象分配内存空间和初始化的过程

  1. 首先为对象分配合适大小的内存空间
  2. 接着为实例变量赋默认值
  3. 设置对象的头信息,对象hash码,GC分带年龄,元数据信息等
  4. 执行构造函数(init)初始化

JVM夺命连环10问
类加载自顶向下分为:

  1. Bootstrap ClassLoader启动类加载器:默认会去加载JAVA_HOME/lib目录下的jar
  2. Extention ClassLoader扩展类加载器:默认会去加载JAVA_HOME/lib/ext目录下的jar
  3. Application ClassLoader应用程序类加载器:加载web程序中ClassPath下的类
  4. User ClassLoader用户自定义类加载器:由用户自己定义

当我们在加载类的时候,首先会向上询问父加载器是否已经加载,如果没有则依次向上询问,如果没有加载,则从上到下依次尝试是否能加载当前类,直到加载成功。

JVM夺命连环10问
JVM夺命连环10问
JVM夺命连环10问

统一标记出需要回收的对象,标记完成之后统一回收所有被标记的对象。
标记的过程需要遍历所有的GC ROOT,清除的过程也要遍历堆中的所有对象。
效率较低,存在内存碎片的问题。

JVM夺命连环10问

解决性能和内存碎片的问题。
将内存分为大小相等的两块区域,每次使用其中的一块,当一块内存使用完之后,将还存活的对象拷贝到另一块内存区域中,然后清空当前内存。
可使用内存空间缩小了一半。

年轻代+老年代的内存结构:
Eden + S0 + S1 组成,98%的对象都是朝生夕死,实际存活的对象并不是很多,完全不需要用到一半内存浪费,所以默认比例是8:1:1。

这样,在使用的时候只使用Eden区和S0,S1其中一个,把存活的对象拷贝到另外一个未使用的Survivor区,同时清空Eden区和使用的Survivor区,内存的浪费只有10%了。

如果最后未使用的Survivor放不下存活对象,这些对象就进入Old老年代

JVM夺命连环10问

因为进入老年代的对象存活率比较高,再频繁的复制对性能影响较大,也不会有另外的空间进行兜底。
针对老年代的特点,通过标记-整理算法,标记出所有的存活对象,让所有的存活对象都向一端移动,然后清理到边界以外的内存空间。

JVM夺命连环10问
如何标记一个对象的存活?

简单的通过一个引用计数法,给对象设置一个引用计数器,每当有地方引用它,就给计数器+1,反之则计数器-1。但是无法解决循环引用的问题。

Java通过可达性分析算法来达到标记存活对象的目的,定义一系列的GC ROOT为起点,从起点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC ROOT没有任何一个引用链相连,则判定这个对象是可回收的。

可以作为GC ROOT的对象包括:

  • 栈中引用的对象
  • 静态变量,常量引用的对象
  • 本地方法栈native方法引用的对象

JVM夺命连环10问
JVM夺命连环10问
年轻代的垃圾收集器包含有:SerialParNewParallel
老年代则包括:Serial Old老年代版本CMSParallelOld老年代版本JDK11中的G1收集器

Serial:单线程版本收集器,进行垃圾回收会STW(Stop The World),会暂停其他工作线程

ParNew:Serial的多线程版本,用于和CMS配合使用

Parallel Scavenge:可以并行收集的多线程垃圾收集器

Serial Old:Serial的老年代版本,也是单线程

Parallel Old:Parallel Scavenge老年代版本

CMS(Concurrent Mark Sweep):CMS收集器是以获取最短停顿时间为目标的收集器,相对于其他的收集器 STW 更短,可以并行收集,基于标记清除算法。
分为4步:

  1. 初始标记:标记 GC ROOT能关联到的对象,需要STW
  2. 并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,不需要STW
  3. 重新标记:修正并发标记期间,因程序继续运作而导致标记产生变化的标记,需要STW
  4. 并发清除:清理删除掉标记阶段判断的已经死亡的对象,不需要STW

从整体过程来看:

  • 并发标记并发清除耗时最长,不需要停止用户进程
  • 初始标记重新标记耗时较短,需要停止用户线程

G1(Garbage First):G1收集器是JDK9的默认垃圾收集器,而且不再区分年轻代和老年代进行回收

JVM夺命连环10问
JVM夺命连环10问
G1作为JDK9之后服务端默认收集器,且不再区分年轻代和老年代进行回收。

它把内存划分为多个Region,每个Region的大小可以通过 -XX G1HeapRegionSize设置,大小为1~32M

对于大对象的存储则衍生出Humongous的概念,好过Region大小一半的对象会被认为是大对象,而超过整个Region大小的对象被认为是超级大对象,将会被存储在连续的N个Humongous Region中,G1在回收的时候会在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先回收收益最大的Region。

G1回收过程分为以下四个步骤:

  1. 初始标记:标记GC ROOT能关联到的对象,需要STW
  2. 并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,扫描完成后还会重新处理并发标记过程中产生变动的对象
  3. 最终标记:短暂暂停用户线程,再处理一次,需要STW
  4. 筛选回收:更新Region的统计数据,对每个Region的回收价值和成本排序,根据用户设置的停顿时间指定回收计划。再把需要回收的Region中存活对象复制到空的Region,同时清理旧的Region。需要STW

JVM夺命连环10问

当一个新的对象来申请内存空间的时候,如果Eden区无法满足内存分配需求,则触发YGC,使用中的Survivor区和Eden区存活对象送到未使用的Servivor区。

如果YGC之后还是没有足够空间,则直接进入老年代分配,如果老年代也无法分配空间,触发FGC,FGC之后还是放不下则报出OOM异常。

JVM夺命连环10问

YGC之后,存活的对象将会被复制到未使用的Survivor区,如果S区放不下,则直接晋升至老年代。

而对于那些一直在Survivor区来回复制的对象,通过 -XX MaxTenuringThreshold配置交换阈值,默认15次,如果超过次数同样进入老年代。

还有一种动态年龄的判断机制,不需要等到MaxTenuringThreshold就能晋升老年代。
如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

JVM夺命连环10问

发生FullGC有可能是内存分配不合理。比如Eden区太小,导致对象频繁进入老年代,这时候通过启动参数配置就能看出来,另外可能存在内存泄露,通过以下步骤进行排查:

1. jstat -gcutil或者查看gc.log日志,查看内存回收情况
JVM夺命连环10问
S0,S1分别代表两个Survivor区占比

E代表Eden区占比,图中可以看到使用78%

O代表老年代,M代表元空间,YGC发生次54次,YGCT代表YGC累计耗时,GCT代表GC累计耗时

JVM夺命连环10问
[GC [FGC 开头代表垃圾回收的类型

PSYoungGen: 6130K->6130K(9216K)] 12274K->14330K(19456K), 0.0034895 secs代表YGC前后内存使用情况

Times: user=0.02 sys=0.00, real=0.00 secs,user表示用户态消耗的CPU时间,sys表示内核态消耗的CPU时间,real表示各种墙时钟的等待时间

这两张图只是举例并没有关联关系,比如你从图里面看能到是否进行FGC,FGC的时间花费多长,GC后老年代,年轻代内存是否有减少,得到一些初步的情况来做出判断。

2. dump出内存文件在具体分析,比如通过jmap命令jmap -dump:format=b,file=dumpfile pid导出之后再通过Eclipse Memory Analyzer等工具进行分析,定位到代码,修复

CPU飙高,同时FGC怎么办?

  1. 找到当前进程的pid,top -p pid -H 查看资源占用,找到线程
  2. printf “%x\n” pid,把线程pid转为16进制,比如0x32d
  3. jstack pid|grep -A 10 0x32d查看线程的堆栈日志,还找不到问题继续
  4. dump出内存文件用MAT等工具进行分析,定位到代码,修复

JVM夺命连环10问

所有调优的目的都是为了用更小的硬件成本达到更高的吞吐,JVM调优也是一样,通过对垃圾收集器和内存分配的调优达到性能的最佳。

JVM夺命连环10问
JVM夺命连环10问

  1. -Xms设置初始堆的大小;-Xmx设置最大堆的大小
  2. -XX:NewSize年轻代大小;-XX:MaxNewSize年轻代最大值;-Xmn相当于同时配置-XX:NewSize 和 -XX:MaxNewSize 为一样的值
  3. -XX:NewRatio设置年轻代和老年代的比值,如果为3,表示年轻代和老年代比值为1:3,默认值为2
  4. -XX:SurvivorRatio年轻代和两个Survivor的比值,默认8,代表比值为8:1:1
  5. -XX:PretenureSizeThreshold 当创建的对象超过指定大小时,直接把对象分配在老年代
  6. -XX:MaxTenuringThreshold:设定对象在Survivor复制的最大年龄阈值,超过阈值转移到老年代
  7. -XX:MaxDirectMemorySize:当Direct ByteBuffer分配的堆外内存到达指定大小后,即触发FullGC

JVM夺命连环10问

  1. 开启GC日志:-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:gc.log
  2. 一般设置-Xms=-Xmx,这样可以获得固定大小的堆内存,减少GC的次数和耗时,使得堆相对稳定
  3. -XX:+HeapDumpOnOutOfMemoryError:让JVM在发生内存溢出的时候自动生成内存快照,方便排查问题
  4. -Xmn设置新生代的大小:太小会增加YGC,太大会减小老年代大小,一般设置为整个堆的1/4到1/3
  5. 设置-XX:+DisableExplicitGC:禁止系统System.gc(),防止手动误触发FGC产生问题

????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????