← 全部文章

JVM 面试笔记

Java12 min read

目录

线上短信平台JVM调优

我在短信平台中做过一次 JVM 调优。当时系统使用 JDK8 + CMS,堆大小 1GB,线上出现 Full GC 频繁的问题。通过分析 GC 日志发现 Old 区长期接近 100%,CMS 回收效率很低,并且出现 2 秒左右的 STW。最终我们将堆扩大到 2GB,并将 CMS 切换为 G1,同时设置 MaxGCPauseMillis=100 等参数。优化后 Full GC 基本消失,GC 停顿降到 50~100ms,系统 RT 也明显稳定。

  • 为什么G1能解决 CMS:减少停顿,但无法避免碎片 G1:控制停顿,并解决碎片问题

我们原来的 GC 是 CMS,它使用 Mark-Sweep 算法,会产生内存碎片,同时在对象分配速度较高时容易出现 Concurrent Mode Failure,从而退化为 Full GC,导致 2 秒以上的停顿。而 G1 使用 Region 化内存管理和 Mark-Compact 算法,可以避免碎片,并且优先回收垃圾最多的 Region,同时通过 MaxGCPauseMillis 控制停顿时间,因此更适合像短信平台这种字符串和大对象较多的服务。

G1使用Region管理内存,那如果是大对象的话,Region如何存储呢

  • G1 中的大对象如果超过 RegionSize 的一半,就会被当作 Humongous Object 处理。这类对象不会进入年轻代,而是直接在 Old 区分配多个连续的 Region 来存储,并且这些 Region 会被标记为 Humongous Region。由于对象必须连续存储,因此会按 Region 整数倍分配,多余空间会产生浪费。同时 Humongous Region 只能整体回收,不能像普通 Region 那样部分回收,因此在大对象频繁创建的场景下,会导致 Old 区增长较快,甚至引发 GC 抖动。

  • 此外,如果堆中连续 Region 不足,即使总内存足够,也可能因为找不到连续空间而触发 GC,这也是 G1 在大对象场景下可能出现性能问题的原因之一。

垃圾回收算法

常见的三个:

  • 复制清除: 理论上将内存分为两片区域,只在一片区分存放对象,一片满了,保留可用对应复制到另一片,然后清空当前片内存。 空间浪费、回收速度快
  • 标记清除: 根据不同的算法,比如三色标记对可用对象进行标记,将没标记到的,直接干掉。 空间没问题,回收速度可以,但是有内存碎片问题。
  • 标记压缩(整理): 根据不同的算法,比如三色标记对可用对象进行标记,将没标记到的,直接干掉。然后整理空间,避免碎片化问题。 空间ok,速度稍慢,没有碎片化问题。

常见的垃圾回收器

从古老的,聊到新的。

  • Serial New、Serial Old:非常远古的垃圾回收器,单线程回收,速度嘎嘎慢。

  • Parallel New、Parallel Old:JDK1.8默认的垃圾回收器,多线程回收,速度稍快,有STW。

    • STW 是 Stop The World 的缩写,指在 JVM 进行垃圾回收时,会暂停所有应用线程,只允许 GC 线程执行。这样做的原因是为了保证对象引用关系在回收过程中不会发生变化,避免出现数据不一致的问题。

STW 会导致应用短暂暂停,所以如果 GC 停顿时间较长,会影响系统响应时间。像 Parallel GC 这种回收器主要通过 STW 加多线程回收来提高吞吐量,而像 CMS 和 G1 则通过并发回收减少 STW 时间。

  • ParNew、CMS:CMS是并行的垃圾回收器,除了初始标记、重新标记位置需要STW之外,回收的时候是并行的,没有STW。有内存碎片问题

  • G1(JDK9默认):分区回收,把整个堆分成上千个区域,每次就回收垃圾最多的位置,STW时间短。

  • ZGC:毫秒级别的回收,不需要做JVM调优相关的操作。

JDK1.8的垃圾回收机制,为什么不用CMS

JVM 的垃圾回收主要发生在堆内存中。在 JDK8 中堆主要分为新生代和老年代。JVM 采用分代回收策略,因为绝大多数对象生命周期较短。

新生代包括 Eden、Survivor0 和 Survivor1,通常使用复制算法。当 Eden 满时触发 Minor GC,存活对象会复制到 Survivor 区,多次存活后晋升到老年代。

老年代一般使用标记整理算法。JDK8 常见垃圾回收器包括 Serial、Parallel、CMS 和 G1。

JDK8 默认垃圾回收器是 Parallel GC,而不是 G1。从 JDK9 开始 G1 才成为默认回收器。

G1 的核心思想是把堆划分成多个 Region,通过并发标记统计各 Region 垃圾比例,优先回收垃圾最多的 Region,并通过 Mixed GC 同时回收年轻代和部分老年代。

相比 CMS,G1 可以减少内存碎片,并且能够通过 MaxGCPauseMillis 参数实现可预测的停顿时间,因此更适合大堆和低延迟场景。

