Java会自动管理和释放内存,它不像C/C++那样要求我们手动管理内存,JVM提供了一套全自动的内存管理机制,当一个Java对象不再用到时,JVM会自动将其进行回收并释放内存,那么对象所占内存在什么时候被回收,如何判定对象可以被回收,以及如何去进行回收工作也是JVM需要关注的问题。

对象存活判定算法

对象在什么情况下可以被判定为不再使用已经可以回收了?

引用计数法

如果要经常操作一个对象,那么首先一定会创建一个引用变量:

//str就是一个引用类型的变量,它持有对后面字符串对象的引用,可以代表后面这个字符串对象本身
String str = "abc";

只要一个对象还有使用价值,就可以通过它的引用变量来进行操作,那么可否这样判断一个对象是否还需要被使用:

  • 每个对象都包含一个引用计数器,用于存放引用计数(存放被引用的次数)
  • 每当有一个地方引用此对象时,引用计数+1
  • 当引用失效(比如离开了局部变量的作用域或是引用被设定为null)时,引用计数-1
  • 当引用计数为0时,表示此对象不可能再被使用,因为这时我们已经没有任何方法可以得到此对象的引用了

但是这样存在一个问题,如果两个对象相互引用呢?

public class Main {
public static void main(String[] args) {
Test a = new Test();
Test b = new Test();

a.another = b;
b.another = a;

//这里直接把a和b赋值为null,这样前面的两个对象我们不可能再得到了
a = b = null;
}

private static class Test{
Test another;
}
}

按照引用计数算法,那么当出现以上情况时,虽然无法得到此对象的引用,并且此对象也无需再使用,但是由于这两个对象直接存在相互引用的情况,那么引用计数器的值将会永远是1,永远不会被回收,引用计数法并不是最好的解决方案。

可达性分析算法

目前比较主流的编程语言,一般都会使用可达性分析算法来判断对象是否存活,它采用了类似于树结构的搜索机制。

首先每个对象的引用都有机会成为树的根节点(GC Roots),可以被选定作为根节点条件如下:

  • 虚拟机栈中的局部变量:当前正在执行的方法中的局部变量引用的对象
  • 方法区中的静态变量:类的静态成员变量引用的对象。
  • 方法区中的常量:运行时常量池中的常量引用的对象(如字符串常量)
  • 本地方法栈中的 JNI 引用:Native 方法引用的对象
  • 被添加了锁的对象:比如synchronized关键字
  • JVM 内部的特殊对象:如基本数据类型对应的 Class 对象、异常对象等

一旦已经存在的根节点不满足存在的条件时,那么根节点与对象之间的连接将被断开。此时虽然对象1仍存在对其他对象的引用,但是由于其没有任何根节点引用,所以此对象即可被判定为不再使用。只要某个对象无法到达任何GC Roots,则证明此对象是不可能再被使用的,就可以被回收。

最终判定

虽然在经历了可达性分析算法之后基本可能判定哪些对象能够被回收,但是并不代表此对象一定会被回收,我们依然可以在最终判定阶段对其进行挽留。

finalize()是 Java 中的一个对象生命周期方法,定义在Object类中。它允许对象在垃圾回收之前执行一些清理操作。

/**
* Called by the garbage collector on an object when garbage collection
* determines that there are no more references to the object.
* A subclass overrides the {@code finalize} method to dispose of
* system resources or to perform other cleanup.
* ...
*/
protected void finalize() throws Throwable { }

如果子类重写了此方法,那么子类对象在被判定为可回收时,会进行二次确认,也就是执行finalize()方法,而在此方法中,当前对象是完全有可能重新建立GC Roots的。

所以,如果在二次确认后对象不满足可回收的条件,那么此对象不会被回收,巧妙地逃过了垃圾回收的命运。比如下面这个例子:

public class Main {
private static Test a;
public static void main(String[] args) throws InterruptedException {
a = new Test();

//这里直接把a赋值为null,这样前面的对象不可能再得到了
a = null;

//手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
System.gc();

//等垃圾回收
Thread.sleep(1000);

System.out.println(a); // 仍然没有被回收
}

private static class Test{
@Override
protected void finalize() throws Throwable {
a = this;
}
}
}

