垃圾回收与内存分配


查看本地虚拟机版本

C:\Program Files\Java\jdk1.8.0_91\bin\java -XX:+PrintFlagsFinal -version | findstr "GC"

image.png
image.png

堆空间的基本结构

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 内存中对象的分配与回收。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)
从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation)

下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
image.png
JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存 。
关于堆空间结构更详细的介绍,可以回过头看看 JVM内存结构 这篇文章。

内存分配和回收策略

主要进行GC的区域

Minor GC、Major GC、Full GC

JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代、方法区) 区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)
部分收集(Partial GC):不是完整收集整个 Java 堆的垃圾收集。其中又分为:

  • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
  • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
    • 目前,只有 CMS GC 会有单独收集老年代的行为
    • 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
  • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
    • 目前只有 G1 GC 会有这种行为

整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾
:::tips
image.png
上面的说法已经在《深入理解 Java 虚拟机》第三版中被改正过来了。感谢 R 大的回答:
image.png
:::

内存分配策略

对象优先在Eden区分配

大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。下面我们来进行实际测试一下。
测试代码:

public class GCTest {
	public static void main(String[] args) {
		byte[] allocation1, allocation2;
		allocation1 = new byte[30900*1024];
	}
}

通过以下方式运行:
image.png
添加的参数: -XX:+PrintGCDetails
image.png
运行结果 (红色字体描述有误,应该是对应于 JDK1.7 的永久代):
image.png
从上图我们可以看出 Eden 区内存几乎已经被分配完全(即使程序什么也不做,新生代也会使用 2000 多 k 内存)。
假如我们再为 allocation2 分配内存会出现什么情况呢?

allocation2 = new byte[900*1024];

68747470733a2f2f6f73732e6a61766167756964652e636e2f6769746875622f6a61766167756964652f6a6176612f6a766d2f32383132383738352e6a7067.jpg
给 allocation2 分配内存的时候 Eden 区内存几乎已经被分配完了
当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。GC 期间虚拟机又发现 allocation1 无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。执行 Minor GC 后,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存。可以执行如下代码验证:

public class GCTest {

	public static void main(String[] args) {
		byte[] allocation1, allocation2,allocation3,allocation4,allocation5;
		allocation1 = new byte[32000*1024];
		allocation2 = new byte[1000*1024];
		allocation3 = new byte[1000*1024];
		allocation4 = new byte[1000*1024];
		allocation5 = new byte[1000*1024];
	}
}

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象, 最典型的大对象是那种很长的字符串以及数组。
大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。

  • -XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制
  • G1垃圾回收器会根据-XX:G1HeapRegionSize参数设置的堆区域大小和-XX:G1MixedGCLiveThresholdPercent参数设置的阈值,来决定哪些对象会直接进入老年代。
  • Parallel Scavenge垃圾回收器中,默认情况下,并没有一个固定的阈值(XX:ThresholdTolerance是动态调整的)来决定何时直接在老年代分配大对象。而是由虚拟机根据当前的堆内存情况和历史数据动态决定。

长期存活的对象进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。
对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置。
JavaGuide/docs/java/jvm/jvm-garbage-collection.md at main · Snailclimb/JavaGuide
image.png

动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄

空间分配担保

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。
:::tips
《深入理解 Java 虚拟机》第三章对于空间分配担保的描述如下:
JDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。
如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次 Full GC。
JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。
:::

Full GC的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

  1. 调用 System.gc(): 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
  2. 老年代空间不足: 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过-Xmn虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过-XX:MaxTenuringThreshold调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
  3. 空间分配担保失败: 使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第五小节。
  4. JDK 1.7 及以前的永久代空间不足在: JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出java.lang.OutOfMemoryError。为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
  5. Concurrent Mode Failure: 执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC

如何判断对象可以回收

引用计数法

给对象添加一个引用计数器,

  • 当一个对象被引用时计数器加1,如果被引用两次就变成2,
  • 如果减少一个引用(引用失效), 计数值就减1,
  • 当计数值为0时,就表示该对象不被引用,可以被垃圾收集器回收。

这个方法实现简单,效率高,但是有一个弊端,如下图所示,两个对象出现循环引用时,两个对象的计数都永远不为0,导致两个对象都无法被释放, 无法被垃圾回收, 会造成内存泄漏。正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法
image.png
所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

