持续更新

面试题系列都持续更新

内存结构与对象创建

请详细介绍JVM内存区域的整体布局,各个区域的作用和特点

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域

Java 运行时数据区域(JDK1.8 )

JVM 运行时数据区分成五块:堆、方法区、虚拟机栈、本地方法栈、程序计数器。其中堆和方法区是所有线程共享的,另外三个是每个线程私有的。

  • 堆:存放对象实例与数组,是 GC 管理的核心区域。它线程共享,是JVM 中最大的一块内存,分为新生代和老年代

    image-20260314155842050
    • 堆中包含字符串常量池,是 JVM 为了提升性能和减少内存消耗,针对字符串专门开辟的一个区域,主要目的是为了避免字符串的重复创建。因为同一个字面量的字符串,在常量池中只会保留一个引用
  • 元空间(方法区):JDK1.8前叫方法区,它存储类的元信息,包括类结构、常量池、静态变量、JIT 编译后的代码缓存等内容,而且其中包含运行时常量池,其中垃圾回收行为较少

    JDK 8 之前方法区用永久代实现,容易撑爆内存,JDK 8 换成了元空间,直接用系统内存,上限高多了。

    • 运行时常量池存储 Class 文件编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)
  • 虚拟机栈:线程私有,描述 Java 方法执行的内存模型。每个方法执行时创建一个栈帧,除了一些 Native 方法,其他所有的 Java 方法的调用都是通过栈来实现的。

    • 关于栈帧,栈由一个个栈帧组成,栈帧是方法调用的最小单位,每调用一个 Java 方法,就会创建一个栈帧并压入栈,每个栈帧中都拥有,局部变量表、操作数栈、动态链接、方法返回地址等内容,方法执行完就弹出来。
  • 本地方法栈:为 Native 方法 服务,它线程私有,功能与虚拟机栈几乎一样

  • 程序计数器:记录当前线程执行的字节码行号,用于线程切换后恢复执行位置。它线程私有,而且不会出现 OOM

  • 直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。它是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的,IO 密集型场景下有他在能提升不少性能。

堆放对象,栈管运行,方法区存类信息,程序计数器保证线程切换不乱。

堆内存的作用和组成

堆是 JVM 所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 中几乎所有的对象实例和数组都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么 “必须” 了

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆。从垃圾回收的角度来讲,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

堆内存结构

JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。

Java堆和栈的区别?存储内容和生命周期的差异?结合JVM内存结构详细说明

Java 堆和栈是 JVM 内存结构中的两个核心区域,它们在存储内容和生命周期上有本质区别。

  • 栈内存:存储方法调用时的局部变量、方法参数和返回地址。每个线程都有独立的栈空间,当方法被调用时会在栈中创建栈帧,方法执行完毕后栈帧立即销毁。比如你在方法中声明的int count = 10,这个 count 变量就存储在栈中,方法结束后立即回收,生命周期非常短暂。
  • 堆内存:存储所有对象实例和实例变量,是所有线程共享的区域。当你使用new User()创建对象时,User 对象就分配在堆中。堆中的对象生命周期由垃圾回收器管理,只有当对象不再被任何引用指向时才会被GC回收,生命周期相对较长。

从访问速度来看,栈采用后进先出的数据结构,访问速度快;堆需要通过引用访问,速度相对较慢。栈空间相对较小,如果递归调用过深会导致StackOverflowError;堆空间较大,但如果创建对象过多会引发OutOfMemoryError

JVM 方法区存储哪些信息?在不同JDK版本中的变化?运行时常量池和字符串常量池的区别?

JVM 方法区是运行时数据区的一部分,主要存储类的元数据信息,包括类的结构定义、方法字节码、字段信息、常量池、静态变量等。

JDK 1.7及之前,方法区在HotSpot虚拟机中通过永久代实现,位于堆内存中,永久代固定大小,容易出现OutOfMemoryError

JDK 1.8是关键转折点,彻底移除了永久代,引入元空间替代方法区。元空间使用本地内存而非堆内存中的永久代,默认情况下只受系统可用内存限制,减少了OOM。

字符串常量池在 JDK1.7 中从永久代移至单独的堆空间,而运行时常量池在 JDK1.8 中从堆跟着元空间来到了本地内存。

image-20260314172232423

