今天被人问GC的相关知识,索性整理了一下到目前常用的一些垃圾回收器,从垃圾回收的机制到垃圾回收器的实现

1. 垃圾回收

在 C/C++ 这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要手动释放,否则就会出现内存泄漏

内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出。

在这段代码中,通过死循环不停创建Test类的对象,每一轮循环结束之后,这次创建的对象就不再使用了。但是没有手动调用删除对象的方法,此时对象就会出现内存泄漏。

这段代码中,手动调用delete删除对象,就不会出现内存泄漏

我们称这种释放对象的过程为垃圾回收,而需要程序员编写代码进行回收的方式为手动回收。手动回收的方式相对来说回收比较及时,删除代码执行之后对象就被回收了,可以快速释放内存。缺点是对程序员要求比较高,很容易出现创建完对象之后,程序员忘记释放对象

Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器

垃圾回收器如果发现某个对象不再使用,就可以回收该对象

  1. 自动垃圾回收,自动根据对象是否使用由虚拟机来回收对象
    • 优点:降低程序员实现难度、降低对象回收bug的可能性
    • 缺点:程序员无法控制内存回收的及时性
  2. 手动垃圾回收,由程序员编程实现对象的删除
    • 优点:回收及时性高,由程序员把控回收的时机
    • 缺点:编写不当容易出现悬空指针、重复释放、内存泄漏等问题

那么垃圾回收器需要负责对哪些部分的内存进行回收呢?

首先是线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。所以**这一部分不需要垃圾回收器负责回收**

1.1 方法区的回收

方法区中能回收的内容主要就是**不再使用的类**

判定一个类可以被卸载。需要同时满足下面三个条件:

此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象

  1. 这段代码中就将局部变量对堆上实例对象的引用去除了,所以对象就可以被回收

  1. 加载该类的类加载器已经被回收

这段代码让局部变量对类加载器的引用去除,类加载器就可以回收

  1. 该类对应的 java.lang.Class 对象没有在任何地方被引用

代码:

package chapter04.gc;

import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;

/**
 * 类的卸载
 */
public class ClassUnload {
    public static void main(String[] args) throws InterruptedException {

        try {
            ArrayList<Class<?>> classes = new ArrayList<>();
            ArrayList<URLClassLoader> loaders = new ArrayList<>();
            ArrayList<Object> objs = new ArrayList<>();
            while (true) {

                URLClassLoader loader = new URLClassLoader(
                    new URL[]{new URL("file:D:\\lib\\")});
                Class<?> clazz = loader.loadClass("com.itheima.my.A");
                Object o = clazz.newInstance();


                //                objs.add(o);
                //                classes.add(clazz);
                //                 loaders.add(loader);

                System.gc();

            }


        } catch (Exception e) {

            e.printStackTrace();
        }
    }
}

添加这两个虚拟机参数进行测试:

-XX:+TraceClassLoading -XX:+TraceClassUnloading

如果注释掉代码中三句add调用,就可以同时满足3个条件。但是需要手动调用System.gc()方法,让垃圾回收器进行回收

如果需要手动触发垃圾回收,可以调用System.gc()方法

注意事项:

调用System.gc()方法并不一定会立即回收垃圾,仅仅是向 Java 虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收 Java 虚拟机会自行判断

执行之后,日志中就会打印出类卸载的内容:

那么类卸载主要用在什么场景下呢?

开发中此类场景一般很少出现,主要在如OSGiJSP的热部署等应用场景中

每个 jsp 文件对应一个唯一的类加载器,当一个 jsp 文件修改了,就直接卸载这个 jsp 类加载器。重新创建类加载器,重新加载 jsp 文件

1.2 对象可以回收

垃圾回收器要回收对象的第一步就是判断哪些对象可以回收。Java 中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收

比如下面代码的内存结构图:

第一行代码执行之后,堆上创建了 Demo 类的实例对象,同时栈上保存局部变量引用堆上的对象

第二行代码执行之后,局部变量对堆上的对象引用去掉,那么堆上的对象就可以被回收了

一个更复杂的案例:

这个案例中,如果要让对象 a 和 b 回收,必须将局部变量到堆上的引用去除

那么问题来了,A 和 B 互相之间的引用需要去除吗?

答案是不需要,因为局部变量都没引用这两个对象了,在代码中已经无法访问这两个对象,即便他们之间互相有引用关系,也不影响对象的回收


判断对象是否可以回收,主要有两种方式:****引用计数法和可达性分析法

1.2.1 引用计数法

引用计数法会为每个对象维护一个引用计数器,当对象被引用时加 1,取消引用时减 1