finalize()方法并不是在主线程调用的,而是虚拟机自动建立的一个低优先级Finalizer线程进行处理。同一个对象的finalize()方法只会有一次调用机会,也就是说,如果连续两次这样操作,那么第二次,对象必定被回收:

public static void main(String[] args) throws InterruptedException {
a = new Test();
a = null;
System.gc();
Thread.sleep(1000);
System.out.println(a);
// 再来一次
a = null;
System.gc();
Thread.sleep(1000);

System.out.println(a); // a没了
}

当然,finalize()方法也并不是专门防止对象被回收的,我们可以使用它来释放一些程序使用中的资源等。finalize()方法的使用需要非常谨慎,因为它可能导致性能问题和不可预测的行为。

垃圾回收算法

垃圾收集器会不定期地检查堆中的对象,查看它们是否满足被回收的条件。我们该如何对这些对象进行回收?

标记-清除算法

标记清楚算法会标记出所有需要回收的对象,然后再依次回收掉被标记的对象,或是标记出所有不需要回收的对象,只回收未标记的对象。

clear

虽然此方法非常简单,但是缺点也是非常明显的 ,首先如果内存中存在大量的对象,那么可能就会存在大量的标记,并且大规模进行清除。

并且一次标记清除之后,连续的内存空间可能会出现许许多多的空隙,碎片化会导致连续内存空间利用率降低

标记-复制算法

标记复制算法就是将内存区域划分为大小相同的两块区域,每次只使用其中的一块区域,每次垃圾回收结束后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。虽然浪费了一些时间进行复制操作,但是这样能够很好地解决对象大面积回收后空间碎片化严重的问题

copy

这种算法非常适用于新生代(因为新生代的回收效率极高,一般不会留下太多的对象)的垃圾回收,新生代Survivor区就是这个思路,包括8:1:1的比例也正是为了对标记复制算法进行优化而采取的

标记-整理算法

虽然标记-复制算法能够很好地应对新生代高回收率的场景,但是放到老年代,它就显得很鸡肋了。一般长期不回收的对象,才有机会进入到老年代,所以老年代一般都是些钉子户,可能一次GC后,仍然存留很多对象。而标记复制算法会在GC后完整复制整个区域内容,并且会折损50%的区域,显然这并不适用于老年代。

标记整理算法在标记所有待回收对象之后,不急着去进行回收操作,而是将所有待回收的对象整齐排列在一段内存空间中,而需要回收的对象全部往后丢,这样,前半部分的所有对象都是无需进行回收的,而后半部分直接一次性清除即可。

mark_clean

虽然这样能保证内存空间充分使用,并且也没有标记复制算法那么繁杂,但是缺点是效率比前两者都低。甚至,由于需要修改对象在内存中的位置,此时程序必须要暂停才可以,在极端情况下,可能会导致整个程序发生停顿

一般将标记清除算法和标记整理算法混合使用,在内存空间还不是很凌乱的时候,采用标记清除算法,当内存空间凌乱到一定程度后,进行一次标记整理算法

分代收集机制

JVM将堆内存划分为新生代老年代永久代(其中永久代是HotSpot虚拟机特有的概念,在JDK8之前方法区实际上就是采用的永久代作为实现,而在JDK8之后,方法区由元空间实现,并且使用的是本地内存,容量大小取决于物理机实际大小)

不同的分代内存回收机制也存在一些不同之处,在HotSpot虚拟机中,新生代被划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1,老年代的GC评率相对较低,永久代一般存放类信息等(其实就是方法区的实现)

heap_memo

新生代

  • 存放新创建的对象
  • 特点:
    • 大多数对象的生命周期很短,因此新生代是垃圾回收最频繁的区域
    • 新生代采用标记-复制算法进行垃圾回收
  • 分区:
    • Eden区:新创建的对象首先分配到 Eden 区
    • Survivor区:分为 From 区和 To 区,用于存放经过垃圾回收后仍然存活的对象

老年代

  • 存放生命周期较长的对象
  • 特点:
    • 对象在新生代经过多次垃圾回收后仍然存活,会被晋升到老年代
    • 老年代的垃圾回收频率较低,但每次回收的时间较长
    • 老年代采用标记-清除算法标记-整理算法进行垃圾回收

