如果我是面试官,会怎么问JVM(一)

从实际工作中开始问

问题1: 工作中有没有遇到过JVM相关的异常?一般有哪几种异常?

考察:希望面试者有实战经验,并能总结经验,能在工作中提升思考

答:有。常见的JVM异常有OutOfMemoryError、StackOverflowError。

问题2: 这些异常相应会发生在哪些内存区域?

考察:引出问题3,考察对JVM分区的了解,分区都不熟悉的,基本JVM其他问题也没有必要问了

答:OutOfMemoryError可能发生在堆、虚拟机栈、本地方法栈、方法区、直接内存(非JVM内存),只有程序计数器不会发生。 StackOverflowError只会在虚拟机栈或本地方法栈溢出时抛出此异常。

问题3: 上述内存区域分别存储什么?

考察:了解每个内存区域存储对象,才能相应去做一些问题排查和调优

答:

先贴一张Java虚拟机运行时数据区图,上面问题也是对下图每个部分的细问。

  1. 程序计数器:用来存储指向下一条指令的地址 说明:任何时间一个线程只有一个方法在执行,程序计数器会存储当前线程正在执行的Java方法的JVM指令地址,如果是本地方法,则存储的是未指定值(undefined)

相关问题1: 程序计数器存储当前线程执行地址有什么用?(选问) 答:因为CPU需要不断切换线程,切换回来以后,就得知道从哪开始继续执行。JVM字节码解释器就需要改变程序计数器的值来明确下一条应该执行什么样字节码指令

相关问题2:程序计数器为什么被设为线程私有?(选问) 答:CPU不停的任务切换,线程执行的任务经常会中断或恢复,每个线程各自记录当前字节码执行地址,就不会出现互相干扰。

参考:宋红康《JVM从入门到精通》

  1. 虚拟机栈 (也直接上宋红康的图吧)

每个线程都有自己的栈(线程私有),栈中的数据是以栈帧格式存在,每个方法有各自对应的栈帧。

一个活动的线程,一个时间点,只会有一个活动的栈帧。图中栈帧4称为当前栈帧。方法1调用方法2时,栈帧2就被创建出来,放在栈的顶端,成为当前帧。如果方法2执行完成(正常return或抛出异常),就会传回方法的执行结果给方法1对应的栈帧1,接着虚拟机会丢弃栈帧2,使栈帧1重新成为当前栈帧。

栈帧内部结构如下图,其中:

局部变量表:主要用于存放了方法参数和定义在方法体内的局部变量。这些数据类型包含基本数据类型、对象引用(reference,可能是一个执行对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。所需容量大小,是编译期确定下来的,方法运行期间不会改变局部变量表大小。最基本的存储单元是slot,64位长度的long和double占用两个slot,其余数据类型只占用一个slot。

操作数栈:主要保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。32位的类型占用一个栈单位深度,64位占用两个栈单位深度,也是编译器就定义好了所需最大深度(max_stack)。如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条要执行的字节码指令。

局部变量表是通过数组的索引的方式进行数据访问,操作数栈是通过入栈、出栈操作来完成数据访问。

动态链接:如invokedynamic指令,动态链接的作用是为了将存储在常量池中方法的符号引用转换为调用方法的直接引用。 方法返回地址: 存放调用该方法的程序计数器的值。方法正常退出时,调用者的程序计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定。

一些附加信息:栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。如对程序调试提供支持的信息。

  1. 本地方法栈 本地方法(Native Method): 是一个Java调用非Java代码的接口。有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。 本地方法栈(Native Method Stack):用于管理本地方法的调用。

在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。

  1. 方法区

方法区用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

类型信息:对每个加载的类型(class、interface、enum、annotation),存储以下的类型信息 1)这个类型的完整有效名称(全名=包名.类名)2)这个类型直接父类的完整有效名 3)这个类型的修饰符(public,abstract,final的某个子集)4)这个类型直接接口的一个有序列表

域(Field)信息:保存类型的所有Field的相关信息以及Field的声明顺序。Field的相关信息包括Field名称、Field类型、Field修饰符(public、private、protected、static、final、volatile、transient的某个子集)

方法(Method)信息:包含方法名称,方法的返回类型(或void),方法参数的数量和类型(按顺序),方法的修饰符(public、private、protected、static、final、synchronized、native、abstract的一个子集),方法的字节码、操作数栈、局部变量表及大小,异常表以及方法的声明顺序

常量:包含数量值、字符串值、类引用、字段引用、方法引用等。可以看着一张表,虚拟机指令根据这张表找到要执行的类名、方法名、参数类型、字面量等类型。