比如下图中,对象A的计数器初始为 0,局部变量 a1 对它引用之后,计数器加 1 就变成了 1。同样 A 对 B 产生了引用,B 的计数器也是 1

引用计数法的优点是实现简单,C++ 中的智能指针就采用了引用计数法,但是它也存在缺点,主要有两点:

  1. 每次引用和取消引用都需要维护计数器,对系统性能会有一定的影响
  2. 存在循环引用问题,所谓循环引用就是当 A 引用 B,B 同时引用 A 时会出现对象无法回收的问题

这张图上,由于 A 和 B 之间存在互相引用,所以计数器都为 1,两个对象都不能被回收。但是由于没有局部变量对这两个代码产生引用,代码中已经无法访问到这两个对象,理应可以被回收

我们来做一个实验,验证下 Java 中循环引用不会导致内存泄漏,Java 虚拟机没有使用引用计数法

首先我们要学会去看一个对象有没有被回收,可以通过垃圾回收日志来查看。如果想要查看垃圾回收的信息,可以使用**<font style="color:#DF2A3F;">-verbose:gc</font>**参数

加上这个参数之后执行代码,发现对象确实被回收了:

通过不同的死循环创建对象,内存并没有上升,一直维持在 1000K,说明每轮循环创建的两个对象在垃圾回收之后都被回收了

1.2.2 可达性分析法

Java 使用的是可达性分析算法来判断对象是否可以被回收,可达性分析将对象分为两类

  1. 垃圾回收的根对象(GC Root)
  2. 普通对象

下图中 A 到 B 再到 C 和 D,形成了一个引用链,可达性分析算法指的是如果从某个到 GC Root 对象是可达的,对象就不可被回收


哪些对象被称之为 GC Root 对象呢?

  1. 线程 Thread 对象,引用线程栈帧中的**方法参数局部变量**等
  2. 系统类加载器加载的java.lang.Class对象,引用类中的静态变量

  1. 监视器对象,用来保存同步锁synchronized关键字持有的对象

  1. 本地方法调用时使用的全局对象

通过 Arthas 和eclipse Memory Analyzer (MAT) 工具可以查看 GC Root,MAT 工具是 eclipse 推出的 Java 堆内存检测工具

  1. 使用 Arthas 的heapdump命令将堆内存快照保存到本地磁盘中
    • headdump D:/test/test.hprof
    • 生成了一个堆内存快照(简单来说就是包含了所有堆中的对象信息)
  2. 使用 MA T工具打开堆内存快照文件
  3. 选择 GC Roots 功能查看所有的 GC Root

package com.itheima.jvm.chapter04;

import java.io.IOException;

public class ReferenceCounting {
    public static A a2 = null;
    public static void main(String[] args) throws IOException {
        //        while (true){
        A a1 = new A();
        B b1 = new B();
        a1.b = b1;
        b1.a = a1;
        a2 = a1;
        System.in.read();
        //            a1 = null;
        //            b1 = null;
        //        }

    }
}

class A {
    B b;
    //    byte[] t = new byte[1024 * 1024 * 10];
}

class B {
    A a;
    //    byte[] t = new byte[1024 * 1024 * 10];
}

MAT 对 4 类 GC Root 对象做了分类

1.3 常见的引用对象

可达性算法中描述的对象引用,一般指的是强引用。GC Root 对象和普通对象之间存在引用关系,只要这层关系存在,普通对象就不会被回收

Java 中的引用关系

  1. 强引用
    • GC Root 引用的对象
  2. 软引用
    • SoftReference 引用的对象
    • 常用于缓存中
    • 软引用对象本身也需要被强引用
  3. 弱引用
    • WeakReference 引用的对象
  4. 虚引用
  5. 终结器引用

1.3.1 软引用(SofeReference)

软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。在 JDK 1.2 版之后提供了SoftReference类来实现软引用,软引用常用于缓存中

如下图中,对象 A 被 GC Root 对象强引用了,同时我们创建了一个软引用SoftReference对象(它本身也是一个对象),软引用对象中引用了对象 A

接下来强引用被去掉之后,对象 A 暂时还是处于不可回收状态,因为有软引用存在并且内存还够用

如果内存出现不够用的情况,对象 A 就处于可回收状态,可以被垃圾回收器回收

这样做有什么好处?如果对象 A 是一个缓存,平时会保存在内存中,如果想访问数据可以快速访问。但是如果内存不够用了,我们就可以将这部分缓存清理掉释放内存。即便缓存没了,也可以从数据库等地方获取数据,不会影响到业务正常运行,这样可以减少内存溢出产生的可能性