public class ReferenceCountingGC {

    public Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGC objectA = new ReferenceCountingGC();
        ReferenceCountingGC objectB = new ReferenceCountingGC();
        objectA.instance = objectB;
        objectB.instance = objectA;
    }
}
------
著作权归@pdai所有
原文链接:https://pdai.tech/md/java/jvm/java-jvm-gc.html

可达性分析算法

这个算法的基本思想就是通过一系列的称为 "GC Roots"的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收
通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。下图中Object5~Object7之间虽然有引用关系, 但他们到GCRoots不可达, 因此就是需要被回收的对象.
image.png

  • JVM 中的垃圾回收器通过可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看能否沿着 GC Root对象 为起点的引用链找到该对象,如果找不到,则表示可以回收
  • Java虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容(哪些对象可以作为 GC Roots 呢?):
    • 虚拟机栈(栈帧中的本地变量表) 中引用的对象。
    • 方法区中类的静态属性引用的对象
    • 本地方法区中常量引用的对象
    • 本地方法栈中 JNI(即一般说的Native方法) 引用的对象
    • 所有被同步锁持有的对象

对象可以被回收,就代表一定会被回收吗?
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。当对象没有覆盖finalize方法,或finalize方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

Object 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。忘掉它的存在吧!
参考:JEP 421: Deprecate Finalization for Removal, 是时候忘掉finalize方法了

public static void main(String[] args) throws IOException {

    ArrayList<Object> list = new ArrayList<>();
    list.add("a");
    list.add("b");
    list.add(1);
    System.out.println(1);
    System.in.read();

    list = null;
    System.out.println(2);
    System.in.read();
    System.out.println("end");
}

对于以上代码, 可以使用如下命令将堆内存信息转储成一个文件, 然后使用Eclipse Memory Analyzer工具进行分析
第一步:使用 jps 命令,查看程序的进程
20210209111015399.png
第二步:
20210209111229838.png
使用jmap -dump:format=b,live,file=1.bin 16104命令转储文件

dump:转储文件
format=b:二进制文件
file:文件名
16104:进程的id

第三步:打开 Eclipse Memory Analyzer 对1.bin文件进行分析。
20210209111656952.png
分析的gc root,找到了 ArrayList 对象,然后将list置为null,再次转储,那么 list对象 就会被回收

方法区的回收

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,因此在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:

  • 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

可以通过-Xnoclassgc参数来控制是否对类进行卸载

finalize()

finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是try-finally等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
当一个对象可被回收时,如果需要执行该对象的finalize()方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了finalize()方法自救,后面回收时不会调用finalize()方法。

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

运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?
image.png

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

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

引用类型

image.png

上图中, 实心线表示强引用, 虚线代表其他四种引用

无论是通过 引用计数算法 判断对象的引用数量,还是通过 可达性分析算法 判断对象是否可达,判定对象是否可被回收都与引用有关。

四种引用概述

  1. 强引用, 只有所有 GC Roots 对象都不通过 强引用 引用该对象,该对象才能被垃圾回收
  2. 软引用(SoftReference), 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象, 可以配合引用队列来释放软引用自身
  3. 弱引用(WeakReference), 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象, 可以配合引用队列来释放弱引用自身
  4. 虚引用(PhantomReference), 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
  5. 终结器引用(FinalReference), 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收), 再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象。

强引用(SoftReference)

以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

Object obj = new Object();

软引用(SoftReference)

如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联
------
著作权归@pdai所有
原文链接:https://pdai.tech/md/java/jvm/java-jvm-gc.html
/**
 * 演示 软引用
             打印垃圾回收细节
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc
 */
public class Demo2_3 {

    public static int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) throws IOException {
        method2();
    }

    // 设置 -Xmx20m , 设置堆内存20m, 演示堆内存不足,
    public static void method1() throws IOException {
        ArrayList<byte[]> list = new ArrayList<>();

        for(int i = 0; i < 5; i++) {
            list.add(new byte[_4MB]);
        }
        System.in.read();
    }

    /**
     * 演示 软引用
     */
    public static void method2() throws IOException {
        // list --> SoftReference --> byte[]
        ArrayList<SoftReference<byte[]>> list = new ArrayList<>();
        for(int i = 0; i < 5; i++) {
            SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
            System.out.println(ref.get());
            list.add(ref);
            System.out.println(list.size());
        }
        System.out.println("循环结束:" + list.size());
        for(SoftReference<byte[]> ref : list) {
            System.out.println(ref.get());
        }
    }
}

