面灵AI→

银行科技岗技术面:Java JVM 高频问题与解析

轮次
技术面
时间
2026-09
来源
牛客网

《面试题目》

  1. 讲讲 JVM 内存结构?程序计数器、虚拟机栈、本地方法栈、堆、方法区、直接内存各自的作用和特点。
  2. 讲讲双亲委派机制是什么?有什么好处?能破坏吗?
  3. 讲讲类加载的过程?加载、验证、准备、解析、初始化各自干了什么?
  4. JVM 中有几种类加载器?它们之间是什么关系?
  5. 讲讲什么是垃圾?怎么判断一个对象是否可以被回收?垃圾回收算法都有哪些?各有什么优缺点?
  6. 堆空间为什么要分代?新生代和老年代分别适合用什么回收算法?
  7. 讲讲对象创建的过程?从 new 到对象可用经历了哪几步?
  8. 讲讲 Java 的四种引用类型?分别什么时候被回收?
  9. 什么是内存泄漏?常见场景有哪些?
  10. 并行和并发?

《参考解析》

JVM 内存结构与各区域的异常、参数

按「线程私有 / 线程共享」两大类记最不容易漏。

线程私有:程序计数器记录当前线程执行到哪条字节码指令,执行 Native 方法时值为空,它是唯一不会抛 OOM 的区域(编译期就能确定大小);虚拟机栈每个方法对应一个栈帧,栈帧里放局部变量表、操作数栈、动态链接和方法出口信息,-Xss 控制大小,递归太深抛 StackOverflowError,扩容失败抛 OutOfMemoryError;本地方法栈结构类似,只不过服务的是 Native 方法,HotSpot 里它和虚拟机栈是合二为一的。

线程共享:堆是最大的一块,几乎所有对象实例和数组都在这里分配,也是 GC 的主战场,靠 -Xms/-Xmx 控制;方法区存类的元信息、运行时常量池、静态变量、JIT 编译后的代码缓存,JDK 8 之前用永久代(受 -XX:MaxPermSize 限制、容易出现 PermGen space OOM),JDK 8 起改成元空间,直接使用本地内存,参数是 -XX:MaxMetaspaceSize;另外 JDK 7 起字符串常量池就从方法区挪到了堆里。直接内存不属于运行时数据区,通过 DirectByteBuffer 使用,不受堆大小限制、只受物理内存和 -XX:MaxDirectMemorySize 限制,NIO/Netty 大量用它来避免一次堆内到堆外的拷贝;它的回收依赖虚引用(Cleaner)在堆内对象被回收时触发,所以堆内存长期不 GC 时也可能出现「堆外内存泄漏」。

双亲委派机制与它的三次「破坏」

机制本身很简单:类加载器收到加载请求时,先把请求委派给父加载器,父加载器再往上委派到启动类加载器;只有父加载器反馈「找不到」,子加载器才尝试自己加载。ClassLoader.loadClass 的逻辑是:先 findLoadedClass 查是否已加载,再 parent.loadClass,都失败才 findClass。

好处有两条:一是避免同一个类被重复加载,保证 JVM 里一个类只有一个 Class 对象;二是安全——用户自己写一个 java.lang.String 是不会被加载的,因为请求最终会委派到启动类加载器,由它加载 JDK 自带的那个版本,防止核心 API 被篡改。

「能破坏吗」的答案:能,历史上被破坏过三次。第一次是 JDK 1.2 之前,loadClass 还没被改成现在这套模板方法,子类直接重写 loadClass 就不会走委派;第二次是 SPI 场景,JDBC 这类接口定义在 rt.jar(由启动类加载器加载),实现类却由应用类加载器加载,父加载器看不到子加载器的类,于是引入线程上下文类加载器(Thread.currentThread().getContextClassLoader())让父加载器「反向」用子加载器加载;第三次是热部署与模块化,Tomcat 的 WebAppClassLoader 会优先自己加载 Web 应用里的类(除了 JDK 核心类),OSGi 则把类加载做成了网状依赖,都是为了实现应用隔离与热替换。

类加载过程和类加载器体系

五个阶段:加载——通过类的全限定名拿到二进制字节流,转成方法区的运行时数据结构,并在堆里生成对应的 Class 对象;验证——文件格式、元数据、字节码、符号引用四类校验,确保不会危害虚拟机;准备——为静态变量在方法区分配内存并赋零值(static int a = 10 在这一步是 0;static final 常量因为编译期已确定,直接赋真值);解析——把常量池里的符号引用替换为直接引用,可以在初始化前做,也可以延迟到首次使用时(动态绑定/反射相关);初始化——执行 <clinit> 方法,也就是静态变量赋值和 static 代码块,JVM 保证父类先初始化、且多线程下只执行一次(靠类加载器的锁)。触发初始化的「主动引用」有六种:new/访问静态字段/调用静态方法(对应的 getstatic/putstatic/invokestatic)、反射、初始化子类时先初始化父类、作为启动类的 main 类、MethodHandle 解析结果、JDK 7 之后的动态语言支持。

