JVM(二):垃圾回收


一些VM参数:

含义 参数
堆初始大小 -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

一、判断对象能否回收的方式

引用计数法

引用计数法即某对象被引用一次,则引用次数加1,当引用次数为0时,则被回收。

缺点;若出现循环引用,则无法回收,会导致内存泄漏。

可达性分析算法

  • JVM中的垃圾回收器通过可达性分析来探索所有存活的对象;
  • 扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收;
  • 可以作为GC Root的对象
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。 
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象

五种引用

image-20220720214142295

强引用

  1. 只有所有GC Roots对象都不通过【强引用】引用该对象,该对象才能被垃圾回收。

软引用(SoftReference)

  1. 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象
  2. 可以配合引用队列来释放软引用自身

代码示例(软引用):

import java.io.IOException;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

/**
 * 演示软引用
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc
 */
public class test1 {
    private static final int _4MB = 4 * 1024 * 1024;
    public static void main(String[] args) throws IOException {
        soft();
    }

    public static void soft() {
        // list --> SoftReference --> byte[]
        List<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());
        }
    }
}

运行结果:

image-20220720221059254 image-20220720221214034

代码示例(添加引用队列):

import java.io.IOException;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

/**
 * 演示软引用,添加引用队列
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc
 */
public class test1 {

    private static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) {
        List<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[]> reference : list) {
            System.out.println(reference.get());
        }
    }
}

运行结果:

image-20220720221747202

弱引用(WeakReference)

  1. 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象。
  2. 可以配合引用队列来释放弱引用自身。

代码示例:

import java.io.IOException;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;

/**
 * 演示弱引用
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc
 */
public class test1 {

    private static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) {
        //  list --> WeakReference --> byte[]
        List<WeakReference<byte[]>> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
            list.add(ref);
            for (WeakReference<byte[]> w : list) {
                System.out.print(w.get()+" ");
            }
            System.out.println();

        }
        System.out.println("循环结束:" + list.size());
    }
}

运行结果:
image-20220720222749278

image-20220720222948373

虚引用(PhantomReference)

  1. 必须配合引用队列使用,主要配合ByteBuffer使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存
  2. 在ByteBuffer对象回收后,直接内存中的内存还未被回收。这时需要将虚引用对象Cleaner放入引用队列中,然后调用它的clean方法来释放直接内存

终结器引用(FinalReference)

  1. 所有的类都继承自Object类,Object类有一个finalize方法。当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中,然后根据终结器引用对象找到它所引用的对象,然后调用该对象的finalize方法。调用以后,该对象就可以被垃圾回收了
    • 如上图,B对象不再引用A4对象。这时终结器对象就会被放入引用队列中,引用队列会根据它,找到它所引用的对象。然后调用被引用对象的finalize方法。调用以后,该对象就可以被垃圾回收了

二、垃圾回收算法

标记-清除

  1. 概念:将没有被引用的对象标记出来,然后清除。这里的清除并不是把内存空间置零操作,而是把这些空间记录下来,待后面分配空间的时候,去寻找是否有空闲的空间,然后进行覆盖分配。
  2. 优点:速度快。
  3. 缺点:产生内存碎片较多。
img

标记-整理

  1. 概念:将没有被引用的对象标记出来,然后清除。再将未被清除的对象通过地址变换整理到一起。
  2. 优点:没有内存碎片,连续空间比较充足。
  3. 缺点:涉及到地址的改变,开销大,效率低。
img

复制

  1. 概念:将内存分为等大小的两个区域,FROM和TO(TO中为空)。先将被GC Root引用的对象从FROM放入TO中,再回收不被GC Root引用的对象。然后交换FROM和TO。
  2. 优点:不会有内存碎片。
  3. 缺点:始终会占用双倍的内存空间。
img img img img

分代回收

  1. 内存结构