特别注意:

软引用对象本身,也需要被强引用,否则软引用对象也会被回收掉

软引用的执行过程如下:

  1. 将对象使用软引用包装起来
    • **new SoftReference<对象类型>(对象)**
  2. 内存不足时,虚拟机尝试进行垃圾回收
  3. 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象
  4. 如果依然内存不足,抛出OutOfMemory异常
/**
 * 软引用案例2 - 基本使用
 */
public class SoftReferenceDemo2 {
    public static void main(String[] args) throws IOException {

        byte[] bytes = new byte[1024 * 1024 * 100];
        SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes);
        bytes = null;  // 断开 btyes 的强引用
        System.out.println(softReference.get());
        // -Xmx200m
        // 堆内存上限设为 200mb 时,这个时候会出现内存不足,GC就会回收软引用的内存
        byte[] bytes2 = new byte[1024 * 1024 * 100]; // 
        System.out.println(softReference.get()); // 出输出null
        //
        //        byte[] bytes3 = new byte[1024 * 1024 * 100];
        //        softReference = null;
        //        System.gc();
        //
        //        System.in.read();
    }
}


软引用对象本身怎么回收呢?

如果软引用对象里边包含的数据已经被回收了,那么软引用对象本身其实也可以被回收了

SoftReference提供了一套队列机制,软引用创建的时候会关联一个引用队列;如果软引用关联的对象被回收时,会将软引用对象放入到引用队列中;然后再遍历引用队列,将软引用对象关联的强引用删除

  1. 软引用创建时,通过构造器传入引用队列

  1. 在软引用中包含的对象被回收时,该软引用对象会被放入引用队列

  1. 通过代码遍历引用队列,将SoftReference的强引用删除
// 测试时,需要将堆内存max设置为200m: -Xms200m
public class SoftReferenceDemo3 {
    public static void main(String[] args) throws IOException {
        ArrayList<SoftReference> softReferences = new ArrayList<>(); // 存放软引用对象
        ReferenceQueue<byte[]> queues = new ReferenceQueue<byte[]>(); // 软引用队列
        for (int i = 0; i < 10; i++) {
            // bytes只是一个局部变量,每次for循环之后强引用就会去除
            byte[] bytes = new byte[1024 * 1024 * 100]; 
            // 传参包含了引用队列,当强引用去除且内存不足时,软应用对象就会加入到队列中
            SoftReference studentRef = new SoftReference<byte[]>(bytes,queues); 
            // 软引用对象本身也是一个局部变量,为了防止for循环结束后,软引用对象的强制引用被解除,将其添加到List中
            softReferences.add(studentRef); 
        }
        SoftReference<byte[]> ref = null;
        int count = 0;
        // 统计队列中有多少个软引用对象(队列中的软引用对象因为我们添加到List的原因,此时因为强引用存在的原因,一直没有被回收)
        // 当我们从队列中取出时(就相当于手动释放软引用对象),GC 就会收回这个软引用对象
        while ((ref = (SoftReference<byte[]>) queues.poll()) != null) {
            count++;
        } 
        System.out.println(count);	// 打印 9
    }
}

**软引用的缓存案例:**使用软引用实现学生信息的缓存,能支持内存不足时清理缓存

package chapter04.soft;

import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.HashMap;
import java.util.Map;
/**
 * 软引用案例4 - 学生信息的缓存
 */
public class StudentCache {

    private static StudentCache cache = new StudentCache();

    public static void main(String[] args) {
        for (int i = 0; ; i++) {
            StudentCache.getInstance().cacheStudent(new Student(i, String.valueOf(i)));
        }
    }

    private Map<Integer, StudentRef> StudentRefs;	// 用于Cache内容的存储
    private ReferenceQueue<Student> q;	// 垃圾Reference的队列

    // 继承SoftReference,使得每一个实例都具有可识别的标识
    // 并且该标识与其在HashMap内的key相同
    private class StudentRef extends SoftReference<Student> {
        private Integer _key = null;
        public StudentRef(Student em, ReferenceQueue<Student> q) {
            super(em, q);
            _key = em.getId();
        }
    }

    // 构建一个缓存器实例
    private StudentCache() {
        StudentRefs = new HashMap<Integer, StudentRef>();
        q = new ReferenceQueue<Student>();
    }

    // 取得缓存器实例
    public static StudentCache getInstance() {
        return cache;
    }

