面试系列(一):JVM

394 阅读23分钟

Root对象都有哪些?

  • Root根对象主要有栈帧中的方法的参数,局部变量,临时变量。
  • 方法区中的类静态属性引用的对象。
  • 常量池中常量引用的对象。
  • 本地方法栈中native方法引用的对象。
  • Java虚拟机内部的引用,例如基本数据类型对象的class对象。
  • 还有系统类加载器。

 

请大概讲一下CMS垃圾收集器?

  CMS垃圾收集器是HotSpot虚拟机提供的一款垃圾收集器,是第一款真正针对并发收集的垃圾收集器,它实现了让用户线程和垃圾收集线程基本上同时工作。

CMS垃圾收集器主要针对的还是分代收集中的老年代进行垃圾收集。当然,在新生代中,Hotspot也提供了一款能配合CMS的垃圾收集器,ParNew垃圾收集器。这两款垃圾收集器,进行垃圾收集的时候,主要的目的还是降低延迟性。说白了,就是为了降低STW的时间。因为现在互联网上,基于互联网网站的服务,都会比较关心服务的响应速度,希望系统的停顿时间比较短,以给用户更好的用户体验。我们公司也同样如此,是B/S系统的服务,也因此,我们部门的JVM中使用的垃圾收集器是CMS垃圾收集器和ParNew垃圾收集器。Jdk采用的1.8,服务器的配置是4核8G,也刚好满足了CMS垃圾收集器对核心数的要求。

为什么CMS垃圾收集器对服务器的核心数有要求呢?因为我们知道,CMS垃圾收集器的步骤有四步。

初始标记,并发标记,重新标记,并发清除。

初始标记是最好理解的,就是垃圾收集器去获取到所有的root对象,然后通过root对象先将root对象能直接关联到的对象标记一下。因为需要获取到所有的root对象,所以这一步仍然需要STW,不过时间上很短,是可以接受的。

并发标记的话,是通过root对象去遍历整个对象图的过程,这一步是可以和用户线程同时执行的,不需要STW。

而重新标记阶段则是为了修正并发期间期间,因为和用户线程同时执行,那么对象的状态必然会有变化,那么重新标记就是为了对这些变动的对象进行重新扫描,并进行标记。这一阶段也是需要STW的,原因很明显,是为了不再出现对象变动的情况。

而并发清除,就是要清除掉所有的已经被标记死亡的对象。CMS的清除算法主要是标记-清除算法。

那么,为什么CMS对服务器的核心数有要求呢?因为我们知道,在并发标记和并发清除阶段,用户线程和垃圾收集线程是同时执行的,这样的话,垃圾收集这种操作,必然是占据了一部分线程资源的,那么如果当服务器核心数不高的话,CMS垃圾收集对用户线程的影响就会变得很大。

CMS有缺点,主要缺点是CMS会产生一些浮动的垃圾,在并发清除阶段,因为垃圾收集线程是用户线程并发执行的,那么这意味着标记阶段已经结束了,那么用户线程新产生的垃圾对象,就没有得到回收。而此同时,又是由于垃圾回收阶段,用户线程也需要继续执行,这意味着JVM必须预留一部分空间给到用户线程,这就是CMS垃圾收集器对JVM的堆空间的利用率就没那么高,有一些浪费。

一般来说,我们设置的老年代的空间达到了92%的时候,会启动CMS垃圾收集器,但是如果在CMS收集的时候,预留的空间不够我们的用户线程分配对象,那么就会引发一次Full gc,那么,就会冻结用户线程的执行,并且紧急采用serial old垃圾收集器进行老年代的垃圾收集,这样的话,STW的时间就很长了。

还有一个缺点,因为CMS垃圾收集的清楚对象的算法是标记-清楚算法,这意味着会产生内存碎片,如果一个大的对象需要生成,但是堆里面没有合适的内存空间给这个对象,那么也会产生一次Full gc。CMS提供了一份参数可以进行配置,就是在进行full gc之前,将内存碎片进行标记-整理。

 