对比 常量池 运行时常量池
不同时期 是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用 类加载后,常量池数据就会存放到方法区的运行时常量池中
引用的不同 常量池中是符号地址 经过运行期解析后已经换位真实地址,具备动态性

Hotspot中方法区的变化:

问题3.1 JDK8之后内存模型的修改,永久代为什么会被替换成元空间?

考察:了解不同版本JDK一些特性是必要的,如显示JVM日志便不同。 答:主要有永久代去除,替换为元空间,其中元空间存储在直接内存中,非虚拟机内存。主要做此改变的原因有1)永久代设置的空间大小很难确定,如果动态加载类过多,容易产生OOM 2)对永久代很难进行调优,没必要放在虚拟机内存中

对比 JDK7 JDK8
名字 永久代 元空间
内存空间 虚拟机内存中 本地内存,非虚拟机内存
内存溢出抛错 java.lang.OutOfMemoryError: PermGen space java.lang.OutOfMemoryError: Metaspace
  1. 堆 此内存区域的唯一目的就是存放对象实例,Java世界里几乎所有的对象实例都在这里分配内存。 不是全部是因为如即时编译技术的进步,尤其逃逸分析技术的日渐强大,栈上分配、标量替换等优化手段便不是分配堆内存。

其中对象的内存布局

问题3.2 内存区域实战

考察: 考察对上述内存区域理解程度,是背的还是真有理解。

下面例子中:

  • StaticObjTest、Test类信息存储在哪?(方法区,另外还有接口等信息)
  • static等修饰符信息存储在哪?(方法区)
  • staticObj静态全局变量存放在哪?(方法区,但在堆中)
  • instanceObj全局变量存放在哪?(随Test实例对象存放在堆中)
  • localObj局部变量存放在哪?(虚拟机栈的局部变量表中)
  • 加final的常量存储在哪?(方法区运行时常量池中)

问题4: 什么样的情况堆会发生上述异常?什么样的情况栈会发生上述异常?什么样的情况方法区会发生上述异常?

考察:知道什么样情况下会发生异常,开发时才会相应注意

堆OutOfMemoryError:如JVM 堆内存设置较小,又有比较多对象创建时 栈OutOfMemoryError:如果Java虚拟机栈时可以动态扩展,在尝试扩展时候无法申请到足够的内存,或在创建新的线程时没有足够的内存区创建对应的虚拟机栈时 栈StackOverflowError:如果是固定的虚拟机栈,线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,会抛出StackOverflowError 方法区OutOfMemoryError:如直接内存不足

《深入理解Java虚拟机》中的每种报错的举例(加深印象):

堆OOM

java 复制代码
/**
* -Xms20M -Xmx20M -XX:+UseZGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./oom.hprof
*/
public class HeapOOM {
    static class OOMObject{}
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

栈SOF

java 复制代码
/**
* -Xss128k
*/
public class JavaVmStackSOF {

    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    
    public static void main(String[] args) {
        JavaVmStackSOF sof = new JavaVmStackSOF();
        try {
            sof.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length: " + sof.stackLength);
            throw e;
        }
    }
}

栈OOM

java 复制代码
public class JavaVmStackOOM {

    private void dontStop() {
        while (true) {}
    }

    public void stackLeakByThread() {
        while (true) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            }).start();
        }
    }
    public static void main(String[] args) {
        new JavaVmStackOOM().stackLeakByThread();
    }
}

方法区运行时常量池OOM

java 复制代码
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        short i = 0;
        while (true) {
            list.add(String.valueOf(i).intern());
        }
    }
}

问题5: 遇见这些JVM异常如何解决?

考察点1: JVM参数配置 考察点2: JVM问题排查和优化

答:

  1. JVM参数配置 在出问题之前,必须要做的是,设置一些必要的JVM参数。如机器资源有限情况下,设置JVM的初始堆内存大小(-Xms)、设置JVM的最大堆内存大小(-Xmx)

在JDK8下设置gc日志相关:

java 复制代码
-XX:+HeapDumpOnOutOfMemoryError:在内存溢出时生成堆转储文件。
-XX:HeapDumpPath:指定堆转储文件的路径。
-XX:+PrintGCDetails:打印详细的垃圾回收信息。
-XX:+PrintGCDateStamps:在垃圾回收日志中打印日期时间戳

JDK17参考es设置如下:

java 复制代码
-Xlog:gc*:file=gc.log:time,uptime,pid,tid,level,tags:filecount=24,filesize=20M

以及当前服务使用的何种垃圾回收器,垃圾回收过程如何,这些尽量做到了然于胸。具体gc日志就不展开了。可以另开一篇写。

  1. JVM问题排查和优化

