Skip to content

JVM 面试问题

一、JVM 内存结构

答: 主要有 5 个组成部分

  • heap: 主要将存储对象实例到内存中, 为对象实例开辟内存空间,线程共享。

  • stack: 主要进行方法调用方法执行 的数据结构,每一个栈都有独立的栈帧,线程是不共享。

  • 元空间 metaspace: 主要存放类信息,包括类的信息、局部变量表、方法信息、常量池

  • 本地方法栈 native: 主要使用 Java 代码调用C语言,简称 JNI.

  • 程序计数器 Program Counter Register: 在多线程情况下,线程上下文切换时,主要记录线程执行行号

  • 1.class 文件: 一组以 8 个字节 为基础单位的二进制流, 各个数据项目严格 按照顺序 紧凑地排列在文件之中。
  • 2.magic 魔数: 识别 Class 文件格式。
  • 3.minor_version 次版本号 和 major_version 主版本号。
  • 4.constant_pool 常量池。
  • 5.access_flags 访问标志。
  • 6.this_class 类索引。
  • 7.super_class 父类索引。
  • 8.interfaces 接口索引集合。
  • 9.fields 字段表集合。
  • 10.methods 方法表集合。
  • 11.attributes 属性表集合。
  1. 软件-执行过程:
  • JVM 先加载包含字节码的 class 文件,存放在方法区
  • 执行方法区内的代码, 分配到内存中。
  • 调用 java 方法时,会生成栈,每一个栈包含: 局部变量表、操作数栈、动态链接、方法返回地址等信息
  • 退出当前执行的方法时,不管正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃
  1. 硬件-执行过程:
  • Java 虚拟机需要将字节码翻译成机器码.
  • 翻译过程产生二种形式: 解释执行即时编译(Just-In-Time compilation,JIT)
    • 解释执行: 即将遇到的字节一边码翻译成机器码一边执行。解释执行在启动时节约编译时间执行速度较快。
    • 即时编译(Just-In-Time compilation,JIT): 即将一个方法中包含的所有字节码编译成机器码后再执行。 :::

二、ClassLoader 内加载器

  • 类加载主要分为 5 个过程:
    1. 加载(Loading): 加载二进制然后转换成 JVM 需要的结构,最后生成对应 Class 对象。
    2. 验证(Verification): 文件格式验证、元数据验证、字节码验证、符号引用验证。
    3. 准备(Preparation): 为 类的静态变量 分配内存并设置初始值。
    4. 解析(Resolution): Java 虚拟机将 常量池内的符号引用 替换为 直接引用 的过程。
    5. 初始化(Initialization): 就是执行类构造器 clinit()方法 的过程。
  • 其中,验证、准备和解析 这三个阶段可以统称为 链接(Linking)

双亲委派机制:

  • java 类加载的过程中,从下往上 查询 class 文件是否已经加载过该 class 文件,如被加载过不会继续加载该 class 文件。
  • 然后从上往下从 BootstrapClassLoader (启动类加载器) -> ExtClassLoader (扩展类加载器) -> AppClassLoader (应用类加载器) -> CustemClassLoader (自定义加载器)进行加载。
  • 它会一级一级往下找,如何父类没有该加载器的话,他会往下一级父类找,直到往下找不到,才会抛出异常 报ClassNotFound的异常