请大概讲一下G1垃圾收集器?

  G1垃圾收集器是第一款开创了垃圾收集器面向局部收集的设计思路。它主要的一个特质,就是可以预测停顿时间,能够支持指定在一个以毫秒为单位的时间区间内完成垃圾回收。

  在G1之前,所有的垃圾收集器的目标要么就是整个新生代,要么就是整个老年代,或者是整个java堆。但是G1不是这样的,它跳出了这个设计思路。

  G1将这个连续的java堆分成了许许多多的大小相等的独立区域,既Region区间,Region区间可以扮演新生代的eden区,Survivor区域,或者是老年代。

  垃圾收集器对不同角色的区域,通过不同的策略来进行收集处理,这样不论是新创建的对象还是已经存活了一段时间的对象,都能够有着很好的收集效果。

  除此之外,当一个对象的大小超过了Region的一半的时候,当做大对象,而我们将存放这个大对象的Region视为Humongous Region。

  那么,G1是如何回收这些Region区域的呢?首先,G1将会去追踪各个Region的价值大小,这个价值,就是回收所能获取的内存大小,和回收需要的时间的比,然后在后台维护一个优先序列表,每次根据用户指定的回收时间的时间大小,来判断会回收哪些Region。

  要实现G1垃圾收集器的这种思路,主要的难点是如何分析解决各个Region之间的跨Region引用对象?

  在CMS垃圾收集器中,也有新生代和老年代之间的跨代引用的问题,解决方案是在新生代中,创建一个记忆集,用来存储老年代中有哪些内存区域有和新生代有跨代引用。而在G1中,为了能够分析解决跨Region引用,在每一个Region中都创建了一个记忆集,用来记载这个Region和哪些Region有引用关联。正是因为这些庞大的记忆集,导致了G1需要使用大概10%-20%的java堆空间来保存这些记忆集,这无疑是个庞大的内存支出。这也是G1的一个最大的缺陷。

  而且要注意的是,G1在维护卡表,也就是记忆集的时候,不禁使用了和CMS相同写后屏障来维护,而且还使用了写前屏障来维护卡表,原因很简单,因为G1使用的标记方法里面,使用的原始快照,而CMS使用的是增量更新

  G1的流程和CMS大概相似,也是先初始标记,然后在并发标记,然后最终标记,最后进行清除。要注意的是,G1的清除阶段,不是和用户线程并发执行的,而是STW的,也就是说,G1不会产生浮动垃圾,这一点和CMS是不一样的。

 

 

请大概讲一下你的OOM(内存溢出)的排查经验?请大概一下你的JVM的调优经验?

  内存溢出这种情况,在我的实际开发中,还是比较少见的,但是如果出现了线上服务器的问题,出现这种内存溢出的情况又是比较常见。

  首先,我要先确定是否是内存溢出。如果报错日志中出现OutOfMemoryError,那么我们基本上可以判断是内存溢出,但是是什么样的内存溢出呢?

  这个时候,我一般是和运维联系,让他们dump一份内存快照出来,然后通过工具jProfile进行分析。

  首先,我会检查GC root的引用链,找到泄露对象是通过什么样的引用路径,和GC root有关联,然后再判断这个对象是否是需要存活着的,如果不是,那么就是内存泄露了,那么就是需要优化代码了。如果对象是需要存活着的,那么就不是内存泄露了,这就意味着我们的堆空间太小了,需要调整,修改java堆的参数,-Xmx,和-Xms。

  有一次,我们发现,我们的网站,在线上环境的时候,很久才会出现一次STW,但是在预发布环境,却经常出现STW,然后查看预发布日志,发现了在STW的前后,出现了内存溢出的报错日志。

  本地测试的时候,运行了很久的程序,却始终没有出现STW的情况,于是猜测是否是本地跑的电脑的性能比较好的原因,因为IDEA设置JVM内存大小是4个G,于是和运维沟通,运维表示预发布环境的服务器配置是2核4G,我们都知道,实际JVM的堆大小,是不可能设置为4个G的,因为还有直接内存需要留出空间来,也因此,JVM的堆大小,运维配置的是1.5个G,分配给老年代的是1G。

  于是和运维沟通,在JVM配置中开启了OOM快照,然后等到出现了OOM的时候,让运维dump了这份快照给我,然后通过jProfile进行分析。

  通过分析,发现了一个char[]数组,占据了将近300多M的内存空间,然后还有String对象占据了180多M的内存空间。

  初步估计,是这个对象内存溢出了。

  然后通过查询这个对象的引用链,查到了我们程序中的业务代码,主要逻辑是因为我们将快递的三级地址更新为四级地址的时候,为了兼容之前的数据,使用了大量的String对象,来进行三级地址和四级地址之间的转换,而三级地址,有着几万个地址,这些三级地址都用了大量的String对象引用,然后通过转换为更多的四级地址,而每一次查看地址,都需要调用这个方法,因此产生了大量的String对象,而这些对象,在新生代里面大量的生成,还没来得及在新生代进行垃圾回收,就因为新生代的内存空间不够,而直接转移到了老年代,从而导致了老年代的内存快速的被占用,因此导致了多次的老年代垃圾回收,甚至因为垃圾回收的速度跟不上对象生成的速度,导致出现了OOM的情况。

  当下的解决方案是,因为String对象是常量,一旦修改后,又会生成一个新的对象,所以先将String对象修改StringBuffer对象,然后将转换代码优化一下,前端做成了一次只调用一个地址的操作,然后一级一级的调用接口,从而减少了无用的地址转换。

  另外,我们在配置方面,将新生代扩大,从之前的0.5个G扩大为0.8个G,这样可以避免大量的对象一次性的进入老年代,让这些朝生夕灭的对象,可以在新生代尽快的被回收掉,避免老年代出现多次的垃圾回收甚至是OOM现象。

  这样解决后,预发布环境就稳定了,不会再时不时的宕机了。

  后期做业务迭代的时候,将四级地址冗余到了我们这边的数据库,直接来数据库中进行查询,而不是通过三级地址进行转换。