首先说几个个人经验: a. 内存溢出时,检查下大对象和线程使用情况,对象生命周期是否过长?持有状态时间是否过长?存储结构是否合理?连接的资源是否有及时释放? b. 内存泄漏问题,主要通过GC Roots引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾回收器无法回收它们 b. 分析此类问题,最好用的工具还是使用Mat,看Mat的Dominator Tree报告,对代码熟悉的,很容易意思到问题出现在哪里

利用工具: gceasy.io/:可以用来看垃圾回收时... fastthread.io/:查看线程信息 jmap: 生成堆转储文件jmap -dump:format=b,file=/path/to/dumpfile.hprof mat:分析堆转储文件

mat的使用经验:

  • 内存泄漏建议,可以查看是否有内存泄漏
  • 注意大对象的生命周期

实际线上问题排查(不限于JVM异常)可参考:mp.weixin.qq.com/s/znVGpJhtA...

问题6: 学习JVM哪些知识,能让你更好更快的解决上面的问题?(从中根据面试者回答中,调知识点问)

考察:内存区域理解,以及垃圾回收过程和垃圾回收器的熟悉程度

总结上面问题,如不是无知的把堆栈内存设置过小,基本还是源于代码的不规范:如大对象持有时间过久、存储结构设计的不合理、代码写的死循环或redis连接未关闭等

有的需要学习源码,对组件或工具更好的使用。有的问题要排查更快,需要更好的利用工具,如线上环境熟练使用jdk工具、通过gc日志定位问题等。

另外,掌握更多JVM的知识,也能避免一些这样的问题,在问题发生时,也能更快定位。

首先,便是前面提及内存区域,不同的内存区域存放哪些东西。

其次,服务使用哪种垃圾回收器?为何使用这种垃圾回收器?理论上代码没有太大问题的情况,只要JVM设置也不是很不合理,应该触发垃圾回收就能释放空间,继续正常工作,为什么会有的发生Full GC都不释放内存的情况?

下面从垃圾回收器一些特性做介绍,再从垃圾回收过程介绍为何有这种特性。

垃圾回收器

下图是截止JDK8的7款垃圾回收器 JDK8垃圾回收器的组合 在G1之后的垃圾回收器,主要就是Shenandoah GC和ZGC。主打特性是低停顿时间。尽可能对吞吐量(会变小)影响不大的情况下,在任意堆内存大小,都可以把垃圾收集的停顿时间限制在10ms以内。

对于上面一些概念的解释:

  • 串行和并行(如Seria和ParNew):是按线程数分,串行指单线程执行垃圾回收任务,并行指多线程执行垃圾回收任务。但都是独占式,需要Stop-the-world,此期间其他任务不能执行。
  • 并发和独占(如CMS和Seria):并发式垃圾回收器与应用程序线程交替执行工作,以减少应用程序停顿时间(仍有Stop-the-world过程);独占式垃圾回收器指运行期间,停止所有用户线程,直致垃圾回收任务完成。
  • 停顿时间:也就Stop-the-world过程,应用程序不能对外服务的时间
  • 吞吐量:用户代码运行时间 / 应用总运行时间(代码运行时间 + 内存回收时间)

问题6.1 垃圾回收器具体执行的过程?

考察:对垃圾回收器熟悉程度

还在用JDK8的,主要使用CMS和G1,到了JDK17有的使用ZGC,所以主要聊聊CMS、G1、ZGC的垃圾回收器执行过程。

  1. CMS 可通过gceasy等工具,看CMS每个阶段耗时,加深垃圾回收过程理解

  2. G1

  3. ZGC

blog.csdn.net/qq_39338954... (CMS、G1、ZGC最下面的图,均在《深入理解Java虚拟机》书中有)

问题6.2 如何选择垃圾回收器?

现在主流的垃圾回收器,追求的都是低停顿时间,所以首先评估自己服务器资源和业务需求,还有JDK版本。

如果内存很小,小于100M;或机器资源有限,是单核,对停顿时间也没有要求,串行收集器就足够。

但现在基本都是多CPU服务器,如果追求低停顿时间,就往G1、ZGC(一般来说JDK15以后才推荐)等选。但真实情况,很多企业还停留在CMS垃圾回收器。

垃圾回收过程

问题6.3 垃圾是什么?为什么需要进行回收?

考察:对垃圾回收知识的理解

前面一些问题已经涉及到,主要进行垃圾回收的还是堆内存,而堆内存主要也是用来存储实例化对象。

所以,运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

