Lazy loaded image
八股盛宴
JVM
字数 6558阅读时长 17 分钟
2025-5-18
2026-1-30
type
Post
status
Published
date
Jan 30, 2026 12:23 PM
slug
summary
JVM 是运行在计算机上的程序,负责解释执行和内存管理。文中讨论了字节码文件的结构、类加载器的工作机制及其分类、双亲委派机制、运行时数据区的组成、自动垃圾回收的原理及其算法,最后介绍了不同的垃圾回收器及其特点。重点强调了类的唯一性、内存管理的复杂性以及垃圾回收的效率和策略。
tags
Java
category
八股盛宴
icon
password

基础知识

JVM 本质上是一个运行在计算机上的程序,主要职责是解释运行、内存管理和即时编译。
JVM 遵循 JVM 虚拟机规范,各大厂家研发不同版本。本文章讨论的是 hotspot(oracle jdk
notion image

字节码文件

基本信息
  1. 文件头 文件无法通过文件扩展名来确定文件类型,软件使用文件的头几个字节校验文件类型,这一部分也被成为 Magic Value。
  1. 主副版本号 编译字节码文件的 jdk 版本号,主要用来判断当前字节码版本和运行环境 jdk 是否兼容。
  1. 访问标志
  1. 类、父类、接口索引
  1. 常量池
  1. 字段
  1. 方法

类加载器

类的生命周期
  1. Loading类加载器根据类的全限定名通过不同渠道以二进制流的方式获取字节码信息。程序员可以通过 JAVA 代码扩展不同渠道(动态代理)。JVM 会将字节码的信息保存在内存方法区(InstanceKlass),同时生成类似数据保存在堆区( java.lang.Class ),作用是在 java 代码中去获取类的信息以及存储静态字段的数据,也就是反射方法区中的对象是由 c++编写,java 代码不能直接操作,所以在堆区创建一个 java 编写的对象以便获取。而且堆区中的类对象包含数据更少,这是基于开发者需要和安全性考虑,只让开发者访问一部分数据。
  1. Linking不需要程序员参与。
  1. 验证内容是否合规。
  1. 为静态变量分配内存并赋初始值。
  1. 将常量池中的符号引用替换成指向内存的直接引用。
  1. Initialiazation执行静态代码块中的代码,并为静态变量赋值。类的初始化并不会在加载到堆区后马上进行,需要满足触发条件
  1. 访问类的静态变量或者静态方法,访问父类的静态变量,不会触发子类的初始化。
  1. 调用 Class.forName(className)
  1. new Instance,数组创建不会导致数组中元素类初始化。
  1. 执行 Main 方法的当前类
  1. Using
  1. Unloading
类加载器负责把字节码文件加载到内存。

类加载器分类

除了顶层启动类加载器,所有的其他加载器都有自己的父类加载器。
  1. 启动类加载器 Bootstrap ClassLoader(c++)或者 BootClassLoader(java) 加载 Java 最核心的类,是由虚拟机提供的类加载器。默认加载/jre/lib 下的类文件。 开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
  1. 扩展类加载器 Extension ClassLoader(jdk 1.8) 或者 Platform ClassLoader(jdk 9) 负责将Java_Home /lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。 开发者可以直接使用标准扩展类加载器。
  1. 应用程序类加载器 / 系统加载器 负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。 由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,因此一般称为系统(System)加载器。 开发者可以直接使用系统类加载器。
  1. 自定义类加载器

双亲委派机制

类加载器与类唯一性
类加载器虽然只用于实现类的加载动作,但是对于任意一个类,都需要由加载它的类加载器和这个类本身共同确立其在Java虚拟机中的唯一性。通俗的说,JVM中两个类是否“相等”,首先就必须是同一个类加载器加载的,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要类加载器不同,那么这两个类必定是不相等的(同时在堆区也会有两份 class 文件)
这里的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。
双亲委派机制的核心是解决类由谁加载
双亲委派模型的工作过程为:如果一个类收到类加载请求,首先检查自己是否加载过,若是则直接返回,否则把这个请求委派给父类加载器完成而不是尝试自己加载,因此所有的类加载请求都会传到顶层的启动类加载器,只有当父类加载器反馈自己无法完成该加载请求,才会自上而下尝试加载。
这显然避免了重复加载的问题,同时自然的类随着类加载器带有优先级
例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。
相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。

破坏双亲委派机制

双亲委派模型是Java设计者推荐给开发者的类加载器的实现方式,并不是强制规定的。
大多数的类加载器都遵循这个模型,但是JDK中也有较大规模破坏双亲模型的情况。
  1. 自定类加载器 自定义类继承 ClassLoader 类,重写 loadClass 方法(首先尝试自己加载而不是寻找父类加载器)和 findClass(从特定路径寻找类) 方法。 Tomcat 通过这种方式实现应用之间的类隔离。 ___。 __根据类的唯一性,这需要确保二者的类加载器不同,所以 Tomcat 使用自定义类加载器来实现应用之间类的隔离,每一个应用会有一个独立的类加载器加载对应类。 _自定义类仍然可以用于类加密、热部署和热更新以及模块化和插件化。
    1. 一个 Tomcat 程序可以运行多个 Web 应用,如果两个应用中出现了限定名相同的类,Tomcat 需要保证他们都能被加载并且明确是两个不同的类
  1. 线程上下文加载器线程上下文类加载器允许开发者在特定的线程中指定一个类加载器,这样在该线程内的一些操作(如资源查找等)就会使用这个指定的类加载器,而不是默认的类加载器。 每个类都会使用加载自己的类加载器去加载其他类,但是对于SPI来说,有些接口是Java核心库所提供的,而Java核心库是由启动类加载器来加载的,而这些接口的实现却来自于不同的jar包(厂商提供), Java的启动类加载器是不会加载其他来源的jar包,这样传统的双亲委托模型就无法满足SPI的要求。而通过给当前线程设置上下文类加载器,就可以由设置的上下文类加载器来实现对于接口实现类的加载。
  1. OSGi 实现一整套类加载机制,允许同级类加载器之间互相调用。

运行时数据区

线程私有

  1. Program Counter Register 程序计数器,每个线程会通过程序计数器记录当前要执行的字节码指令地址。 可以控制程序指令的进行和线程切换恢复执行。
  1. Java Virtual Machine Stack 每个 Java 方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  1. Native Method Stacks 本地方法栈存储本地方法栈帧,在 HotSpot 虚拟机中,Java 虚拟机栈和本地方法栈实现上使用了同一个栈空间。

线程共享

  1. Java Heap 分配内存空间以创建并存储对象。
    1. Heap 对于堆空间来说,有三个需要关注的值,_used、total、max__。_ used 指当前已使用的堆内存,total 指 JVM 已经分配的可用堆内存,max 指 JVM 可以分配的最大堆内存。 max 默认是系统内存 1/4,total 默认是系统内存 1/64。在实际应用中一般需要设置 max 和 total 的值。(-Xmx -Xms)
    2. Object
      1. 创建过程
        1. 类加载检查
        2. 划分可用空间 同步锁定:对分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。 分配缓冲:另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
        3. 初始化零值 内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值(如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行)。 这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
        4. 设置对象头
        5. 执行 init 方法
      2. 内存布局
        1. Header存储对象自身的运行时数据。 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它 为“Mark Word”。指向它的类型元数据的指针。
        2. Instance Data 对象真正存储的有效信息。
        3. Padding占位符。HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,如果对象实例数据部分没有对齐的话,就需要通过占位符对齐填充来补全。
      3. 访问定位
        1. 句柄访问 句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
          1. notion image
        2. 直接指针 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,_就虚拟机HotSpot而言,它主要直接指针进行对象访问。__ _
          1. notion image
  1. Method Area JDK 7 将 Method Area 存放在在 Java Heap: Permanent Generation (-XX:MaxPermSize=Value 控制) JDK 8 将 Method Area 存放在操作系统维护的直接内存(元空间) (默认情况下只要不超过操作系统承受上限,可以无限分配。也可以使用 -XX:MaxMataSpaceSize=Value 控制)
    1. 类的元信息主要是类的定义
    2. 运行时常量池 字节码文件中通过编号查表的方法找到常量,这种常量池成为静态常量池。当静态常量池加载到内存中,可以通过内存地址快速定位到常量池中内容,这种常量池称为运行时常量池
    3. 字符串常量池存储代码中定义的常量字符串内容。

自动垃圾回收

Java 为了简化对象释放,引入自动垃圾回收机制。
Java 通过垃圾回收器对不再使用的对象完成自动回收。

标记可回收

常见对象引用

  1. Strongly Re-ference 指在程序代码中普遍存在的引用赋值。 无论任何情况,只要强引用关系存在,GC 永远不会回收被引用的对象。
  1. Soft Reference 描述非必须对象。 JDK 提供 SoftReference Class 实现,主要应用于缓存。 只被软引用关联的对象,在系统将要发生内存溢出异常前,会被列入回收范围进行二次回收,回收后若仍没有足够内存,才会抛出 OOM。
  1. Weak Reference 描述非必须对象。 JDK 提供 WeakReference Class 实现,弱引用主要在 ThreadLocal 应用。 GC 工作时无论当前内存是否足够都会回收掉只被弱引用关联的对象。
  1. Phantom Reference 最弱引用关系。 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。常规开发不会使用。
  1. 终结器引用 指在对象需要被回收时,终结器引用会关联对象并放置在 Finalizer 类中的引用队列中,在稍后由 FinalizerThread 线程从队列中获取对象,然后执行对象的 fianlize 方法,在对象第二次被回收时,该对象才真正被回收。 这个过程中可以在 finalize 方法中再将自身对象使用强引用关联。 在最终清除资源之前,给予一定缓冲区来进行必要的处理和恢复操作。常规开发不会使用。

判断对象存活

  1. 引用计数法 引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1. 但每次引用和取消引用都需要维护计数器,影响系统性能。同时存在循环引用,出现对象无法回收的问题。
  1. 可达性分析算法 可达性分析算法将对象分为两类:垃圾回收的根对象普通对象,对象与对象之间存在引用关系。 可达性分析算法指的就是如果某个对象到 GC Root 是可达的,该对象就不会被回收。 JVM 采用此方法判断对象是否可回收。 GC Root 大致有:
    1. 线程 Thread 对象,引用线程栈帧中的方法参数、局部变量。
    2. 系统类加载器加载的 java.lang.Class 独享,引用类中的静态变量。
    3. 监视器对象,用来保存同步锁 synchronized 关键字持有对象。
    4. 本地方法调用时使用的对象。

方法区回收

主要回收废弃常量和不再使用的类。
  1. 手动触发回收 System.gc(),但是这并不会立即回收垃圾,仅仅是向 JVM 发送垃圾回收请求,是否需要执行由 JVM 判断。
  1. 自动判定回收
    1. 此类所有实例对象都已经被回收,Java Heap 中不存在任何派生子类实例。
    2. 加载该类的类加载器已经被回收,也就是说自定义类只要被加载就永远不会被回收,因为它们的加载器是应用加载器。
    3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类方法。
JVM 被允许对满足上述条件的类进行回收。
HotSpot 提供参数 -Xnoclassgc 参数控制是否回收。
在大量使用反射、动态代理等字节码框架,动态生成 JSP 这类频繁自定义类加载器的场景中,通常需要 JVM 具备类型卸载能力,以保证不会对方法区造成过大的内存压力。

堆回收

垃圾回收算法

  1. Mark-Sweep 标记所有需要回收对象,标记完成后,统一回收所有被标记对象。 这种方法有以下缺点:
    1. 执行效率不稳定,标记和清除的效率随对象数量增长而降低。
    2. 内存空间碎片化,标记清除后产生大量不连续内存碎片,可能导致无法为大对象分配空间而不得不提前触发另一次垃圾回收。
  1. Marker-Compact 标记过程和 Marker-Sweep 相同,但是后续步骤不是直接对回收对象进行清理,而是让所有对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。 这问题,但是移动存活对象并更新所有引用是一种相当负重的操作,而且这种移动对象,像这样长时间的停顿被表述 (Stop The World)。 另外 Mark-Sweep 算法也是需要停顿用户线程来标记、清理可回收对象的,只是时间相对较短。
    1. 解决了 Marke-Sweep 算法带来的内存空间碎片化
      操作必须全程暂停用户应用程序才能进行
      STW
  1. Semispace Copying 将 Java Heap 按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用尽,就将仍然存活的对象复制到另外一块内存,并把已经使用的内存空间一次性清理。对于大多数对象可回收的情况,算法需要复制的就是极少数的对象。而且每次都是针对整个半区进行内存回收,分配内存的时候也不用考虑空间碎片。 但是如果内存中大多数对象存活,将会产生大量内存间复制的开销;
  1. Generational Collection 开发者观察到这样一些事实:
    1. 绝大多数对象都是朝生夕灭。
    2. 经过垃圾回收次数越多的对象就越难以消亡。
    3. 跨代引用相对于同代引用来说仅占极少数。
于是收集器进一步划分 Java Heap 为更多区域,然后将回收对象根据其年龄(经过垃圾收集的次数)分配到不同区域存储,不同区域可以采用适应环境的回收算法。
notion image
具体的说,内存区域被划分为 Young 和 Old,前者存储存活时间较短的对象,后者存放存活时间较长的对象。其中 Young 又被进一步划分为 Eden 和 Survivor 区域。(默认 Eden 和 Survivor 大小比例 8:2)而 Survivor 又被划分为 From 和 To 区域。
新创建对象会被放入 Young:Eden 区域,随着对象增多到达上限,触发所谓 Young GC 也叫 Minor GC
Young GC 类似于 Semispace Copying 算法,回收 Young:Elden 和 Young:Survivor:From 区域内的可回收对象,其他对象放入 Young:Survivor:To 区域并交换 From 区域和 To 区域(这里的交换是逻辑交换),倘若 Young:Survivor:To 不足以容纳所有对象触发分配担保机制即多余对象直接进入老年代。
每次 Young GC 会记录对象年龄,当对象年龄达到阈值,对象晋升至 Old 区域,如此积累,当 Old 区域空间不足时,先尝试触发 Young GC,再紧接着触发 Full GC。Full GC 会对整个 Java Heap 进行垃圾回收,倘若仍然无法腾出空间,当新对象继续放入 Old 时抛出 OOM 异常。

垃圾回收器

垃圾回收器是垃圾回收算法的具体实现。
经典搭配:
  1. Serial & Serial Old
    1. Serial:单线程串行回收年轻代(Semispace Copying),单核 CPU 表现较好。
    2. Serial Old:单线程串行回收老年代(Sweep-Compact)
  1. ParNew & CMS
    1. ParNew:多线程回收年轻代(Semispace Copying)
    2. CMS:并发回收老年代(Mark-Sweep),垃圾回收停顿时间较短。
  1. Parallel Scavenge & Parallel Old
    1. Parallel Scavenge,多线程并行回收 Young(Semispace Copying),能自动调整堆内存。
    2. Parallel Old,多线程并发回收 Old(Mark-Sweep),多核 CPU 效率高。
不同于上述垃圾回收器,G1 收集器建立了可预测的停顿时间模型(_Pause Prediction Model__,即能够实现在一个长度为 m 毫秒的时间片段内,消耗在垃圾回收的时间大概率不超过 n 毫秒_)。
G1 不再坚持固定大小以及固定数量的分代区域划分,Java Heap 被划分为多个大小相等的 Region(Region 可以根据需要扮演 Eden、Survivor 或者 Old)。
将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?记忆集。
使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。
这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。
根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。
G1 将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 的整数倍,这样可以有计划的避免在整个 Java Heap 中进行全区域的垃圾收集。同时 G1 会跟踪每个 Region 的回收优先级(受回收获得空间和回收所需时间影响),每次根据用户设置允许收集停顿时间(默认 200 ms)优先处理回收优先级最高的 Region,这也是 Garbage First 名称的由来。
这种使用 Region 划分内存空间、具有优先级的区域回收方式,保证 G1 在有限的时间内获取尽可能高的收集效率。
  • Young GC当 G1 判断年轻代不足(默认 Eden / Java Heap = 60%),触发 Young GC。 回收 Eden 和 Survivor 中可回收对象,会导致 STW,G1 支持设置最大暂停时间并尽力保证。
    • 标记 Eden 区域和 Survivor 区域中存活对象。
    • 根据设定最大暂停时间复制存活对象到新的 Survivor 区域,清空标记区域。(过程中会记录每次垃圾回收时 Eden 和 Survivor 的平均耗时作为下次回收的参考依据)
    • 当某个存活对象年龄达到阈值(默认 15),移入 Old 区域。
    • 某个对象大小超过 Region / 2,直接放入 Humongous(Old),对象过大可以横跨多个 Region。
  • Mixed GC 当 G1 判断老年代过大(默认 Old / Java Heap = 45%),触发 Mixed GC。 Mixed GC 回收部分 Eden、Old 以及 Humongous,选择依据 G1 维护的优先级列表,回收采用复制算法如果清理过程中没有足够的 Region 存放转移对象触发 Full GC,单线程执行 Mark Sweep 算法,此时会导致不受控制的用户线程停顿。

参数调优

 
上一篇
CDN
下一篇
Java Serializable