银行科技岗技术面:Java JVM 高频问题与解析
- 轮次
- 技术面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 讲讲 JVM 内存结构?程序计数器、虚拟机栈、本地方法栈、堆、方法区、直接内存各自的作用和特点。
- 讲讲双亲委派机制是什么?有什么好处?能破坏吗?
- 讲讲类加载的过程?加载、验证、准备、解析、初始化各自干了什么?
- JVM 中有几种类加载器?它们之间是什么关系?
- 讲讲什么是垃圾?怎么判断一个对象是否可以被回收?垃圾回收算法都有哪些?各有什么优缺点?
- 堆空间为什么要分代?新生代和老年代分别适合用什么回收算法?
- 讲讲对象创建的过程?从 new 到对象可用经历了哪几步?
- 讲讲 Java 的四种引用类型?分别什么时候被回收?
- 什么是内存泄漏?常见场景有哪些?
- 并行和并发?
《参考解析》
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 保可见性和有序性但不管原子性。