JVM查漏补缺

yin_bo_ Lv3

Java为什么一次编译,处处运行

  • JVM就是java的运行环境,代码会被编译成.class文件,把.class文件给JVM,JVM就能帮你运行
  • 至于java为什么能跨平台,是因为不同的操作系统有不同的JVM,这些JVM都能把.class文件解释执行。
  • 但是docker出现后,Java这种跨平台的优势就被大大削弱了

JVM,JRE,JDK有什么关系

  • JVM就是运行.class文件的虚拟机
  • JRE就是JVM加上一些基本类库
  • JDK就是JRE加上能把java代码编译成.class文件的开发工具

介绍一下Java内存区域的线程私有的组成

  1. 程序计数器:内部保存的字节码的行号,用于记录正在执行的字节码指令的地址。
    • 程序计数器不会出现OutOfMemoryError,因为他的生命周期随着线程的创建而创建,随着线程的结束而死亡
    • 多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候知道该线程运行到哪了
  2. Java虚拟机栈
    • 简称,生命周期也与线程相同,除了一些native方法通过本地方法栈来调用,其他所有的java方法都是通过栈来实现的
    • 每个方法的调用都有一个对应的栈帧被压入栈内,调用结束后栈帧弹出。
    • 栈可能出现的错误
      1. StackOverFlowError:如果栈的内存大小不能动态拓展,那么当线程请求栈的深度超过虚拟机栈的最大深度时,会抛出栈溢出异常。
      2. OutOfMemoryError:如果栈的内存可以动态拓展,那么当虚拟机在动态拓展栈中无法申请足够内存空间,那么会抛出该异常。
    • Hot Spot虚拟机不支持栈扩容,每个线程的虚拟机栈大小在创建时已经固定了,所以不会OOM
  3. 本地方法栈
    • 虚拟机栈执行Java方法,底层会调用很多C或者C++写的Native方法,这里就需要通过本地方法接口(JNI)进行调用,这些Native方法就会放到本地方法栈。
    • 和虚拟机栈的作用类似,也会有栈帧,也会出现这两种报错。

介绍一下Java内存区域的线程公共的组成

    • JVM所管理内存中最大的一块,堆是所有线程共享的一块内存区域,在虚拟机启动时创建
    • 此内存区域的唯一目的是存放对象实例,几乎所有的new出来的对象实例以及数组都在这分配内存
    • 对象放在堆里,而基本类型会被放到栈帧里,因为基本类型比较小,方便分配和销毁
    • 当堆中没有内存空间可分配给实例,也无法拓展时,也会抛出OutOfMemoryError异常。
    • 堆是垃圾收集器管理的主要区域,也被称为GC堆(Garbage Collected Heap),从垃圾回收的角度,由于现在收集器都采用分代垃圾收集算法,所以堆可以细分为新生代和老年代,年轻代又分为Eden区,两个大小严格相同的Survivor区
    • 一个对象会先到Eden区,如果垃圾回收之后还存活,就会被移动到Survivor区,再经过几次垃圾回收,仍然存活于Survivor区的对象会被移动到老年代区,也就是生命周期比较长
    • jdk1.8之后方法区/永久代就放到本地内存了,不在堆中存储。因为永久代会为GC带来不必要的复杂度,并且回收效率偏低
    • 造成栈溢出java heap space的原因是堆中没有足够的连续空间,经过垃圾回收,也无法腾出足够空间
    • 造成栈溢出的两种情况
      1. 内存泄漏:对象用完了但没释放
      2. 内存膨胀:短时间创建了太多对象。
  1. 方法区
    • 方法区是 JVM 规范中的一块内存区域,存放类的元数据信息,JDK8 之后使用元空间(Metaspace)来实现方法区。
    • 在元空间中,存储以下核心数据
      1. 类的基本信息Class Metadata
        • 包括类的完整结构,如类名,父类,实现的接口,类加载器等
      2. 方法的字节码Method Bytecode
        • 每个方法的原始指令序列
      3. 运行常量池
        • 类的符号引用,方法引用,常量等。
    • 需要注意的是,字符串常量池并不在元空间中,而是在堆中。
      • 字符串对象数量非常大,因此JVM专门开辟了一块区域存字符串(String类),主要目的是为了避免字符串重复创建
      • 比如将一个字符串赋值给a,字符串常量池会创建该字符串,如果将同样的字符串赋值给b,那么不会重新创建相同字符串,而是在常量池中找。
  2. 直接内存
    • 不属于JVM中的内存结构,不受JVM管理,是虚拟机的系统内存,也就是操作系统中的内存,常见于NIO操作,用于数据缓存区,他分配回收成本比较高,但是读写性强。
    • NIO也就是NEW I/O ,基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,避免了在 Java 堆和 Native 堆之间来回复制数据。
    • 比如Java代码完成文件拷贝,可以通过常规IO和NIO
      • 常规IO:Java调用本地Native 函数,存入系统内存,再转到Java堆内存
      • NIO:直接使用Native函数分配堆外内存