method1 方法解析:首先会设置一个堆内存的大小为 20m,然后运行 mehtod1 方法,会抛异常,堆内存不足,因为 mehtod1 中的 list 都是强引用
image.png

method2 方法解析:在 list 集合中存放了 软引用对象,当内存不足时,会触发full gc,将软引用的对象回收。细节如图:
20210209130334776.png
上面的代码中,当 软引用 引用的对象被回收了,但是软引用还存在,所以,一般软引用需要搭配一个引用队列一起使用。
修改 method2 后, 如下:

// 演示 软引用 搭配引用队列
public static void method3() throws IOException {
    ArrayList<SoftReference<byte[]>> list = new ArrayList<>();
    // 引用队列
    ReferenceQueue<byte[]> queue = new ReferenceQueue<>();

    for(int i = 0; i < 5; i++) {
        // 关联了引用队列,当软引用所关联的 byte[] 被回收时,软引用自己会加入到 queue 中去
        SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
        System.out.println(ref.get());
        list.add(ref);
        System.out.println(list.size());
    }

    // 从队列中获取无用的 软引用对象,并移除
    Reference<? extends byte[]> poll = queue.poll();
    while(poll != null) {
        list.remove(poll);
        poll = queue.poll();
    }

    System.out.println("=====================");
    for(SoftReference<byte[]> ref : list) {
        System.out.println(ref.get());
    }
}

image.png

弱引用(WeakReference)

如果一个对象只具有弱引用,那就类似于可有可无的生活用品
弱引用软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
/**
 * 演示弱引用
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc
 * @Version 1.0
 * @Author:MenFanys
 * @Date:2023/10/26 14:00
 */

public class Code_09_WeakReferenceTest {

    public static void main(String[] args) {
		//method1();
        method2();
    }

    public static int _4MB = 4 * 1024 *1024;

    // 演示 弱引用   文件:Demo2_5
    public static void method1() {
        //  list --> WeakReference --> byte[]
        List<WeakReference<byte[]>> list = new ArrayList<>();
        for(int i = 0; i < 10; i++) {
            WeakReference<byte[]> weakReference = new WeakReference<>(new byte[_4MB]);
            list.add(weakReference);

            for(WeakReference<byte[]> wake : list) {
                System.out.print(wake.get() + ",");
            }
            System.out.println();
        }
    }

    // 演示 弱引用搭配 引用队列
    public static void method2() {
        List<WeakReference<byte[]>> list = new ArrayList<>();
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();

        for(int i = 0; i < 9; i++) {
            WeakReference<byte[]> weakReference = new WeakReference<>(new byte[_4MB], queue);
            list.add(weakReference);
            for(WeakReference<byte[]> wake : list) {
                System.out.print(wake.get() + ",");
            }
            System.out.println();
        }
        System.out.println("===========================================");
        Reference<? extends byte[]> poll = queue.poll();
        while (poll != null) {
            list.remove(poll);
            poll = queue.poll();
        }
        for(WeakReference<byte[]> wake : list) {
            System.out.print(wake.get() + ",");
        }
    }
}

虚引用(PhantomReference)

又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。
"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用 与 软引用 和 弱引用 的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,在程序设计中一般很少使用弱引用虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;
------
著作权归@pdai所有
原文链接:https://pdai.tech/md/java/jvm/java-jvm-gc.html

垃圾回收算法

标记-清除算法

定义:标记-清除(Mark-and-Sweep) 算法分为“标记(Mark)”和“清除(Sweep)”阶段, 首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:

  1. 效率问题:标记和清除两个过程效率都不高。
  2. 空间问题:标记清除后会产生大量不连续的内存碎片, 进而导致无法给大对象分配内存

image.png
image.png

标记-整理算法

标记-整理(Mark-and-Compact)算法 是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
缺点: 由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。
优点: 没有内存碎片
image.png

复制算法