至于运行时常量池和字符串常量池的区别

  • 运行时常量池是每个类在加载时生成的,存储该类的字面量和符号引用,包括类名、方法名、字段名等编译期确定的信息。
  • 字符串常量池是 JVM 全局唯一的,专门存储字符串对象的引用,在堆中维护一个哈希表结构。

两者最核心的区别在于运行时常量池是类级别的,随着类加载而创建;字符串常量池是JVM级别的,所有类共享。

也就是说,为什么运行时常量池会在之后跟着元空间来到了本地内存,而字符串常量池一直在堆内存中。

image-20260314172915761

简单说一下 Java 对象在JVM中的创建过程?

JVM(HotSpot 虚拟机)中对象的创建过程主要分为以下五步:

  1. 类加载检查:虚拟机执行 new 指令时,先检查常量池中对应类的符号引用是否已加载、解析和初始化,未完成则先执行类加载过程。
  2. 分配内存:类加载通过后,根据类加载确定的对象大小从 Java 堆划分内存
  3. 初始化零值:将分配的内存空间,除了对象头外,初始化为零值,确保 Java 代码中未赋初始值的实例字段可直接使用对应类型的零值。
  4. 设置对象头:在对象头中记录类元数据信息、哈希码、GC 分代年龄、锁状态等必要信息,具体设置依虚拟机运行状态(如是否启用偏向锁)而定。
  5. 执行 init 方法:虚拟机视角下对象已创建,但需执行<init>方法,将对象初始化到程序员期望的状态,最终生成可用对象。

Java对象在内存中的存储布局?对象头包含哪些信息?

Java对象在内存中的存储布局主要分为三个部分:对象头实例数据对齐填充

  • 对象头包含两大核心信息。

    • 首先是Mark Word,这是一个多用途的字段,存储着对象的hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等运行时数据。Mark Word的大小在32位JVM中是32bit,64位JVM中是64bit。
    • 然后是类型指针,指向方法区中该对象所属类的 Class 对象的指针,JVM通过这个指针确定对象是哪个类的实例。

    如果对象是数组,对象头还会包含一个数组长度字段,占用32bit来记录数组的长度。

  • 实例数据存储对象真正的有效信息,也就是代码中定义的所有实例变量

  • 对齐填充为了凑 HotSpot 对于对象起始地址必须是8字节的整数倍的要求的空白填充

Java类的初始化顺序?静态变量、静态代码块、实例变量的执行顺序?

Java 类的初始化严格遵循 JVM 规范的执行顺序。

当首次加载类时,静态变量和静态代码块按照在类中的声明顺序依次执行,静态变量和静态代码块谁在前面谁先执行,这个过程只发生一次。

当创建对象实例时,实例变量和实例代码块按声明顺序执行,最后才是构造方法。需要注意的是,如果存在继承关系,父类的静态部分最先执行,然后是子类的静态部分,接着是父类的实例部分,最后是子类的实例部分。

image-20260314164458703

静态优于实例,父类优于子类,声明顺序决定执行顺序

这个机制保证了类的状态在使用前已经正确初始化,是Java内存模型和类加载机制的重要体现。

内存溢出,栈溢出和内存泄漏的区别?各自的典型场景?结合JVM相关机制说明

内存溢出(OOM)是 JVM 堆内存不足以分配新对象时抛出的OutOfMemoryError,本质是瞬时内存需求超过了可用内存上限。

栈溢出是 JVM 栈(虚拟机栈,本地方法栈)空间耗尽,方法调用层次过深,压入栈的栈帧太多了,超出栈容量限制,弹出StackOverflowError,一般情况下认为栈溢出是 OOM 的特殊场景,因为它们都是 JVM 内存资源耗尽的异常

而内存泄漏是程序中不再使用的对象无法被 GC 回收,导致内存持续增长,最终可能引发 OOM。

内存溢出就像水杯装不下更多的水而溢出,是容器容量的限制

内存泄漏则像水龙头关不严一直滴水,是使用过程中的管理缺陷

栈溢出像是书架的隔层被无限叠加的书本压垮,是方法调用层级超限

从 JVM 角度看,OOM发生在堆空间、方法区或直接内存耗尽时,比如创建大数组、加载过多类或使用线程池时因无界队列导致任务堆积。

内存泄漏则是 GC Root 仍然持有无用对象的引用,使得这些对象在标记-清除过程中无法被识别为垃圾而被回收。

