第一部分:运行时内存数据区篇 (Q1 - Q10)
Q1:JVM 运行时数据区包含哪些部分?根据 JVM 规范是如何划分的?
参考回答:
根据《Java 虚拟机规范》,JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。主要包含以下五大部分:
- 程序计数器(Program Counter Register): 当前线程所执行的字节码的行号指示器。
- Java 虚拟机栈(Java Virtual Machine Stacks): 主管 Java 方法运行的内存模型,每个方法执行时都会同步创建一个栈帧。
- 本地方法栈(Native Method Stack): 与虚拟机栈发挥的作用非常相似,区别在于本地方法栈为虚拟机使用到的 Native 方法服务。
- Java 堆(Java Heap): 虚拟机所管理的内存中最大的一块,所有线程共享,几乎所有的对象实例以及数组都在这里分配内存。
- 方法区(Method Area): 线程共享的区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
Q2:哪些内存区域是线程私有的?哪些是线程共享的?为什么要这样设计?
参考回答:
- 线程私有区域: 程序计数器、Java 虚拟机栈、本地方法栈。
- 线程共享区域: Java 堆、方法区(包含运行时常量池)。
设计原因:
- 线程私有是为了保证线程切换后能恢复到正确的执行位置,并且多线程并发执行方法时,各自的局部变量、内部调用互不干扰,从而天然避免了局部变量的线程安全问题。
- 线程共享是为了让多线程之间能够高效传递和共享数据。例如,对象实例占用的内存较大,若每个线程都独立拷贝一份,内存空间会瞬间暴涨;方法区存储的类元数据也是同理,共享一套元数据可以极大节流内存并加快访问速度。
Q3:程序计数器的作用是什么?它会抛出 OutOfMemoryError (OOM) 吗?
参考回答:
- 作用: 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。在多线程环境下,它用于记录当前线程被挂起时执行到的位置,以便线程获得 CPU 时间片切换回来时能继续正确执行。
- 是否抛出 OOM: 不会 。程序计数器是 JVM 规范中唯一一个没有规定任何
OutOfMemoryError情况的区域,它的生命周期随着线程的创建而创建,随着线程的消亡而消亡,占用极小的固定内存空间(一个字长)。
Q4:虚拟机栈的内部结构是怎样的?什么是栈帧?
参考回答:
- 虚拟机栈: 它的生命周期与线程相同。每个 Java 方法从被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 栈帧(Stack Frame): 是用于支持虚拟机进行方法调用和方法执行的数据结构。每一个栈帧都包含以下核心组成部分:
- 局部变量表(Local Variable Table): 存放方法参数和方法内部定义的局部变量(基本数据类型、对象引用 reference 和 returnAddress)。
- 操作数栈(Operand Stack): 作为一个后进先出的栈,用于在方法执行过程中存放计算的中间结果及临时变量。
- 动态连接(Dynamic Linking): 指向运行时常量池中该栈帧所属方法的符号引用,用于将符号引用转换为直接引用。
- 方法返回地址(Return Address): 存放调用该方法的指令的下一条指令的地址,用于方法正常或异常退出后返回到调用位置。
Q5:栈溢出(StackOverflowError)与栈内存溢出(OutOfMemoryError)的区别和诱因是什么?
参考回答:
-
StackOverflowError(栈溢出):
-
定义: 线程请求的栈深度大于虚拟机所允许的深度。
-
典型诱因: 发生了没有出口的无限制递归调用,或者方法内部声明了过于庞大的局部变量表导致单个栈帧过大。
-
OutOfMemoryError(栈 OOM):
-
定义: 如果虚拟机栈允许动态扩展,当扩展时无法申请到足够的内存;或者在创建新线程时,操作系统因内存不足无法为该线程分配对应的虚拟机栈空间。
-
典型诱因: 机器物理内存本身不足,或者盲目调大了单个线程的栈容量(如通过
-Xss参数调得过大),同时并发创建了海量的线程。
Q6:方法区的作用是什么?Java 7 的永久代(PermGen)和 Java 8+ 的元空间(Metaspace)有什么本质区别?
参考回答:
- 作用: 用于存储已被虚拟机加载的类元信息、常量、静态变量、即时编译器(JIT)编译后的代码缓存等。
- 本质区别:
- 内存位置不同: Java 7 及以前使用的"永久代"是 JVM 规范对方法区的一种实现,其占用的内存是 JVM 控制的堆内存 的一部分,受
-XX:PermSize和-XX:MaxPermSize限制。而 Java 8 彻底废除了永久代,改用"元空间"来实现方法区,元空间并不在虚拟机堆内存中,而是直接映射到操作系统的本地内存(Native Memory)中。 - 溢出风险不同: 永久代因为有硬性的上限,当加载的类数量极多时(如频繁使用 Spring CGLIB 动态代理、大量的整编 JSP),极易触发
java.lang.OutOfMemoryError: PermGen space。元空间默认只受本地物理内存限制,可通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize控制,大幅降低了 OOM 的概率。 - 内容迁移: 在 Java 7 时,已经将永久代里的字符串常量池、静态变量移动到了 Java 堆中;Java 8 只是把剩下的类型信息、方法元数据等移到了元空间。
Q7:什么是运行时常量池?它和字符串常量池(String Table)是什么关系?
参考回答:
- 运行时常量池(Runtime Constant Pool): 属于方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容在类加载后被存放到方法区的运行时常量池中。
- 字符串常量池(String Table): JVM 为了提升性能和减少内存开销,专门为 String 类建立的全局全局缓存表。
- 二者关系与演进: * 在 JDK 6 及之前,字符串常量池处于永久代内部,属于运行时常量池的一部分。
- 从 JDK 7 开始,为了防止永久代内存溢出并提高垃圾回收效率,JVM 将字符串常量池从运行时常量池中剥离出来,移动到了 Java 堆(Heap) 中。运行时常量池中原先的字符串符号引用在解析时,会直接指向堆中的字符串常量池实例。
Q8:什么是直接内存/堆外内存(Direct Memory)?它是如何分配和回收的?
参考回答:
- 定义: 直接内存不属于 JVM 运行时数据区的一部分,也不是 JVM 规范中定义的内存区域,而是操作系统的本地物理内存。Java 的 NIO(New Input/Output)为了避免在 Java 堆和本地外部内存之间频繁复制数据,引入了基于 Channel 与 Buffer 的 I/O 方式,可以直接使用 Native 函数库分配堆外内存。
- 分配方式: 在 Java 层面通过
ByteBuffer.allocateDirect(int capacity)进行分配,其底层调用的是 JDK 内部未公开的Unsafe.allocateMemory()方法。 - 回收机制: 直接内存的回收不受 JVM 垃圾回收器的直接管辖。它是通过
DirectByteBuffer内部的一个特殊引用Cleaner(继承自PhantomReference虚引用)来实现的。当DirectByteBuffer对象在 Java 堆中变得不可达并被 GC 回收时,由后台的 ReferenceHandler 线程执行Cleaner的clean()方法,调用Unsafe.freeMemory()来释放本地操作系统内存。也可以通过显式调用 JVM 参数-XX:MaxDirectMemorySize来限制其最大上限。
Q9:Java 对象在堆内存中的内存布局是怎样的?
参考回答:
在 HotSpot 虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:
-
对象头(Header): 包含两个核心子部分:
-
Mark Word(标记字段): 存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。在 64 位虚拟机中占 8 字节。
-
Klass Pointer(类型指针): 对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。开启指针压缩时占 4 字节,未开启占 8 字节。(若是数组,对象头中还会多出 4 字节的数组长度)。
-
实例数据(Instance Data): 对象真正存储的有效信息,即程序代码中所定义的各种类型的字段内容(父类继承的字段和子类自身的字段)。
-
对齐填充(Padding): 并不是必然存在的,仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,当对象实例数据部分没有对齐时,需要通过对齐填充来补全。
Q10:Java 对象的访问定位方式有哪些?句柄访问和直接指针访问有什么区别?
参考回答:
Java 程序需要通过虚拟机栈上的 reference 数据来操作堆上的具体对象。主流的访问定位方式有两种:
-
句柄访问: Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的是对象的句柄地址。句柄中包含了对象实例数据(在堆中)与类型数据(在方法区中)各自的具体地址信息。
-
优点: reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
-
直接指针访问(HotSpot 默认采用): reference 中存储的直接就是对象在堆中的物理地址。对象的内存布局中就必须考虑如何放置访问类型数据的相关信息。
-
优点: 速度更快,它节省了一次指针定位的时间开销。由于对象的访问在 Java 中非常频繁,积少成多之下直接指针访问能带来可观的执行效率提升。
第二部分:对象生命周期与 GC 算法篇 (Q11 - Q23)
Q11:Java 对象创建的完整流程是怎样的?
参考回答:
当虚拟机遇到一条字节码 new 指令时,会触发以下一系列步骤:
- 类加载检查: 检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先执行相应的类加载过程。
- 分配内存: 在类加载检查通过后,虚拟机将为新生对象分配内存(对象所需内存大小在类加载完成后便可完全确定)。
- 初始化零值: 内存分配完成后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值,这保证了对象的实例字段在 Java 代码中可以不赋初值就直接使用。
- 设置对象头: 虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄、锁状态标志等。这些信息存放在对象头中。
- 执行构造方法(
<init>): 从 JVM 角度看,此时对象已创建成功。但从 Java 程序角度看,才刚刚开始执行构造方法,初始化成员变量,按照程序员的意愿进行初始化,至此一个真正可用的对象才算完全产生。
Q12:JVM 是如何给对象分配内存的?"指针碰撞"与"空闲列表"有什么区别?
参考回答:
给对象分配内存本质上是从 Java 堆中划分出一块确定的物理空间,分配方式取决于 Java 堆是否规整:
-
指针碰撞(Bump the Pointer): * 适用场景: Java 堆内存是绝对规整的(用过的内存在一边,空闲的在另一边,中间有一个指针作为分界点)。
-
工作机理: 分配内存时仅仅把那个分界点指针向空闲空间那边挪动一段与对象大小相等的距离。Serial、ParNew 等带压缩整理过程的收集器采用此方案。
-
空闲列表(Free List):
-
适用场景: Java 堆内存是不规整的(已用内存和空闲内存相互交错)。
-
工作机理: 虚拟机必须维护一个列表,记录哪些内存块是空闲的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。CMS 等基于标记-清除算法的收集器采用此方案。
Q13:多线程并发分配内存时,JVM 是如何保证线程安全的?什么是 TLAB?
参考回答:
对象创建在虚拟机中是非常频繁的行为,在并发情况下非线程安全,JVM 采用两种手段来保证安全:
- CAS 加上失败重试: 虚拟机采用 CAS(Compare And Swap)原子操作配以失败重试的方式,保证更新堆内存指针操作的原子性。
- TLAB(Thread Local Allocation Buffer,线程本地分配缓冲区): 这是更高效的方案。把内存分配的动作按照线程划分在不同的空间之中进行。
- 工作原理: 每个线程在 Java 堆的 Eden 区中预先分配一小块独享的内存,称为 TLAB。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有当 TLAB 用完并重新分配新的 TLAB 时,才需要加锁进行 CAS 同步。通过此机制,绝大多数对象的分配都能在线程私有的缓冲区中无锁、飞速完成。
Q14:如何判断一个对象是可以被回收的?引用计数法和可达性分析算法有什么区别?
参考回答:
-
引用计数法(Reference Counting):
-
原理: 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。
-
致命缺陷: 无法解决对象之间相互循环引用的问题。例如对象 A 引用了对象 B,对象 B 也引用了对象 A,除此之外再无其他引用。由于它们的计数器都不为 0,引用计数法无法通知垃圾回收器回收它们。
-
可达性分析算法(Reachability Analysis,现代 JVM 采用):
-
原理: 通过一系列被称为 "GC Roots" 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链(Reference Chain)"。
-
判定标准: 当一个对象到所有的 GC Roots 之间没有任何引用链相连(即从 GC Roots 到这个对象不可达)时,则证明此对象是不可能再被使用的,宣告其死亡。
Q15:什么是 GC Roots?在 Java 中,哪些对象可以作为 GC Roots?
参考回答:
- 定义: 可达性分析算法中作为图搜索起点的一组根对象集合。这些对象必然是当前活跃的、不可能被清理的内存引用锚点。
- 可作为 GC Roots 的对象主要包括以下几种:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象: 如当前正在运行的方法中所使用的参数、局部变量、临时变量等。
- 方法区中类静态属性引用的对象: 如 Java 类中的引用类型静态变量。
- 方法区中常量引用的对象: 如字符串常量池里的引用。
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
- JVM 内部的引用: 如系统类加载器、常驻的异常对象(如
NullPointerException、OutOfMemoryError)、系统核心类等。 - 所有被同步锁(
synchronized关键字)持有的对象。
Q16:Java 中的四种引用(强、软、弱、虚)分别是什么?各自的应用场景有哪些?
参考回答:
-
强引用(Strong Reference): 类似于
Object obj = new Object()这种普遍的引用。只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象,宁可抛出 OOM。 -
软引用(Soft Reference): 描述一些有用但非必需的对象。在系统将要发生内存溢出异常(OOM)之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出 OOM。
-
场景: 网页缓存、图片缓存等可有可无的内存缓存空间。
-
弱引用(Weak Reference): 强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
-
场景:
WeakHashMap、ThreadLocal中的 ThreadLocalMap 的 Key 存储,防止内存泄漏。 -
虚引用(Phantom Reference): 也被称为"幽灵引用"或"幻影引用",它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
-
场景: 唯一目的就是能在这个对象被收集器回收时收到一个系统通知 。配合
ReferenceQueue使用,主要用于管理直接内存/堆外内存的释放(如DirectByteBuffer的Cleaner)。
Q17:finalize() 方法有什么作用?对象能在这里实现"自救"吗?是保证一定会运行吗?
参考回答:
- 自救原理: 在可达性分析法中被判定不可达的对象,并不会立刻死掉,而是处于"缓刑"阶段。要宣告一个对象真正死亡,至少要经历两次标记过程。如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记。随后进行一次筛选,筛选的条件是此对象是否有必要执行
finalize()方法。如果对象在finalize()中成功把自己赋予了某个类变量或者对象的成员变量,那在第二次标记时它将被移出"即将回收"的集合,完成自救。 - 是否保证运行: 绝不保证 。JVM 确实会触发这个方法,但是是由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去触发。虚拟机只会保证触发该方法的开始,但并不承诺一定会等待它运行结束。因为如果一个对象在
finalize()方法中执行缓慢,或者发生了死循环,将很可能导致 Finalizer 队列中的其他对象永远等待,甚至导致整个内存回收子系统崩溃。 - 工程建议: 该方法已被 JDK 官方废弃,其运行代价高昂,不确定性大,无法保证执行顺序,绝不建议使用。
Q18:请简述"标记-清除(Mark-Sweep)"算法的原理及优缺点。
参考回答:
- 基本原理: 算法分为"标记"和"清除"两个阶段:首先标记出所有需要回收的对象(也可以反过来标记存活的对象),在标记完成后,统一回收掉所有被标记的对象。
- 优点: 最基础的算法,实现逻辑非常直接。
- 缺点:
- 执行效率不稳定: 如果 Java 堆中包含大量对象,且其中大部分是需要被回收的,这时必须进行大量的标记和清除动作,导致效率随着对象数量增长而降低。
- 内存空间碎片化(最致命): 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集。
Q19:请简述"标记-复制(Mark-Copying)"算法的原理及优缺点。
参考回答:
- 基本原理: 为了解决清除算法的碎片化和效率问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 现代改良(Appel 式回收): 现代虚拟机用它来回收年轻代。将年轻代内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间(S0、S1),HotSpot 默认比例是 8:1:1。每次分配只使用 Eden 和其中一块 Survivor。发生垃圾回收时,将 Eden 和使用中的 Survivor 中依然存活的对象一次性复制到另一块空的 Survivor 上,最后清理掉 Eden 和刚用过的 Survivor。
- 优点: 不会产生内存碎片。分配内存时只需挪动堆顶指针,按顺序分配,运行高效。
- 缺点: 如果对象存活率较高(如老年代场景),就会进行较多的复制操作,效率会变低。同时,会浪费一部分内存空间作为担保担保(Appel 式回收中浪费 10% 的 Survivor 空间)。
Q20:请简述"标记-整理(Mark-Compact)"算法的原理及优缺点。
参考回答:
- 基本原理: 专门针对老年代的对象存活特征设计。其中的标记过程仍然与"标记-清除"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。
- 优点: 既不会像复制算法那样浪费一半的内存空间,也不会像清除算法那样产生零碎的内存碎片,非常适合对象存活率极高的老年代场景。
- 缺点: 移动存活对象并更新所有引用这些对象的地方(指针重定向)是一种极为负重的操作。在移动过程中,必须全程暂停用户应用程序(即 Stop The World,STW),对垃圾回收的吞吐量和延迟有显著影响。
Q21:什么是分代收集理论?年轻代(Young Generation)和老年代(Old Generation)的默认划分比例是多少?
参考回答:
- 分代收集理论: 现代主流垃圾回收器遵循的通用设计原则。它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis): 绝大多数对象都是朝生夕灭的(年轻代)。
- 强分代假说(Strong Generational Hypothesis): 熬过越多次垃圾收集过筛的对象就越难以消亡(老年代)。
- 划分比例: JVM 根据对象的不同生命周期,将 Java 堆划分为年轻代和老年代。
- 年轻代 vs 老年代: 默认物理比例是 1:2 (即年轻代占整个堆内存的 1/3,老年代占 2/3)。可通过参数
-XX:NewRatio进行调整。 - 年轻代内部: 进一步划分为一个 Eden 区 和两个 Survivor 区(From/S0, To/S1) ,默认比例是 8:1:1 (即 Eden 占年轻代的 80%,两个 Survivor 各占 10%)。可通过参数
-XX:SurvivorRatio进行调整。
Q22:Minor GC、Major GC、Full GC 有什么区别?触发 Full GC 的常见条件有哪些?
参考回答:
-
区别划分:
-
Minor GC(又称 Young GC): 指目标只是年轻代的垃圾收集。触发非常频繁,回收速度快。
-
Major GC(又称 Old GC): 指目标只是老年代的垃圾收集。目前只有 CMS 收集器会有单独收集老年代的行为。
-
Full GC: 收集整个 Java 堆(年轻代、老年代)以及方法区/元空间的垃圾收集。会导致长时间的系统停顿,应当尽量避免。
-
触发 Full GC 的常见条件:
- 老年代空间不足: 经历了 Minor GC 后,准备晋升到老年代的对象极其庞大,而老年代剩余连续空间不足。
- 元空间(Metaspace)达到阈值: 运行过程中动态加载了大量的类,导致元空间耗尽,触发 Full GC 伴随对方法区的类卸载。
- 显式调用
System.gc(): 代码中主动调用了此方法,建议虚拟机进行 Full GC(虽然不一定会立刻执行,但概率极高。线上一般通过-XX:+DisableExplicitGC来禁用该功能)。 - 空间分配担保失败: 年轻代发生 Minor GC 前,老年代的连续空闲空间小于历次晋升老年代对象的平均大小。
Q23:对象晋升到老年代的判定规则有哪些?
参考回答:
虚拟机为了让高龄存活对象和特大对象尽早进入老年代,制定了以下四条判定铁律:
- 达到长期存活的年龄阈值: 对象在 Eden 区出生并经过第一次 Minor GC 后若存活,移动到 Survivor 区,此时对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁。当它的年龄增加到一定程度(默认 15 岁 ,可通过
-XX:MaxTenuringThreshold设置),就会被晋升到老年代中。 - 大对象直接进入老年代: 严重占用年轻代空间的特大对象(如很长的字符串或者元素极多的字节数组)。通过设置
-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,避免在 Eden 区及两个 Survivor 区之间发生大面积的物理内存复制。 - 动态年龄判断: 如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半(50%) ,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到
MaxTenuringThreshold中要求的年龄。 - Minor GC 空间分配担保: 发生 Minor GC 时,Eden 区加上 From Survivor 区中所有存活的对象无法完全塞入 To Survivor 区。此时需要老年代启动物理空间分配担保,无法容纳的部分通过担保机制直接晋升进入老年代。
第三部分:垃圾收集器篇 (Q24 - Q29)
Q24:经典的 Serial、ParNew 和 Parallel Scavenge 收集器有什么区别?各自适合什么场景?
参考回答:
-
Serial 收集器: 最基础、历史最悠久的单线程年轻代收集器。在它进行垃圾收集时,必须暂停其他所有工作线程(STW),直到它收集结束。
-
场景: 适合客户端模式(Client)下内存有限的单核微型处理器环境。
-
ParNew 收集器: 实质上是 Serial 收集器的多线程并行版本,采用了多个 CPU 核心并行进行垃圾收集,其回收算法、STW 机制与 Serial 完全一致。
-
场景: 在 JDK 8 及以前,它是配合老年代 CMS 收集器的黄金年轻代收集器搭配。
-
Parallel Scavenge 收集器: 也是一个并行的多线程年轻代收集器,基于标记-复制算法。它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 的目标是达到一个可控制的吞吐量(Throughput)。
-
场景: 适合后台繁重计算、对交互响应延迟没有苛刻要求的高吞吐量大数据分析服务器环境(它是 JDK 8 默认的垃圾收集器)。
Q25:请深度拆解 CMS(Concurrent Mark Sweep)收集器的完整工作流程。它有哪些致命缺点?
参考回答:
- 工作流程: CMS 是一种以获取最短停顿时间为目标的、基于标记-清除算法的老年代垃圾收集器。其完整生命周期包含四个核心阶段:
- 初始标记(Initial Mark - STW): 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度极快,需要暂停用户线程。
- 并发标记(Concurrent Mark): 从 GC Roots 的直接关联对象开始遍历整个对象图的过程,耗时较长但不需要暂停用户线程,与应用程序并发运行。
- 重新标记(Remark - STW): 为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。停顿时间比初始标记稍长,但远比并发标记短。
- 并发清除(Concurrent Sweep): 清理掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以该阶段也是与用户线程并发运行的。
- 致命缺点:
- 产生内存空间碎片: 基于标记-清除算法,回收后产生大量离散碎片,常常由于无法容纳大对象提前触发 Full GC。
- 产生"浮动垃圾(Floating Garbage)": 在并发清除阶段,用户线程还在运行,这期间产生的垃圾无法在当次 GC 中被收割,只能留待下一次垃圾回收,需要预留一部分物理空间供用户线程临时使用。
- 并发阶段极度消耗 CPU 算力: 与用户线程争夺算力,导致应用程序在 GC 期间发生明显的吞吐量下降。
Q26:G1(Garbage First)收集器的特点是什么?它是如何实现"可预测的停顿时间"的?
参考回答:
- 核心特点: G1 是一款面向服务端应用的里程碑式垃圾收集器(JDK 9 开始的默认收集器)。它彻底改变了传统将堆内存固定划分为年轻代、老年代的思维。
- Region 概念: G1 将连续的 Java 堆划分为多个大小相等的独立区域(Region) (默认可达上千个)。每一个 Region 都可以根据需要,扮演 Eden 空间、Survivor 空间或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理。此外,还有专门用来存放大对象的 Humongous Region。
- 实现可预测停顿时间的底层机理: G1 将每个 Region 作为单次回收的最小单元。它在后台维护一个优先回收价值列表 ,每次根据用户通过
-XX:MaxGCPauseMillis参数设定的可接受最大停顿时间目标(默认 200 毫秒),优先在列表中挑选出回收收益最大、最没有负担的那些 Region 进行打包回收。这种只针对局部 Region 开展价值量化评估并进行有选择回收的机制,完美实现了在满足用户延迟约束的前提下榨取最大的吞吐量(即 "Garbage First" 的由来)。
Q27:在 G1 收集器中,什么是卡表(Card Table)和记忆集(RSet)?它们解决了什么问题?
参考回答:
- 面对的难题: 在分代或分 Region 回收的体系下,存在严重的跨代/跨 Region 引用问题。例如老年代中的对象可能引用了年轻代 Eden 区的对象。如果进行年轻代 Minor GC 的时候,为了查明存活关系,不得不把整个庞大的老年代全部扫描一遍,就会彻底颠覆局部回收的效率。
- 卡表(Card Table): 一种字节数组结构。它将整个 Java 堆的物理空间划分为一个个固定大小(通常为 512 字节)的内存区域,称为"卡页(Card Page)"。如果一个卡页内存在老年代对象指向年轻代对象的交叉引用,卡表数组对应位置的值就会被标记(变脏,Dirty)。
- 记忆集(Remembered Set - RSet): 每一个 Region 内部都自带一个 RSet 结构。它的逻辑是"谁引用了我"。RSet 内部会通过读取卡表的信息,详细记录当前 Region 内的对象被外界哪些 Region 的哪些卡页给引用了。
- 解决的问题: 在进行年轻代垃圾收集时,G1 的垃圾回收线程只需把当前 Region 自带的 RSet 列入搜索范围,直接把 RSet 对应脏卡页的对象加入 GC Roots。这样就能精确锁定老年代中的跨代指针,完全避免了全局全表扫描老年代,极大提升了局部 GC 的并发处理效能。
Q28:什么是 Stop The World (STW)?在垃圾回收过程中为什么一定要 STW?
参考回答:
- 定义:
Stop The World是指 JVM 垃圾回收机制在执行某些核心标记或移动动作时,出于一致性考量,强制暂停所有处于运行状态的用户应用程序线程。 - 为什么一定要暂停(以可达性分析为例): 可达性分析算法是基于快照一致性的。在对整个 Java 堆的对象引用关系图进行分析检索时,必须确保这个对象图结构保持在某个确定性的物理时间点,不能像"流沙"一样随着用户程序运行而动态改变。
- 反面示例: 如果不执行 STW,垃圾回收线程前脚刚判定一个对象不可达、属于垃圾,用户线程后脚立刻在方法中将一个 GC Root 强引用重新挂载指回了这个对象。如果垃圾收集器继续根据旧快照执行清理,就会导致正在被程序高频使用的存活对象被不留情面地"误杀",引发系统产生严重的空指针崩溃或严重的业务逻辑跑偏。
Q29:ZGC(Z Garbage Collector)垃圾收集器的核心创新点是什么?它是如何做到将停顿时间控制在 10 毫秒以内的?
参考回答:
- 核心创新点: ZGC 是一款在 JDK 11 引入、在 JDK 15 完善的划时代全并发低延迟 垃圾收集器。它的核心创新点在于采用了染色指针(Colored Pointers)和读屏障(Load Barriers)技术,实现了垃圾回收全阶段(除了极其短暂的、与堆大小无关的初始标记/重定位初始化外)都能与用户线程并发运行。它彻底消除了 G1/CMS 收集器中随着堆内存扩大停顿时间成正比抽高的致命弊端。
- 控制停顿在 10 毫秒以内的核心运行机制:
- 染色指针技术: ZGC 将对象的 64 位 reference 指针直接拿出来使用,将其高 4 位用于存储对象当前的 GC 状态元数据(如 Marked0、Marked1、Remapped 等物理标记)。这使得 ZGC 仅仅通过检查 reference 指针本身而不需要去堆里读取对象头,就能瞬间查明对象的生存和移动状态。
- 并发整理与读屏障: 传统的收集器在移动存活对象(整理阶段)时必须全程 STW,以防止指针错乱。而 ZGC 允许在并发阶段让垃圾回收线程去移动 Region 内的对象。当用户线程试图去读取(Load)一个正在被移动或者已经被移动但指针还没修正的对象时,会触发读屏障 拦截。读屏障会根据染色指针的位信息,自动将该 reference 更新修正为对象被移动后的最新真实物理地址(即指针自愈,Self-Healing)。通过读屏障的自愈保护,ZGC 将最耗时的移动对象、重定位指针操作彻底搬到了并发阶段,从而将系统的停顿时间牢牢锁死在常数级(普遍在毫秒以内)。
第四部分:类加载子系统篇 (Q30 - Q35)
Q30:类的生命周期包含哪些阶段?类加载(Class Loading)包含其中的哪几个过程?
参考回答:
类的完整生命周期从 Class 文件加载进 JVM 内存开始,直到被卸载出内存为止。它包含以下七个严谨的进化阶段:
- 加载(Loading) →\rightarrow→ 验证(Verification) →\rightarrow→ 准备(Preparation) →\rightarrow→ 解析(Resolution) →\rightarrow→ 初始化(Initialization) →\rightarrow→ 使用(Using) →\rightarrow→ 卸载(Unloading)。
其中,标准的类加载(Class Loading)子系统特指前面的五个过程,其中验证、准备、解析这三个阶段在逻辑上又被合称为连接(Linking)阶段。
Q31:类加载的"准备"阶段和"初始化"阶段对静态变量(static)的处理有什么本质不同?
参考回答:
-
准备(Preparation)阶段: 是正式为类中定义的静态变量(即被
static修饰的变量)分配内存并设置类变量初始零值 的阶段。此时在内存中赋的值仅仅是该数据类型的默认零值(如int赋0,引用类型赋null),而不是代码中显式指定的赋值。 -
示例:
public static int value = 123;在准备阶段完成后,value的实际取值在内存中是0,而不是123。 -
特殊情况(
final): 如果类变量被static final共同修饰,则说明它是常数常量。在编译时已经为该字段生成了 ConstantValue 属性,因此在准备阶段变量就会被直接赋予代码中的实际值。即public static final int value = 123;在准备阶段value就会被直接赋为123。 -
初始化(Initialization)阶段: 是整个类加载过程的最后一个核心步骤。直到这一步,JVM 才真正开始执行类中编写的 Java 程序代码。初始化阶段本质上是执行虚拟机编译器自动生成的类构造器
<clinit>()方法 的过程。在这一步,显式赋予静态变量的真实赋值动作、静态代码块(static {})里的操作才会被真正按顺序搬上舞台执行。此时上面的value = 123的赋值才会真正兑现。
Q32:Java 中自带的类加载器(Class Loaders)有哪些?各自负责加载什么路径下的类?
参考回答:
在 JVM 层面,内置了层次分明的三大核心类加载器:
-
启动类加载器(Bootstrap ClassLoader): 处于类加载器链的最顶层。由 C++ 语言实现(在 HotSpot 中),不继承自
java.lang.ClassLoader。 -
职责: 负责加载虚拟机核心基础类库,专门扫描
<JAVA_HOME>/lib路径,或者被-Xbootclasspath参数所指定的路径中能够被虚拟机识别的类(如rt.jar、resources.jar、java.lang.*核心类)。 -
扩展类加载器(Extension ClassLoader / JDK 9+ 后改为 Platform ClassLoader): 由 Java 语言编写,父类加载器为 Bootstrap。
-
职责: 负责加载 Java 的一些扩展通用类库。专门扫描
<JAVA_HOME>/lib/ext目录,或者被java.ext.dirs系统变量所指定的路径中的所有类库。 -
应用程序类加载器(Application ClassLoader / 也称系统类加载器 System ClassLoader): 父类加载器为 Extension/Platform。
-
职责: 负责加载用户类路径(ClassPath)上所有的类库。如果我们在程序中没有自定义过类加载器,通常这个就是程序中的默认类加载器。
Q33:什么是双亲委派模型(Parents Delegation Model)?为什么要设计这种机制?
参考回答:
- 工作机理: 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。它的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此,所有的加载请求最终都应该传送到最顶层的启动类加载器中。只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
- 设计意图(为什么要):
- 构建安全、稳固的 Java 基础秩序: 保证核心类库在各种类加载器环境中都能保持绝对的唯一性 。例如
java.lang.String类存放在rt.jar中,通过双亲委派机制,无论哪一个类加载器要加载 String,最终都会委派给启动类加载器。这就确保了全系统使用的始终是同一种高标 String 对象。 - 防范恶意代码篡改: 如果没有双亲委派机制,用户自己随手写了一个
java.lang.String并存放在 ClassPath 中,系统若直接由应用类加载器加载,就会导致 JVM 核心基础功能瘫痪。双亲委派能够有效筑起防御安全防火墙。
Q34:如何打破双亲委派模型?有哪些经典的工业级应用场景?
参考回答:
- 如何打破: 双亲委派模型的逻辑核心固化在
java.lang.ClassLoader的loadClass(String name, boolean resolve)方法中。如果要打破它,用户只需编写自定义类加载器,并直接重写整个loadClass方法,破坏其中优先向上委派父类加载器的代码逻辑即可。 - 经典工业级应用场景:
- Tomcat 容器(Web 应用间隔离): 一个 Web 服务器可能会同时部署多个不同的 Web 应用程序。这些应用可能会依赖同一个第三方类库的不同版本(例如应用 A 依赖 Spring 4,应用 B 依赖 Spring 5)。如果遵循双亲委派模型,应用类加载器只会加载一份 Spring 类,导致版本冲突。Tomcat 通过打破双亲委派,为每个 Web 应用开辟专属的
WebAppClassLoader,优先加载自身/WEB-INF/classes路径下的类,完美实现了 Web 应用间的类隔离。 - SPI 机制(Service Provider Interface - 逆向委派): Java 的核心基础类(由 Bootstrap 加载)若需要调用由第三方厂商实现的具体接口(如
java.sql.DriverManager核心类需要去调用外部厂商提供的 MySQL 或 Oracle 驱动代码)。因为核心类库无法向下看见 ClassPath 路径,所以 Java 通过引入线程上下文类加载器(Thread Context ClassLoader),强行允许顶层启动类加载器通过逆向指针,调用 Application 类加载器去加载驱动代码,这也是对双亲委派的一种著名打破。
Q35:如何实现一个自定义类加载器?应该重写 loadClass() 还是 findClass()?
参考回答:
- 实现步骤: 1. 创建一个自定义类,继承自
java.lang.ClassLoader。
- 在自定义类中编写读取 Class 文件二进制字节流的逻辑。
- 调用父类的
defineClass(String name, byte[] b, int off, int len)方法将字节数组转换为类实例。
- 重写方法的抉择:
- 应当重写
findClass()(推荐): 遵循常规的扩展开发规范。因为 ClassLoader 内部的loadClass()已经完整封装了双亲委派的逻辑:若父加载器加载失败,会自动触发调用findClass()方法。因此,重写findClass()既能实现自定义类的加载路径读取,又能完美维持住原生的双亲委派优良基因。 - 不要轻易重写
loadClass(): 只有在有明确的工业特殊诉求、需要彻底打破双亲委派模型 (例如上述的 Tomcat 容器类隔离)时,才去重写loadClass()。
第五部分:性能调优与故障排查篇 (Q36 - Q40)
Q36:常用的 JVM 调优与监控参数有哪些?请写出其具体的物理含义。
参考回答:
企业生产级常用的刚性控制和调优参数如下:
-Xms: 初始堆内存大小(例如-Xms4g表示初始化堆分配 4GB)。为了防止年轻代老年代高频扩容带来性能震荡,通常设置与-Xmx完全相等。-Xmx: 最大堆内存大小(例如-Xmx4g表示最大可扩展到 4GB)。-Xss: 设置每个线程栈的容量大小(例如-Xss256k,调小该值可以允许系统创建并跑起更多的并发线程)。-XX:MetaspaceSize: 元空间的初始触发阈值大小。-XX:MaxMetaspaceSize: 元空间的最大可用物理上限。-XX:NewRatio: 年轻代与老年代的比例大小(例如-XX:NewRatio=2表示年轻代占 1,老年代占 2,年轻代占整个堆的 1/3)。-XX:SurvivorRatio: 年轻代内部 Eden 区与一个 Survivor 区的容量比例(例如-XX:SurvivorRatio=8兑现 8:1:1)。-XX:+PrintGCDetails/-Xlog:gc*(JDK 9+): 开启并打印垃圾回收的详细日志输出。-XX:+HeapDumpOnOutOfMemoryError: 当系统不幸发生 OOM 崩溃时,强制自动导出当前内存的堆转储快照(Dump 文件),这是排查线上内存泄漏的黄金底牌。
Q37:JDK 自带的命令行排查与监控工具有哪些?各自发挥什么作用?
参考回答:
在没有图形化界面的 Linux 生产服务器环境下,JDK 内置的命令行五虎将是排查故障的标配利器:
jps(JVM Process Status Tool): 类似于 Linux 的ps命令,用于列出当前系统中正在运行的所有 Java 虚拟机进程的 PID 和主类名称。jstat(JVM Statistics Monitoring Tool): 性能监控的主力军。用于监视虚拟机各种运行状态信息(如jstat -gc PID 1000 10表示每隔 1000 毫秒打印一次该进程的 GC 堆内存占用、Minor/Full GC 发生总次数及耗时,常用于评估垃圾回收是否过于高频)。jinfo(Configuration Info for Java): 实时查看和动态调整虚拟机的各项显式或隐式参数配置信息。jmap(Memory Map for Java): 内存排查王。用于生成堆转储快照(Dump 文件,如jmap -dump:format=b,file=heap.hprof PID)。也可以通过jmap -histo PID快速列出当前堆中对象的实例数量和占用总内存的直方图。jstack(Stack Track for Java): 线程排查王。用于生成当前虚拟机内所有线程的线程快照(Thread Dump)。主要用于定位线程死锁(Deadlock)、死循环、由于网络/锁等待导致的线程长时间挂起等故障。
Q38:如果线上 Java 程序突然出现 CPU 飙升到 100% 的故障,你应该如何快速排查并精确定位问题?
参考回答:
这是一套标准的生产环境应急排查 SOP。在 Linux 命令行环境下,通过以下四个步骤可以在两分钟内完成精准代码定位:
- 第一步:锁定高 CPU 消耗的 Java 进程 PID
使用top命令实时查看系统负载,找出当前把 CPU 资源抢占拉满的那一个 Java 进程的 PID。 - 第二步:定位该进程中具体是哪一个线程在疯狂消耗 CPU
执行top -Hp <进程PID>。该命令会将这个 Java 进程内部所有跑着的线程按照 CPU 消耗进行降序排列,记录下排在最前面的那几个线程的线程 ID(TID)。 - 第三步:将线程 ID 转换为十六进制格式
由于 JVM 内部的线程快照中,线程的操作系统标识是用十六进制(nid=0x...)来展示的。所以需要利用 Linux 自带的命令工具将上一步拿到的十进制线程 ID 进行转换:
printf "%x\n" <线程TID>(例如线程 ID 10086 转换后为0x2766)。 - 第四步:利用
jstack抓取线程日志并用十六进制 ID 进行 Grep 过滤锁定代码
执行以下联合指令,直接将矛头直指高负载线程当前正在执行的那一行 Java 代码:
jstack <进程PID> | grep -A 20 <十六进制线程ID(如0x2766)>
通过输出的调用栈信息,可以瞬间看清究竟是哪个类的哪一个方法的哪一行代码正在触发无出口死循环,或者是由于频繁的 Full GC 导致垃圾回收线程把 CPU 拉满。
Q39:如果线上系统突发 java.lang.OutOfMemoryError: Java heap space 内存溢出问题,你应该如何排查和根治?
参考回答:
解决 OOM 问题必须走"线下离线快照分析"路线,核心诊断和工程解法如下:
- 获取堆内存快照(Dump 文件):
如果在启动时配置了-XX:+HeapDumpOnOutOfMemoryError,直接去服务器指定路径拷贝自动生成的.hprof镜像文件。若没有配置,在进程还未僵死前,赶快执行jmap -dump:format=b,file=online_oom.hprof <进程PID>强行导出快照。 - 导入图形化内存分析工具(MAT / JProfiler):
将 Dump 文件下载到本地,导入到 Eclipse MAT(Memory Analyzer Tool) 或 JProfiler 等专业工具中。 - 定位内存大户与引用链分析:
- 查看 Histogram(直方图) 和 Dominator Tree(支配树),查找当前内存中究竟是哪一种对象实例的数量最多、占用的 Shallow Heap 和 Retained Heap 物理空间最大(通常是大量的 byte 数组、HashMap 节点或大文本数据)。
- 顺着可疑对象的引用链向上追溯(寻找 GC Roots 路径),看清到底是被哪一个业务主类、哪一个长生命周期的线程所持有而迟迟无法释放。
- 辨析性质并施治:
- 若是内存泄漏(Memory Leak): 说明这些对象本该死掉,但由于代码编写不规范(如静态集合类不断 append、单例对象持有短生命周期引用、资源流未在
finally中 close、ThreadLocal用完未调用remove()),导致 GC Roots 一直牵连可达。解法是顺着 MAT 给出的代码行数纠正代码逻辑,断开错误引用。 - 若是内存溢出(Memory Overflow): 说明代码本身无误,确实是因为业务并发量暴涨、单次拉取数据库的数据量太大(如没有做分页限制的
select *行为),导致现有堆内存容纳不下。解法是优化业务逻辑(如改写为分批拉取),或者调大服务器的-Xmx物理堆上限。
Q40:什么是 JIT 即时编译器?什么是逃逸分析技术?它能带来哪些极致优化?
参考回答:
-
JIT(Just-In-Time)即时编译器: Java 刚开始执行时,是由解释器逐条读取字节码并翻译成机器码去运行,速度较慢。当虚拟机发现某个方法或者某段代码块运行得特别频繁(通过计数器判定为"热点代码"),JIT 即时编译器就会介入,直接把这段热点字节码一次性编译为与本地操作系统平台直接相关的本地机器码,并存入方法区的代码缓存中。后续执行时直接跑机器码,执行效率会有质的飞跃。
-
逃逸分析(Escape Analysis): 是现代 JIT 即时编译器中最高级的底层优化分析技术。它的基本原理是:分析新创建的对象实例的动态影响范围。
-
未逃逸: 当一个对象在方法内部被创建后,它仅仅在当前方法内部被使用,绝对不会被外部其他方法或者其他线程引用或改写(即没有逃逸出当前方法边界)。
-
基于逃逸分析带来的三大极致优化效果:
- 栈上分配(Stack Allocation): 在 Java 传统的认知中,所有对象必须在堆中分配,堆又是垃圾回收的重灾区。但通过逃逸分析判定对象未逃逸,JIT 会打破常规,直接让这个对象在当前线程的虚拟机栈(Stack)上随方法栈帧一起分配内存。方法一旦执行结束出栈,对象内存瞬间随之销毁,完全不需要垃圾回收器的介入,极大地减轻了堆 GC 的负荷。
- 锁消除(Lock Elimination): 如果一个对象被判定未逃逸,说明这个对象绝无可能被多线程并发并发访问。如果代码中对这个对象加了锁(如使用了
StringBuffer.append()或synchronized(obj)),JIT 会在编译时简单暴力的把这些无用的同步锁强行卸载清除掉,消除不必要的并发锁开销。 - 标量替换(Scalar Replacement): 标量是指无法再进一步拆分的原始数据类型(如
int、long、reference)。如果逃逸分析发现一个对象不需要逃逸,JIT 甚至不会在内存中去真的创建这个整体对象,而是直接把这个对象打散,将其内部高频使用的成员属性直接拆解暴露出来,当成几个普通的局部变量在栈或寄存器上直接运行。这也是栈上分配的核心落地方案。