为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
优点: 不会有内存碎片
虽然改进了标记-清除算法,但依然存在下面这些问题:

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

image.png
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

分代垃圾回收

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

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

图片.png
过程如下:

  • 新创建的对象首先分配在 eden区
  • 新生代空间不足时,触发minor gc, eden区 和 from区 存活的对象使用- copy复制 到 to 中,存活的对象年龄加一,然后交换 from to
  • minor gc会引发stop the world(STW),暂停其他线程,等垃圾回收结束后,恢复用户线程运行
  • 幸存区对象 的寿命超过阈值时,会晋升到老年代,最大的寿命是15(4bit)
  • 老年代空间 不足时,会先触发minor gc,如果空间仍然不足,那么就触发full fc, 停止(STW, Stop The World)的时间更长

年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为Minor GC,对老年代GC称为Major GC,而**Full GC**是对整个堆来说的,在最近几个版本的JDK里默认包括了对永生带即方法区的回收(JDK8中无永生带了),出现Full GC的时候经常伴随至少一次的Minor GC,但非绝对的。Major GC的速度一般会比Minor GC慢10倍以上。

image.png

相关 JVM 参数

含义参数
堆初始大小-Xms
堆最大大小-Xmx 或 -XX:MaxHeapSize=size
新生代大小-Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size )
幸存区比例(动态)-XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区比例-XX:SurvivorRatio=ratio
晋升阈值-XX:MaxTenuringThreshold=threshold
晋升详情-XX:+PrintTenuringDistribution
GC详情-XX:+PrintGCDetails -verbose:gc
FullGC前MinorGC-XX:+ScavengeBeforeFullGC

GC 分析

/**
 * 演示内存的分配策略
 * @Version 1.0
 * @Author:MenFanys
 * @Date:2023/10/26 13:59
 */
public class Demo2_1 {
    private static final int _512KB = 512 * 1024;
    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 6 * 1024 * 1024;
    private static final int _7MB = 7 * 1024 * 1024;
    private static final int _8MB = 8 * 1024 * 1024;
	// 堆初始   堆最大  新生代    幸存区比例         打印GC详情                       FullGC前MinorGC
    // -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc -XX:-ScavengeBeforeFullGC
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            ArrayList<byte[]> list = new ArrayList<>();
            list.add(new byte[_8MB]);
            list.add(new byte[_8MB]);
        }).start();

        System.out.println("sleep....");
        Thread.sleep(1000L);
    }
}

通过上面的代码, 给list分配内存, 来观察 新生代老年代 的情况, 什么时候触发minor gc, 什么时候触发full gc等情况, 使用前需要设置 jvm 参数。

垃圾回收器

:::tips
相关概念:

  • 并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。

  • 并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上

  • 吞吐量:即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99% 。
    :::
    如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
    虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。
    JDK 默认垃圾收集器(使用java -XX:+PrintCommandLineFlags -version命令查看):

  • JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)

  • JDK 9 ~ JDK20: G1

串行

特点: 单线程, 适合堆内存较少,适合个人电脑
image.png

// serial在新生代, 使用复制算法, serialOld在老年代使用标记+整理算法
-XX:+UseSerialGC = serial + serialOld

安全点:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象
因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态

Serial收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的"单线程" 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。
下图注意: 新生代采用标记-复制算法,老年代采用标记-整理算法。
serial-garbage-collector.png
虚拟机的设计者们当然知道 Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。
它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

ParNew收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等) 和 Serial 收集器完全一样。
特点:多线程、ParNew 收集器默认开启的收集线程数与CPU的数量相同,在 CPU 非常多的环境中,可以使用**-XX:ParallelGCThreads**参数来限制垃圾收集的线程数。和 Serial 收集器一样存在 Stop The World 问题
下图注意: 新生代采用标记-复制算法,老年代采用标记-整理算法。
parnew-garbage-collector.png
它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

并行和并发概念补充:
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(Concurrent):指 用户线程 与 垃圾收集线程 同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。

Serial Old收集器