内存泄漏的典型场景包括:静态集合持有大量对象引用、监听器注册后未注销、ThreadLocal使用后未清理、数据库连接等可关闭资源使用后未关闭等。这些情况下,对象在逻辑上已无用但在GC算法中仍被标记为可达,无法回收,容易导致内存泄漏

OOM的典型场景则是瞬时创建大量对象、递归调用导致栈溢出、或者确实需要处理超出堆大小的数据。

内存泄漏是渐进式的,通过监控堆内存使用趋势可以发现;而 OOM 往往是突发的,需要通过调整JVM参数或优化算法解决。两者的根本区别在于,内存泄漏是引用管理问题,OOM是资源容量问题。

长期的内存泄漏会逐步蚕食可用内存,最终触发内存溢出;而有些内存溢出是瞬时的大内存需求造成的,与泄漏无关。

什么是 Java 中的 JIT?JIT 编译后的代码存在哪?

JIT,Just-In-Time Compiler,即时编译器,和 AOT 提前编译相对,它是 JVM 中解释执行 + 编译执行混合模式的核心组件

在程序运行时,JIT 将高频执行的字节码编译为机器码,提升程序执行效率。非热点代码仍以字节码形式存在,通过解释执行。

我们都知道

1
.java 源代码 → javac 编译 → .class 字节码 → JVM 解释执行(逐行翻译字节码为机器码)

而 JIT 是对这个流程的优化

1
.java → .class → 解释执行(低频代码) + JIT 编译(高频代码)→ 机器码直接执行

JIT 不会一启动就编译所有代码,否则和编译语言一样了就,而是通过 热点计数器 识别热点代码,满足以下条件才触发编译:

  • 方法调用计数器:某个方法被调用的次数超过阈值,默认 10000 次
  • 回边计数器:循环体执行的次数超过阈值

JIT 编译后的机器码存储在 JVM 的 CodeCache 代码缓存 中,这是一块独立管理的内存区域,是专门存放 JIT 编译后的机器码的空间。

CodeCache 代码缓存 也是需要垃圾回收的,当 CodeCache 满时,JIT 会停止编译,程序退回到解释执行,性能大幅下降;

什么是JVM TLAB?Thread Local Allocation Buffer 的作用和原理?

TLAB(Thread Local Allocation Buffer)是JVM在堆内存中为每个线程预分配的私有内存缓冲区,主要解决多线程环境下对象分配时的竞争问题。

我们都知道 JVM 中有一些区域是线程共享的,在多线程应用中,如果所有线程都直接在共享的 Eden 区分配对象,就需要通过同步机制来保证线程安全,这会带来显著的性能开销。

TLAB 通过为每个线程在 Eden 区预分配一块独占的连续内存空间,让线程可以在自己的 TLAB 中无锁地进行对象分配。每个TLAB维护一个top指针,当需要分配对象时,直接将top指针向前移动对象大小的距离即可。

当 TLAB 空间不足时,JVM 会为线程分配新的 TLAB,而原 TLAB 中的剩余空间会被填充 dummy 对象避免内存碎片。如果要分配的对象过大超过 TLAB 剩余空间,JVM 会直接在 Eden 区进行分配。

这种设计显著减少了GC前的内存分配竞争,提升了高并发场景下的分配性能

image-20260314162651945

垃圾回收

JVM垃圾回收算法有哪些?各自的优缺点和适用场景?

JVM垃圾回收算法主要包括标记-清除复制算法标记-整理分代收集四种核心算法。

  • 标记-清除算法是最基础的回收算法,先标记需要回收的对象,再统一清除。它的优点是实现简单,缺点是会产生大量内存碎片,影响大对象分配,而且通常需要暂停应用程序清理。
  • 复制算法将内存分为两块,每次只使用一块,GC 时候将存活对象复制到另一块区域。这种算法避免了内存碎片,回收效率高,但内存利用率只有50%,所以适合新生代这种存活对象较少的场景。
  • 标记-整理算法在标记阶段后,将存活对象向内存一端移动,然后清理边界外的内存。它既避免了内存碎片,又不浪费内存空间,但移动对象的成本较高,更适合老年代这种存活率高的区域。
  • 分代收集算法是目前主流的垃圾回收策略,根据对象存活周期的不同将堆内存划分为新生代和老年代,然后使用不同的算法,新生代采用复制算法,老年代采用标记-清除或标记-整理算法。这种组合策略充分发挥了各算法的优势,在Hotspot虚拟机中被广泛应用,能够在不同场景下提供最优的回收性能。