java
    private final ClassLoader parent;

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 1. 判断是否加载过Class
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    // 2. 当前类加载器是否存在父类
                    if (parent != null) {
                        // 3. 父类递归加载器
                        c = parent.loadClass(name, false);
                    } else {
                        // 4. 否则使用启动类加载器
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }
                // 5. 加载器还是为空
                if (c == null) {
                    long t1 = System.nanoTime();
                    // 6. 调用当前类加载器,查询Class文件
                    c = findClass(name);
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
  1. 先查询当前加载器,是否已加载到内存中。
java
 Class<?> c = findLoadedClass(name);
  1. 未加载到内存中,向父类加载器路径包下找。
java
 if (parent != null) {
    // 3. 父类递归加载器
    c = parent.loadClass(name, false);
 }
  1. 父类未查询到,向启动类加载器路径包下找。
java
  c = findBootstrapClassOrNull(name);
  1. 如果启动类加载器未找到,跳出递归,查询当前类的 findClass
java
c = findClass(name);

例如:

  • Tomcat
    • 应用的类加载器优先自行加载应用目录下的 class,并不是先委派给父加载器,加载不了才委派给父加载器。
    • 因为 tomcat 中有些 class 文件和父类加载器目录包存在命名和包路径相同的文件,所以为了能加载到 tomcat 下相同包命名 class 文件在应用加载器,所以需要打破双亲委派机制。
    • 打破的目的是为了完成应用间的类隔离

Just In Time Compiler 的简称,即时编译器。

  • 为了提高热点代码的执行效率。
  • 在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器就是 JIT。

二、JVM 堆(Heap)

  • 对象 在 堆 Heap 内存进行分配。
  • 某些对象没有逃逸出方法,可能被优化为在栈上分配

永久代(Perm): Hotspot 中方法区的实现。

  • JDK1.6 版本以前,字符串常量池放在方法区(永久区)。
  • JDK1.7 版本,常量池、静态变量放在堆(不合理静态变量不属于堆)。
  • JDK1.8 版本,废除永久代, 变成为元空间, 单独将“字符串常量池”放到堆,堆单独给字符串常量池开辟空间,其他常量池存放在方法区。

  • JVM 中的常量池: 在 java 用于保存在编译期已确定的,已编译的 class 文件中的一份数据。它包括了关于类,方法,接口等中的常量.
  • JVM 中的常量池分为三种:
    • 1.字符串常量池:存放在堆中,主要存放字符串相关内容。
    • 2.运行时常量池:存放在元空间,Java 虚拟机会将 Class 文件常量池里的内容 转移到 运行时常量池 里。
    • 3.class 常量池(静态常量池):当 Class 文件Java 虚拟机 加载进来后,存放各种 字面量 (Literal)符号引用。 :::
  • 1.对象经历了垃圾回收,超过一定阀值,就会进入老年代 。

    -XX:MaxTenuringThreshold 可以指定该阀值

    1. eden 区空间已满,并且 Survivor 空间已满,年轻代的对象将进入老年代。 :::
  • 1.: 无限创建线程,会导致StackOverflowError
  • 2.: 查询大对象或递归循环,导致内存异常 java.lang.OutOfMemoryError: Java heap space
  • 3.方法区: 经常会遇到的是动态生成大量的类。
  • 4.直接内存 OOM,涉及到 -XX:MaxDirectMemorySize 参数和 Unsafe 对象对内存的申请

三、JVM 栈(Stack)

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 地方返回
  • 附加信息

栈溢出(Stack Overflow Error) : 当栈深度超过虚拟机分配给线程的栈大小时, 就会出现栈溢出错误。 例如:

  • 方法递归过深,会产生栈溢出。
  • 不断地创建线程,会产生栈溢出。
  • 栈帧局部变量过多,会产生栈溢出。
  • 方法内联: 调用方函数代码"复制"到调用方函数中,减少因函数调用开销的技术。

java
private int add2(int x1 , int x2 , int x3 , int x4) {
  return add1(x1 , x2) + add1(x3,x4);
}

private int add1(int x1 , int x2) {
  return x1 + x2;
}

运行一段时间后,代码被内联翻译成:

java
private int add2(int x1 , int x2 , int x3 , int x4) {
  //return add1(x1 , x2) + add1(x3,x4);
  return x1 + x2 + x3 + x4; 
}

方法内联的条件

  • JVM 会自动的识别热点方法,并对它们使用方法内联优化。那么一段代码需要执行多少次才会触发 JIT 优化呢?通常这个值由-XX:CompileThreshold 参数进行设置:
    • 使用 client 编译器时,默认为 1500;
    • 使用 server 编译器时,默认为 10000;

但是一个方法就算被 JVM 标注成为热点方法,JVM 仍然不一定会对它做方法内联优化。其中有个比较常见的原因就是这个方法体太大了,分为两种情况。

  • 如果方法是经常执行的,默认情况下,方法大小小于 325 字节的都会进行内联(可以通过** -XX:MaxFreqInlineSize=N**来设置这个大小)
  • 如果方法不是经常执行的,默认情况下,方法大小小于 35 字节才会进行内联(可以通过** -XX:MaxInlineSize=N **来设置这个大小)

我们可以通过增加这个大小,以便更多的方法可以进行内联; 但是除非能够显著提升性能,否则不推荐修改这个参数。 因为更大的方法体会导致代码内存占用更多,更少的热点方法会被缓存,最终的效果不一定好。

四、JVM 元空间(metaspace)

  • 内存逃逸:内存逃逸是指原本应该被存储在栈上的变量,因为一些原因被存储到了堆上
  • 逃逸分析: 是编译语言中的一种优化分析,而不是一种优化的手段。通过对象的作用范围的分析,为其他优化手段提供分析数据从而进行优化。
  • 逃逸行为分为两种:
    • 方法逃逸: 当一个对象在方法中定义之后,作为参数传递到其它方法中.
    • 线程逃逸: 如类变量实例变量,可能被其它线程访问到

  • 方法逃逸,示例:
java
package com.calvin.jvm.heap.gc.example;

/**
 * 内存逃逸分析
 *
 * @author Calvin
 * @date 2023/9/12
 * @since v1.0.0
 */
public class MemoryEscapeAnalysis {

    /**
     * 用户
     *
     * @author calvin
     * @date 2023/06/28
     */
    public static class User {
        /**
         * 字节
         */
        private byte[] bytes = new byte[1024 * 1024];
    }


    /**
     * 方法逃逸
     *
     * - 当一个对象在方法中定义之后,作为参数传递到其它方法中.
     *
     * @return {@link User}
     */
    public static User methodEscapeByHeap() {
        User user = new User();
        return user;  
    }


    /**
     * 方法未逃逸
     *
     * @return {@link User}
     */
    public static void methodEscapeByStackFrame() {
        User user = new User();
    }


    /**
     * 主方法
     *
     * -XX:+DoEscapeAnalysis (显示开启逃逸分析)
     * -XX:+PrintEscapeAnalysis (查看逃逸分析的筛选结果)
     *
     * @param args 参数列表
     */
    public static void main(String[] args) throws InterruptedException {


        System.out.println("================================= 开始: 方法未逃逸(存放在栈帧)=================================");
        long start2 = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            // 当前 user 被使用该方法存放在栈帧中
            methodEscapeByStackFrame(); 
        }
        long end2 = System.currentTimeMillis();
        System.out.println("程序执行的时间:" + (end2 - start2) / 1000d + "秒");
        System.out.println("================================= 结束: 方法未逃逸(存放在栈帧)=================================");


        System.out.println("================================= 开始: 方法逃逸 (存放在堆空间) =================================");
        long start1 = System.currentTimeMillis();
        for (int i = 0; i < 1000; i++) {
            // 当前 user 被使用该方法存放在堆空间中
            User user = methodEscapeByHeap(); 
        }
        long end1 = System.currentTimeMillis();
        System.out.println("程序执行的时间:" + (end1 - start1) / 1000d + "秒");
        // Thread.sleep(1000 * 10);
        System.out.println("================================= 结束: 方法逃逸 (存放在堆空间) =================================");

    }

}
  • 线程逃逸, 示例:
java
package com.calvin.jvm.heap.gc.example;

/**
 * 内存逃逸分析
 *
 * @author Calvin
 * @date 2023/9/12
 * @since v1.0.0
 */
public class MemoryEscapeAnalysis {


    /**
     * 用户
     */
    private static User user = new User(); 

    /**
     * 用户
     *
     * @author calvin
     * @date 2023/06/28
     */
    public static class User {
        /**
         * 字节
         */
        private byte[] bytes = new byte[1024 * 1024];
    }



    /**
     * 主方法
     *
     * -XX:+DoEscapeAnalysis (显示开启逃逸分析)
     * -XX:+PrintEscapeAnalysis (查看逃逸分析的筛选结果)
     *
     * @param args 参数列表
     */
    public static void main(String[] args) throws InterruptedException {

        System.out.println("================================= 开始: 线程逃逸 (存放在堆空间) =================================");
        // 如类变量或实例变量,可能被其它线程访问到;
        Thread threadA = new Thread(() -> {
            User user1 = user; 
            System.out.println("threadA 用户信息" + user1);
            try {
                Thread.sleep(1000 * 10);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread threadB = new Thread(() -> {
            User user2 = user; 
            System.out.println("threadB 用户信息" + user2);
            try {
                Thread.sleep(1000 * 10);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        threadA.start();
        threadB.start();
        System.out.println("================================= 开始: 线程逃逸 (存放在堆空间) =================================");

    }
}

四、JVM GC 垃圾收集器

  • JVM 中通过可达性算法判断对象是否存活,不存活的为垃圾对象。
  • 从 GC Roots 节点不断往下找判断对象是否存在引用链(reference chain)如果不存在GC Roots 引用链上就判断为垃圾对象。

在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:

  • 1.在虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 2.在方法区中类静态属性引用的对象。
  • 3.在方法区中常量引用的对象。
  • 4.在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • 5.Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
  • 6.所有被同步锁(synchronized关键字)持有的对象
  • 7.反映 Java 虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存
  • 引用强度依次逐渐减弱: 强引用 > 软引用 > 弱引用 > 虚引用
    • 强引用: 垃圾收集器就永远不会回收掉被引用的对象。
    • 软引用 (SoftReference 类) : 非必须的对象, 在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。
    • 弱引用 (WeakReference 类): 非必须的对象, 被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
    • 虚引用 (PhantomReference 类): 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚 引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。 :::
  • 1.引用计数法: 使用一个计数器来记录每个对象被引用的次数

    • 当对象被引用时,计数器+1;
    • 当对象的引用被解除时,计数器-1;
    • 当引计数器为 0时,对象会被回收
    • 缺点: 程序如果引用关系过于复杂,会降低程序性能,同时引用计数法存在循环依赖问题缺陷。例如: A->B, B->A
  • 2.可达性分析算法: 用于确定程序中哪些对象仍然被使用,哪些可以回收,通过根下标记判断那些对象可以回收。 大部分垃圾收集器遵从了分代收集(Generational Collection)理论。 针对新生代与老年代回收垃圾内存的特点,提出了 3 种不同的算法:

    • 标记清除-算法(Mark-and-Sweep):先标记无用的对象后清除对象,但产生不完成存储块的碎片空间。(产生问题: 存储空间不连续,碎片空间多)

    • 复制-算法(Copying):将一块分配的内存空间,分成 2 半,当一半存储满了,将存活对象进行复制到另外一半未使用空间去,将原来剩下来的对象进行清除。(产生问题:多数对象存活,复制来复制去,将过多的消耗性能。同时浪费一半内存空间)

    • 标记整理(压缩)-算法(Mark-Compact):将存活对象进行移动当内存空间另外一端(整理),在清除存活对象边界以外的垃圾对象进行清除。(解决了浪费内存问题,让内存得到高效利用,同时解决了碎片化内存不连续的问题。)

应用于新生代收集器

  • Serial收集器: 是一个单线程垃圾收集器, 使用 copy 复制算法, 使用场景内存少(5MB ~ 20MB) 主要应用于客户端, 会产生 STW。
  • 命令如下:
    shell
    # 选择Serial收集器参数:
    java -XX: +UseSerialGC  xxx.jar

  • ParNew收集器: 是一个多线程垃圾收集器, 使用 copy 复制算法, 使用场景内存内存上升(20MB ~ 1G) 主要应用于服务端,可以和 CMS 更好使用, 但同时还是会产生 STW.

  • 命令如下:

    shell
    # 选择ParNew收集器参数:
    java -XX: +UseParNewGC xxx.jar
    # 注:JDK9之后取消了这一控制参数,选用CMS默认的新生代收集器为ParNew,也就是ParNew彻底并入了CMS。

  • Parallel Scavenge收集器: 是一个并行多线程垃圾收集器, 使用 copy 复制算法, 使用场景内存内存上升(20MB ~ 1G) 主要应用于服务端, CMS 等收集器的关注点是尽可能 地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。
  • Parallel Scavenge收集器:还具有自适应的调节策略,通过-XX: +UseAdaptiveSizePolicy 参数控制, 当该参数激活后,将不需要人工指定新生代(-Xmn)、Eden 与 Survivor 的比例(-XX: SurvivorRatio), 虚拟机会根据当前系统的运行情况收集性能监控信息,动态的调整这些参数以提供最合适的停顿时间或者最大吞吐量
  • JDK 1.8 默认垃圾收集器是 Parallel Scavenge + Parallel Old 组合, 使用命令 -XX: +PrintCommandLineFlags -version
  • 所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值 (吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))
  • 高吞吐量可以最高效率的利用处理器资源,吞吐量高,就意味着垃圾收集时间短,而更多的精力投入到程序的主要运算任务上。
  • 命令如下:
shell
# Paraller Scavenge 控制参数:
-XX: +UseParallelGC
# 吞吐量大小,是垃圾收集时间占总时间的百分比
-XX: GCTimeRatio
# 最大垃圾收集停顿时间,收集器尽力保证内存回收花费的时间不超过设定的值
-XX: MaxGCPauseMillis


应用于老年代收集器


  • Serial Old收集器: 是一个单线程垃圾收集器, 所有使用 Mark-sweep-compact 标记整理算法, 会产生 STW。
  • 命令如下:
shell
# 选择Serial Old收集器参数:
java -XX: +UseSerialOldGC xxx.jar

  • Parallel Old收集器: 是一个并行多线程垃圾收集器, 使用 Mark-sweep-compact 标记整理算法。
  • 产生问题: 单线程,导致用户线程停顿过久,所以为了解决单线程问题,使用多线程,而产生垃圾收集器。

  • CMS收集器: concurrent mark sweep 是一个并发标记垃圾收集器,主要工作在老年代,为了解决STW, CMS 新特性是垃圾线程和用户线程同时进行, 应用于 B/S 系统的服务端上。

  • CMS收集器: 是基于标记-清除算法实现的,运作过程分为四步:其中,初始标记重新标记这两个步骤仍然需要暂停用户线程

    • 1.初始标记:仅仅只标记一下 GC ROOTS 直接关联的对象,速度很快。
    • 2.并发标记:从 GC ROOTS 的直接关联对象开始遍历整个对象图耗时虽然长,但是不需要停顿用户线程
    • 3.重新标记:其目的是为了修正并发标记期间,因用户程序继续运行而导致标记变动的那一部分对象
    • 4.并发清除:清理标记阶段判定为死亡的对象
  • CMS 使用面广,最主要的优点在于:

    • 在最耗时的并发标记与并发清除阶段,CMS 无需暂停用户线程,CMS 收集器的内存回收过程是可以与用户线程一起并发执行的。
  • CMS 的优点也为 CMS 带来了部分负面的影响:

    • 由于 CMS 并发标记和并发清理阶段是和用户线程同时进行的,程序难免会在运行中产生新的垃圾对象,如果这部分的“浮动垃圾”产生在并发标记结束后,那么 CMS 无法在当次收集中处理掉。
    • CMS 面临的另一问题是,并发标记虽然不会停止用户线程,但是会占用一部分CPU资源从而导致应用程序变慢。
    • CMS 默认启动的回收线程数是(处理器核心数量+3)/ 4当处理器核心数量不足4个时,CMS对程序的影响会变大
    • CMS 收集结束后会产生打量的内存空间碎片,可能会导致大对象因无法找到连续的内存空间而引发一次Full GC
  • 由于 CMS 与用户线程同时运行的情况,CMS 必须预留足够的内存空间给用户线程使用,因此 CMS 无法像其他垃圾回收器一样等着老年代几乎被填满时再收集。

    • JDK5 默认 CMS 收集器当老年代使用了 68%的空间后被激活,而这一阈值在 JDK6 时提升到了 92%,无论何种阈值,CMS 都面临着“并发失败”的情况,
    • 即 CMS 预留空间不足以让程序运行产生的新对象的需要。当出现并发失败的情况时,虚拟机将采用预案:冻结用户线程,启用 Serial Old 收集器来重新进入老年代回收垃圾,Serial Old 的特性造就了停顿时间更长的情况.
shell
# 调整CMS触发的百分比
java -XX: CMSInitiatingOccupancyFraction  xxx.jar


新生代和老年代同时应用的收集器

  • Garbage First 收集器
  • ZGC 收集器
  • Shenandoah

五、JVM 调优