Serial Old 是 Serial收集器 的老年代版本,它同样是一个单线程收集器, 也是给 Client 模式下的虚拟机使用。
如果用在 Server 模式下,它主要有两大用途:

  • 一种用途是在 JDK1.5 以及以前(Parallel Old 诞生以前)的版本中与 Parallel Scavenge 收集器搭配使用,
  • 另一种用途是作为 CMS 收集器的后备方案, 在并发收集发生 Concurrent Mode Failure 时使用

serial-garbage-collector.png
特点:同样是单线程收集器,采用标记-整理算法

吞吐量优先

特点: 多线程
适应场景: 堆内存较大,多核 cpu才能发挥性能
目标: 让单位时间内,STW 的时间最短 0.2 0.2 = 0.4
image.png

-XX:+UseParallelGC ~ -XX:+UsePrallerOldGC // 
-XX:+UseAdaptiveSizePolicy
-XX:GCTimeRatio=ratio // 1/(1+radio)
-XX:MaxGCPauseMillis=ms // 200ms
-XX:ParallelGCThreads=n

Parallel Scavenge收集器

与吞吐量关系密切,故也称为吞吐量优先收集器
Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。 那么它有什么特别之处呢?其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。

-XX:+UseParallelGC
    使用 Parallel 收集器+ 老年代串行

-XX:+UseParallelOldGC
    使用 Parallel 收集器+ 老年代并行

Parallel Scavenge收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间 与 CPU总消耗时间 的比值。** Parallel Scavenge收集器提供了很多参数供用户找到 最合适的停顿时间 或 最大吞吐量**,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
**特点:**属于新生代收集器也是采用复制算法的收集器(用到了新生代的幸存区),又是并行的多线程收集器(与 ParNew 收集器类似)该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与 ParNew 收集器最重要的一个区别)
下图注意: 新生代采用标记-复制算法,老年代采用标记-整理算法。
68747470733a2f2f6f73732e6a61766167756964652e636e2f6769746875622f6a61766167756964652f6a6176612f6a766d2f706172616c6c656c2d73636176656e67652d676172626167652d636f6c6c6563746f722e706e67.png
这是 JDK1.8 默认收集器
使用java -XX:+PrintCommandLineFlags -version命令查看

-XX:InitialHeapSize=262921408 -XX:MaxHeapSize=4206742528 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)

JDK1.8 默认使用的是Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC参数,则默认指定了-XX:+UseParallelOldGC,可以使用-XX:-UseParallelOldGC来禁用该功能

GC自适应调节策略:
Parallel Scavenge 收集器可设置-XX:+UseAdptiveSizePolicy参数。
当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRation)、
晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为 GC 的自适应调节策略。

Parallel Scavenge收集器使用两个参数控制吞吐量:

  • XX:MaxGCPauseMillis=ms控制最大的垃圾收集停顿时间(默认200ms)
  • XX:GCTimeRatio=rario直接设置吞吐量的大小

Parallel Old收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和"标记-整理"算法。在注重吞吐量以及 CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和 Parallel Old收集器。
特点:多线程,采用标记-整理算法(老年代没有幸存区)
68747470733a2f2f6f73732e6a61766167756964652e636e2f6769746875622f6a61766167756964652f6a6176612f6a766d2f706172616c6c656c2d73636176656e67652d676172626167652d636f6c6c6563746f722e706e67 (1).png

响应时间优先

特点: 多线程
适用场景: 堆内存较大,多核 cpu, 才能发挥作用
目标: 尽可能让 STW 的单次时间最短 0.1 0.1 0.1 0.1 0.1 = 0.5
图片.png

  • -XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld:
    • Concurrent并发的, UseConcMarkSweepGC工作在老年代
    • UseParNewGC, 工作在新生代
    • 如果并发失败就会退回到 SerialOld垃圾回收器
  • -XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads: 设置 并行线程数 和 并发线程数
  • -XX:CMSInitiatingOccupancyFraction=percent: 设置CMS垃圾回收时的内存占比限制
  • -XX:+CMSScavengeBeforeRemark:
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
-XX:CMSInitiatingOccupancyFraction=percent
-XX:+CMSScavengeBeforeRemark

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如 web 程序、b/s 服务

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 "标记-清除"算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。CMS收集器的整个运行过程分为四个步骤:

  • 初始标记:暂停所有其他线程, 标记 GC Roots 能直接到的对象。速度很快, 但是仍存在 Stop The World 问题。(需要停顿)
  • 并发标记:同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。进行 GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。(它在整个回收过程中耗时最长,不需要停顿)
  • 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。仍然存在 Stop The World 问题. (需要停顿)
  • 并发清除:对开启用户线程, 标记的对象进行清除回收,清除的过程中,可能任然会有新的垃圾产生,这些垃圾就叫浮动垃圾,如果当用户需要存入一个很大的对象时,新生代放不下去,老年代由于浮动垃圾过多,就会退化为 serial Old 收集器,将老年代垃圾进行标记-整理,当然这也是很耗费时间的!(不需要进行停顿)