引用计数法的缺陷有办法解决吗?

  引用计数法的缺陷,主要是因为如果出现互相引用的多个对象,而导致了这些无用对象无法被回收的情况。当然,它还有其他的缺陷,比如说计数器值的增减处理比较繁重,因为对象的每次改变,计数器都需要立刻进行修改,这对CPU的性能是有影响的。

  而循环引用的问题,这种缺陷是有办法解决的,这种方法的思路就是典型的试验删除算法。

  首先,我们要有一个假设,就是对于一个对象,当你删除它的其中一个引用之后,它的引用计数仍然大于0,这种对象才有可能是循环引用对象,因为它的引用关联都是内部对象产生的,而和GC ROOT没有关联。

  那么,我们可以先对这个对象进行子图追踪,也就是使用传统的可达性分析,通过GC ROOT进行查询,如果没有从GC ROOT对象处可达,那么这就意味着这个对象就是可以回收的。

线上CPU超过了100%,你觉得是什么原因造成的?

  首先可以确定的一点是,如果产生了CPU过载了到了100%,那么必然是有线程在占用了系统资源。

  首先要查询一下CPU中进程中的占据CPU性能的排序,使用-top指令进行排序,一般来说,极大的可能是我们JVM进程排第一。

  找到我们的进程号,然后通过【-top h p 进程号】这个命令去查询进程中所有线程占据CPU的排序。

  找到哪一个线程占据的CPU有问题,然后将这个线程的堆栈日志dump下来进行分析。看是代码的问题,还是我们JVM参数设置有问题。

增量更新和原始快照的区别?

  首先我们要知道的是,增量更新和原始快照这两种方案,主要为了解决了在进行可达性分析的时候,三色标记法的时候,可能出现的黑色对象被误标记为白色对象而导致清理的情况。

  那么,我们首先要知道的是,为什么一个应该存在的黑色对象,会被误认为白色对象而被清理掉呢?

  因为我们知道CMS垃圾回收器,它有一个状态叫做并发标记,这种状态下,垃圾回收的线程在进行对象的分析标记的时候,用户线程是同步在执行的。那么,当我们的垃圾回收线程在进行标记的时候,用户线程既然同步在工作,那么必然会将对象的状态进行改变,一个对象可能从不可以回收变成了可以回收,或者从可以回收变成了不可以回收,而我们的垃圾回收器却没有注意到对象状态的改变,而是对这个对象的分析已经结束了。那么就会出现这种可以回收的对象,其实已经状态改变了,变成了不可以回收。

  那么如何解决这个问题呢?CMS垃圾回收器和G1垃圾回收器,选择了不同的方案进行分析。

CMS垃圾回收器使用的是我们的增量更新,增量更新的思路,是一个可以被回收的对象,变成了不能回收的对象,那么必然是有一个不可以回收的对象,和它之间建立了引用关系。那么,在CMS的并发标记阶段,如果用户线程将一个可回收对象和一个不可回收对象建立了引用,那么我们就讲这个新插入的引用记录下来,等到了CMS的重新标记阶段,我们将这些记录过的引用中的不可回收对象为根,重新进行可达性分析,这个时候用户线程是停顿的,所以可以解决了这个问题。