在实际生产环境中,如果出现 GC 频繁或者 Full GC,需要通过 GC 日志、jstat 等工具分析堆使用情况,并通过调整堆大小、GC 参数或优化对象创建方式进行调优。

JDK9之后没有JRE了?

是的,JDK9之后,就不区分所谓的JDK跟JRE了。 JDK8之前,必然是区分JDK,JRE啥的,核心的类库基本都是rt.jar中。 这里的rt.jar,非常大,60M左右,只要JVM启动,就需要将这么大的rt.jar甩到你的内存里,成本有点高。违反了单一职责的原则。 JRE中对一些常用的JDK中的工具无法使用,导致线上排查的时候还是需要提供JDK环境。

在JDK9版本之后,JDK就不细分为,JDK跟JRE了,只有一套JDK了。 其次,JDK9之后,将一些核心的类库都做了模块化的区分。你需要哪个jmod,再去加载对应的jmod即可。不需要类似之前的rt.jar的方式,一次性全搞进来。。。。

image.png

双亲委派的变化

本质就是在加载.class文件时,会向上委托的方式,优先让最高层的ClassLoader去加载,如果加载不到,再往下去委派。

代码实现本质就是基于递归走的。

第一次走是从Custom - App - Ext - Bs,这次走的过程是加载过没?加载过,直接用。

第二次是从Bs - Ext - App - Custom,这次是去加载,根据每个ClassLoader负责的位置,去加载。

核心目的是为了规避核心类被篡改。 image.png

在JDK8以及之前,整体是这哥几个。

ApplicationClassLoader -委派> ExtensionClassLoader -委派> BootstrapClassLoader -加载> ExtensionClassLoader -加载> ApplicationClassLoader

而在JDK9的变化之后,其中 ExtensionClassLoader 就没有了,他被替换为了 PlatformClassLoader(platform平台)

其他的类加载器没啥变化,依然是 BootstrapClassLoaderApplicationClassLoader。 负责的内容有一内内的小变化

BootstrapClassLoader:java.base的jmod加载,常用的String,Integer……

PlatformClassLoader:java.base之外的jmod,比如Statement,需要加载java.sql.jmod,日志,需要加载java.logging.jmod …………

ApplicationClassLoader:依然是加载你的classpath目录下的各种内容……

双亲委派在JDK9之后,依然存在。

但是因为这种模块化的加载方式,他提供了一方一个loadClass的方法。

final Class<?> loadClass(Module module, String name) {…………}

这种方式可以直接定位到具体的Module(即上图中的.mod文件),确认好需要哪个类加载器去加载,减少一次所谓的委托过程。

双亲委派依然有,在没有使用loadClass的Module方式,或者无法直接确认模块时,他依然要走双亲委派的过程,去基于Application一层一层委托。

JVM内存结构细化

  • 线程私有:虚拟机栈,本地方法栈,程序计数器
  • 线程共享:堆,方法区(方法区是JVM虚拟机的规范,一般实现是元空间)

image.png

程序计数器: 占用的空间特别小,他只需要记录程序执行到哪个指令了,方便在CPU切换时间片回来的时候,可以继续往下执行。

虚拟机栈: 方法执行时,需要栈帧压栈执行。需要细化栈帧内部都有啥?

  • 局部变量表: 存储方法中声明的一些变量,比如基本数据类型,对象的引用地址等等~
  • 操作数栈: 方法指向过程中,一些临时变量数据,存储到这个操作数栈中。
  • 动态链接: 符号引用转换为直接引用。 比如 Class "com.mashibing.Test" Method "heiHeiHei" (String s1) void -> 0x45646abc
  • 方法返回地址: 方法结束,栈帧弹栈,可以正常结束,异常结束。

本地方法栈: 跟虚拟机栈一样,只不过这哥们执行native方法。

堆: 堆里主要就是存储对象,数组,运行时常量池啥啥的。最主要的就存对象。

  • 区域划分: 分为新生代跟老年代
    • 新生代: 1/3
      • Eden: 8/10,新对象直接扔Eden区
      • survivor0:1/10,存放存活对象的。
      • survivor1:1/10,跟上面交替存储存活对象。
    • 老年代: 2/3
  • 基本指令:
    • xms,xmx:分别是堆内存初始大小,跟堆内存最大大小。设置成一样的。
    • NewRatio:指定新生代跟老年代的比例,N:1默认 2:1,你可以设置1:1,或者其他。
    • SurvivorRatio:指定Eden区空间的比例,N:1:1默认 8:1:1,也可以调整。
  • 细化对象在堆内存的结构:
    • 对象头(MarkWord,ClassPoint)12字节/16字节。
    • 数组长度(只有数组有)4字节
    • 实例数据(对象里的各种属性数据啥的~)
    • 对象填充/补齐 (确保对象是8n大小)

