进大厂背过的Java面试题(JVM篇),全在这儿了!

12 阅读16分钟

前言

在上一篇文章中,我分享了[这些年,为了进大厂背过的Java面试题(基础篇),全在这儿了!受到了很多小伙伴的关注和好评,这也让我更有动力继续为大家输出干货。

作为一名有着 10 年 Java 开发经验的老鸟,回想起当年为了进大厂疯狂背面试题的日子,真是感慨万千。

如今市面上的面试题分享大多泛泛而谈,对于真正想提升技术、应对大厂面试的小伙伴帮助有限。

各位coder们都知道,JVM(Java 虚拟机)是 Java 语言的核心,也是面试中的高频考点。尤其是在冲击像阿里这样的大厂时,对 JVM 的深入理解更是必不可少。

今天就把这些宝贵的经验和面试题分享给大家,希望能帮助大家少走弯路,顺利拿下心仪的 offer。


JVM 内存划分

1. JVM 运行时数据区域

JVM 的运行时数据区域就像是一个大型的“工厂车间”,各个区域分工明确,有条不紊地运行着。它主要分为**程序计数器、Java虚拟机栈、本地方法栈、Java 堆、方法区**这几个部分。

在这里插入图片描述

程序计数器可以看作是一个“记录员”,它记录着当前线程所执行的字节码的行号。

Java 虚拟机栈则像是一个“工作流水线”,每个方法在执行时都会创建一个栈帧,栈帧中存储着局部变量表、操作数栈等信息。

本地方法栈与 Java 虚拟机栈类似,只不过它是为本地方法服务的。

Java 堆是“原材料仓库”,对象都在这里分配内存,是 JVM 内存中最大的一块区域。

方法区则像是“技术资料室”,存储着已经被 JVM 加载的类信息、常量、静态变量等数据。

2. 堆内存分配策略

堆内存的分配策略就像是仓库管理员分配货物存放位置的规则。常见的分配策略有指针碰撞空闲列表

在这里插入图片描述

指针碰撞适用于内存规整的情况,就好比仓库里货物摆放得整整齐齐,新货物直接放在空闲区域的起始位置即可。

空闲列表则适用于内存不规整的情况,此时仓库里货物摆放杂乱,需要有一个“清单”记录哪些区域是空闲的,以便分配新货物。

在这里插入图片描述

3. 创建一个对象的步骤

创建一个对象就像是在工厂里生产一件产品,需要经过多个步骤。

在这里插入图片描述

首先是类加载检查,就好比确认生产这件产品的图纸(类信息)是否已经准备好。

然后为对象分配内存,这就像是在仓库里找一块地方存放产品。

接着进行内存初始化,将分配到的内存空间初始化为零值。

之后设置对象头信息,就像是给产品贴上标签,记录一些元数据。

最后执行对象的初始化代码,对对象进行真正的初始化操作。

下面我们手搓一下代码:

public class ObjectCreationExample {
    public static void main(String[] args) {
        // 创建一个对象
        MyObject myObject = new MyObject();
    }
}

class MyObject {
    private int value;

    public MyObject() {
        value = 10;
    }
}

4. 对象引用

对象引用就像是产品的“提货单”,通过它我们可以找到堆内存中的对象。在 Java 中有强引用、软引用、弱引用虚引用这几种类型。

在这里插入图片描述

强引用是最常见的引用方式,只要强引用存在,对象就不会被垃圾回收。

软引用则相对较弱,当内存不足时,软引用所引用的对象会被回收。

弱引用更弱,只要垃圾回收器扫描到弱引用所引用的对象,不管内存是否充足,都会回收该对象。

虚引用则是最弱的引用,它主要用于在对象被回收时收到一个通知。

示例代码:

import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;

public class ObjectReferenceExample {
    public static void main(String[] args) {
        // 强引用
        MyObject strongRef = new MyObject();

        // 软引用
        SoftReference<MyObject> softRef = new SoftReference<>(new MyObject());

        // 弱引用
        WeakReference<MyObject> weakRef = new WeakReference<>(new MyObject());

        // 虚引用暂未演示,因为使用场景较为特殊
    }
}

JVM 类加载过程

1. 双亲委派机制

双亲委派机制就像是一个“层层上报”的审批流程。

在这里插入图片描述

当一个类加载器收到加载类的请求时,它首先会将请求委托给父类加载器,父类加载器再委托给它的父类加载器,以此类推,直到最顶层的启动类加载器。 如果父类加载器能够加载这个类,就直接返回,子类加载器就不再尝试加载。 只有当父类加载器无法加载时,子类加载器才会尝试自己加载。

这种机制的好处是保证了类的唯一性和安全性,避免了同一个类被不同的类加载器重复加载。

2. tomcat 的类加载机制

Tomcat 的类加载机制与传统的双亲委派机制有所不同,它采用了“逆双亲委派”的方式。

在这里插入图片描述

这是因为 Tomcat 需要支持多个 Web 应用程序,每个应用程序可能有自己独立的类库和版本。

Tomcat 有自己的一套类加载器层次结构,它允许 Web 应用程序优先加载自己的类库,而不是先委托给父类加载器。 这样可以避免不同 Web 应用程序之间的类库冲突。


JVM 垃圾回收

1. 存活算法和两次标记过程

判断对象是否存活有两种常见算法:引用计数法和可达性分析算法。

引用计数法就像是给对象设置一个“人气值”,每有一个引用指向它,人气值就加一,引用失效人气值就减一,当人气值为零时,对象就可以被回收。但这种算法无法解决循环引用的问题。

可达性分析算法则是从一系列的“GC Roots”对象出发,通过引用关系向下搜索,能够被搜索到的对象就是存活的,无法被搜索到的对象就是可回收的。

两次标记过程是在可达性分析算法的基础上进行的。

第一次标记时,标记出所有不可达的对象。 第二次标记时,会检查这些对象是否有必要执行 finalize() 方法。 如果对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被调用过,那么这些对象就会被直接回收。

在这里插入图片描述

2. 垃圾回收算法

常见的垃圾回收算法有标记 - 清除算法、标记 - 整理算法、复制算法分代收集算法

标记 - 清除算法就像是在仓库里找出废弃的货物,然后直接清理掉。但这种算法会产生大量的内存碎片。

标记 - 整理算法则是在标记出废弃货物后,将存活的货物整理到一起,避免产生内存碎片。

复制算法是将内存分为两块,每次只使用其中一块,当这块内存满了,就把存活的对象复制到另一块内存,然后把原来的内存空间一次性清理掉。这种算法不会产生内存碎片,但会浪费一半的内存空间。

分代收集算法是根据对象的存活周期不同将内存划分为不同的区域,比如新生代、老年代和永久代(在 Java 8 中是元空间)。不同的区域采用不同的垃圾回收算法,新生代对象创建和消亡频繁,适合使用复制算法;老年代对象存活时间长,适合使用标记 - 整理算法。

在这里插入图片描述

3. 垃圾收集器

Java 中有多种垃圾收集器,每种垃圾收集器都有其特点和适用场景。

在这里插入图片描述

  • Serial 收集器:这是最基本、最古老的收集器,它是单线程的,在进行垃圾回收时,会暂停所有的用户线程。就像是一个独自工作的清洁工,在打扫仓库时,所有人都得停下来等他打扫完。虽然效率相对较低,但在单核 CPU 环境下,简单高效。
  • ParNew 收集器:Serial 收集器的多线程版本,多个线程同时进行垃圾回收,提高了回收效率。可以想象成一群清洁工一起打扫仓库,速度自然比一个人快。它常与 CMS 收集器搭配使用。
  • Parallel Scavenge 收集器:关注的是系统的吞吐量,它在垃圾回收时,尽可能地减少垃圾回收所占用的时间,让系统有更多的时间处理用户请求。好比是一个高效的清洁工,快速打扫完仓库,让仓库能尽快恢复正常运作。
  • Serial Old 收集器:Serial 收集器的老年代版本,同样是单线程的,主要用于在 Client 模式下的虚拟机,或者作为 CMS 收集器出现 Concurrent Mode Failure 失败后的后备预案。
  • Parallel Old 收集器:Parallel Scavenge 收集器的老年代版本,多线程收集器,注重吞吐量,与 Parallel Scavenge 收集器配合使用,可以在注重吞吐量的应用场景中发挥很好的效果。
  • CMS(Concurrent Mark Sweep)收集器:以获取最短回收停顿时间为目标的收集器,它采用的是“标记 - 清除”算法。在垃圾回收过程中,尽量让垃圾回收线程和用户线程同时运行,减少对用户线程的影响。就像是在不影响仓库正常运作的情况下,悄悄打扫仓库的某些区域。不过它会产生内存碎片,并且对 CPU 资源比较敏感。
  • G1(Garbage - First)收集器:这是一款面向服务器的垃圾收集器,它将堆内存划分为多个大小相等的独立区域(Region),并且可以预测垃圾回收的停顿时间。它结合了多种垃圾回收算法的优点,在回收效率和停顿时间上取得了较好的平衡。可以把它想象成一个智能的仓库管理员,能够合理规划每个区域的清理工作,提高整体效率。

4. 配置垃圾收集器

在 Java 中,可以通过命令行参数来配置垃圾收集器。 例如,使用 Serial 收集器可以在启动 Java 程序时添加参数-XX:+UseSerialGC; 使用 Parallel Scavenge 收集器可以添加参数-XX:+UseParallelGC; 使用 CMS 收集器可以添加参数 -XX:+UseConcMarkSweepGC; 使用G1 收集器可以添加参数 -XX:+UseG1GC

在这里插入图片描述

示例代码(以使用 G1 收集器为例):

public class GarbageCollectorConfigExample {
    public static void main(String[] args) {
        // 这里只是示例,实际运行时通过命令行参数配置垃圾收集器
        // 例如:java -XX:+UseG1GC GarbageCollectorConfigExample
        System.out.println("Running with G1 Garbage Collector");
    }
}

5. JVM 性能调优

JVM性能调优是一个复杂但又非常重要的工作,它可以让我们的应用程序运行得更加高效稳定。调优的目标通常是减少垃圾回收的频率和时间,提高系统的吞吐量和响应速度。

在这里插入图片描述

调优的步骤一般包括: 首先通过工具(如 jstat、jvisualvm 等)收集 JVM 的运行数据,分析内存使用情况、垃圾回收频率等指标; 然后根据分析结果调整 JVM 的参数,如堆大小、新生代和老年代的比例、垃圾收集器的选择等; 最后再次进行性能测试,验证调优效果。

例如,当发现应用程序频繁进行垃圾回收,导致响应时间变长时,可以适当增加堆的大小,减少垃圾回收的频率。但堆大小也不能无限增大,否则可能会导致内存占用过多,甚至出现 OOM(OutOfMemoryError)错误。

6. JDK 新特性

随着 JDK 的不断发展,每个版本都带来了许多新特性,这些新特性在 JVM 方面也有体现。

例如,Java 8 引入了元空间(Metaspace)来替代永久代(PermGen)。元空间使用本地内存,避免了永久代内存大小难以控制的问题,减少了 OOM 错误的发生。

Java 11 进一步增强了垃圾回收器的性能,例如 G1 收集器在性能上有了显著提升,并且引入了 ZGC(Z Garbage Collector),ZGC 是一款低延迟的垃圾收集器,能够在极短的时间内完成垃圾回收,适用于对延迟要求极高的应用场景。


线上故障排查

1. 硬件故障排查

在排查线上故障时,硬件问题是首先要考虑的因素之一。硬件故障可能导致系统性能下降、程序崩溃等问题。

例如,服务器的内存不足可能导致 JVM 频繁进行垃圾回收,甚至出现 OOM 错误。我们可以通过服务器的监控工具(如 top、htop 等)查看内存使用情况,检查是否存在内存泄漏或者内存分配不合理的情况。

如果 CPU 使用率过高,可能是程序中存在死循环或者算法复杂度太高。 可以使用工具(如 perf、jstack 等)分析 CPU 占用情况,找出占用 CPU 资源较多的线程和代码片段。

2. 报表异常 | JVM 调优

当报表出现异常时,可能是由于 JVM 性能问题导致数据处理缓慢或者内存不足。

在这里插入图片描述

比如,在生成复杂报表时,需要处理大量的数据,如果 JVM 的堆大小设置过小,可能会导致内存溢出。 此时,我们可以通过调整 JVM 参数,增加堆大小,优化垃圾回收器的配置,提高数据处理的效率。

示例代码(假设是一个简单的报表生成程序,由于内存不足导致异常):

import java.util.ArrayList;
import java.util.List;

public class ReportGenerator {
    public static void main(String[] args) {
        List<String> data = new ArrayList<>();
        for (int i0; i < Integer.MAX_VALUE; i++) {
            data.add("Data item " + i);
        }
    }
}

在这个简单示例中,由于不断往 ArrayList 中添加数据,很容易导致内存溢出。 实际场景中,我们需要根据具体情况调整 JVM 参数,例如通过 -Xmx 和 -Xms 参数设置堆的最大和初始大小,选择合适的垃圾收集器来优化性能。

3. 大屏异常 | JUC 调优

大屏展示通常涉及到大量数据的实时处理和渲染,如果出现异常,除了前端页面的问题,也可能与后端的多线程处理有关,这就需要对 JUC(Java 并发包)进行调优。

在这里插入图片描述

在多线程环境下,可能会出现线程安全问题、死锁、线程饥饿等。 例如,在使用 ConcurrentHashMap 时,如果没有正确理解其特性,可能会在高并发场景下出现数据不一致的情况。

import java.util.concurrent.ConcurrentHashMap;

public class BigScreenDataProcessor {
    private static ConcurrentHashMap<String, Integer> dataMap = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        // 模拟多个线程同时操作数据
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                dataMap.put("key" + i, i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                Integer value = dataMap.get("key" + i);
                if (value != null) {
                    // 进行一些处理
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

在这个示例中,虽然 ConcurrentHashMap 是线程安全的,但在实际应用中,还需要根据业务逻辑确保数据的一致性和正确性。 调优时,可能需要调整线程池的大小、合理使用锁机制或者采用更高效的并发数据结构。

4. 接口延迟 | SWAP 调优

接口延迟可能是由于系统资源紧张,当物理内存不足时,操作系统会使用 SWAP 空间(交换空间)。过多地使用 SWAP空间会导致系统性能急剧下降,因为数据在磁盘和内存之间频繁交换。

在这里插入图片描述

我们可以通过查看系统的 SWAP 使用情况(例如在 Linux 系统下使用 free -h 命令)来判断是否存在问题。如果 SWAP 使用率过高,可以考虑增加物理内存,或者优化应用程序的内存使用,减少不必要的内存占用。

另外,优化接口的算法复杂度、减少数据库查询次数等也能有效降低接口延迟。例如,对频繁调用的接口进行缓存处理,避免重复查询数据库。

5. 内存溢出 | Cache 调优

内存溢出是一个常见且严重的问题,除了调整 JVM 堆大小外,对缓存(Cache)的优化也非常关键。

如果缓存中存储了大量过期或者无用的数据,会占用大量内存。 例如,使用 Guava Cache 时,需要合理设置缓存的过期时间、最大容量等参数。

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;

public class MemoryOverflowCacheExample {
    private static Cache<String, Object> cache = CacheBuilder.newBuilder()
           .maximumSize(1000)
           .expireAfterWrite(10, java.util.concurrent.TimeUnit.MINUTES)
           .build();

    public static void main(String[] args) {
        for (int i = 0; i < 2000; i++) {
            cache.put("key" + i, "value" + i);
        }
    }
}

在这个示例中,设置了缓存的最大容量为 1000,并且数据在写入 10 分钟后过期。 通过合理调整这些参数,可以避免缓存占用过多内存,从而减少内存溢出的风险。

6. CPU 飙高 | 死循环

CPU 飙高很多时候是由于程序中存在死循环。

死循环会让 CPU 一直忙于执行无用的指令,导致系统性能下降。

public class InfiniteLoopExample {
    public static void main(String[] args) {
        while (true) {
            // 这里是死循环,CPU 会一直被占用
        }
    }
}

当遇到 CPU 飙高的情况时,可以使用 jstack 命令获取线程堆栈信息,找到处于死循环的线程,然后定位到具体的代码位置进行修复。

总结

JVM 相关的知识在 Java 开发中至关重要,无论是面试还是实际的项目开发与线上故障排查,都离不开对它的深入理解和掌握。

从概念到理解再到代码手搓实战,每一个知识点都是 Java 编程世界里的重要基石。

希望通过我这 10 年开发经验总结的这些面试题和实战案例,能帮助大家更好地理解 JVM 的各个知识点。

如果这篇文章对你有帮助,欢迎点赞、收藏,也欢迎大家在评论区分享你的看法和经验,提出你的疑问。

关注我,后续还会分享更多关于 Java 开发的技术干货,让我们一起在 Java 的技术海洋中不断探索前行!

分享一份精心整理的大厂面试手册,包含计算机基础、Java基础、多线程、JVM、数据库、Redis、Spring、Mybatis、SpringMVC、SpringBoot、分布式、微服务、设计模式、架构、校招社招分享等高频面试题,非常实用,有小伙伴靠着这份手册拿过字节offer~【领取/点击】