HotSpot 虚拟机在Java堆中对象分配、布局和访问的全过程

对象如何创建的

  1. 类加载检查
  • 虚拟机遇到一条new指令时,首先检查该类是否被加载,解析,初始化过,被加载过则跳过类加载,如果没有,触发类加载过程
    • 类加载过程: 加载➡验证➡准备➡解析➡初始化
  1. 分配内存
    • 对象实例存在堆中,JVM会在堆中为对象分配一块内存,分配方式如下
      1. 指针碰撞
        • 适用场合:堆内存规整
        • 使用该分配方式的 GC 收集器:Serial, ParNew
        • 用过的内存整合到一边,没用过的内存放在另一边,中间有一个分解指针,分配对象后指针向空间内存移动对象大小,效率非常高。
      2. 空闲列表
        • 适用场合:堆内存不规整的情况下。
        • 使用该分配方式的 GC 收集器:CMS
        • 虚拟机会维护一个列表,该列表会记录那些内存块是可用的,在分配的时候,找到一块足够大的内存块划分给对象实例,更新空闲列表,
  2. 初始化零值
    • 将分配的内存空间都初始化为零值,保证对象实例不赋值就可以直接使用,比如int n; 访问n为0
  3. 设置对象头
    • 每个对象都要设置对象头,对象头包含以下数据
      • 该实例存于哪个类
      • 该如何找到类的元数据信息
      • 哈希码
      • GC分代年龄
  4. 执行构造方法(init初始化方法)
    • 上面的工作完成之后,对于虚拟机,一个对象生成了,但从Java程序的视角看,还没有执行init 方法,所有的字段还都为0,所以需要执行构造方法,将对象进行初始化

对象的内存布局

  • 在HotSpot虚拟机中,对象在内存中的布局分为三个区域
    1. 对象头Header
      • 包含两部分信息
      1. 标记字段(Mark Word):用于存储对象的运行时数据,如哈希码,GC分代年龄等
      2. 类型指针(Klass pointer):对象指向他的类元数据指针,虚拟机可以通过该指针确定这个对象是哪个类的实例
    2. 实例数据Instance Data: 对象真正存储的有效信息
    3. 对齐填充Padding: 不是必然存在的,仅仅占位作用。比如系统要求对象起始地址必须8字节的整数倍,就需要补齐。

类加载过程

类的生命周期

  • 类从加载到虚拟机内存 到 卸载出内存位置,生命周期分为七个阶段
    1. 加载(Loading)
    2. 验证(Verification)
    3. 准备(Preparation)
    4. 解析(Resolution)
    5. 初始化(Initialization)
    6. 使用(Using)
    7. 卸载(Unloading)。
  • 其中验证,准备,解析三个为**连接(linking)**阶段
  • 所以加载类文件主要三步:加载➡连接(验证,准备,解析)➡初始化
  1. 加载

    • 主要完成三件事
      1. 通过全类名,获取类的二进制数据流
      2. 协议类的二进制数据流为方法区的数据结构(把类信息存入方法区)
      3. 创建java.lang.Class类的实例,表示该类型,作为方法区这个类的各种数据的访问入口
      • 简单来说,就是动态加载Java类的字节码(.class文件)到JVM中并生成一个代表该类的Class对象
  2. 验证

    • 验证类是否符合JVM规范,会进行以下验证
      • 文件格式验证
      • 元数据验证
      • 字节码验证
      • 符号引用验证
  3. 准备

    • 正式为类变量分配内存设置类变量初始值(注意,只有类变量static变量)
  4. 解析

    • 虚拟机将常量池内的符号引用更替为直接引用,就是将类名,方法名啥的转化为具体内存地址
  5. 初始化

    • 执行初始化方法<clinit>()方法,是类加载的最后一步,这一步JVM才开始执行类中定义的Java代码
    • <clinit>()方法是编译之后自动生成的。
  6. 类卸载

    • 使该类的Class对象被GC
    • 在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。