    // 以软引用的方式对一个Student对象的实例进行引用并保存该引用
    private void cacheStudent(Student em) {
        cleanCache();// 清除垃圾引用
        StudentRef ref = new StudentRef(em, q);
        StudentRefs.put(em.getId(), ref);
        System.out.println(StudentRefs.size());
    }

    // 依据所指定的ID号,重新获取相应Student对象的实例
    public Student getStudent(Integer id) {
        Student em = null;
        // 缓存中是否有该Student实例的软引用,如果有,从软引用中取得。
        if (StudentRefs.containsKey(id)) {
            StudentRef ref = StudentRefs.get(id);
            em = ref.get();
        }
        // 如果没有软引用,或者从软引用中得到的实例是null,重新构建一个实例,
        // 并保存对这个新建实例的软引用
        if (em == null) {
            em = new Student(id, String.valueOf(id));
            System.out.println("Retrieve From StudentInfoCenter. ID=" + id);
            this.cacheStudent(em);
        }
        return em;
    }

    // 清除那些所软引用的Student对象已经被回收的StudentRef对象
    private void cleanCache() {
        StudentRef ref = null;
        while ((ref = (StudentRef) q.poll()) != null) {
            StudentRefs.remove(ref._key);
        }
    }

    //    // 清除Cache内的全部内容
    //    public void clearCache() {
    //        cleanCache();
    //        StudentRefs.clear();
    //        //System.gc();
    //        //System.runFinalization();
    //    }
}

class Student {
    int id;
    String name;