img
  1. 回收流程

    • 新创建的对象都被放在了新生代的伊甸园
    img
    • 当伊甸园中的内存不足时,就会进行一次垃圾回收,这时的回收叫做 Minor GC,Minor GC 会将伊甸园和幸存区FROM存活的对象复制到 幸存区 TO中, 并让其寿命加1,再交换两个幸存区
    img img img
    • 再次创建对象,若新生代的伊甸园又满了,则会再次触发 Minor GC(会触发 stop the world, 暂停其他用户线程,只让垃圾回收线程工作),这时不仅会回收伊甸园中的垃圾,还会回收幸存区中的垃圾,再将活跃对象复制到幸存区TO中。回收以后会交换两个幸存区,并让幸存区中的对象寿命加1
    img
    • 如果幸存区中的对象的寿命超过某个阈值(最大为15,4bit),就会被放入老年代
    img
    • 如果新生代老年代中的内存都满了,就会先触发Minor GC,再触发Full GC,扫描新生代和老年代中所有不再使用的对象并回收。

GC分析

参考:Java对象晋升的四种方式 | 一袖南烟顾 (hexoblog.gq)

大对象处理策略

当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代

线程内存溢出

某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行

这是因为当一个线程抛出OOM异常后它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,进程依然正常

三、垃圾回收器

相关概念

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

  2. 并发收集:指用户线程与垃圾收集线程同时工作(宏观意义)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上;

  3. 吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%。

串行

特点:

  • 底层是一个单线程的垃圾回收器;
  • 适合堆内存较小,cpu数量少,适合个人电脑。

图示:

Serial 收集器 ParNew/Serial Old 收集器
  • 安全点:在垃圾回收时,对象引用状态可能改变,为防止用户线程找不到对象,需要让用户线程在某个点停止,这个点就是安全点。在串行方式下,垃圾回收时只有一个垃圾回收线程,其他线程处于阻塞态。

收集器

Serial 收集器
  • Serial收集器是最基本的、发展历史最悠久的收集器;
  • 特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。
ParNew 收集器
  • ParNew收集器其实就是Serial收集器的多线程版本;
  • 特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。和Serial收集器一样存在Stop The World问题。
Serial Old 收集器
  • Serial Old是Serial收集器的老年代版本;
  • 特点:同样是单线程收集器,采用标记-整理算法

吞吐量优先

特点

  • 多线程;
  • 适合堆内存较大的场景;
  • 需要多核cpu支持(否则多线程争强一个cpu效率低);
  • 让单位时间内,STW的时间最短(单次回收时间可以长,但总时间需要短:0.2+0.2=0.4)。
  • JDK1.8默认使用的收集器。

虚拟机参数:

-XX:+UseParallelGC ~ -XX:+UseParallelOldGC  开启吞吐量优先垃圾回收器,开启一个另一个也开启了
-XX:+UseAdaptiveSizePolicy  自适应策略,开启后会自动去调整新生代占比,晋升阔值等
-XX:GCTimeRatio=ratio  垃圾回收时间占比,ratio=19时,即100分钟只允许5分钟垃圾回收
-XX:MaxGCPauseMillis=ms  200ms即每次垃圾回收的时间
-XX:ParallelGCThreads=n  允许的并行线程数

图示:

img

收集器

Parallel Scavenge 收集器
  • 与吞吐量关系密切,故也称为吞吐量优先收集器;
  • 特点:属于新生代收集器也是采用复制算法的收集器(用到了新生代的幸存区),又是并行的多线程收集器(与ParNew收集器类似),拥有自适应策略,开启后会自动去调整新生代占比,晋升阔值等。
Parallel Old 收集器
  • 是Parallel Scavenge收集器的老年代版本;
  • 特点:多线程,采用标记-整理算法(老年代没有幸存区)。

响应时间优先

特点:

  • 多线程;
  • 适合堆内存较大;
  • 需要多核cpu;
  • 尽可能让单次STW的时间最短(0.1+0.1+0.1+0.1+0.1=0.5)。

虚拟机参数

# 虚拟机参数
# 并发
-XX:+UseConcMarkSweepGC  
-XX:+UseParNewGC~SerialOld   
-XX:ParallelGCThreads=n  表示并行的垃圾回收线程数,一般跟cpu数目相等
-XX:ConcGCTreads=threads  并发的垃圾回收线程数目,一般是ParallelGCThreads1/4。即一个cpu做垃圾回收,剩下3个cpu留给用户线程。
-XX:CMSInitiatingOccupancyFraction=percent  开始执行CMS垃圾回收时的内存占比,早期默认65,即只要老年代内存占用率达到65%的时候就要开始清理,留下35%的空间给新产生的浮动垃圾,在JDK6开始,提升至92%-XX:+CMSScavengeBeforeRemark  开启或关闭在CMS重新标记阶段之前的清除(YGC)尝试,以减少remark耗时