类加载器

什么是类加载器

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
    每个 Java 类都有一个引用指向加载它的 ClassLoader。
    数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

  • 简单来说,类加载器的主要作用就是动态加载Java类的字节码(.class文件)到JVM中并生成一个代表该类的Class对象
    字节码是.java文件经过javac编译得来的

类加载器加载规则

  • JVM启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载
  • 对于已经加载的类会放在ClassLoader
    在类加载的时候,系统会首先判断当前类是否被加载过,已经被加载的类会直接返回,否则才会尝试加载,相同二进制名的类之后加载一次
    JVM中内置三个重要的类加载器
    1. BootstrapClassLoader(启动类加载器)
      • 最顶层的加载类,由C++实现,通常加载JDK内部的核心类库java.lang.*
    2. ExtensionClassLoader(拓展类加载器)
      • 负责加载ext目录下的jar包和类
    3. AppClassLoader(应用程序类加载器)
      • 面向用户的加载器,负责加载当前应用下classpath的所有jar包和类

双亲委派模型

  • 双亲委派就是类加载顺序的规则
    类加载器有多种,加载一个类需要通过双亲委派模型来决定由哪个类加载器加载

  • 也就是在类要被加载的时候,这个类加载器会委派他的上一级类加载器也就是爸爸类加载器去加载,上一级类加载器委派再上一级
    一直委托到最上级Bootstrap,如果不能加载,就让下一级加载
    所以其实应该叫单亲委派模型的(,但是国内就是叫双亲委派模型

  • 这样的好处是避免类加载重复,不会两个类加载器都加载了同一个类
    还有就是保证Java核心类安全,比如如果没用双亲委派,应用层加载器可以加载一个别人自己写的假String,而双亲委派可以让Bootstrap优先加载,可以保证核心类不被篡夺

  • 双亲委派模型并不是强制的,我们可以通过重写loadClass()方法

    • 在里面决定优先加载哪里的类去自定义一个classLoader

垃圾回收基本概念

堆空间的基本结构

  • Java堆是垃圾回收器管理的主要区域,大部分对象实例都在这里分配,因此也被称为GC堆
  • 现在GC都采用分代垃圾收集算法,所以Java堆被划分为几个不同的区域,我们可以根据各个区域的特点选择合适的垃圾收集算法
    • 新生代(Eden区 两个Survivor区)
      • 对象优先在Eden分配,Minor GC后进入Survivor区
    • 老年代
      • 存放长期存活的对象,GC频率低(Major GC / Full GC)
    • 元空间或永久代,这里元空间使用直接内存

内存分配/回收原则

  • 对象一般会优先在新生代的Eden区分配。

    • 当Eden区空间不足时,会触发一次Minor GC,回收不可达对象,并将仍然存活的对象复制到Survivor区。
    • 如果对象在多次GC后仍然存活,或者Survivor区放不下,则会晋升到老年代。
  • 大对象(需要大量连续内存空间的对象,比如字符串,数组)为了避免在新生代频繁复制,可能会直接分配到老年代。

  • 长期存活对象将进入老年代

    • 虚拟机会给每个对象一个年龄计数器,对象每熬过一次Minor GC,年龄就增加一岁,当年龄增加到一定程度(默认是15岁),就会被晋升为老年代。

GC分配

  • 针对HotSpot虚拟机,GC分为以下两大种
    1. 部分收集(Partial GC)
      • 新生代收集(Minor/Young GC):只对新生代进行垃圾收集,发生频率比较高,执行速度快
      • 老年代收集(Major/Old GC):只对老年代进行垃圾收集
      • 回合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集
    2. 整堆收集(Full GC)
      • 收集整个Java堆和方法区,开销比较大,尽量避免使用

死亡对象判断方法

堆垃圾回收前第一步就是要判断哪些对象已经死亡(即不能被使用的对象),有以下两种方法

  1. 引用计数法
    • 给对象添加一个引用计数器
      • 每当有一个地方引用他,计数器+1 ; 当引用失效,计数器-1
      • 任何时候计数器为0的对象就是不可能再被使用的
    • 这个方法实现简单,效率高,但是主流VM没有使用这个算法来管理内存(Python仍然在使用),主要原因是难解决对象之间循环引用的问题,循环引用问题如下:
      • 假如对象objAobjB互相引用对方,然后再无任何引用,但是他们因为互相引用,所以计数器不为0,所以不能通知GC去回收他们
  2. 可达性分析算法
    • 以一系列GC Roots作为起始节点,从这些节点出发遍历对象引用关系,形成引用链。如果一个对象无法通过引用链与GC Roots建立联系,则说明该对象是不可达的,可以被回收。
      • 节点所走过的路径被称为引用链
      • 当一个对象到GC Roots没有任何引用链的时候,证明此对象不可用,需要被回收。
    • 下面这些对象可以作为GC Roots
      1. 虚拟机栈里栈帧的局部变量表中引用的对象
      2. 本地方法栈(Native 方法)中引用的对象
      3. 方法区中类静态属性引用的对象
      4. 方法区中常量引用的对象
      5. 所有被同步锁持有的对象
  • 需要注意,在可达性算法中,如果对象没和GC Roots建立联系,也不是非死不可
    • 对象会经历两次标记过程,如果对象重写了finalize方法,并在其中重新建立引用关系,则可以避免背回收(只会执行一次)。

常见的引用类型有哪些

  • 无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。
  • Java中引用按照强度从强到弱分为四种: 强引用 > 软引用 > 弱引用 > 虚引用
  1. 强引用(Strong Reference)
    • 就是程序代码中普遍存在的引用赋值,如下
      Object obj = new Object();
    • 一个对象如果具有强引用,那么垃圾回收器绝对不会回收他
    • 如果内存空间不足,宁愿抛OOM,也不会回收强引用对象
  2. 软引用(Soft Reference)
    • 如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。
    • 常用于做缓存,代码如下
      SoftReference<Object> softRef = new SoftReference<>(new Object());
  3. 弱引用(Weak Reference)
    • 只要发生GC,就一定会回收,不管内存是否充足。
    • 虽然仍然可达,但是GC会忽略这个引用,仍然可以拿到对象,在下一次GC时会被回收
    • 不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
    • 代码如下
      WeakReference<Object> weakRef = new WeakReference<>(new Object());
  4. 虚引用(Phantom Reference)
    • 顾名思义,就是形同虚设,该引用不会决定对象生命周期,对象只有虚引用没有引用一样,在任何时候都有可能被垃圾回收
      PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);
    • 必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
  • 程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