    public Student(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

1.3.2 弱引用(WeakReference)

弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。在 JDK 1.2 版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用

弱引用对象本身也可以使用引用队列进行回收

// 弱引用案例
public class WeakReferenceDemo2 {
    public static void main(String[] args) throws IOException {
        byte[] bytes = new byte[1024 * 1024 * 100];
        WeakReference<byte[]> weakReference = new WeakReference<byte[]>(bytes);
        bytes = null; // 删除了 bytes 的强引用,bytes此时只被软引用关联
        System.out.println(weakReference.get()); // 能够得到内存地址
        System.gc(); // 请求 JVM 指向 GC
        System.out.println(weakReference.get()); // 不管内存够不够都会回收弱引用,所以输出 Null
    }
}

1.3.3 虚引用和终结器引用

这两种引用在常规开发中是不会使用的

  1. 虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java 中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现
  2. 终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做
package chapter04.finalreference;

/**
 * 终结器引用案例
 */
public class FinalizeReferenceDemo {
    public static FinalizeReferenceDemo reference = null;

    public void alive() {
        System.out.println("当前对象还存活");
    }

    @Override
    protected void finalize() throws Throwable {
        try{
            System.out.println("finalize()执行了...");
            //设置强引用自救
            reference = this;
        }finally {
            super.finalize();
        }
    }

    public static void main(String[] args) throws Throwable {
        reference = new FinalizeReferenceDemo();
        test();
        test();
    }

    private static void test() throws InterruptedException {
        reference = null;
        //回收对象
        System.gc();
        //执行finalize方法的优先级比较低,休眠500ms等待一下
        Thread.sleep(500);
        if (reference != null) {
            reference.alive();
        } else {
            System.out.println("对象已被回收");
        }
    }
}

1.4 垃圾回收算法

Java 是如何实现垃圾回收的呢?简单来说,垃圾回收要做的有两件事:

  1. 找到内存中存活的对象
  2. 释放不再存活对象的内存,使得程序能再次利用这部分空间

1960 年 John McCarthy 发布了第一个GC算法:标记-清除算法

1963 年Marvin L. Minsky 发布了复制算法

本质上后续所有的垃圾回收算法,都是在上述两种算法的基础上优化而来


垃圾回收算法的评价标准:

Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World简称STW,如果STW时间过长则会影响用户的使用。

如下图,用户代码执行和垃圾回收执行让用户线程停止执行(STW)是交替执行的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

所以判断GC算法是否优秀,可以从三个方面来考虑:

  1. 吞吐量

吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。

  1. 最大暂停时间

最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。比如如下的图中,黄色部分的STW就是最大暂停时间,显而易见上面的图比下面的图拥有更少的最大暂停时间。最大暂停时间越短,用户使用系统时受到的影响就越短。

  1. 堆使用效率

不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法

上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得

一般来说,堆内存越大,最大暂停时间就越长。想要减少最大暂停时间,就会降低吞吐量

没有一个垃圾回收算法能兼顾上述三点评价标准,所以不同的垃圾回收算法它的侧重点是不同的,适用于不同的应用场景


1.4.1 标记 – 清除算法

标记清除算法的核心思想分为两个阶段:

  1. 标记阶段
    • 将所有存活的对象进行标记。Java 中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象
  2. 清除阶段
    • 从内存中删除没有被标记的的对象也就是非存活对象

标记阶段,从 GC Root 对象开始扫描,将对象 A、B、C 在引用链上的对象标记出来

清除阶段,将没有标记的对象清理掉,所以对象D就被清理掉了

  • 优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可
  • 缺点:
    • 碎片化问题
    • 分配速度慢
  1. 碎片化问题

由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配

如下图,红色部分已经被清理掉了,总共回收了9个字节,但是每个都是一个小碎片,无法为5个字节的对象分配空间

  1. 分配速度慢

由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。 我们需要用一个链表来维护,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够我们去创建一个对象。如下图,遍历到最后才发现有足够的空间分配3个字节的对象了。如果链表很长,遍历也会花费较长的时间

1.4.2 复制算法

复制算法的核心思想是:准备两块空间 From 空间和 To 空间,每次在对象分配阶段,只能使用其中一块空间(From空间)

  1. 将堆内存分割成两块 From 空间 To 空间,对象分配阶段,创建对象

  1. GC 阶段开始,将 GC Root 搬运到 To 空间

  1. 将 GC Root 关联的对象,搬运到 To 空间

  1. 清理 From 空间,并把名称互换

优点:

  • 吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动
  • 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间

缺点:

  • 内存使用效率低,每次只能让一半的内存空间来为创建对象使用

1.4.3 标记 – 整理算法

标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案

核心思想分为两个阶段:

  1. 标记阶段
    • 将所有存活的对象进行标记。Java 中使用可达性分析算法,从 GC Root 开始通过引用链遍历出所有存活对象
  2. 整理阶段
    • 将存活对象移动到堆的一端,清理掉存活对象的内存空间

优点:

  • 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
  • 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间

缺点:

  • 整理阶段的效率不高,整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索 3 次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能

1.4.4 分代垃圾回收算法

将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是**分代垃圾回收算法(Generational GC)**

分代垃圾回收将整个内存区域划分为**年轻代(Eden,Survivor)**和 老年代

我们通过 Arthas 来验证下内存划分的情况:

在 JDK8 中,需要添加**<font style="color:#DF2A3F;">-XX:+UseSerialGC</font>**参数使用分代回收的垃圾回收器

在 Arthas 中使用memory命令查看内存,显示出三个区域的内存情况

  1. Eden + survivor这两块区域组成了**年轻代**
  2. tenured_gen指的是晋升区域,其实就是**老年代**

堆内存相关的虚拟机参数:

  1. -Xms
    • 设置堆的最小和初始大小,必须是 1024 倍数且大于 1MB,例如<font style="color:rgb(0, 0, 0);">-Xms6291456</font>
  2. <font style="color:rgb(0, 0, 0);">-Xmx</font>
    • 设置最大堆的大小,必须是1024倍数且大于2MB,例如<font style="color:rgb(0, 0, 0);">-Xmx83886080</font>
  3. <font style="color:rgb(0, 0, 0);">-Xmn</font>
    • 新生代大小,例如<font style="color:rgb(0, 0, 0);">-Xmn256m</font>
  4. -XX:SurvivorRatio
    • 伊甸园区和幸存区的比例,默认为 8,例如新生代 1g 内存,伊甸园区 800MB,** S0 和 S1 各 100MB**
  5. -XX:+PrintGCDetails
    • 打印详细的 GC 日志
  6. verbose:gc
    • 打印部分 GC 日志

代码:

// 参数设置
// -XX:+UseSerialGC  -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio=3  -XX:+PrintGCDetails
public class GcDemo0 {

    public static void main(String[] args) throws IOException {
        List<Object> list = new ArrayList<>();
        int count = 0;
        while (true){
            System.in.read();
            System.out.println(++count);
            // 每次添加 1m 的数据
            list.add(new byte[1024 * 1024 * 1]);
        }
    }
}

使用 Arthas 的memory展示出来的效果

heap 展示的是可用堆,survivor 区每次只有一块能使用,所以 60 - 4 = 56m


  1. 分代回收时,创建出来的对象,首先会被放入 Eden 伊甸园区

  1. 随着对象在 Eden 区越来越多,如果 Eden 区满,新创建的对象已经无法放入,就会触发年轻代的 GC,称为**Minor GC 或者 Young GC。Minor GC **将 eden 和 From 中可以回收的对象进行回收操作,把没有回收的对象放入 To 区

  1. 接下来,S0 会变成 To 区,S1 变成 From 区。当 eden 区满时再往里放入对象,依然会发生 Minor GC

此时会回收 eden 区和 S1(from) 中的对象,并把 eden 和 from 区中剩余的对象放入 S0

每次 Minor GC 中都会为对象记录他的年龄,初始值为 0,每次 GC 完加 1

  1. 如果 Minor GC 后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代

当老年代中空间不足,无法放入新的对象时,先尝试minor gc;如果还是不足,就会触发Full GC

Full GC会对整个堆进行垃圾回收,如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常


下图中的程序为什么会出现OutOfMemory?

Full GC无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常

继续使用这段代码测试:

// -XX:+UseSerialGC  -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio=3  -XX:+PrintGCDetails
public class GcDemo0 {

    public static void main(String[] args) throws IOException {
        List<Object> list = new ArrayList<>();
        int count = 0;
        while (true){
            System.in.read();
            System.out.println(++count);
            //每次添加1m的数据
            list.add(new byte[1024 * 1024 * 1]);
        }
    }
}

结果如下:

老年代已经满了,而且垃圾回收无法回收掉对象,如果还想往里面放就发生了OutOfMemoryError

1.5 垃圾回收器

垃圾回收器是垃圾回收算法的具体实现


为什么分代 GC 算法要把堆分成年轻代和老年代?

首先我们要知道堆内存中对象的特性:

  1. 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收
    • 比如用户获取订单数据,订单数据返回给用户之后就可以释放了
  2. 老年代中会存放长期存活的对象
    • 比如 Spring 的大部分 bean 对象,在程序启动之后就不会被回收了
  3. 在虚拟机的默认设置中,新生代大小要远小于老年代的大小,这样 Minor GC 的耗时将非常少

所以分代 GC 算法将堆分成年轻代和老年代主要原因 有

  1. 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能
  2. 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高
  3. 分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW 时间就会减少

由于垃圾回收器分为年轻代和老年代,除了 G1 之外其他垃圾回收器必须成对组合进行使用

具体的关系图如下:

1.5.1 Serial 垃圾回收器

Serial 垃圾回收器是一个单线程串行回收年轻代的垃圾回收器,使用复制算法进行垃圾回收

选择该垃圾回收器的虚拟机参数为:**-XX:+UseSerialGC****,**新时代使用串行的回收器

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 优点
    • 单 CPU 处理器下吞吐量非常出色
  • 缺点
    • 多 CPU 下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
  • 适用场景
    • Java 编写的客户端程序或者硬件配置有限的场景

1.5.2 SerialOld 垃圾回收器

SerialOld 采用单线程串行回收老年代的垃圾回收器,使用标记-整理算法进行垃圾回收

选择该垃圾回收器的虚拟机参数为:**-XX:+UseSerialGC****,**该虚拟机参数会使新生代、老年代都使用串行回收器

  • 优点
    • 单 CPU 处理器下吞吐量非常出色
  • 缺点
    • 多 CPU 下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
  • 适用场景
    • 与 Serial 垃圾回收器搭配使用,或者在 CMS 特殊情况下使用

1.5.3 ParNew 垃圾回收器

ParNew 垃圾回收器本质上是对 Serial 在多 CPU 下的优化,通过**复制算法使用多线程年轻代**进行垃圾回收

选择该垃圾回收器的虚拟机参数为:**-XX:+UseParNewGC**,新生代使用 ParNew 回收器

  • 优点
    • 多 CPU 处理器下停顿时间较短
  • 缺点
    • 吞吐量和停顿时间不如 G1,所以在 JDK9 之后不建议使用
  • 适用场景
    • JDK8 及之前的版本中,与 CMS 老年代垃圾回收器搭配使用

1.5.4 CMS 垃圾回收器

CMS(Concurrent Mark Sweep)垃圾回收器通过**标记清除算法使用多线程老年代**进行垃圾回收

选择该垃圾回收器的虚拟机参数为:**-XX:+UseConcMarkSweepGC****,**老年代使用 CMS 回收器

  • CMS 允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间,STW 短
  • Concurrent 就是允许用户线程和回收线程并发执行
  • Mark 就是标记
  • Sweep 就是清理

XX:+UseConcMarkSweepGC,该虚拟机参数会老年代使用 标记 – 清除 算法进行垃圾回收

  • 优点
    • 系统由于垃圾回收出现的停顿时间较短,用户体验好
  • 缺点
    • 内存碎片问题
    • 退化问题
    • 浮动垃圾问题
  • 适用场景
    • 大型的互联网系统中用户请求数据量大、频率高的场景,比如订单接口、商品接口等

CMS 执行步骤:

  1. 初始标记:用极短的时间标记出 GC Roots 能直接关联到的对象
  2. 并发标记:标记所有的对象,用户线程不需要暂停
  3. 重新标记:由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记
  4. 并发清理:清理死亡的对象,用户线程不需要暂停

缺点:

  1. CMS 使用了 标记 – 清除 算法,在垃圾收集结束之后会出现大量的内存碎片,CMS 会在Full GC时进行碎片的整理。这样会导致用户线程暂停,可以使用**<font style="color:#DF2A3F;">-XX:CMSFullGCsBeforeCompaction=N</font>**该虚拟机参数(默认0)设置 N 次**<font style="color:#DF2A3F;">Full GC</font>**之后再整理
  2. 无法处理在并发清理过程中产生的“浮动垃圾”(并发清除阶段产生的垃圾),不能做到完全的垃圾回收
  3. 如果老年代内存不足无法分配对象,CMS 就会退化成Serial Old单线程回收老年代

并发线程数:

在 CMS 中并发阶段运行时的线程数可以通过-XX:ConcGCThreads参数设置,默认值为 0,由系统计算得出

计算公式为:-XX:ParallelGCThreads定义的线程数 + 3) / 4, ParallelGCThreads 是 STW 停顿之后的并行线程数

ParallelGCThreads 是由处理器核数决定的:

  1. 当 cpu 核数小于 8 时:ParallelGCThreads = CPU 核数
  2. 当 cpu 核数大于等于 8 时:ParallelGCThreads = 8 + (CPU核数 – 8 ) * 5 / 8

例如电脑上逻辑处理器有 12 个时,计算如下

  • ParallelGCThreads = 8 + (12 - 8)* 5/8 = 10
  • ConcGCThreads = (-XX:ParallelGCThreads定义的线程数 + 3) / 4 = (10 + 3) / 4 = 3

最终可以得到这张图:

并发标记和并发清理阶段,会使用 3 个线程并行处理。重新标记阶段会使用 10 个线程处理

由于 CPU 的核心数有限,并发阶段会影响用户线程执行的性能

1.5.5 Parallel Scavenge 垃圾回收器

Parallel Scavenge 是 JDK8 默认的**年轻代垃圾回收器,采用的复制算法多线程**并行回收,关注的是系统的吞吐量,具备自动调整堆内存大小的特点

选择该垃圾回收器的虚拟机参数为:**-XX:+UseParallelGC**

  • 优点
    • 吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数
  • 缺点
    • 不能保证单次的停顿时间
  • 适用场景
    • 后台任务,不需要与用户交互,并且容易产生大量的对象
    • 比如:大数据的处理,大文件导出

常用参数:

Parallel Scavenge 允许手动设置最大暂停时间和吞吐量。Oracle 官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小