类加载器有四类:启动类加载器(Bootstrap,C++ 实现,加载 JAVA_HOME/lib 下的核心类,它在 Java 里拿不到引用,getParent() 返回 null)、扩展/平台类加载器(JDK 9 前是 ExtClassLoader 加载 lib/ext,之后是 PlatformClassLoader)、应用类加载器(AppClassLoader,加载 classpath)、以及自定义类加载器。它们的关系是「父子委派」而不是继承关系(靠组合持有 parent 引用)。

对象存活判定、回收算法与分代收集

判定「垃圾」不能靠引用计数(循环引用会永远无法回收),JVM 用的是可达性分析:从 GC Roots 出发做图遍历,走不到的对象才判定为可回收。GC Roots 包括虚拟机栈中局部变量表里的引用、方法区静态变量和常量引用的对象、JNI 引用的对象、被 synchronized 持有的锁对象、以及系统类加载器加载的类等。注意「不可达」不等于「一定被回收」:对象还有一次「自救」机会,即 finalize()(已废弃,别在业务里依赖)。

三种基础回收算法:标记-清除实现简单,但会产生内存碎片,且碎片多了会触发更频繁的分配失败;标记-复制把空间对半分(或按 Eden:S0:S1 = 8:1:1 用),存活对象复制到另一块再整体清空,没有碎片、分配还快(指针碰撞),代价是浪费空间且存活率高时复制成本大;标记-整理让存活对象向一端移动并清理边界外内存,没有碎片也不需要预留一半空间,但移动对象要更新所有引用,停顿更长。

分代收集是把这三种算法各用在合适的地方:绝大多数对象「朝生夕死」,新生代用复制算法(Minor GC,Eden 满了触发,存活对象进 Survivor,年龄到阈值 15 晋升老年代,大对象直接进老年代);老年代对象存活率高、复制不划算,用标记-清除或标记-整理(CMS 用并发标记清除、停顿短但有碎片和 Concurrent Mode Failure 风险;G1 把堆切成 Region,按停顿目标优先回收收益高的区域,做 Mixed GC;ZGC/Shenandoah 用染色指针 + 读屏障把停顿压到 10ms 以内)。

四种引用与内存泄漏

强引用是最常见的 new 出来的引用,只要还能通过 GC Roots 到达就绝不回收,宁可抛 OOM;软引用(SoftReference)在内存不足时被回收,适合做缓存(但 JDK 里软引用回收时机依赖 -XX:SoftRefLRUPolicyMSPerMB,实际业务缓存放 Redis/Caffeine 更可控);弱引用(WeakReference)只要发生 GC 就会被回收,ThreadLocal 的 key、WeakHashMap 都用它来避免把对象「钉死」;虚引用(PhantomReference)拿不到对象本身,唯一用途是在对象被回收时收到通知,DirectByteBuffer 就是靠它(Cleaner)释放堆外内存。

内存泄漏指对象已经不再被业务使用、却仍被 GC Roots 引用而无法回收,最终把堆撑爆。常见场景:静态集合/缓存不断 put 却没有淘汰策略;ThreadLocal 用完不 remove()——线程池里线程会被复用,ThreadLocalMap 的 value 就一直挂在那个线程上(key 是弱引用会被清掉,value 是强引用,形成 key 为 null 的 Entry);监听器、回调、观察者注册后没注销;连接、流、游标没在 finally / try-with-resources 里关闭;线程池、定时任务持有大对象;非静态内部类(匿名类)隐式持有外部类引用,被长生命周期对象引用时连带外部类一起泄漏。排查手段是 jmap -dump + MAT(看支配树和 GC Roots 引用链)、jstat -gcutil 看老年代增长趋势、-XX:+HeapDumpOnOutOfMemoryError 自动留现场。

对象创建过程,以及并行与并发

new 一个对象的完整流程:类加载检查(常量池能否定位到类的符号引用、类是否已加载/初始化,没有就先触发类加载)→ 分配内存(规整的堆用指针碰撞,有碎片的用空闲列表;并发安全靠 CAS 加重试,或更常用的 TLAB:每个线程先在 Eden 里预分一小块私有区域,分配在自己 TLAB 里做,只有 TLAB 用完才需要同步)→ 内存置零(保证实例字段有零值,所以对象还没执行构造方法时字段也不是随机值)→ 设置对象头(Mark Word 存哈希码、GC 分代年龄、锁状态;类型指针指向 Class 对象)→ 执行 <init>,也就是构造方法、实例初始化块和字段赋值,这一步之后对象才真正「可用」。

这里有个经典引申:构造方法里如果发生指令重排序(分配内存 → 构造 → 赋引用 可能被重排成 分配内存 → 赋引用 → 构造),其他线程就可能看到一个「引用非空但字段还是零值」的半初始化对象——这正是双重检查锁单例(DCL)里 instance 必须用 volatile 修饰的原因。

并行 vs 并发:并发(concurrency)指多个任务在同一时间段内交替推进,宏观上「同时」、微观上是分时切换,是程序的结构属性;并行(parallelism)指多个任务在同一时刻真正同时执行,需要多核/多处理器,是执行属性。一句话概括:并发是「同时处理很多事」,并行是「同时做很多事」;单核也能并发(时间片),但不能并行。相关概念还有 Amdahl 定律(加速比受串行部分限制)和「并发编程三大特性」——原子性、可见性、有序性,synchronized 保原子性和可见性,volatile 保可见性和有序性但不管原子性。