如何判断一个常量是废弃常量?

运行时常量池是可以被GC回收的。
判断一个常量是否为废弃常量,主要看是否还有引用指向它。
如果没有任何对象引用该常量,那么该常量就可以被视为废弃常量,在GC时会被回收。
假如字符串”abc”没有被引用,但是存在常量池中,就会被GC回收。

对于字符串常量池(位于堆中),其回收机制与普通对象类似,只要没有引用即可回收。

而对于运行时常量池(位于方法区),只有在类被卸载时,对应的常量才有可能被回收。

如何判断一个类是无用的类

  • 在方法区里,GC回收的是无用的类
    • 判断一个常量或对象是否无用很简单,但是判断一个类无用要难很多,需要满足以下条件
      • 该类所有实例对象都被回收,也就是Java堆中不存在该类的任何实例
      • 加载该类的ClassLoader已经被回收
      • 该类对应的java.lang.Class对象没有被引用
    • 这样类就可能无用,虚拟机可以对该类进行回收。

为什么要ClassLoader被回收

  • 类是由ClassLoader加载的,只要ClassLoader还存在,就可以重新访问该类。

为什么类很少被回收

  • 因为大多数ClassLoader生命周期很长,因此类很难被卸载和回收。

垃圾回收算法

标记-清除算法

  • 标记-清除(Mark-and-Sweep) 算法分为标记和清除两个阶段
    首先标记出所有应存活对象
    在标记完成后回收没有被标记的所有对象
  • 这是最基础的收集算法,后续的算法是对其不足进行改进得到的,会带来两个明显的问题
    1. 效率:标记清除两个过程效率都太低
    2. 空间:清除后会产生大量不连续的内存碎片

复制算法

  • 可以将内存分为两个大小相同的内存块
    每次使用其中一块,当这一块的内存用完之后,将存活的对象复制到另一块,然后把使用的空间清理掉。
    这样就能使每次的内存回收都是对内存区间的一半进行回收。
  • 改进了标记清除算法会产生大量不连续内存碎片的问题,但是还有以下问题
    1. 可用内存变为原来的一半
    2. 不适合老年代,如果存活对象过多,复制会降低很多性能