JVM 如何判断 Java 对象是否存活?

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡,即不能再被任何途径使用的对象

一般就是引用计数法和可达性分析算法两种情况

引用计数法

  • 给对象中添加一个引用计数器:

    • 每当有一个地方引用它,计数器就加 1;
    • 当引用失效,计数器就减 1;
    • 任何时候计数器为 0 的对象就是不可能再被使用的。

    这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。

所谓对象之间的相互引用问题,如下面图所示:除了对象 objAobjB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

对象之间循环引用

可达性分析算法:

  • 从一系列称为 GC Roots 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

    下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。

    可达性分析算法
  • 哪些对象可以作为 GC Roots 呢?

    • 虚拟机栈(栈帧中的局部变量表)中引用的对象
    • 本地方法栈(Native 方法)中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 所有被同步锁持有的对象
    • JNI(Java Native Interface)引用的对象

对象可以被回收,就代表一定会被回收吗?

即使在可达性分析法中不可达的对象,也并非是 非死不可 的,这时候它们暂时处于 缓刑阶段 ,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

垃圾回收算法有哪些?

标记-清除算法

标记-清除(Mark-and-Sweep)算法分为 标记(Mark)和 清除(Sweep) 阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题:标记和清除两个过程效率都不高。
  2. 空间问题:标记清除后会产生大量不连续的内存碎片。
标记-清除算法

复制算法

为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。

它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

复制算法

虽然改进了标记-清除算法,但依然存在下面这些问题:

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差

标记-整理算法

标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与 标记-清除 算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存

标记-整理算法

由于多了整理这一步,因此效率也不高,因此它适合老年代这种垃圾回收频率不是很高的场景

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

至于 HotSpot 为什么要分为新生代和老年代?也可以根据上述内容进行回答

JVM Minor GC、Major GC、Full GC的区别?如何避免频繁Full GC?

Minor GC只清理新生代,当Eden区空间不足时触发,因为存活对象少,可以使用复制算法清理新生代中的垃圾对象,将存活对象移动到 Survivor 区或晋升到老年代。执行频率高但耗时短,对应用影响较小。

Major GC专门处理老年代,当老年代空间不足时触发。由于老年代对象存活率高,通常使用标记-清除或标记-整理算法,执行时间比Minor GC长,会造成较明显的应用暂停。

Full GC是全堆垃圾回收,同时清理新生代、老年代和方法区。触发场景包括老年代空间不足、方法区空间不足、System.gc()调用等。Full GC是最耗时的,会导致应用完全暂停

JVM采用分代设计是基于弱分代假说,也就是大部分对象都是朝生夕死的。新创建的对象放在新生代,每次 GC 后存活就增长年龄,默认为 15 岁就会被晋升到老年代中。

而大对象直接进入老年代

JVM Full GC主要在以下情况下触发:

  • 因为分配担保机制,老年代空间不足时会直接触发Full GC,这通常发生在大对象直接进入老年代或 Minor GC 后存活对象晋升到老年代时发现空间不够的情况。
  • 元空间(方法区)满载了也会引发Full GC
  • 显式调用System.gc()
  • 收集器并发失败

要避免频繁 Full GC,建议从几个方面入手:

  1. 合理配置堆内存大小,尤其是老年代部分的大小,一般老年代应占总堆内存的2/3左右。
  2. 优化对象生命周期管理,比如及时释放大对象引用,避免在循环中创建大量临时对象。
  3. 优化大对象处理,使用流式或者分片的形式处理
  4. 选择合适的垃圾收集器,G1在 GC 的时候大堆内存时表现更好,能减少Full GC频率。
  5. 合理设置MetaspaceSize参数
  6. 别用 System.gc()
image-20260314171018153

那么常见的垃圾回收器,比如G1,你知道它和传统的 CMS 回收器在工作原理上有什么主要区别吗?那么,ZGC 呢?

G1和CMS都是追求低停顿低延迟设计的收集器,但设计思路差别挺大的。

G1 是 JDK 9 后的默认回收器

CMS 是基于传统的物理分代,就是堆固定分成年轻代和老年代。针对老年代使用 标记 - 清除 回收。因为 CMS 是针对老年代设计的,新生代仍用 ParNew 进行回收。但是,标记 - 清除算法 带来低延迟的后果就是,产生内存碎片,长期运行易触发 Full GC,而且对CPU资源比较敏感。