如果不及时清理,这些不使用了的对象仍一直占用内存,内存小的情况,导致其他新的对象无法使用,甚至内存溢出,服务异常。

这里,涉及的知识点就包括判断对象存活的方式,有两种:引用计数算法和可达性分析

问题6.4 GC Roots的对象包括哪些

小技巧:由于Root采用栈方式存放变量和指针,如果一个指针,它保存了对内存里面的对象,但是它自己又不在堆内存里面,那它就是一个Root。

如:虚拟机栈中引用的对象,指向了对象,但自己在栈内存中;方法区类静态属性引用的对象,方法区中常量引用的对象,指向了对象,但自己在方法区中。反之,类中的对象的成员变量,是存储在对象实例中,故不是。

问题6.5 什么时候进行垃圾回收?

也就是触发垃圾回收的时机。这里首先有个知识点,现在Java虚拟机基本都是分代的,分新生代和老年代。

为什么要做这种划分?

这是因为大多数对象的生命周期较短,方生方死,很快就会变得不可达并可以被垃圾回收器回收。为了更有效地进行垃圾回收,JVM将堆内存划分为不同的区域,每个区域有不同的管理策略。好处自然就是1)能高效回收短期对象 2)减少Full GC频率,Full GC需要Stop-the-world,有一定停顿时间,服务对外不可用。

所以,新生代基本用复制算法进行垃圾回收。在JDK8中,新生代中Eden :From : To 为8:1:1,整个新生代比老年代为1:2。但在JDK9默认使用了G1垃圾回收器,就尽量避免显示去设置年轻代大小,因为年轻代GC是并行独占式,最好让垃圾回收器自己调节。如果设置了固定年轻代大小,会覆盖暂停时间的目标。(所以面试官不要再问这种比例问题了)

这个问题自然也就得分新生代和老年代触发GC的时机。在这之前,又得先熟悉对象分配原则,分配对象流程图如下: 新生代GC(Minor GC)触发机制:

  • 当新对象申请内存时,Eden区放不下,会触发一次Minor GC(Survivor区放不下不会触发)

(另外注意Minor GC也会有Stop-the-world过程,暂停其他用户的线程)

老年代GC(Major GC/Full GC)触发机制:

  • 调用System.gc()时,系统建议执行Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC后,进入老年代的平均大小大于老年代的可用内存
  • 由Eden区、From区向To区复制时,对象大小大于To区可用内存,则把该对象转入老年代,如果老年代的可用内存小于对象大小,则触发Full GC

对上面老年代GC需要补充的是一些内存分配策略(详见《深入理解Java虚拟机》第三章最后)。

  1. 基本上对象是优先分配新生代Eden区,但大对象会直接进入老年代

  2. 长期存活的对象将进入老年代 虚拟机对每个对象定义了一个对象年龄的计数器,存储在对象头中,每经过一次Minor GC后仍然存活,年龄就加1,当年龄增加到一个阈值(默认15),就会被晋升到老年代中

  3. 动态对象年龄判定 如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

  4. 空间分配担保 在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间,是否大于新生代所以对象的总空间,如果大于,那这次Minor GC可以确保是安全的。

如果小于,虚拟机会先看-XX:HandlePromotionFailure的参数的设置值是否允许担保失败。如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC;如果小于,或-XX:HandlePromotionFailure设置不允许空间担保,则需要进行一次Full GC。

问题6.6 垃圾回收算法

考察:怎么回收的问题

复制算法 标记清除算法 标记整理算法:

三种算法优缺点对比

参考: 《深入理解Java虚拟机》第三版 宋红康《JVM从入门到精通》

声明:上文中图片大多来自宋红康课程ppt,如有侵权,请联系删除。

相关推荐
无尽的大道2 小时前
Java反射原理及其性能优化
jvm·性能优化
AAA 建材批发王哥(天道酬勤)8 小时前
JVM 由多个模块组成,每个模块负责特定的功能
jvm
JavaNice哥15 小时前
1初识别jvm
jvm
涛粒子15 小时前
JVM垃圾回收详解
jvm
YUJIANYUE15 小时前
PHP将指定文件夹下多csv文件[即多表]导入到sqlite单文件
jvm·sqlite·php
逊嘘15 小时前
【Java语言】抽象类与接口
java·开发语言·jvm
鱼跃鹰飞1 天前
大厂面试真题-简单说说线程池接到新任务之后的操作流程
java·jvm·面试
王佑辉1 天前
【jvm】Major GC
jvm
阿维的博客日记1 天前
jvm学习笔记-轻量级锁内存模型
jvm·cas·轻量级锁