标记-整理算法

  • 根据老年代的特点提出的标记算法
    标记过程和标记清除算法一样,但后续步骤是让所有存活的对象往一端移动,然后清理掉端边界以外的内存
  • 整理的效率不高,适合老年代这种GC频率不高的场景

分代收集算法

  • 当前JVM使用的就是分代收集算法
    就是根据对象存活周期的不同将内存分为几块
    一般Java堆分为新生代和老年代,这样我们可以在不同的年代选择适合的GC算法
  • 新生代里每次GC都会死大量对象,因此存活的对象很少,复制成本很低,可以用复制算法
  • 老年代里对象存活几率较高,可以用标记整理或者标记清除算法

垃圾收集器

  • 垃圾收集器是内存回收的具体实现
  • JDK默认垃圾收集器:
    JDK8 : Parallel Scavenage(新生代) + Parallel Old(老年代)
    JDK9 - JDK22 : G1收集器

Serial 收集器

  • Serial(串行)收集器是最基本的收集器
    也就是单线程区完成垃圾收集工作,在这期间必须暂停所有工作线程(Stop-The-World)
    Serial 本身只管新生代

ParNew 收集器

  • 就是串行收集器的多线程版本
    可以多线程进行垃圾回收,其他与串行收集器相同
  • 可以与CMS收集器(真正意义上的并发收集器)配合工作
    ParNew 只负责新生代(复制算法),老年代可以使用CMS

Parallel Scavenge + Parallel Old

  • 与ParNew 收集器同样是并行收集器,但是不能配合CMS收集器
  • 更专注于并行(多线程,提高吞吐量)而不是并发(用户线程和垃圾收集线程同时执行,追求低停顿)

CMS 收集器

  • Concurrent Mark Sweep收集器更专注用户体验和减少停顿时间
    是HotSopt 虚拟机上第一款并发收集器,实现了GC线程与用户线程基本上同时工作。

  • CMS也是基于标记清除(MS)实现的,整个过程分为四步

    1. 初始标记:短暂停顿(Stop-The-World),标记从 GC Roots 可直接引用的对象。
    2. 并发标记:同时开启GC和用户线程,用可达性分析去记录可达对象
      可达性分析不能保证包含所有可达对象,因为用户线程可能会不断更新引用
      所以GC线程无法保证可达对象实时标记
    3. 重新标记:对并发标记的补充,标记没标记到的可达对象,比并发标记时间短
    4. 并发清除:开启用户线程,同时GC对未标记区域清除
  • 主要优点:并发收集,低停顿

  • 主要缺点

    1. GC线程和用户线程抢CPU
    2. 无法处理浮动垃圾(并发标记/清除期间新产生的垃圾)
    3. MS算法会产生大量空间碎片
    4. 内存不够时,会退化为 Serial Old Full GC,导致长时间停顿
  • CMS 垃圾回收器在高版本JDK(java 14)已经被弃用

G1 收集器

  • G1(Garbage-First) 是一款面向服务器的垃圾收集器,主要针对大型服务器
    高并发的同时,也注意高并行,所以响应时间快,也具备高吞吐量

  • G1收集器的运行分为以下四步

    1. 初始标记:短暂停顿(Stop-The-World),标记从 GC Roots 可直接引用的对象。
    2. 并发标记:同时开启GC和用户线程,用可达性分析去记录可达对象
    3. 最终标记:短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。
    4. 筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。
  • G1 收集器是首个不区分新生代和老年代的垃圾收集器

    • 他把内存分为一个个的Region,每个Region的大小和存放的对象都不一样
      G1计算各个Region的回收价值,优先选择回收价值最大的 Region进行回收(这也就是它的名字 Garbage-First 的由来)
      然后通过可操控的STW去决定回收哪些Region(默认为200ms)

ZGC 收集器

  • 与GC相似,也采用复制算法,不过ZGC对该算法做了重大改进
    ZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。
  • 标题: JVM查漏补缺
  • 作者: yin_bo_
  • 创建于 : 2026-03-12 22:33:20
  • 更新于 : 2026-03-26 19:12:54
  • 链接: https://www.blog.yinbo.xyz/2026/03/12/面试题/JVM查漏补缺/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
目录
JVM查漏补缺