G1 打破分代物理隔离,将堆划分为大小相等的 Region(区域,默认 1-32MB),Region 可动态标记为 Eden/Survivor/Old/Humongous(存储大对象),年轻代和老年代只是这些 Region 的逻辑集合,不是固定的。

G1的收集过程主要是 Young GC 和 Mixed GC。Mixed GC 是它的特色,它会选择一些回收价值高的老年代 Region 和年轻代一起回收,这样能更精确地控制每次垃圾回收的时间。这也就是为何 G1 能做到提供一个可预测的停顿时间模型,比如设置一个200毫秒的最大停顿目标,它会尽量在这个时间内完成回收。

另外,G1在回收过程中会做压缩整理,所以基本没有内存碎片的问题。我觉得G1更适合堆内存比较大,又对延迟有要求的应用。

ZGC 也是低延迟垃圾回收器,JDK 11 正式发布,JDK 17 成为长期支持版默认推荐。

ZGC 是为超大堆(TB 级)、超低延迟场景设计的回收器,比如大型分布式系统和云原生场景

ZGC 是基于 Page 的区域化,ZGC 将堆划分为大小可变的 Page(页面),分为三类:

  • Small Page(2MB):存储小对象;
  • Medium Page(32MB):存储中等对象;
  • Large Page(大小不固定):存储大对象(超过 32MB)

Page 可动态创建 和 销毁,无固定的分代物理隔离,新生代 / 老年代仍然是逻辑上的概念

ZGC 最核心的创新就是染色指针,将对象的标记信息存储在指针本身,而非对象头。

而且 ZGC 自己也带内存整理,而且 ZGC 的内存整理也非常快。但是 ZGC 暂不适合小堆,而且 Windows 暂不支持

类的文件结构和类的加载过程

Java类加载机制的整体流程是怎么样的?

  • 加载:加载由类加载器完成,JVM 通过类的全限定名获取.class文件的二进制字节流,并在方法区创建对应的运行时数据结构,同时在堆中生成一个 java.lang.Class 对象作为访问入口。
  • 验证:确保字节码的安全性和正确性,包括文件格式验证、元数据验证、字节码验证和符号引用验证,防止恶意代码破坏JVM运行状态。
  • 准备:为类的静态变量分配内存,并设置默认初始值,比如 static int 会被设为0,static 引用类型设为 null,不会执行显式赋值语句。
  • 解析:将常量池中符号引用(变量名)替换为直接引用(地址),让JVM能直接定位到具体的内存地址。
  • 初始化:执行类构造器<clinit>()方法,这时才会执行静态变量的显式赋值和静态代码块。只有主动使用类时才会触发初始化,如创建实例、调用静态方法、访问静态字段等。整个过程确保了类在使用前已完全准备就绪。
image-20260314165227008

JVM中如何判断两个类是否相等?类的唯一性标识?

两个条件

  1. 类的全限定名相同
  2. 加载该类的ClassLoader相同

不同ClassLoader加载的同名类在JVM中被视为完全不同的类

即使是完全相同的class文件,如果被不同的ClassLoader实例加载,JVM也会将它们识别为不同的类。

这种机制保证了类加载的隔离性,是JVM实现应用隔离和热部署的基础。

类的唯一性标识就是“全限定类名 + ClassLoader实例”这个组合。

在实际应用中,这个特性经常体现在Web容器场景。比如 Tomcat 为每个 Web 应用创建独立的WebAppClassLoader,即使多个应用都包含相同的com.example.User类,它们在 JVM 中也是完全隔离的不同类型。当你尝试在应用间传递对象时,就会遇到ClassCastException,因为接收方的 ClassLoader 无法识别发送方 ClassLoader 加载的类实例。

讲一下底层

每个ClassLoader实例都维护着自己的类缓存,当它加载一个类时,会将 全限定类名+自身实例 作为 key 存储 Class 对象。

不同的ClassLoader就像不同的命名空间,即使加载相同的类,它们在各自的空间里也是独立存在的。

类加载及类加载器

Java 的类加载过程是怎样的?

类加载就是把 .class 字节码文件的二进制数据读进内存,经过校验、转换,最终变成 JVM 能用的 Class 对象。

二进制流不一定非得来自 .class 文件,也可以是字节码工具动态生成的、或者从网络传过来的,只要格式对,JVM 都认。