那么G1垃圾回收器的解决方案就不一样了,它使用的是原始快照方案。当在并发标记阶段,一个对象的最后引用要被删除的时候,我们将这个对象的这个最后一个引用关联的对象记录下来,然后在重新标记阶段,将这些对象为根,重新进行可达性分析。

 

Java里面的一个类是如何加载到JVM中的?

    首先,我们知道,在一个类被类加载器加载之前,肯定是要先变成字节码文件的。那么一个java类是如何变成字节码的,那就是通过我们的前端编译器将我们的java文件编译成class文件。

    在这个编译过程中,我们的java文件中的一些语法糖,比如说我们代码中的注解,就会通过我们的编译器中的插入式注解处理器进行处理,将其生成符合java逻辑的代码,比如说我们经常使用的lombok插件中的注解,就会被处理成合理的java代码。还有一些比如泛型,自动拆装箱等等语法糖,都会解析为合理的java代码,然后在将代码解析为字节码的过程中,我们知道会有一些编译优化的情况发生,这些也是发生在这个时候。

    当我们的java文件解析为了字节码class文件以后,那么接下来就需要我们的类加载器来进行加载了。

    这些字节码文件会被我们JVM的类加载器进行解析,第一步当然就是进行校验,来判断我们编译后的class字节码是否符合java虚拟机规范,比如说文件格式校验,元数据校验,字节码校验,符号引用校验等等。

    在校验完了之后,就正式开始将类里面定义的变量分配内存并且设置各个变量的默认初始值,这里使用的内存空间,就是我们的方法区的内存空间,并且将字节码中的符号引用,转换为直接引用指向这片内存空间。

    在之后,就到了我们程序运行阶段,当程序有调用这个类的时候,我们才会将这个类新增一个实例对象,然后对类里面的变量进行初始化赋值。

 

请大概讲一下什么是双亲委派机制。

双亲委派机制,实际上就是加载一个类的时候,选用类加载器的一个策略。

JVM提供了三种类加载,最基础的是启动类加载器Bootstrap ClassLoader,然后就是扩展类加载器Extension ClassLoader,然后就是应用类加载器Application ClassLoader。

Bootstrap ClassLoader加载器和Extension ClassLoader加载器,是JVM用来加载jdk和一些java自带包的类加载器,而Application ClassLoader是用来加载我们的业务代码的一个类加载器。

那么,他们之间是如何实现双亲委派机制的呢?

当一个类进来的时候,会首先通知一个类加载器加载这个类,但是这个类加载不会去加载这个类,而是会去通知它的父类加载器加载,直至到最顶层的启动类加载器Bootstrap ClassLoader,只有当父类加载器反馈自己没有办法加载这个类的时候,才会通知下面的子类加载器进行加载,直至这个类被加载成功。

zgc和g1的区别

ZGC 与 G1 是 JVM 中面向不同场景设计的低延迟垃圾收集器,核心区别体现在‌设计目标、内存布局、回收机制和停顿控制‌上。以下是详细对比:

一、核心架构与设计目标对比

特性G1 (Garbage-First)ZGC (Z Garbage Collector)
设计目标平衡吞吐量与可控停顿(百毫秒级)15亚毫秒级停顿‌(通常 <10ms),堆大小无影响47
堆内存支持百GB级别(典型 6GB~100GB)58TB级超大堆‌(支持 8MB~4TB)47
内存模型分区(Region) ‌:固定大小(2MB~32MB),逻辑分代(Eden/Survivor/Old/Humongous)35分区(Region/ZPage) ‌:动态大小(2MB/32MB/动态大页),‌无分代设计‌46

关键差异‌:ZGC 放弃分代模型,通过染色指针实现全堆并发操作;G1 保留逻辑分代,依赖 Region 优先级回收。

二、垃圾回收机制对比

  1. 标记阶段
  • G1‌:

    • 分阶段执行(初始标记 → 并发标记 → 最终标记)
    • 初始标记与最终标记需 STW‌(暂停应用线程)14
    • 使用 ‌SATB 算法‌(Snapshot-At-The-Beginning)保证标记一致性4
  • ZGC‌:

    • 完全并发标记‌(仅初始标记需极短 STW)
    • 通过‌染色指针(Colored Pointers) ‌ 在指针中存储标记状态,无需对象头修改34
  1. 转移阶段(对象复制)