  • 最大暂停时间
    • -XX:MaxGCPauseMillis=n 设置每次垃圾回收时的最大停顿毫秒数
  • 吞吐量
    • -XX:GCTimeRatio=n 设置吞吐量为n(用户线程执行时间 = n/n + 1)
  • 自动调整内存大小
    • -XX:+UseAdaptiveSizePolicy让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小

1.5.6 Parallel Old 垃圾回收器

Parallel Old 是为 Parallel Scavenge 收集器设计的**老年代版本,利用标记整理算法进行多线程**并发收集

选择该垃圾回收器的虚拟机参数为:**-XX:+UseParallelOldGC**

  • 优点
    • 并发收集,在多核 CPU 下效率较高
  • 缺点
    • 暂停时间会比较长
  • 适用场景
    • 与 Parallel Scavenge 配套使用

1.5.6 G1 垃圾回收器

G1(Garbage First)垃圾回收器,回收年代为**年轻代+老年代,采用的是复制算法多线程**并行回收

JDK9 之后强烈建议使用 G1 垃圾回收器

选择该垃圾回收器的虚拟机参数为:-XX:+UseG1GCJDK9 之后默认不需要打开,-XX:MaxGCPauseMillis=毫秒值设置最大暂停的时间

  • CMS 关注暂停时间,但是吞吐量方面会下降
  • Parallel Scavenge 关注吞吐量,允许用户设置最大暂停时间 ,但是会减少年轻代可用空间的大小

而 G1 设计目标就是将上述两种垃圾回收器的优点融合