图示:

Concurrent Mark Sweep收集器运行示意图

CMS收集器

  • 定义:Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器;
  • 特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片,适用于注重服务响应速度的场景。
  • 运行过程:
    • 初始标记:标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题
    • 并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行;
    • 重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题;
    • 并发清除:对标记的对象进行清除回收。
  • 注意事项:
    • CMS在执行最后一步并发清理的时候,由于其他线程还在运行,就会产生新的垃圾,而新的垃圾只有等到下次垃圾回收才能清理了。这些垃圾被称为浮动垃圾。因此,需要预留一点空间存放浮动垃圾。
    • 可通过-XX:CMSInitiatingOccupancyFraction=percent设置开始执行CMS垃圾回收的内存占比,早期默认为65%(JDK6以后改为92%),预留35%的空间存放浮动垃圾;
    • 由于使用标记-清除算法,会产生内存碎片,这样就出现给大对象分配内存时空间不足的情况,导致并发失败,于是CMS退化成SerialOld串行的垃圾回收,通过标记-整理来获得空间,这样就会使得响应时间变长。上条中预留空间太小也会出现并发失败的情况。
    • 可通过-XX:+CMSScavengeBeforeRemark开启或关闭在CMS重新标记阶段之前的清除(YGC)尝试。由于CMS重新标记是扫描整个堆的,所以不免会扫描一些将要回收的新生代的对象,若这些对象引用了老年代的对象,remark时又需要用可达性分析去找老年代的对象,这样就比较耗性能。所以可以通过在remark前尝试回收以下新生代中的垃圾一提高性能。

G1

特点

  • Garbage First,JDK9默认收集器;
  • 同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是200ms;
  • 超大堆内存,会将堆划分为多个大小相等的Region(每个Region可以分新生代老年代);
  • 整体上是标记-整理算法,两个区域(Region)之间是复制算法;

虚拟机参数

-XX:+UseG1GC
-XX:G1HeapRegionSize=size  设定每个Region大小,取值范围为 1MB~32MB
-XX:MaxGCPauseMillis=time  设定允许的收集停顿时间

图示

G1收集器运行示意图

收集过程

  • 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针(G1 为每一个 Region 设计了两
    个名为 TAMS(Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上)的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 MinorGC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。

  • 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB(原始快照搜索算法)记录下的在并发时有引用变动的对象。

  • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。

  • 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

注意

  • Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 HumongousRegion 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。

  • 停顿时间不能设置太小,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发 Full GC 反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

  • 在JDK8u20中添加字符串去重:

    • 优点:节省大量空间;

    • 缺点:略微多占用了CPU空间,新生代回收时间略微增加;

    • 参数:-XX:+UseStringDeduplication

    • 过程:

      • 将所有新分配的字符串放入一个队列;

      • 当新生代回收时,G1并发检查是否有字符串重复;

      • 如果它们值一样,让它们引用同一个char[]。

    • String.intren()的区别:

      • String.intern()关注的是字符串对象;
      • 而字符串去重关注的是char[];
      • 在JVM内部,使用了不同的字符串表。

G1和CMS比较

  1. G1可以指定最大停顿时间,具有分区域的内存布局和按收益动态确定回收集的创新设计;
  2. G1整体上基于标记-整理算法,局部(Region)上基于复制算法,运行时不会产生内存碎片,而CMS基于标记-清除算法,运行时会产生内存碎片;
  3. G1收集垃圾时产生的内存占用和额外执行负载较高,CMS较低;
  4. G1 和 CMS 都使用卡表来处理跨代指针,但 G1 的卡表实现更为复杂,而且堆中每个 Region(无论新生代还是老年代),都必须有一份卡表,这导致 G1 的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来 CMS 的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要(由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的),所以G1较CMS内存占用更高。

文章作者: 一袖南烟顾
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 一袖南烟顾 !
评论
  目录