特性G1ZGC
执行方式部分并发‌:筛选回收阶段需 STW15完全并发‌:读屏障实时处理引用更新34
算法复制算法(Young GC) + 标记-整理(Mixed GC)5并发复制算法‌(无 STW 的对象迁移)47
碎片处理混合回收时整理老年代碎片5复制过程天然消除碎片4

ZGC 优势‌:转移阶段通过读屏障拦截旧指针访问,自动转发到新地址,实现零停顿迁移(JDK16+ 优化本地转移)34。

三、停顿时间控制能力

指标G1ZGC
停顿时间范围100ms~500ms(可配置 -XX:MaxGCPauseMillis)15≤10ms‌(JDK16+ 可缩至 1ms 内)47
堆大小影响堆越大,停顿可能越长57停顿与堆大小无关‌(时间复杂度 O(1))411
延迟敏感性中等(适用响应要求不严苛的场景)7极致‌(金融交易、实时系统首选)47

四、资源开销与适用场景

维度G1ZGC
CPU 开销中等(并发阶段占用部分 CPU)78较高‌(染色指针处理需额外计算)47
内存开销较低(Region 元数据占用小)8略高(染色指针需保留元数据位)47
推荐场景- 堆内存 <100GB - 允许百毫秒级停顿的 Web 服务57- 堆内存 ≥100GB - 延迟敏感型系统(如高频交易、实时分析)47

总结:核心差异与选型建议

  1. 底层机制差异‌:

    • G1 采用 ‌SATB + 分代分区‌,通过优先级回收减少 STW5;
    • ZGC 依赖 ‌染色指针 + 全并发复制‌,消除分代与 STW47。
  2. 延迟表现‌:

    • G1 停顿可控但随堆增长而上升;‌ZGC 停顿恒定为亚毫秒级‌411。
  3. 选型策略‌:

    • G1‌:通用服务(如后台系统)、堆内存中等(<100GB)、允许适度停顿58;
    • ZGC‌:超大堆(≥100GB)、延迟敏感场景(如金融核心系统)47。

配置示例‌:

  • 启用 G1:-XX:+UseG1GC -XX:MaxGCPauseMillis=200
  • 启用 ZGC:-XX:+UseZGC -Xmx16g(JDK15+ 生产可用)

java中线上OOM问题怎么排查,如何避免?

在Java应用程序中遇到OOM(Out of Memory)问题是非常常见的,下面是一些排查和避免OOM的方法:

排查OOM问题

  1. 检查JVM参数配置:JVM的参数配置是一个非常重要的因素,需要保证JVM分配的内存能够满足应用程序的需求。一般来说,可以通过 -Xmx 参数来调整JVM的最大内存大小,如果内存不足,可以适当增加该参数的值。
  2. 分析内存泄露:内存泄露是OOM的主要原因之一,一些不正确的代码可能会导致对象无法被垃圾回收器回收,从而导致内存泄露。可以通过内存分析工具(如MAT)来定位内存泄露的位置,找出造成内存泄露的代码并进行修复。
  3. 分析对象的生命周期:Java对象的生命周期可能会影响内存使用情况,如果某些对象一直存在于内存中,会导致内存的占用不断增加。可以通过一些工具(如VisualVM)来分析对象的生命周期,找出那些长时间存在于内存中的对象。
  4. 分析代码逻辑:代码逻辑中可能存在一些不合理的部分,比如使用递归方式进行操作可能会导致内存使用量大量增加,需要避免这种情况。需要仔细分析代码逻辑,找出可能导致内存使用过多的地方。

避免OOM问题

  1. 合理使用JVM参数配置:JVM参数配置是避免OOM的第一步,需要根据应用程序的需求和硬件配置来设置JVM参数。一般来说,需要根据应用程序的内存使用情况来适当调整JVM参数,确保JVM能够满足应用程序的内存需求。
  2. 注意代码逻辑:在编写代码时需要注意避免可能导致内存使用过多的操作,比如递归操作、大量使用字符串拼接等。需要尽可能避免使用不必要的对象和数据结构,减少内存使用量。
  3. 及时释放资源:Java程序中使用的资源(如文件、网络连接等)需要及时释放,避免资源占用过多导致OOM。可以使用try-with-resources语句块来自动释放资源。
  4. 使用缓存:缓存是提高Java应用程序性能的一种常用方式,但需要注意缓存的大小和生命周期,避免缓存占用过多内存导致OOM。可以使用一些缓存框架来管理缓存,自动清理过期的缓存数据。