整个类加载流程分为三大阶段:加载、连接、初始化。连接又能拆成验证、准备、解析三步,所以细分下来是 5 个阶段:

  • 加载:把二进制流读入内存,在方法区生成类的运行时数据结构,同时在堆中创建一个 Class 对象作为访问入口并分配内存
  • 连接:
    • 校验:校验二进制流是否符合 Class 文件规范,防止恶意代码搞崩 JVM。
    • 准备:给类变量(static)分配内存并且设置零值初始化
    • 解析:把常量池里的符号引用替换成直接引用。符号引用就是一个字符串形式的标识,比如 java/lang/Object;直接引用是真正的内存地址或偏移量,能直接定位到目标。
  • 初始化:执行类构造器 <clinit>() 方法,这时候才真正执行 static int a = 123 这种赋值操作,静态代码块也是在这个阶段跑的。
image-20260308114150105

JVM 规范规定了 6 种情况必须立即对类进行初始化

  • 遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时。
  • 使用 java.lang.reflect 包对类进行反射调用时。
  • 初始化子类时发现父类还没初始化,先把父类初始化了。
  • JVM 启动时指定的主类
  • 接口中定义了 default 方法,实现类初始化前要先初始化这个接口。

什么是JVM类加载器?Java提供了哪些默认的类加载器?各自的层次结构是怎么样的?有什么好处?(JVM双亲委派模型的工作原理?设计目的和优势?)

JVM 类加载器是负责将.class字节码文件加载到内存并转换为 Class 对象的组件,它是连接磁盘上的.class文件和运行时对象的桥梁。

没有类加载器,整个Java程序就无法运行。Java采用双亲委派模型实现类加载的层次结构,确保了类加载的唯一性和安全性。

从顶层到底层,默认类加载器包括:

  • 启动类加载器:负责加载$JAVA_HOME/lib目录下的核心类库rt.jar中的java.lang.Objectjava.util.HashMap等基础类
  • 扩展类加载器:加载$JAVA_HOME/lib/ext目录下的扩展类库
  • 应用程序类加载器:加载 classpath 路径下的应用程序类和第三方依赖

双亲委派机制是 JVM 类加载器加载类的核心机制

双亲委派机制确保类加载请求首先向上委托给父加载器,只有父加载器无法加载时子加载器才会尝试加载。比如当你的系统尝试加载java.lang.String时,请求会一直委派到启动类加载器完成加载,而不会使用你自定义的同名类,这种设计有效避免了核心类被恶意替换。

image-20260309085201605

这种设计的主要目的是保证Java核心类库的安全性和一致性。通过优先级机制,防止了恶意代码替换系统核心类。比如你无法在应用中自定义一个java.lang.String类来覆盖系统的String类,因为系统的String总是会被优先加载。而且也避免了类的重复加载

值得一提的事,在JVM中类的唯一性不仅仅由类名决定,还包括加载它的类加载器。即使两个类有完全相同的名字和字节码,如果由不同的类加载器加载,JVM也会将它们视为不同的类。双亲委派模型通过统一的委派路径,确保同一个类总是由同一个类加载器加载,从而保证了类的全局唯一性。

JVM类卸载的条件?什么情况下类会被卸载?

JVM类卸载需要同时满足三个严格条件

  1. 该类的所有实例都被 GC
  2. 加载该类的ClassLoader已被GC
  3. 该类的Class对象没有在任何地方被引用

在实际应用中,Bootstrap ClassLoaderExtension ClassLoaderApplication ClassLoader 这三大 JVM 自带的类加载器加载的类几乎永远不会被卸载,因为这些类加载器的生命周期与 JVM 相同。真正会被卸载的主要是自定义 ClassLoader 加载的类

方法区的垃圾回收主要就是针对类的卸载。动态代理生成的类、JSP编译后的Servlet类、OSGi模块中的类,都是典型的可能被卸载的场景。需要注意的是,即使满足了卸载条件,类的卸载也只是”可能”发生,具体取决于垃圾收集器的实现和JVM的回收策略。

JVM 监控和调优

常用的JVM监控和调优命令?jstat、jmap、jstack、jhat的使用?

在JVM监控调优中,这几个命令各有专门用途。

  • jstat实时性能监控,例如,通过jstat -gc pid能持续观察垃圾回收频率和耗时,jstat -gcutil pid 1000每秒输出 GC统计信息,帮你快速定位 GC 性能问题。
  • jmap:专门处理内存分析jmap -histo pid显示对象实例统计,也能使用 jmap dump 生成堆转储文件,这在排查内存泄漏时特别有效
  • jstack:负责线程分析jstack pid输出所有线程堆栈信息,当应用出现死锁或CPU占用异常时,通过线程状态能快速定位问题线程。
  • jhat堆分析工具,常用就是用它分析堆转储文件,jhat heap.hprof启动Web服务器分析 dump 文件。

