Skip to content

JVM与内存管理

更新: 10/30/2025 字数: 0 字 时长: 0 分钟

TIP

本文内容基于周志明老师的《深入理解Java虚拟机 第三版》,为我个人的读书笔记

Java在运行时会将自己内部的内存划分为几个不同的区域,这些区域的总称叫做运行时数据区,根据Java虚拟机规范规定,JVM中运行时区域分为:

  • 方法区
  • 虚拟机栈
  • 本地方法栈
  • 程序计数器

程序计数器

这块区域用来控制Java的运行流程(下一条应该执行哪个指令),在一个已知的时刻,一个内核只会执行一个线程中的指令。由此,在多线程编程中,该区域承担着保证线程切换后能恢复到之前的状态继续执行的功能,因此每个线程都会有一个独立的程序计数器,这一块是线程私有的内存。当当前线程执行的是Java方法时,此处存放的是一个虚拟字节码的地址,而执行的是Native方法时,存储的则是空(Undefined)

这块区域是在《Java虚拟机规范》中唯一一个没有规定OOM情况的区域

Java虚拟机栈

类似C语言的栈,在进行方法调用时,JVM也会向栈中弹入一个栈帧,这个栈就是Java虚拟机栈。

由于每个线程都有自己的一套方法调用顺序,因此虚拟机栈也是线程独有的。虚拟机栈的每个栈帧中存放了当前方法的局部变量表、操作数栈、动态连接、方法出口等信息,直到该方法完全执行完毕,这个栈帧才会被弹出栈

局部变量表中存放了当前方法的所有局部变量,包含八种基本类型,对象引用和returnAddress 类型(指向了一条字节码指令的地址)

《Java虚拟机规范》中规定这个地方存在两种异常:

  • 由于栈扩展时无法申请到足够的空间而导致的OOM(在HotSpot虚拟机中栈容量不可以扩展,所以不会因为扩展出现OOM,只要线程申请栈成功了就不会OOM,只有申请栈内存失败时才会出现OOM)
  • 由于线程请求的栈深大于虚拟机栈最大允许的栈帧而导致的StackOverflowError(eg.过多的递归)

本地方法栈

与虚拟机栈类似,只是服务的对象是Java中的Native方法

Java 堆

这块区域是所有线程共有的,根据《Java虚拟机规范》,几乎所有的对象实例的内存都是在这里分。由于这里是对象内存分配的位置,因此GC也在这里发生,故而又名为GC堆。由于对象的内存是在Java堆中开辟的,因此当没有足够的内存时就会出现OOM

方法区

方法区也是线程共有的内存区域,用于存储类型信息,常量,静态变量等数据,根据《Java虚拟机规范》,这块区域在逻辑上属于Java堆的一部分,但又专门使用了“Non-Heap”这一词汇来与Java堆进行区分。

在JDK8之前,存在一个叫做永久代的东西,这时HotSpot团队对方法区的实现方式,其他的JVM是没有永久代这个说法的,最初设计永久代是希望可以通过Java堆的GC一同将永久代的数据进行回收(是的,即时进了永久代也不能表示数据真正”永久“,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载),但是根据实际情况发现这一设计会导致很多Bug(主要是永久代的GC不理想照成的内存泄漏),因此在JDK8及以后的版本中彻底放弃了永久代的设计

由此我们不难发现,如果方法区无法满足内存分配需求,也会产生OOM

运行常量池

运行时常量池是方法区的一部分,承载了Class文件中的常量池表(内含编译期生成的各种字面量与符号引用)中的内容,运行常量池中处理符号引用外也会存放由符号引用转换而成的直接引用

当常量池无法再申请到内存时会出现OOM

对JVM中区域的总结

JVM作为Java乃至后续各种JVM语言的基石,其设计一定是相当合理的,JVM中的每一个区域都有自己的意义,共同实现JVM语言的运行。

  • 虚拟机栈:本质上实现的其实是语言中执行流程的问题,即单线程中下一句应该执行哪个代码,多线程中如何在切换线程时切换回原本的状态
  • 虚拟机栈+本地方法栈:解决方法调用方法的问题,以及隔离不同方法间各自的信息,进而实现了函数的各项功能
  • Java堆:解决对象创建后内存分配以及生命周期的问题,实现了对于对象的托管
  • 方法区+运行时常量池:存储类的基本信息(类的元数据(类名、父类、接口、修饰符等),字面量(如字符串常量), 符号引用(如类名、字段名、方法名等)等),是实现OOP的基石

直接内存

在Java1.4中新增的NIO中,我们可以直接通过Native函数库来直接分配堆外内存,然后通过DirectByteBuffer类来对这块内存进行操作,由于是直接操作物理内存,不用来回在Native堆和Java堆中拷贝,因此性能极高,但是内存肯定不可能是无限的,因此也会出现OOM的情况

对象实例的创建

一般Java程序会在检测到new关键字的时候创建对象,在创建对象前,JVM会现在常量池中尝试定位这个类,如果定位不到,就说明这个类还没有被加载,那么则会先进行加载

加载完成后,会先试图为实例开辟一段空间,根据JVM中内存是否规整(即是否完全分为两块区域:已使用和未使用,这两块区域应该相互隔离)存在两种方案:

  • 当内存不规整时:JVM会维护一个空闲列表,用来记录哪些内存未被使用,当创建对象时,就从空闲列表中寻找一片空间给程序
  • 当内存规整时:JVM会采用指针碰撞的形式,直接将用来分隔空闲区和非空闲区的指针进行移动,进而将空闲区的空间移动给非空闲去