方法区: 方法区是JVM虚拟机的规范,在不同版本的虚拟机里实现不一样,一般聊hotspot版本的

  • JDK1.6~1.7:永久代,占用的是JVM内存
  • JDK1.8空间,占用堆外内存,元空间
  • 存储内容:主要就是Class对象,其次还有常量池(运行时常量池在堆里),static变量。存储内容除了Class空间占用比较多,其他的还好。

新生代何时升级到老年代

1、对象头中的MarkWord里,存储了一个分代年龄,占用了4个bit位,当新生代对象在新生代中经历了一个minorGC没被干掉之后,年龄就会+1,让年龄到了设置的阈值之后(默认15,已知CMS是6),到了阈值后,下一次再执行minorGC,对象就会升级到老年代。

2、当执行MinorGC时,存活的对象需要放到survivor中,当时存活的对象如果超过的survivor的大小,放不下的对象会直接升级到老年代。

3、动态年龄的判断,当某一个年龄的对象的总和,超过了survivor的一半空间,直接将大于等于这个年龄的对象都升级到老年代。

  • 举个栗子:survivor区10MB,其中年龄=8的对象占用了6MB,将年龄≥8的对象,都扔老年代里。

4、大对象会直接扔老年代,虽然不是升级过去的,但是他也是扔到了老年代里。(大对象不设置,默认没有,用PretenureSizeThreshold参数控制)

Java中创建对象,内存分配的过程

1、逃逸分析(决定对象是否能留在栈上)

  • JVM在创建对象前,会先对对象做逃逸分析,判断对象是否可以逃逸出当前的方法或者是线程。
    • 如果对象仅在当前方法里玩, 没逃逸,就可以尝试栈上分配, 随着方法的生命周期走。
    • 如果对象存储静态变量,传递给其他线程,代表 逃逸了,只能扔堆里。
  • 目的:方法执行结束后,栈帧弹栈,栈上分配的对象也会随着栈帧被释放掉,无需GC回收,减少GC压力。

2、大对象

  • JVM通过PretenureSizeThreshold参数的设置,可以指定大对象,如果构建的对象大小大于这个阈值,对象会直接分配的老年代。
  • 目的:因为新生代的Eden,survivor在GC时,要反复的复制这个大对象,浪费空间和时间。

3、TLAB

  • TLAB就是JVM中的Eden区,提前给线程分配的一块私有空间。如果创建的对象可以放到TLAB中,那就直接扔到Eden区的TLAB里。
  • 目的:线程在Eden区开辟内存空间,属于多线程操作共享资源,需要加锁,如果能扔TLAB里,就减少了至少一次锁竞争的问题。

4、Eden

  • 如果TLAB不够,需要在Eden区开辟一片空间,将对象扔到Eden区中。

5、GC

  • 如果Eden区空间不够
    • 触发MinorGC,回收Eden区空间
      • 回收完毕,空间必然足够,对象扔Eden区回收的空间里。
    • 如果老年代没有空间存储晋升的对象,直接触发FULL GC
      • 回收完毕,有空间,对象扔Eden区回收的空间里。
      • 回收完,空间不够,再次触发FULL GC,连着软引用一起干掉
        • 回收完毕,有空间,对象扔Eden区回收的空间里。
        • 回收完,空间不够,OOM。

image.png

类加载过程

加载: 将.class文件,加载到JVM内存里。 (拐到双亲委派)

验证: 判断加载进来的.class文件内容是否对劲。

准备: 给静态变量分配内存,并且赋初始值

解析: 符号引用转换为直接引用

初始化: 静态变量的赋值,静态代码块啥的,就开始走了

线上频繁FULL GC,或者GC时间长,你怎么排查定位处理?

1、需要监控,比如用Prometheus + Grafana,第一时间知道出现了内存相关的问题,无论是频繁GC还是GC时间长。

2、这两个问题可能是什么导致的:

  • 频繁FULL GC更多的趋向于内存泄漏的问题。
  • GC时间长,更多的可能是内存分配不合理,比如新生代小,导致回收太过频繁,导致大量对象升级到了老年代,或者大对象太多之类的。

3、如果有日志,直接分析日志,尝试定位问题。

4、如果测试环境,可以复现问题,那直接在测试环境,无论是Arthas,或者jmap之类的,直接导出堆转储文件,利用MAT工具分析,可以非常直观的看到哪个对象占用的空间比较多。

5、如果测试环境无法复现,那就只能在生产环境玩。

  • 100%会影响程序的性能,咱们需要做到的就是影响的小一点。
  • 先不考虑jmap这种耗时比较大的指令,如果jstat能搞定,直接jstat。
  • 如果还不成,再考虑jmap只导出存活的对象,耗时会短一些,或者直接用jcmd替换jmap去玩。
  • Arthas他导出文件的时候,底层也是jmap之类的。
  • 实在实在定位不到,半夜,jmap直接导出。。。。。。

6、既然除了这种问题,就需要开始的时候,设计好一些内容,比如类似字节的byteJVM的工具,做好一些信息的增量存储,毕竟后期需要全部导出的问题。

← 全部文章