  1. 支持巨大的堆空间回收,并有较高的吞吐量
  2. 支持多 CPU 并行垃圾回收
  3. 允许用户设置最大暂停时间
  • 优点
    • 对比较大的堆如超过 6G 的堆回收时,延迟可控
    • 不会产生内存碎片
    • 并发标记的 SATB 算法效率高
  • 缺点
    • JDK8 之前还不够成熟
  • 适用场景
    • JDK8 最新版本、JDK9 之后建议默认使用

G1 出现之前的垃圾回收器,年轻代和老年代一般是连续的,如下图

G1 的整个堆会被划分成多个大小相等的区域,称之为区 Region,区域不要求是连续的

分为 Eden、Survivor、 Old 区

Region 的大小通过堆空间大小 / 2048计算得到,也可以通过参数-XX:G1HeapRegionSize=32m指定(其中 32m 指定 region 大小为 32M),Region size 必须是 2 的指数幂,取值范围从 1M 到 32M

G1垃圾回收有两种方式

  1. 年轻代回收(Young GC)
  2. 混合回收(Mixed GC)
1.5.6.1 年轻代回收(Young GC)

年轻代回收(Young GC),回收 Eden 区和 Survivor 区中不用的对象。会导致 STW,G1 中可以通过参数

-XX:MaxGCPauseMillis=n(默认200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1 垃圾回收器会尽可能地保证暂停时间

  1. 新创建的对象会存放在 Eden 区。当 G1 判断年轻代区不足(max默认60%),无法分配对象时需要回收时会执行Young GC

img

  1. 标记出 Eden 和 Survivor 区域中的存活对象
  2. 根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的 Survivor区中(年龄+1),清空这些区域

G1 在进行 Young GC 的过程中会去记录每次垃圾回收时每个 Eden 区和 Survivor 区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个 Region 区域了

比如-XX:MaxGCPauseMillis=n(默认200),每个 Region 回收耗时 40ms,那么这次回收最多只能回收 4 个Region

  1. 后续 Young GC 时与之前相同,只不过 Survivor 区中存活对象会被搬运到另一个 Survivor 区

  1. 当某个存活对象的年龄到达阈值(默认15),将被放入老年代

  1. 部分对象如果大小超过 Region 的一半,会直接放入老年代,这类老年代被称为** Humongous 区**。比如堆内存是 4G,每个 Region 是2M,只要一个大对象超过了 1M 就被放入 Humongous 区,所以如果对象过大会横跨多个 Region

  1. 多次回收之后,会出现很多 Old 老年代区,此时总堆占有率达到阈值-XX:InitiatingHeapOccupancyPercent(默认45%),会触发混合回收 MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成

1.5.6.2 混合回收(Mixed GC)

混合回收分为

  1. 初始标记(initial mark)
  2. 并发标记(concurrent mark)
  3. 最终标记(remark 或者 Finalize Marking)
    • 和 CMS 不同的是,G1 再该阶段不管新创建,不在关联的对象,只进行漏标操作
  4. 并发清理(cleanup)
    • 采用复制算法,不会产生内存碎片

G1 对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是 Garbage first 名称的由来

G1 对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是 G1(Garbage first)名称的由来。最后清理阶段使用复制算法,不会产生内存碎片

如果清理过程中发现没有足够的空 Region 存放转移的对象,会出现**<font style="color:#DF2A3F;">Full GC</font>**单线程执行标记-整理算法,此时会导致用户线程的暂停,所以尽量保证应该用的堆内存有一定多余的空间

//-XX:+UseG1GC   -Xmn8g -Xmx16g -XX:SurvivorRatio=8  -XX:+PrintGCDetails -verbose:gc 
public class GcDemo2 {
    public static void main(String[] args) throws IOException {
        int count = 0;
        List<Object> list = new ArrayList<>();
        while (true){
            //System.out.println(++count);
            if(count++ % 10240 == 0){
                list.clear();
            }
            //            byte[] bytes = new byte[1024 * 1024 * 1];
            list.add(new byte[1024 * 1024 * 1 / 2]);
            //            System.gc();
        }
    }
}

每个 region 大小为 2m,一共有 84 个 young 区,26 个幸存者区

初始标记花了 0.0478 秒

img

并发标记总共耗时 10ms,不会产生 STW

1.5.7 垃圾回收器总结

垃圾回收器的组合关系虽然很多,但是针对几个特定的版本,比较好的组合选择如下:

  • JDK8 及 之前
    • ParNew + CMS(关注暂停时间)
    • Parallel Scavenge + Parallel Old (关注吞吐量)
    • G1(JDK8之前不建议,较大堆并且关注暂停时间)
  • JDK9 之后:
    • G1(默认)

从 JDK9 之后,由于 G1 日趋成熟,JDK 默认的垃圾回收器已经修改为 G1,所以强烈建议在生产环境上使用 G1

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值