CMS 收集器的内存回收过程是与用户线程一起并发执行的,可以搭配 ParNew 收集器(多线程,新生代,复制算法)与 Serial Old 收集器(单线程,老年代,标记-整理算法)使用。
68747470733a2f2f6f73732e6a61766167756964652e636e2f6769746875622f6a61766167756964652f6a6176612f6a766d2f636d732d676172626167652d636f6c6c6563746f722e706e67.png
从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感: 吞吐量低, 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高
  • 无法处理浮动垃圾: 可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 标记-清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

G1收集器

垃圾回收器G1

ZGC收集器

ZGC垃圾回收器

垃圾回收调优

查看虚拟机参数命令

D:\JavaJDK1.8\bin\java  -XX:+PrintFlagsFinal -version | findstr "GC"

可以根据参数去查询具体的信息

调优领域

  • 内存
  • 锁竞争
  • cpu 占用
  • io
  • gc

确定目标

低延迟/高吞吐量? 选择合适的GC

  • CMS G1 ZGC
  • ParallelGC
  • Zing

最快的GC

首先排除减少因为自身编写的代码而引发的内存问题

  • 查看 Full GC 前后的内存占用,考虑以下几个问题
    • 数据是不是太多?resultSet = statement.executeQuery(“select * from 大表 limit n”)
    • 数据表示是否太臃肿
      • 对象图
      • 对象大小 16 Integer 24 int 4
    • 是否存在内存泄漏
      • static Map map …
      • 第三方缓存实现

新生代调优

新生代的特点

  • 所有的 new 操作分配内存都是非常廉价的
    • TLAB thread-lcoal allocation buffer
  • 死亡对象回收零代价
  • 大部分对象用过即死(朝生夕死)
  • Minor GC 所用时间远小于 Full GC

新生代内存越大越好么?不是

  • 新生代内存太小:频繁触发 Minor GC ,会 STW ,会使得吞吐量下降
  • 新生代内存太大:老年代内存占比有所降低,会更频繁地触发 Full GC。而且触发 Minor GC 时,清理新生代所花费的时间会更长
  • 新生代内存设置为内容纳[并发量*(请求-响应)]的数据为宜
  • 幸存区需要能够保存 当前活跃对象+需要晋升的对象
  • 晋升阈值配置得当,让长时间存活的对象尽快晋升
-XX:MaxTenuringThreshold=threshold
-XX:+PrintTenuringDistrubution

:::tips
-Xmn
Sets the initial and maximum size (in bytes) of the heap for the young generation (nursery). GC is
performed in this region more often than in other regions. If the size for the young generation is
too small, then a lot of minor garbage collections are performed. If the size is too large, then only
full garbage collections are performed, which can take a long time to complete. Oracle
recommends that you keep the size for the young generation greater than 25% and less than
50% of the overall heap size.
:::

老年代调优

以 CMS 为例:

  • CMS 的老年代内存越大越好
  • 先尝试不做调优,如果没有 Full GC 那么已经,否者先尝试调优新生代。
  • 观察发现 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3
-XX:CMSInitiatingOccupancyFraction=percent

案例

案例1 Full GC 和 Minor GC频繁
案例2 请求高峰期发生 Full GC,单次暂停时间特别长 (CMS)
案例3 老年代充裕情况下,发生 Full GC (CMS jdk1.7)


参考

JVM垃圾回收详解(重点)
image.png
JVM 学习笔记(二)垃圾回收-CSDN博客
黑马程序员JVM完整教程,Java虚拟机快速入门,全程干货不拖沓_哔哩哔哩_bilibili
GC - Java 垃圾回收基础知识

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值