为了保证空间分配这一过程的线程安全,JVM默认采用CAS+错误重试的机制来实现分配内存的原子性,除此之外还有一种模式就是给每个线程预先分配一段空间,线程创建对象只能在这个空间中创建,但该方式需要单独开启

再分配完空间后,会将空间中除了对象头的部分置为0,然后对对象头中数据进行设置(包含hashCode(这一部分是Lazy加载的,只有调用方法的hashCode方法才会得到),元数据,GC年龄等),上述过程完成后就视为在JVM层面创建了一个对象实例

不过在代码层面,还要执行构造方法,init方法,来完成实例中字段的传入

对象的布局

对象分为三部分:对象头,实例数据,对齐填充

其中对象头中包含两类数据,一类是存储对象自身运行时的数据,则个数据中包含哈希码,GC分代的年龄,锁状态的标志等内容,这一部分被精心设计为了32bit或64bit(根据你的系统是32位还是64位决定)

对象的另一部分为类型指针,用来表示这个对象实例对应的是哪个类。

然后是实例数据,就是程序员设计的字段的数据。

最后是对齐填充,由于HotSpot虚拟机中要求对象必须是8字节的倍数,因此会使用该空间将整个对象填充至8的倍数

对象的访问

我们都知道Java是通过引用直接访问到Java堆中开辟的对象,那么引用是如何访问到Java堆中的对象呢

对于JVM一般的实现方式有两种:

  • 引用指向句柄池:在Java堆中专门设置一块空间,用来存放句柄,句柄包含两部分,一部分是对象示例数据的指针(指向Java堆中的Java类实例对象),另一部分则是指向方法区中的类信息
  • 引用指向实例数据:使用指针直接访问对象实例,同时在对象实例中存储一个用来访问类信息的指针

一般JVM都是采用指向实例数据的方式,因为这种方式少一次定位,速度更快

实战——JDK String常量池在不同JVM下的区别

java
  public static void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);
        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }

这段代码在JDK6,7,17中均有不同的答案:

  • JDK6:false false
  • JDK7:true false
  • JDK17:true true

在JDK6中String的intern方法会把第一次遇到的字符串实例放入永久代/方法区中的字符串常量区,返回的是永久代中这个这个字符串实例的引用,而使用StringBuilder创建的字符串是直接创建在Java堆上的

而在JDK7中,永久代中的字符串常量区被移动到了Java堆上,记录的也变成了Java堆中这个实例的引用,对于str1,由于是第一次遇到,因此将这个字符串的引用放入字符串常量区,并放回回来,而str1本身就是这个字符串引用,因此一样,而str2,因为Java在类加载阶段中会加载这个字符串(这个字符串比较特殊),因此这里获取的是类加载时的字符串的引用

而在JDK17中,HotSpot不会再在类加载过程中加载字符串到常量区,因此str2与str1完全相同,故是两个true

垃圾回收

主流的垃圾回收方式有两种——引用计数法和可达性分析法

引用计数法相对简单,一个对象在创建时添加一个引用计数器,每被引用一次引用计数器+1,引用失效则计数器-1,凡是计数器数值为0的对象均是无法被使用的

但是引用计数法会在一些特殊的情况下造成引用内存泄漏