实际使用就是,先用jstat观察 GC 行为确定是否存在内存问题,发现异常后用jmap生成 dump 文件详细分析,如果是 CPU 问题则用jstack查看线程状态。现在更推荐用 JProfiler 替代jhat进行深度内存分析,功能更强大且界面友好。

JVM性能调优的基本思路?性能问题的定位方法?

JVM性能调优需要遵循收集-观察-分析-调整-验证的基本思路。

  1. 首先通过监控工具收集 JVM 运行时数据,包括内存使用、GC行为、线程状态等关键指标
  2. 然后根据应用的情况,出现了什么问题,来对照着观察收集到的数据
  3. 根据观察,分析性能瓶颈的根本原因
  4. 接着针对性调整JVM参数
  5. 最后验证调优效果

首先,对于收集

  • 对于线下这种本地环境,可以使用 JProfiler,在我xx平台的压测阶段,通过 JProfiler 对应用进行采样,开启内存快照、GC 日志记录、方法执行耗时追踪、线程状态监控,采集平台高并发场景下的 JVM 运行数据,确定新生代 / 老年代内存占比、GC 的频率和间隔、接口平均响应时间等内容是否正常。
  • 对于线上环境,无法部署重量级分析工具,通过 Arthas 的dashboard命令实时采集线上运行数据,结合jvm命令查看堆内存 / 非堆内存使用、GC 次数 / 耗时,在不重启服务的前提下收集关键性能日志

然后,对于观察,分析等步骤

  • 通过 JProfiler 的内存视图观察到老年代内存持续上涨、Full GC 频繁触发,通过 JProfiler 的内存快照分析,发现是用户上传的非结构化文档对象未及时释放,定位到了 MongoDB 操作的对象引用未置空,修复后通过 Arthas 验证内存回收正常。

在项目中我总结了 4 类核心性能问题的解决方法

  1. 内存问题定位

    老年代内存持续上涨、Full GC 频繁、应用抛出 OOM 异常

    1. 线上快速定位:用 Arthas 的 dashboard 命令查看堆内存的使用趋势,jvm查看堆 / 非堆内存配置,heapdump生成轻量内存快照,jad反编译可疑类,初步定位内存泄漏的代码位置;
    2. 线下分析:JProfiler,把线上得到的内存快照导入,分析对象实例数 TOP10 等内容,结合 MAT 做泄漏分析,明确根因
  2. GC 问题

    YGC 频繁, Full GC 频繁, GC 耗时过长,GC 间隔过短、GC 耗时超阈值等

    1. 线上实时监控:Arthas 的gc查看 GC 次数 / 耗时 / 间隔,jstat -gcutil实时监控堆内存各区域使用率, vmoption查看 GC 收集器和相关参数;
    2. 线下分析:JProfiler,把线上得到的 GC 日志导入,分析GC 回收效率内存分配速率大对象进入老年代情况,判断是不是 JVM 参数需要调整
  3. 线程 / 锁问题定位

    CPU 使用率过高、接口响应缓慢、线程数骤增、应用无响应

    1. 线上快速定位(Arthas):dashboard查看线程数和阻塞数, thread -b定位阻塞线程的根因,thread -n 5查看 CPU 使用率 TOP5 的线程,lock查看锁持有情况,快速解决死锁 / 锁竞争;
    2. 线下分析(JProfiler):采集线程快照, 分析线程状态分布锁等待时间线程池任务堆积情况 , 优化线程池配置或锁设计;
  4. 慢方法 / 慢 SQL / 执行耗时过长定位

    接口响应时间长、高并发下 CPU 使用率过高、数据库查询缓慢

    1. 线上定位(Arthas):trace 类名 方法名追踪方法调用链路,查看各子方法耗时 → watch 类名 方法名观察方法入参出参,定位异常参数 → sql命令(结合 Arthas 插件)查看慢 SQL 执行情况;
    2. 线下分析(JProfiler):开启方法执行耗时追踪,分析方法执行耗时 TOP10、数据库查询耗时 TOP10,定位慢方法 / 慢 SQL,优化代码或查询语句;