新生代的垃圾回收称为Minor GC

首先,所有新创建的对象,在一开始都会进入到新生代的Eden区(如果是大对象会被直接丢进老年代),在进行新生代区域的垃圾回收时,首先会对所有新生代区域的对象进行扫描,并回收那些不再使用对象

接着,在一次垃圾回收之后,Eden区域没有被回收的对象,会进入到Survivor区。在一开始From和To都是空的,而GC之后,所有Eden区域存活的对象都会直接被放入到To区,最后From和To会发生一次交换,即From区是有数据的,To区是空的

下一次垃圾回收操作与上面是一样的,不过这时由于我们From区域中已经存在对象了,所有From区的对象会进行一次年龄判定(每经历一轮GC年龄+1,如果对象的年龄大于默认值为15,会直接进入到老年代),在Eden区和From区的存活对象复制到To区之后,清空Eden区和From区,交换From区和To区

Major GC是针对老年代(Old Generation)的垃圾回收:

当老年代空间不足时,从GC Roots开始,标记老年代中的存活对象,使用标记-清除算法清除未标记的对象,再用整理算法将存活对象整理到内存的一端,避免内存碎片

Full GC 是对整个堆内存(包括新生代、老年代和元空间的垃圾回收:

触发条件:

  • 老年代空间不足:当老年代无法容纳从新生代晋升的对象时,触发 Full GC。
  • 元空间空间不足:当元空间无法分配新的类元数据时,触发 Full GC。
  • **显式调用 System.gc()**:调用 System.gc() 可能触发 Full GC,但 JVM 不保证立即执行。

过程:

  1. 新生代回收
    • 执行 Minor GC,回收新生代中的垃圾
  2. 老年代回收
    • 执行 Major GC,回收老年代中的垃圾
  3. 元空间回收
    • 回收不再使用的类元数据
特性 Minor GC Major GC Full GC
回收区域 新生代(Eden 区、Survivor 区)。 老年代。 整个堆内存(新生代、老年代)和元空间。
触发条件 Eden 区空间不足。 老年代空间不足。 老年代空间不足、元空间空间不足、显式调用 System.gc()
频率 高,因为大多数对象的生命周期很短。 低,因为老年代的对象生命周期较长。 低,触发条件较为严格。
速度 快,采用复制算法。 慢,采用标记-清除或标记-整理算法。 最慢,需要处理整个堆内存和元空间。
暂停时间 短,对应用程序的影响较小。 较长,对应用程序的影响较大。 最长,对应用程序的影响最大。

空间分配担保

思考一下,有没有这样一种极端情况(正常情况下新生代的回收率是很高的,所以说不用太担心会经常出现这种问题),在一次GC后,新生代Eden区仍然存在大量的对象超出Survivor区的容量,那么该怎么办?

这时就需要用到空间分配担保机制了,可以把Survivor区无法容纳的对象直接送到老年代,让老年代进行分配担保

要是老年代也装不下新生代的数据呢?

这样的话,首先会判断一下之前的每次垃圾回收进入老年代的平均大小是否小于当前老年代的剩余空间

  • 如果小于,那么说明也许可以放得下
  • 否则,会先来一次Full GC,进行一次大规模垃圾回收,尝试腾出空间,再次判断老年代是否有空间存放
  • 要是还是装不下,抛出Out Of Memory异常

元空间

JDK8之前,Hotspot虚拟机的方法区是永久代实现的。在JDK8之后不再使用永久代,而是采用了全新的元空间,元空间主要用于存储以下内容:

  1. 类的元数据
    • 类的全限定名
    • 类的父类、接口信息
    • 类的字段、方法、字节码等
  2. 运行时常量池
    • 字符串常量、数字常量等
  3. 方法区内容

元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。元空间中的类元数据在类卸载时会被垃圾回收,且垃圾回收效率比永久代更高

jvm2

垃圾收集器

Serial收集器

这是一款单线程的垃圾收集器,也就是说,当开始进行垃圾回收时,需要暂停所有的线程,直到垃圾收集工作结束。它的新生代收集算法采用的是标记复制算法,老年代采用的是标记整理算法。

serial

这个收集器的缺点是当进入到垃圾回收阶段时,所有的用户线程必须等待GC线程完成工作

但是在用户的桌面应用场景中,内存一般不大,可以在较短时间内完成垃圾收集,只要不频繁发生,使用串行回收器是可以接受的,所以,在客户端模式(一般用于一些桌面级图形化界面应用程序)下的新生代中,默认垃圾收集器至今依然是Serial收集器

ParNew收集器

这款垃圾收集器相当于是Serial收集器的多线程版本,它能够支持多线程垃圾收集

parnew

除了多线程支持以外,其他内容基本与Serial收集器一致,目前某些JVM默认的服务端模式新生代收集器就是使用的ParNew收集器

Parallel Scavenge/Parallel Old收集器

与ParNew收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案

吞吐量=应用程序运行时间/总时间(应用程序运行时间 + 垃圾回收时间)×100%,目标是尽可能提高吞吐量,即让应用程序的运行时间占比最大化。

  • 垃圾回收器会监控应用程序的运行时间和垃圾回收时间。
  • 如果垃圾回收时间占比超过目标值,垃圾回收器会调整垃圾回收的频率和每次回收的时间,以确保吞吐量目标
  • 如果吞吐量低于目标值,垃圾回收器可能会减少垃圾回收的频率,延长每次垃圾回收的时间
  • 如果吞吐量高于目标值,垃圾回收器可能会增加垃圾回收的频率,缩短每次垃圾回收的时间
parallel

目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old的垃圾回收方案

CMS收集器

CMS收集器是HotSpot虚拟机中第一款真正意义上的并发(注意这里的并发和之前的并行是有区别的,并发可以理解为同时运行用户线程和GC线程,而并行可以理解为多条GC线程同时工作)收集器,它第一次实现了让垃圾收集线程与用户线程同时工作,他主要采用标记清除算法。

cms

它的垃圾回收分为4个阶段:

  • 初始标记(需要暂停用户线程):这个阶段的主要任务仅仅只是标记出GC Roots能直接关联到的对象,速度比较快,不用担心会停顿太长时间
  • 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与应用程序并发运行
  • 重新标记(需要暂停用户线程):修正并发标记期间因应用程序运行而导致的标记变化
  • 并发清除:最后就可以直接将所有标记好的无用对象进行删除,因为这些对象程序中也用不到了,所以可以与用户线程并发运行
  • 并发重置:重置CMS收集器的内部状态,为下一次垃圾回收做准备

虽然它的优点非常之大,但是缺点也是显而易见的,我们之前说过,标记清除算法会产生大量的内存碎片,导致可用连续空间逐渐变少,长期这样下来,会有更高的概率触发Full GC,并且在与用户线程并发执行的情况下,也会占用一部分的系统资源,导致用户线程的运行速度一定程度上减慢

从JDK9开始,CMS收集器被标记为弃用(Deprecated),在JDK14中完全移除

Garbage First (G1) 收集器

它是一款主要面向于服务端的垃圾收集器,并且在JDK9时,取代了JDK8默认的Parallel Scavenge + Parallel Old的回收方案

垃圾回收分为Minor GCMajor GC Full GC,它们分别对应的是新生代,老年代和整个堆内存的垃圾回收,而G1收集器绕过了这些,它将整个Java堆划分成多个大小相同的独立Region块,每个Region块的大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且都为2的N次幂。所有的Region大小相同,且在JVM的整个生命周期内不会发生改变,G1优先回收垃圾最多的区域

每一个Region都可以根据需要,自由决定扮演哪个角色(Eden、Survivor和老年代),收集器会根据对应的角色采用不同的回收策略。此外,G1收集器还存在一个Humongous区域,它专门用于存放大对象(一般认为大小超过了Region容量一半的对象为大对象)这样,新生代、老年代在物理上,不再是一个连续的内存区域,而是到处分布的

g1

回收过程与CMS大体类似,分为以下四个步骤:

  • 初始标记(暂停用户线程):仅仅只是标记GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行
  • 最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的
g1_process

其他引用类型

在Java中,如果变量是一个对象类型的,那么它实际上存放的是对象的引用,但是如果是一个基本类型,那么存放的就是基本类型的值。平时代码中类似于Object o = new Object()这样的的引用类型,细分之后可以称为强引用

当JVM内存空间不足时,JVM宁愿抛出OutOfMemoryError使程序异常终止,也不会靠随意回收具有强引用的存活对象来解决内存不足的问题

软引用

软引用不像强引用那样不可回收,当JVM认为内存不足时,会去试图回收软引用指向的对象,即JVM会确保在抛出OutOfMemoryError之前,清理软引用指向的对象。如果内存充足,是不会轻易被回收的

可以通过以下方式来创建一个软引用:

public class Main {
public static void main(String[] args) {
//强引用写法:Object obj = new Object();
//软引用写法:
SoftReference<Object> reference = new SoftReference<>(new Object());
//使用get方法就可以获取到软引用所指向的对象了
System.out.println(reference.get());
}
}

软引用还存在一个带队列的构造方法,软引用可以和一个引用队列联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用本身加入到与之关联的引用队列中

弱引用

弱引用比软引用的生命周期还要短,在进行垃圾回收时,不管当前内存空间是否充足,都会回收它的内存

创建一个弱引用:

public class Main {
public static void main(String[] args) {
WeakReference<Object> reference = new WeakReference<>(new Object());
System.out.println(reference.get());
}
}

使用方法和软引用是差不多的,但是如果在这之前进行一次GC:

public class Main {
public static void main(String[] args) {
SoftReference<Object> softReference = new SoftReference<>(new Object());
WeakReference<Object> weakReference = new WeakReference<>(new Object());

//手动GC
System.gc();

System.out.println("软引用对象:"+softReference.get());
System.out.println("弱引用对象:"+weakReference.get());
}
}

弱引用对象被回收了,而软引用对象没有被回收。同样的,它也支持ReferenceQueue,和软引用用法一致。

WeakHashMap是一种类似于弱引用的HashMap类,如果Map中的Key没有其他引用那么此Map会自动丢弃此键值对:

public class Main {
public static void main(String[] args) {
Integer a = new Integer(1);

WeakHashMap<Integer, String> weakHashMap = new WeakHashMap<>();
weakHashMap.put(a, "a");
System.out.println(weakHashMap);

a = null;
System.gc();

System.out.println(weakHashMap);
}
}

当变量a的引用断开后,这时只有WeakHashMap本身对此对象存在引用,所以在GC之后,这个键值对就自动被舍弃了。

虚引用

虚引用相当于没有引用,随时都有可能会被回收。虚引用是最弱的一种引用类型,主要用于跟踪对象被垃圾回收的时机,通常用于实现更精细的资源管理或清理机制。

public class PhantomReferenceExample {
public static void main(String[] args) {
// 创建一个对象
Object obj = new Object();

// 创建一个引用队列
ReferenceQueue<Object> queue = new ReferenceQueue<>();

// 创建一个虚引用,指向 obj,并关联引用队列
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);

// 虚引用的 get() 方法始终返回 null
System.out.println("PhantomRef.get(): " + phantomRef.get()); // 输出 null

// 将 obj 置为 null,使其成为垃圾
obj = null;

// 触发垃圾回收
System.gc();

// 检查引用队列
if (queue.poll() != null) {
System.out.println("对象已被回收,虚引用被加入队列");
} else {
System.out.println("对象未被回收");
}
}
}
  • 虚引用的 get() 方法始终返回 null,因此无法通过虚引用访问对象
  • 当对象被垃圾回收时,虚引用会被加入到引用队列中
  • 通过检查引用队列,可以确定对象是否已被回收

四种引用对象对比

特性 强引用 软引用 弱引用 虚引用
引用强度 最强 较强 较弱 最弱
回收时机 不会被垃圾回收 内存不足时回收 下一次垃圾回收时回收 对象被回收后加入引用队列
get() 方法 返回对象 返回对象(如果未被回收) 返回对象(如果未被回收) 始终返回 null
使用场景 默认引用类型 缓存 缓存、监听器 对象回收的跟踪、资源管理