java
public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /**
     * 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];
    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        // 假设在这行发生GC,objA和objB是否能被回收?
        System.gc();
    }
}

比如这个代码,虽然objA和objB都为null吗,但是其内部互相引用仍然存在,因此就出现了内存泄漏

于是就产生了可达性分析法

可达性分析法会设置一个特殊的对象作为GCRoots,一个对象根据自身的引用关系(比如A引用B,B引用C)向上找,凡是能达到GCRoots的就是不可回收的,反之就是可回收的

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

引用的区别

所谓引用就是指一个reference类型的数据里存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。根据垃圾回收的机制,一个对线的引用的存在与否直接关乎对象是否会被回收

根据引用类型的不同,Java1.2后将引用区分为强引用(Strongly Re-ference)、软 引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种

强引用就是我们平常使用new关键字创建的引用,其会根据上面说的回收方式进行回收,一般只要对象存在强引用就不会被回收

软引用则是在系统即将发生内存泄漏前进行检测,如果会发生内存泄漏,则将只有软引用的对象全部回收

弱引用则是在下一次垃圾回收中一定会回收的引用,如果一个对象在下次回收中只存在弱引用,那么它必将会被回收

虚引用无关对象的存活,唯一的意义就是在对象被垃圾回收时会给到一个通知

对象的”自救“

在对象被正式回收前,其实还有一次自救的机会,因为对象回收前会进行一次筛选,赛选的条件是对象是否要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果一个对象有必要执行finalize()方法,则会进入一个名为F-Queue的队列,在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法,在这个finalize方法中,对象有机会创建自己的引用,进而让自己避免被回收的命运

值得注意的是,finalize()方法并不是一个好的方法,这是Java诞生之初为了方便C/C++程序员转型做出的妥协,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。

有些教材中描述它适合做“关闭外部资源”之类的清理性工作,这完全是对finalize()方法用途的一种自我安慰。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好更及时,因此我们可以放弃使用这个方法

垃圾收集算法的基石——分代收集理论

现代的垃圾收集算法大多都基于分代收集理论,这是一套经验理论,建立在三个假说之上:

  • 弱分代假说:绝大多数的对象都是招生夕死的
  • 强分代假说:经过多次垃圾回收还没有被回收的对象都是难以回收的
  • 跨代引用假说:不同分代的引用只占所有引用的一小部分

首先根据前两个假说,我们可以得到:

  • 大多数对象都是创建后没多久就被回收了
  • 很久没被回收的对象那大概率永远都不会被回收

因此我们可以得到结论:要将堆分成两部分,其中一部分较大,用来存放新生的对象,另一个部分较小,用来存放回收了好久都没有被回收的对象,这样我们就能发现,我们只需要对前者进行频繁的垃圾回收,偶尔对后者进行垃圾回收即可

这也就说所谓的新生代和老年代的由来。

第三个假说的作用是为了解决如果存在跨代引用,比如新生代被老年代引用,那么我们在回收新生代的时候,就需要完成一次涉及到老年代的可达性检验,而老年代的引用又很可能相互交织,这就导致这一次回收变成了“全”回收

而根据第三个假说——跨代引用很少,就可以得出,我们只需要对这一小部分单独检查即可,因此现代JVM中会将老年代分为多块,然后专门在新生代上开辟一块空间,名为记忆集,记录老年代的哪一块存在跨代引用,让后单独处理即可

堆的空间划分

根据上面的假说,我们也就将堆分为了两部分——老年代和新生代,其中新生代存放的是创建时间较短的对象,老年代存放的是创建时间较长的对象

大多数的GC发生在新生代上

三种回收算法

标记-清除算法

会对引用进行一次可达性检验,然后标记要回收(也可以标记不可回收的,主要是要标记一下)对象,然后对整个内存进行一次“从左到右”的回收,把要回收的部分回收掉即可

标记-回收算法最大的问题在于,这样会产生很多的内存碎片(因为你无法确定回收的对象是否连续),且标记过程时间完全随着对象的数量增长而增长

标记-复制算法

标记复制算法将内存区域分为两个半区,每次只在一个半区上创建对象,当这个半区的内存使用完后,会将这个半区的数据存货的对象全部复制到另一个半区,然后将回收的对象全部清除,这样就保证了对象永远都创建在一个连续的空闲空间上。

这个算法的问题是会产生大量的内存复制开销,并且可以创建对象的空间缩小到了原来的一半。不过这仍然是大多数JVM默认的回收算法(比如 HotSpot 的 Serial GC、Parallel GC、G1 的 Young GC 都在新生代使用它)

根据三大假说,我们知道,一次回收中只会有一小部分的对象会仍然存活,因此就对半区复制进行了优化,即将新生代分为一个较大的Eden空间和两个较小的Survivor空间,创建对象时,会将对象创建在Eden和其中一个Survivor上,然后垃圾回收时,会将存活的对象放到另一个Survivor上,然后清空被回收的Eden和Survivor

一般Eden:Survivor是8:1的大小,但是仍然可能产生Survivor空间不够放置回收后存活的对象的情况,所以当用来存放存活对象的Survivor不够用时,会将一部分存活对象移植老年代,直到老年代也不够用才会报错

标记-整理算法

上面的两种算法基本都是用在新生代,而在老年代则使用另一种算法——标记-整理算法

与标记-回收算法相同,标记-整理算法都会先进性标记,但不同之处是会将回收后存活的对象进行整理,保证这些对象使用的是一块连续的内存区域,然后将死亡的对象回收掉,这样也保证了对象在一块连续的区域上

但是对于一个运行的程序,尤其是老年代这种几乎不会回收对象的区域,做对象内存移动是很危险的,因此老年代GC时的整理过程会暂停整个程序的运行,直到GC完成,这个过程又被设计者戏称为“Stop The World”(Dio音~)

JVM的暂停

由于主流的JVM采用的是可达性分析来判断一个对象是否应该被回收,因此我们要在回收的最初就要先找到所有的GCRoots(GCRoots的枚举),这一过程也是要进行类似如同上文提到的Stop the world的暂停所有线程的过程,这是因为我们在枚举的这一刻不能允许存在指令对对象的引用进行修改,不然我们就无法保证回收的准确性

既然要寻找GCRoots,那么就要先知道哪些内存区域中存放有对象,HotSpot虚拟机中采用的是一种叫做OopMap的数据结构,这个数据结构中存储了在这一刻内存的哪些区域上有对象

由于每一行指令都有可能改变对象的引用关系,那么如果对个指令都进行OopMap的修改,则是一个十分耗时耗力的操作。因此JVM只会在一些特定的位置进行OopMap的记录,这些位置被称之为安全点

安全点的选取是基于“能否让程序长时间的执行”为标准的,因为每条指令的执行时间都非常短,那么程序就不太可能因为指令流太长这样的原因而长时间的执行

一般我们会在安全点对OopMap进行修改,并且进行GCRoots的枚举,那么现在的问题就是如何能保证在某一个时刻所有的线程都进入到安全点并暂停

主流的方案是主动式和抢占式

抢占式是JVM首先暂停所有线程,然后检测各个线程当前是否在安全点,如果不在则让线程继续运行,直到运行到运行到最近的安全点停止线程来响应GC

主动式是在执行GC的时候仍然让线程正常执行,同时线程不断轮询检测自己受否运行到安全点,如果运行到安全点时线程主动将自己挂起,并将自己当前的状态修改为运行到安全点这样就能保证所有的线程都在安全点,这也是目前默认的暂停方式

有了安全点设计,我们就可以完成GCRoots的枚举,正式切入到GC的过程中,但是安全点有一个小问题,那就是如果线程正在处于Blocked的状态怎么办

举一个例子,我们的两个线程争抢同一把锁,其中一个线程A获取锁后继续执行,而另一个线程B则被阻塞,A线程在归还锁前进入到安全点,将自己挂起,而B由于A的挂起将永远无法在这个GCRoots的枚举前获取到锁进而运行到安全点

为了解决这个问题,引入了安全区域,其实就是JVM标记许多代码区域,执行到这些代码区域的线程都被认为是安全的(在这个区域的线程不会发生引用的变化),比如线程的阻塞就是一个在安全区域的操作,因为阻塞的线程不会改变对象的引用,因此也可以进行GCRoots的枚举

常见的安全区域包括:

  • 阻塞(Object.wait()、锁竞争失败)
  • 休眠(Thread.sleep()
  • I/O 等待

卡表与卡页

我们之前有提到过,为了解决跨代引用导致的对老年代扫描的问题,我们使用了记忆集,而在HotSpot中,记忆集通过卡表实现,卡表是一个数组,每个元素代表一个卡页,卡页代表一定范围内的内存区域,一旦这个卡页中存在跨代引用的对象,就将这个卡表的标识符修改为1,表示这个卡页变脏了,下次GC的时候就要扫描这个卡页上的对象

写屏障

由于JVM在解释后会将字节码转换为机械码流,因此必须思考如何能合理的介入卡表的更新操作,在HotSpot虚拟机中,JVM采用的是写屏障来维护卡表

JVM会将对象的赋值操作看作一个切面,围绕这个切面建立环绕通知,赋值前的称之为写前屏障,赋值后的成为写后屏障,而维护卡表的操作就在写屏障中

可达性分析

虽然GCRoots的枚举会暂停用户线程,但是由GCRoots向下寻找不可达对线的过程是与用户线程的并发的,这就会导致一定的问题

在说问题之前,我们先来说一下可达性分析是如何从GCRoots向下扫描可达的对象的:

首先,我们先将对象标记为三种颜色:

  • 黑色:该对象所有的引用对象都被扫描过,且它自身已经确定与GCRoots可达,这样的对象就是绝对安全的,它可以作为GCRoots的延申,在GC回收后会存活,在可达性分析开始之初,只有GCRoots是黑色的,黑色的对象由于确定不会死亡且已经完全扫描完毕(自己被扫描且自身引用的对象也被全部扫描),因此不会再对黑色对象进行扫描
  • 白色:该对象尚未被扫描到,如果在可达性分析结束后仍然未被扫描到,这可以认为这个对象与GCRoots不可达,可以被GC回收
  • 灰色:中间态,当该对象自身被扫描过,但自身存在没有被扫描的引用时才会出现,用来表明这个节点还要继续向下寻找

由于与用户线程并发,因此对象的引用随时可变,且变化无法确定,就会存在将原本认为应该回收的对象未被回收和不应该回收的对象被错误回收两种情况,对于后者,我们是无法容忍的

根据Wilson于1994年的理论,有且仅有两个条件同时满足时才会导致存活的对象被认为应该回收:

  • 当一个黑色节点连接上了白色节点(保证了这个白色节点不会由这个黑色节点完成可达性分析)
  • 该白色节点相连的所有灰色对象都断开(保证这个白色节点无法从上面说的那个黑色节点外的节点实现可达性分析)

因此现代JVM都会从破坏上面两条条件的一条来解决错误回收的问题

  • 破坏第一条条件————增量更新:即记录所有黑色节点连接白色节点的操作,然后在扫描后重新对记录的黑色节点重新扫描一次,这个操作可以理解为一但黑色节点连接上新的白色节点就会变为灰色节点
  • 破坏第二条条件————原始快照:即记录所有灰色节点与白色节点断开前灰色节点的旧引用,然后在扫描后重新对记录的灰色节点重新扫描一次,这个操作可以理解为主动忽略扫描过程中的删除操作

这两种方式都是在写屏障的时候完成的

主流的垃圾收集器

Serial收集器

这是一个十分早期的收集器,采用了标记-复制算法,主要用来对新生代进行垃圾回收,其特点是在GC的过程中要完全将用户线程暂停,且只有一条GC线程来回收对象

ParNew收集器

Serial收集器的多线程并行版本,支持多条GC线程同时完成GC,在早年间经常与CMS(用来回收老年代对象)一同配合

CMS收集器

CMS(Concurrent Mark Sweep)收集器,采用标记-清楚算法,主要用来对老年代进行垃圾回收。其垃圾回收主要分为以下几个部分:

  1. 初始标记:标记GCRoots可以直接关联到的对象,需要Stop The World,但是所需时间较短
  2. 并发标记:开始扫描对象完成可达性检测,这个过程较慢但是可以与用户线程并发
  3. 重新标记:前文中用来避免对象错误回收的操作,这里用的是增量更新的方式,这个过程也会Stop The World,因为如果这里还是不断的修改引用就会导致无限的重新标记。虽然这个暂停相对初始标记较长,但相对2和4也很短了
  4. 并发清除:对确定要回收的对象进行清楚,因为不需要移动存活对象的位置,因此很安全,可以与用户线程并发执行

CMS是HotSpot的首款真正意义上实现了GC线程和用户线程并发的垃圾收集器,当然,初始阶段与重新标记的阶段仍然要暂停用户线程,但是用时最长的并发标记和并发清除均实现了并发,因此我们仍然可以认为他是并发完成的GC

CMS的问题也很明显,首先是采用了标记清除算法,导致清除完后内存中存在大量片段,其次是无法避免浮动垃圾的产生,以及由于并发导致的处理器资源敏感问题

G1收集器

G1收集器是在JDK7时就已经立项的项目,HotSpot开发团队希望他能在一段很长的时间内可以取代原本CMS收集器,作为主要的GC收集器

在JDK14后CMS被标记为废弃,并在JDK17(LTS版本)作为全堆(即新生代+老年代)的垃圾收集器

G1收集器里程碑式的不再针对老年代或是新生代进行内存回收,而是面向堆内存的任何部分来组成回收集进行回收,对一块内存是否进行回收不在关注处于哪个分代,而是关注哪个区域的中存放的垃圾最多,回收的收益最大,这就是G1提出的Mixed GC

为了实现Mixed GC,G1采用了Region的内存管理方式,即将堆内存分为多个Region,每个Region都可以作为新生代或是老年代的任一部分,收集器会根据不同的策略去处理不同角色的Region,这样不论什么年龄的对象都可以有效的被回收

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为超过了一个Region一半的内存大小的对象就会存放在Humongous中,G1一般把Humongous作为老年代看待

所谓收益即回收所获得的空间大小以及回收所需时间的经验值,G1会根据收益及用户希望的GC暂停时间来维护一个优先级列表,根据优先级列表来对Region进行回收

G1收集器内部维护了一个简单的预测模型,可以根据用户期望的GC回收时间来对Region的进行回收的优先级排序

同时,G1采用了原始快照的模式来解决对象消失问题。并且还在Region中维护了一两个TAMS(Top at Marked Start)指针,用来在回收期间保存并发的用户线程创建的对象,由于TAMS围成的区域只占Region的一部分,因此当用户线程创建对象的速度大于回收的速度,仍然会发生Stop The World

G1一次完整的GC分为四个部分:

  1. 初始标记:仅标记GCRoots能直接关联的对象,并修改TAMS的值,使得下一阶段用户线程并发运行时能正确的在可用的Region中分配对象,这个阶段借用Minor GC(新生代GC)的过程完成,因此此处不许停顿
  2. 并发标记:对可达性分析进行扫描,时间较长但可以与用户线程并发,当图像扫描完毕后,会处理原始快照记录下的并发中变动的对象
  3. 最终标记:对用户线程做一个短暂的暂停,用于处理原始快照中变化的对象
  4. 筛选回收:负责更新Region的统计数据,并且根据用户期望的回收时间进行对Region的回收规划,选择Region进行回收,整体过程是将需要回收的Region中的存活对象移动到空的Region中,再清理旧Region中的所有对象,这个过程由于设计对象的转移,因此必须Stop The World

不难发现,实际上G1只会在对象移动的时候发生Stop The World,因此效率很高,这也是为什么Oracle对G1抱有很大希望

这里值得注意的是,G1默认的用户期望回收时间为200ms,用户设置的期望回收时间应该在100ms到300ms之间,回收期望时间的过短会导致每次回收的内存太小,进而造成对象回收速度跟不上对象创造速度,而对象回收时间过长又会导致对象创建直接占满堆

Epsilon 收集器

Epsilon 收集器是一个非常独特的垃圾收集器,也被称为 “A No-Op Garbage Collector”(无操作收集器)。顾名思义,它不会执行任何垃圾回收操作
那么,一个不进行回收的垃圾收集器还有什么意义呢?

实际上,垃圾收集器除了负责对象回收外,还承担了堆内存管理、对象分配、与解释器和 JIT 编译器协作、以及监控子系统交互等一系列功能。因此,“垃圾收集器”这个名字其实并不完全准确,更合适的称呼应是“自动内存管理子系统”(Automatic Memory Management Subsystem)。

JDK 10 之后,Red Hat 提出了 JEP 304:统一垃圾收集器接口(GC Interface)。其目标是解耦垃圾收集器与虚拟机其他子系统(解释器、编译器、监控等)之间的耦合关系,使得不同的 GC 可以以模块化方式接入 JVM。
Epsilon 的出现正是为了验证这一接口设计的可行性与健壮性。

虽然 Epsilon 不执行回收,但它仍有实际用途。例如,在某些短生命周期或对内存需求可预期的应用中(例如性能测试、延迟敏感的微基准测试、一次性批处理任务等),应用往往会在堆被占满之前就结束执行。在这些场景下,我们并不需要垃圾回收的开销,而只需 Epsilon 提供的分配与内存管理能力即可。

在云原生框架 Quarkus 的官方文档中,确实提到可以使用 Epsilon GC 作为内存管理的实现,用于编写一些 无服务器(Serverless) 的 Java 应用。需要注意的是,这里的“无服务器”并非字面意义上的没有服务器,而是指应用的运行实例由云服务商根据请求动态创建与回收,计算资源(包括内存)按需分配与计费,在这类应用中,由于生命周期极短、内存使用可预测,因此可以利用 Epsilon 这种不执行垃圾回收、但仍负责内存分配的 GC 来降低运行时开销、获得更高的启动速度,这也更加贴合云原生应用的开发理念与需求

JVM内存分配策略

一般对象在创建时会现在Eden中创建,Eden中没有足够的空间就会进行一次垃圾回收,将Eden中原本无用的的对象回收掉,并将存活的对象移动的Survivor区

当存在一个对象过大的时候(大于Region的一半),会直接在老年代中进行创建,这样可以避免大对象的复制与回收造成的巨大性能开销

当一个对象存活的时间够长也会进入老年代

当新生代的空间不够时,一般会将原本新生代的对象放置于老年代,老年代的空间再不够才会报错(-XX:HandlePromotionFailure)

JVM 处理工具

JVM本身提供了很多的小工具,这些工具大多存在于JDK的JMod包下,用来对JVM的状态进行监控

jps指令类似Unix系统的PS指令,用于查看当前jVM虚拟机上运行的应用,一般进程ID会与操作系统的进程ID一致

sh
jps -l

23892 jdk.jcmd/sun.tools.jps.Jps
15784 com.intellij.idea.Main

jstat指令用于监控虚拟机的各种运行状态

image.png

这里是我监控IDEA的GC操作的指令,其中2764是IDEA的进程ID,250指么欸此监控之间间隔的毫秒数,20指监控次数

列名全称含义单位
S0CSurvivor 0 Capacity第一个幸存区(Survivor 0)的容量(字节)KB
S1CSurvivor 1 Capacity第二个幸存区(Survivor 1)的容量(字节)KB
S0USurvivor 0 Used第一个幸存区已使用的空间KB
S1USurvivor 1 Used第二个幸存区已使用的空间KB
ECEden CapacityEden 区的容量KB
EUEden UsedEden 区已使用空间KB
OCOld Capacity老年代(Old Generation)的容量KB
OUOld Used老年代已使用空间KB
MCMetaspace Capacity元空间(Metaspace)的容量KB
MUMetaspace Used元空间已使用空间KB
CCSCCompressed Class Space Capacity压缩类空间容量KB
CCSUCompressed Class Space Used压缩类空间已使用空间KB
YGCYoung GC Count从 JVM 启动到现在,年轻代(Minor GC)发生的次数次数
YGCTYoung GC Time从 JVM 启动到现在,年轻代 GC 总耗时
FGCFull GC Count从 JVM 启动到现在,Full GC(老年代GC)发生的次数次数
FGCTFull GC Time从 JVM 启动到现在,Full GC 总耗时
CGCConcurrent GC Count(G1/ZGC等)并发GC发生的次数次数
CGCTConcurrent GC Time并发GC耗时
GCTTotal GC Time所有GC(年轻代 + 老年代 + 并发)总耗时
jinfo的作用是实时的去查看JVM进程的虚拟机参数
sh
jinfo -flag InitiatingHeapOccupancyPercent 25472
-XX:InitiatingHeapOccupancyPercent=45

这里可以看出IDEA的InitiatingHeapOccupancyPercent为45

jmap用于生成堆快照,可以让开发者在Java进程运行的过程中优雅的生成堆快照文件

sh
jmap -dump:format=b,file=eclipse.bin 3500

这里的指令就是生成3500进程的堆快照文件

jhat是JDK自带的分析工具,可以搭配jmap创建的bin文件一同使用

sh
jhat eclipse.bin

不过值得一提的是,在正常的生产环境中很少使用jhat,因为有更好的工具可供使用

jstack是Java自带的堆栈工具,用于生成虚拟机当前时刻的线程快照

JVM在实战中出现的问题

大文件造成的频繁Full GC与用户进程的暂停

在一个日均 15 万 PV 的文档系统中,开发者发现系统在升级服务器硬件(从 32 位升级至 64 位,并为 JVM 分配 12GB 堆内存)后,出现了不定时的长时间无响应现象。

经监控分析,问题的根源在于频繁触发 Full GC。每次 Full GC 都会暂停所有用户线程,导致系统在停顿期间无法响应请求。根据业务逻辑分析,系统在访问文档时会将体积较大的文档文件(数 GB 级)读取到内存中并反序列化为结构化对象,从而产生大量大对象。这些大对象根据 JVM 的分配策略会直接进入老年代,导致老年代空间迅速被填满,触发频繁的 Full GC。

一个相对简单的缓解方案是适当缩小老年代的内存容量,这样每次 Full GC 处理的区域更小,停顿时间也相应减少。但这种方式会引回升级前的问题——由于堆空间受限,系统可能因频繁的 Minor GC 而整体性能下降。

另一种思路是更换低延迟垃圾收集器,例如 ZGCShenandoah。它们针对大堆与低停顿场景进行了优化,可有效降低 Full GC 导致的长时间暂停。但考虑到系统使用的是较早的 JDK 版本(JDK 5),升级 JVM 存在较高的兼容性与稳定性风险,因此该方案优先级较低。

综合考虑后,第三种可行方案是通过 JVM 集群化部署系统。即在同一台物理服务器上启动多个独立的应用服务器进程,每个进程作为一个独立的 JVM 实例,并通过负载均衡器进行请求分发。这样既能充分利用硬件资源,又能在逻辑上拆分堆空间,避免单个 JVM 出现超大堆带来的长时间停顿问题。

举例而言,可以在该服务器上启动三个 JVM 实例,每个实例分配 4GB 堆内存(总计 12GB,与原配置等效)。

当然,该方案也存在一定缺陷:

  • 多个实例可能同时访问同一文档资源,导致文件锁竞争或 I/O 冲突;
  • 各实例拥有独立的连接池与缓存池,可能造成资源利用率不均(例如 JVM-A 的连接池已满,而 JVM-B 仍有空闲资源)。

尽管如此,该方案在不更换 JVM 的前提下,有效地减少了单次 Full GC 带来的全局停顿问题,属于一种在旧系统中常见且实用的性能优化思路。

堆外内存导致的溢出错误

在一个被部署在4GB内存32位Windows的简单系统中,网站管理员发现该系统频繁的出现内存溢出的问题,但是使用入-XX:+HeapDumpOnOutOfMemoryError参数却发现没有任何反应,只得从日志中进行寻找,最终发现报错

text
[org.eclipse.jetty.util.log] handle failed java.lang.OutOfMemoryError: null
at sun.misc.Unsafe.allocateMemory(Native Method)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:99)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)
at org.eclipse.jetty.io.nio.DirectNIOBuffer.<init>
....

可以观察到报错中涉及了DirectXXX的内容,故而得知是直接内存的溢出造成的BUG

后经发现,由于系统划分了1.6g给到系统的堆,而windows 32位最大给系统分配2g的内存,故而系统可以使用的Direct Memory就只剩下了2-1.6=0.4g,而Direct Memory又无法像堆内存一样智能的GC,只能在Full GC完成后顺带帮忙清理,故而造成了频繁的内存溢出

一种可想的解决方案是使用try-catch检测异常,然后使用System.gc()来手动对Direct Memory进行回收,但是如果JVM开启了-XX:+DisableExplicitGC开关,禁止了人工触发垃圾 收集的话,那就只能眼睁睁看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了

这里顺带总结了除了堆内存外造成内存问题的情况:

  • DirectMemory:可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OutOf-MemoryError或OutOfMemoryError:Direct buffer memory。
  • 线程堆栈:可通过-Xss调整大小,内存不足会发生StackOverflowError或OutOfMemoryError
  • Socket缓存区:每个Socket连接都Receive和Send两个缓存区,分别占大约37KB和25KB内存,连接多的话这块内存占用也比较可观。如果无法分配,可能会抛出IOException:Too many open files异常。
  • JNI代码:如果代码中使用了JNI调用本地库,那本地库使用的内存也不在堆中,而是占用Java虚拟机的本地方法栈和本地内存的。
  • 虚拟机和垃圾收集器:虚拟机、垃圾收集器的工作也是要消耗一定数量的内存的

外部命令导致的系统缓慢

一个系统在做大并发压力测试的时候,发现请求响应较慢,通过堆操作系统监控,发现压力测试时系统CPU使用率较高,但是又不是系统本身占用了较大的CPU资源

最后调查发现,是因为系统中使用了Runtime.getRuntime.exec()这个方法去调用外部的shell脚本,该操作在Java中极其消耗资源,其实先原理是通过复制一个和当前虚拟机环境变量一样的进程,然后使用该进程去执行外部命令,最后再退出这个进程,进而造成的大量资源消耗

这里最简单的修改方式就是更换代码,删除Shell脚本,使用纯Java API完成Shell脚本的功能

虚拟机进程崩溃

一个系统的虚拟机进程频繁自动关闭,观察日志发现报错

text
java.net.SocketException: Connection reset
at java.net.SocketInputStream.read(SocketInputStream.java:168)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:218)
at java.io.BufferedInputStream.read(BufferedInputStream.java:235)
at org.apache.axis.transport.http.HTTPSender.readHeadersFromSocket(HTTPSender.java:583)
at org.apache.axis.transport.http.HTTPSender.invoke(HTTPSender.java:143)
... 99 more

该异常是与远程断开,调查得知是由于外界系统存在问题,导致本系统调用外界一个系统需要3min以上的时间才能返回结果,直接就导致了大量连接的超时,系统选择了异步调用外界服务,但是又导致了大量的线程由于未完成的远程调用而持续占用资源,无法被清理掉,进而导致累计的线程越来越多,最后照成虚拟机崩溃

到达安全点时间过长导致的问题

在一个系统中,发现垃圾收集的停顿经常达到3秒以上,而且实际垃圾收集器进行回收的动作就只占其中的几百毫秒

经过调查发现,是由于发生GC的时候部分线程执行到安全点花费的时间过长导致(此时部分线程已经挂起/暂停,等待这些线程执行到安全点)

最后发现是由于代码中存在一个以int类型为索引的for循环执行时间较长,且该循环在逻辑上应该可以放置安全点但却没有被放置,这是因为HotSpot虚拟机为了避免安全点过多带来过重的负担,不会对int及更小的单位作为索引的循环放置安全点

最终通过将索引直接更换为long解决

class文件

Java语言是运行在虚拟机上的,而在Java虚拟机上,我们不可能直接使用Java语言这种对于计算机来说太过“高级”的语言来执行命令,因此Java会被转化为一种能被JVM识别的文件,文件扩展名为 .class,其中存储的代码被称之为字节码,是一种二进制语言(也可以得出class文件其实是一个二进制文件)

JVM会根据底层硬件架构将字节码转换为对应平台 CPU 能直接执行的机器码,进而实现了一次编译随处运行的功能

将Java代码转换成字节码的这个过程被称之为编译(Complie),这一过程依赖于Javac编译器完成。由此我们其实不难发现,我们在日常编写的Java代码其实和实际运行的字节码并无强制性的捆绑关系,只要一个中间性的程序将其完成转换即可

同时在设计字节码之初,为了考虑到到字节码本身的长期可用于可维护性,开发者将其设计为了一门图灵完备的指令集,进而使其成为了一门通用的,可以被任何一门编程语言转换而成的指令。

也正是这一点,后续又诞生了很多依赖于机械码的JVM语言,这些语言都根植于JVM生态,比如Kotlin,Groovy,Clojure,Scala等,他们都是将自己编译为字节码,然后运行在JVM上

class文件本身是一组class文件是一组以8位为基本单位的二进制流,各个数据项目都严格的排列在文件之中,中间没有任何分隔符,这也就使得class文件能够以很高的效率存储程序,当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8个字节进行存储

任何一个Class文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或 接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。

class文件中只有两种数据格式:表与无符号数

其中无符号数就是基本的数据类型,用u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。

表是由若干无符号数及其他表组合而成的复合数据结构,所有表都会习惯的用_info表示

类加载机制

Java虚拟机会将Java中的class文件加载到内存中,并对数据进行校验,转换解析和初始化,最终形成可以被JVM使用的Java类型。Java或者说是JVM语言都是在运行的时候动态的完成类的加载和初始化,这也就导致了Java的提前编译会变得较为困难,同时类的加载也会占用一定的性能开销,但是也为Java带来了远超其他语言的灵活性,大量的技术都基于Java的动态类加载机制,小到Lombok大到Spring框架,都依赖于类动态加载机制

这里首先要做出一个区分,新手很可能会混淆"类加载"与"实例化"这两个词汇,一般来说,类加载就是将你表述的类文件(.java文件亦或者更直接的说就是编译出的.class文件)加载到内存中进行使用,而实例化则是根据这个类创建出一个对象。更加简单的说,类加载是JVM去获取你在Java中写的一个类/模板,而实例化则是根据这个类/模板创建出一个对象

一个类的生命周期一般由加载,验证,准备,解析,初始化,使用和卸载七个部分组成,其中验证,准备和解析三个阶段又被称之为连接。

整个过程中,加载->验证->准备->初始化->卸载,这五个的开始先后顺序是固定的,而解析阶段这不一定,大多数情况下解析会在初始化之前开始,但也有时解析会发生在初始化之后,这是为了实现Java语言的动态绑定功能。虽然开始顺序是有一定的顺序,但是这些步骤的结束顺序则不一定,也就是说实际过程中这些步骤可能会交叉进行

在《Java 虚拟机规范》中对于上面的七个步骤实际上只严格规定了初始化这个步骤的具体开始时间:

  • 当遇到new getstatic putstatic invokestatic这四个字节码指令时,如果类没有完成初始化,则对其进行初始化。这几个字节码的出现场景为:
    • 使用new关键字
    • 读取或设置一个类型的静态字段(使用final修饰,已在编译器被丢入了常量池的静态字段除外)
    • 调用一个类的静态方法
  • 使用java.lang.reflect的方法对类型进行反射调用的时候,如果类还没有完成初始化则要对其进行初始化
  • 当初始化一个类时发现它的父类还未初始化则要初始化
  • 虚拟机启动时初始化主类
  • 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  • 含有default方法的接口的实现类初始化时需要对接口进行初始化

有且只有以上的七种情况会导致类文件的初始化

类加载的具体过程

加载阶段是加载过程中最先开始的部分,在此过程中Java虚拟机要完成以下三件事情:

  • 根据一个类的全限定名获取其二进制流(不仅可以从Class文件中获取)
  • 将这个字节流所代表的静态存储结构转换为方法去的运行时数据结构
  • 在内存中生成一个java.lang.Class对象,作为方法区这个类的各种数据的访问入口

对于数组类,其本身则不通过类加载器进行创建,而是直接由JVM在内存中动态构建,但数组所承载的元素的类型却仍然需要类加载器来完成加载

如果数组的组件类型(数组本身去掉一个维度的类型,比如二维数组就变成了一维数组,一维数组本身就直接在内存中动态加载)是引用类型,那么就递归的按照普通类的类加载机制去加载这个类

如果数组的组件类型不是引用类型(例如int[ ]数组的组件类型为int),Java虚拟机将会把数组C标记为与引导类加载器关联

数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的 可访问性将默认为public,可被所有的类和接口访问到。

加载结束后,JVM外部的二进制文件就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据结构完全由JVM自身定义,《JVM规范》中并未具体给出.

当数据安置在方法区中,Java堆中就会实例化一个java.lang.Class类的对象,这个对象将会作为程序访问方法全部中的类型数据的外部接口

加载之后就是验证,这是连接阶段的第一步,这一步的目标是确保Class文件的字节流中包含的信息符合《JVM规范》的全部约束要求,保证这些信息不会危害虚拟机自身的安全

加载阶段后则是准备阶段,该阶段中正式为类的变量(也就是所谓的静态变量)分配内存并设置变量的初始值,从逻辑上讲,所有的类变量都分配在方法区中,但方法区本身就是一个逻辑上的区域,在JDK7及以前,该区域由永久代来实现,但在JDK8及以后,类变量则被存放于Java堆中,方法区也成为了堆的一部分

在准备阶段,一般的类变量会被赋予为零值,每一种类型都有其对应的零值,而如果类变量被设置为final,则会在准备阶段就为其实现赋值

本站访客数 人次      本站总访问量