JVM垃圾回收机制GC理解

JVM垃圾回收

为什么进行垃圾回收:Java不像C语句那样,使用时要自己通过代码开辟空间,用过后在手动销毁。Java为我们程序提供虚拟机,使得我们在写代码时更加关注业务逻辑,至于内存空间的开辟,释放这些交给JVM。代码运行在JVM上,JVM会通过算法识别出哪一个是垃圾,再通过垃圾回收算法,自动的将程序中用不到的空间进行释放。C语言,C++好比手动挡,Java好比自动挡。
GC:Garbage Collection 垃圾收集,年轻代的垃圾收集也叫GC,老年代的垃圾收集称为Full GC。
什么是垃圾
程序在运行时,需要在内存中开辟的空间存储数据。但此空间被使用一次后,可能再也不会使用,没有指向它的地址,程序就无法使用这个地址空间的存储内容,像内存中缥缈的幽灵。但这些数据是真真实实在内存存在的,这就是垃圾。java程序在编写代码是,只会开辟空间却不能释放空间,就会使得内存中无用的数据占据的内存空间过多,一点点的缩小着程序实际的可用内存,最终导致内存溢出。
下面,以一个单链表删除一个节点为例,被删除的节点就是一个垃圾

public class Application {
    
    
    public static void main(String[] args) {
    
    
        // 创建三个节点
        Node n1 = new Node("张三");
        Node n2 = new Node("李四");
        Node n3 = new Node("王五");

        // 将3个节点连接起来,形成 n1->n2->n3
        n1.next=n2;
        n2.next=n3;

        // 遍历链表
        Node node=n1;
        while (node!=null){
    
    
            System.out.println(node.data);
            node=node.next;
        }

        // 删除一个节点 将中间的节点删除
        n1.next=n3;
        n3.pre=n1;
        n2=null;

        // 遍历链表
        node=n1;
        while (node!=null){
    
    
            System.out.println(node.data);
            node=node.next;
        }
    }
}
@Data
class Node{
    
    
    Node pre;
    Node next;
    Object data;
    public Node(Object data){
    
    
        this.data=data;
    }
}

思考:n2这个节点是不是没有用了?那么它还占用着空间呢?
原链表:
在这里插入图片描述

删除n2节点后:我们已经知道n2就是个垃圾了,但使用Java不像C,不能够手动操作内存释放空间。但JVM会自动识别出垃圾,帮我们释放。
在这里插入图片描述

这时,JVM就会通过算法(可达性分析)识别出n2就是个垃圾,最后再将它所在的内存空间回收

垃圾回收的作用区域与范围
在这里插入图片描述

俗话说:栈管运行,堆管存储,堆占用的空间最多。GC负责对方法区和堆进行垃圾回收,但主要是针对堆。其他地区空间空间占用极少,且不会产生什么垃圾。

既然堆是垃圾回收的主要场所,那么先忽视垃圾回收的一系列问题,接下来先介绍堆的具体逻辑结构以及垃圾在堆中是怎样个转运过程

分代收集

GC垃圾回收极有可能能不止只回收一次,而同一个内存区域也可能在第一次垃圾回收时还不是垃圾,但在第二次回收就是垃圾。如何高效的扫描出垃圾,高效的清除垃圾,尽可能的不影响JVM正常运行,GC将堆空间在逻辑上划分为三代(物理上都是在内存中),分别是新生代,老年代,元空间(JDK7以前为永久代)。JVM会将这三个区域的存储信息,根据区域的特点采用针对它们的方式进行分代管理。例如:在年轻代进行GC(YoungGC),老年代进行FullGC。
分代收集的思想主要有两个:

  • 绝大多数对象朝生夕死
  • 熬过越多次数的垃圾回收过程的对象就越不可能成为垃圾

在这里插入图片描述
接下来,将描述这几个区域在实际的垃圾回收中是怎样协调运转以及为何这样划分:
我们一开始创建的类对象在初始时都会放在伊甸园区(Eden),随着创建的对象越来越多,如果新创建的对象在Eden没有空间存放,这时会就会触发YoungGC。第一次触发YongGC:会扫描Eden区所有的垃圾和幸存者from区(此时from区为空),然后将不是垃圾复制到幸存区的to区,然后清空Eden区和幸存者from区所有的数据,同时将经历过一次GC扫描后判断不是垃圾的内容的年龄+1。
经历过第一次GC后,当前的堆空间状态:Eden区为空,幸存者to区有少量存活的数据,,这些数据的年龄都+1,将to区转为from区,原幸存者from区转为to区,此时to区为空。由于经历过一次GC后空间得到释放,以后再新创建的对象接着放入Eden区,还是随着程序的运行创建的对象越来越多,当Eden再次满了,这时会触发第二次GC(注意:和这一次幸存者区中有一为空即to区)GC会扫描出Eden区和幸存者from区中不是垃圾的部分,然后将这部分复制到另一个空的幸存者区to区,同时也会将不是垃圾的数据年龄再次+1。由于经历过一次GC后空间得到释放,以后再新创建的对象接着放入Eden区循环往复。
直到某一次(至少GC15次后),由于每一次不被当为垃圾的数据年龄都会+1,直到有些数据的年龄达到15岁(可通过配置JVM参数修改),这时达到15岁的数据已经经历过15次GC,这时系统认为这些数据在接下来会有很少的概率成为垃圾,每次来回移动它们耗时费力,于是将这些数据放入老年代。当触发YoungGC时,不涉及老年代。
新生代的流程就是这样,总结就是 复制+1->清空->互换。复制 不是垃圾的数据到为空的幸存者区(假设0区,此时0区为to区)同时这些数据年龄+1,然后清空Eden区和复制前不为空的幸存者区(假设1区,此时1区为from区),然后将from区与to区互换。此时0区是from区,1区就是to区。下次GC时再次互换。
接下来转到年龄已经达到15次在老年区的数据,每一次发生在年轻代的GC都不会波及到这里,但有可能GC后有新数据加入。随着频繁的GC,不断的有新数据添加,系统会提前预测老年代的空间是否会满(每次新增数据的平均值>老年代剩余空间),当预测下次可能使得老年代空间满,这时就会触发Full GC。扫描老年代的垃圾,并将其清除,释放老年代的内存空间。

在这里插入图片描述
(这个图只是大致流程,忽略很多细节)
接下来介绍元空间
JDK7及以前,元空间之前成为永久代,是方法区的实现,所以又称为非堆。但在逻辑上它是堆的一部分(堆分为年轻代,老年代,永久代),物理上使用的也是同一块内存。它和老年代绑定在一起,无论谁满了都会触发清除老年代和永久代的垃圾。这样就不用单独为永久代编写对应的代码,直接使用老年代的。永久代主要存储类信息普通常量静态常量编译器编译后的代码等。例如:我们创建的对象,根据哪个类创建的,这个类的模板就在永久代中,对象的头信息的中的类针就会指向永久代中它实例的类。在JDK7后,将字符串常量池移动到了堆中。但仍有一个问题,由于永久代存储的数据和堆中不同,永久代到底设置为多大合适,很难确定,因此在JDK8以后将永久代移动到了直接内存中,并改名为元空间(Metaspace),在逻辑上和物理上与老年代分离。
这样元空间使用的就是本地内存,默认最大使用空间就是本地内存大小(也可以配置上限),可以自由的根据实际情况加载类的信息。有自己的垃圾回收频率而不用跟随老年代。 元空间是代替了老年代成为方法区规范的实现。
永久代与元空间最大的不同就是 元空间使用的是直接内存,大小可以不必写死
注意:

  • 新创建的对象都会放入Eden区,但在扫描垃圾时,会将Eden区和不为空的幸存者区放在一起扫描。
  • 幸存者from区和幸存者to区大小永远一致
  • 并不是所有对象一定要年龄达到15才可以进入到老年区。如:一些大对象或在Eden区和幸存者From区幸存者空间大小超过幸存者to区一半大小等这些都是直接放入老年区。
  • 触发老年区的Full GC的条件有很多:
    • 在代码中执行System.gc(),在代码中极少使用
    • 老年代空间不足
    • 空间分配担保失败,在GC前计算出平均每次从年轻代晋升到老年代的空间大小,当大小超出老年代剩余空间就会进行FUll GC
    • 元空间超过阈值,默认情况下,元空间的大小与本地大小有关。当元空间使用总大小超过阈值就会进行Full GC。
  • Full GC造成的STW时间过长(STW是GC的10倍以上),所以GC的调优思想就是要么减少Full GC的次数,要么减少Full GC的STW时间。

思考:既然从Eden区和幸存者区中扫描出不是垃圾的内容要放入另一个幸存者区,那这两个区的功能是固定好的吗?
复制之后有交换,谁空谁是to
在经历过第一次GC后,谁空谁是to区。因为在年轻代垃圾清除采用的是复制算法。扫描出不为垃圾的部分将其转移到另一个目的地。谁做目的地是不固定的,如果固定话假设幸存者1区永远为from区,幸存者0区永远为to区:第一次GC,将不为垃圾部分复制到to区,第二次垃圾回收就要扫描Eden区和to区,要复制的地方就不能为to区了,应该是from区。因为幸存者区也需要进行垃圾回收,所以要有to区,且from区与to区要能够实现逻辑互换。
思考:为什么在新生区清除垃圾时是复制有用的,而不是直接清除无用的?这样不是就不需要幸存者区,会更加节省空间了吗?
根据统计,在使用中98%的对象都是临时对象,也就是说Eden区大部分都是垃圾。在垃圾堆里挑出有用的,比一个个清除垃圾效率更高,所以新生代采用复制有用的部分。但这种复制方式需要额外空间。老年代的数据经过15次GC在成为垃圾的概率很小,所以只需要清除无用的部分即可。
思考:什么时候会触发GC?(GC通常指YoungGC)
当Eden区满了的时候就会触发GC。创建对象时是存储在Eden区的,幸存者from区不参与直接存储新创建的对象。

上面已经简单介绍了垃圾回收过程,那么从微观上来讲,无论是年轻代还是老年代垃圾回收器是如何识别出哪些是垃圾的呢?

如何识别垃圾

无论是那种算法,核心就是找出在程序中没有引用关系的内存地址,因为没有引用关系程序中就一定不会再使用到它了,将其回收。

引用计数法

堆内存中的数据每被引用一次次数就+1,取消引用则次数减1,如果堆内存中某块数据的引用次数为0,则代表没有地方引用此数据则判定为垃圾。
例如:有一个Node类,由node1引用它,引用次数为1->node1,node2都引用它,引用次数为2->node1取消引用它,引用次数为1->node2取消引用它,引用次数为0,此地址的数据是垃圾。

public class Application {
    
    
    public static void main(String[] args) {
    
    
        Node node1 = new Node("张三");
        Node node2=node1;
        System.out.println(node2);
        node1=null;
        System.out.println(node2);
        node2=null;
    }
}

但这种方式存在一个问题:循环引用问题

public class Application {
    
    
    public static void main(String[] args) {
    
    
        Node node1 = new Node("张三");
        Node node2 = new Node("李四");
        node1.setData(node2);
        node2.setData(node1);
        node1=null;
        node2=null;
    }
}

以name为"张三"的Node为例:node1引用,引用次数为1->node2中data引用,引用次数为2->node1引用无效,引用次数为1,但此时由于Node2已经为null,程序中已无法使用此Node,此Node已经为垃圾,但引用次数为1 不为0,无法被识别为垃圾。这就是采用引用计数法带来的循环引用问题。
在这里插入图片描述
引用计数法的缺点:

  • 每个对象都要维护一个引用计数器,有性能损耗
  • 拿以处理循环引用问题

由于引用计数的缺点突出,现在已经基本上不会使用引用计数法了,而是使用下面的可达性分析法。

可达性分析法

从一个可以作为GC roots的对象作为起点,顺着它的引用关系,遍历它的引用链。所有的GC roots对象都没有遍历到就是内存中的垃圾,此地址的数据在程序中已经没有使用它的地方。就像葡萄串一样,从根开始顺着藤蔓向下找,能找到的葡萄粒一定是在串中的,只有掉落的葡萄粒不会被遍历到,这种就是垃圾。
还是上面采用技术法无法解决的例子

public class Application {
    
    
    public static void main(String[] args) {
    
    
        Node node1 = new Node("张三");
        Node node2 = new Node("李四");
        Node node3 = new Node("王五");
       	new Node("赵六");
        node1.setData(node2);
        node2.setData(node1);
        node1=node3;
        node2=null;
    }
}

上面代码可以表示为:
在这里插入图片描述

如果使用可达性分析法,node1,node2和node3都可以作为GC roots,从它三开始遍历它的引用关系。最终也没有遍历到内存中name为张三,李四,赵六这三个节点,因此判定这三个为垃圾会被回收
在这里插入图片描述

既然是从GC roots对象集合进行遍历引用关系,那么那些对象可以作为GC roots呢?GC roots是四类对象的集合

  • 栈中局部变量引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈JNI引用的对象

在上面的例子中,所有作为GC roots的对象都是第1种。

从GC roots遍历有引用关系的对象不是一定不会作为垃圾,要分当时内存情况和那种引用关系。但没有引用关系的一定会被当做垃圾。
由遍历GC roots对象们的引用关系来确定哪些是垃圾,这又牵扯到引用的关系类型。

引用关系四种类型: 强、软、弱、虚

对象间的引用关系可以分为4种,分别是强引用软引用弱引用虚引用。我们平时使用到的引用关系都是强引用,剩余三种方式都是在特殊的功能下使用。
在这里插入图片描述


补充:

  • JVM会在Eden区空间不足或老年代空间不足时触发GC,我们在代码中也可以手动调用GC。使用 System.gc();这种方式不常见,通常只在测试中使用。而且:使用这种方式GC是Full GC。
  • 在默认的情况下,堆内存最小分配占服务器总内存的六十四分之一,最大内存占服务器总内存的四分之一 ,JVM会以最小内存启动,当内存不够用它会调整直到达到最大内存,如果仍不够用则OOM错误。
    验证:
    本机内存16G
    在这里插入图片描述
    也可以通过代码的方式获取:
public class Application {
    
    
    public static void main(String[] args) {
    
    
        OperatingSystemMXBean mem = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
        // 获取内存总容量
        long totalMemorySize = mem.getTotalPhysicalMemorySize();
    }
}

查看JVM中最小堆内存和最大堆大小

public class Application {
    
    
    public static void main(String[] args) {
    
    
        System.out.println(""+Runtime.getRuntime().totalMemory()/1024/1024+"M");
        System.out.println(""+Runtime.getRuntime().maxMemory()/1024/1024+"M");
    }
}

在这里插入图片描述
近似的满足 默认最大堆内存大小=总内存大小/4,默认最小堆内存大小=总内存大小/64
这些默认的大小参数可以在Idea中调整(服务器上可以在启动jar包时以命令行的方式调整),通常为了防止内存波动,将最大对堆内存空间与最小堆内存空间调成一致。
例如: -Xms5m :将堆内存最小空间设置为5M,-Xmx5m 将堆内存最大空间设置为5M
在Idea中配置
在这里插入图片描述
代码验证结果:
在这里插入图片描述


强引用

我们平常使用到的引用都是强引用,其他引用需要特意用代码标出。例如:

public class Application {
    
    
    public static void main(String[] args) {
    
    
        Node node1 = new Node("张三");
        Node node2 = new Node("李四");
        Node node3=node1;
    }
}

node1和node3引用name为张三的Node,node2引用name为李四的Node,共三个引用都是强引用。
强引用的特点就是:在GC roots可达的情况下,强引用永远不会发生垃圾回收。 如果内存不足,也不会回收,直接抛出OOM异常。

软引用 SoftReference

在内存不足的时候,软引用的堆空间地址无效,也会当做垃圾回收。
代码测试思路:测试同一个代码在不同的环境下 (内存充足和内存不足),只有软引用下的堆空间是否会被当做垃圾回收

public class Application {
    
    
    public static void main(String[] args) {
    
    
        User u1 = new User("张三"); //强引用方式
        SoftReference<User> softReferenceU1 = new SoftReference<>(u1); // 软引用方式 与强引用都是引用同一块地址

        System.out.println(u1);   // 以强引用的方式获取引用地址的数据(User (name=“张三”))
        System.out.println(softReferenceU1.get()); // 以软引用的方式获取引用地址的数据(User (name=“张三”))

        u1=null; //此时 User(“张三”)失去了强引用 ,只有一个软引用

        System.gc(); // 经历了一次 Full GC

        System.out.println(softReferenceU1.get()); // 测试 User("张三")是否被回收掉
    }
}
@Data
@AllArgsConstructor
class User{
    
    
    private String username;
}

在这里插入图片描述

  • 内存充足的情况下

结果:
在这里插入图片描述
由此可见:在内存充足时,由于堆内存中User(name=“张三”)仍有一个软引用,使得它没有被当做垃圾回收。

  • 在内存不足的情况下 设置JVM参数,将JVM堆的最大最小内存设置为5MB

由于内存不足会直接报错,这里对上面代码进行修改,使得内存不足的语句进行try包裹,以便输出

public class Application {
    
    
    public static void main(String[] args) {
    
    
        User u1 = new User("张三"); //强引用方式
        SoftReference<User> softReferenceU1 = new SoftReference<>(u1); // 软引用方式 与强引用都是引用同一块地址

        System.out.println(u1);   // 以强引用的方式获取引用地址的数据(User (name=“张三”))
        System.out.println(softReferenceU1.get()); // 以软引用的方式获取引用地址的数据(User (name=“张三”))

        u1=null; //此时 User(“张三”)失去了强引用 ,只有一个软引用


        try {
    
    
            Byte[] load = new Byte[1024 * 1024 * 10];
            // 直接开辟一个10M的内存空间 使得堆内存不足 这是检测是否会只有软引用是否会被当做垃圾回收
        }catch (Exception e){
    
    

        }
        finally {
    
    
            System.out.println(softReferenceU1.get()); // 测试 User("张三")是否被回收掉
        }
    }
}

结果:
在这里插入图片描述

弱引用 WeakReference

只要触发GC,软引用就会失效(只被软引用的空间会被当做垃圾处理)
没有进行GC

public class Application {
    
    
    public static void main(String[] args) {
    
    
        User u1 = new User("张三"); //强引用方式
        WeakReference<User> weakReferenceU1 = new WeakReference<>(u1); // 弱引用方式 与强引用都是引用同一块地址

        System.out.println(u1);   // 以强引用的方式获取引用地址的数据(User (name=“张三”))
        System.out.println(weakReferenceU1.get()); // 以弱引用的方式获取引用地址的数据(User (name=“张三”))

        u1=null; //此时 User(“张三”)失去了强引用 ,只有一个弱引用

        System.out.println(weakReferenceU1.get());

    }
}
@Data
@AllArgsConstructor
class User{
    
    
    private String username;
}

结果:在没有GC之前,若引用下的对象仍可以使用
在这里插入图片描述

进行GC
在这里插入图片描述
思考:代码中为什么要将u1置为null?
使用u1是强引用,强引用的特点就是:在任何情况下,强引用指向的对象永远不会被当做垃圾处理。如果在测试软引用,弱引用,虚引用时不将强引用断开,就无法看到结果。

WeakHashMap

当我们使用HashMap时,Key是一个强引用关系。例如:我创建了一个User u1=User(name=“张三”),想要给u1对象加个附属的值,于是将u1作为key传递给HashMap。当u1对象使用完毕(附属的值也应该消失),想要释放时u1=null,但这时HashMap仍有一个强引用在指着User这个对象。这个对象就无法当做垃圾释放。
在这里插入图片描述
这样对象在垃圾回收时还会存在就会浪费内存引发OOM问题
使用WeakHashMap是一个与对象建立弱引用关系,想要使用此对象,又不想让成为对象不是垃圾的依据。当u1使用完毕,断开强引用时,在GC时就会将垃圾回收,忽略WeakHashMap中的引用。

public class Application {
    
    
    public static void main(String[] args) {
    
    
        WeakHashMap<User, String> weakHashMap = new WeakHashMap<>();
        User u1 = new User("张三");
        User u2 = new User("李四");
        weakHashMap.put(u1,"v1");
        weakHashMap.put(u2,"v2");
        u1=null;  //  User("张三")由于断开强引用,只有一个弱引用的WeakHashMap与之相连,在发生GC时会被回收
        System.out.println(weakHashMap);
        System.gc();
        System.out.println(weakHashMap); // 判断只有WeakHashMap引用下的对象是否被释放
    }
}

结果:
在这里插入图片描述
但当对象作为weakHashMap作为Value时,User对象断开u1的强引用,在GC后对象仍然存在,所以,WeakHashMap的value部分可能是强引用。如图:
在这里插入图片描述

在这里插入图片描述

思考:当key指向的对象被回收后,在通过WeakHashMap的key去获取值时,会返回什么?
在这里插入图片描述

思考:WeakHashMap可能由于Key是弱引用,当引用对象没有强引用后被回收,那么回收后WeakHashMap的value值去哪了?
在这里插入图片描述
思考:WeakHashMap<Object,Object>是否类似于HashMap<Reference<Object>,Object>?

补充:我在ThreadLocalMap源码中中就看到过这种使用弱引用的思路来解决ThreadLocal内存溢出问题:
在这里插入图片描述
将ThreadLocal作为key,当ThreadLocal销毁时,Map中关于此ThreadLocal的数据也会释放。

软引用与虚引用的使用场景

软引用和弱引用通常配合着强引用来使用。软引用是当强引用断开时,在内存允许的情况下还想使用该地址空间。软引用是当强引用断开自己也没必要去使用该地址空间,通常配合着集合,当强引用断开,集合中的元素也没有意义,使用弱引用能够就释放引用的堆地址,集合移除的方式堆释放麻烦,交给GC去释放。

例如:利用软引用生成缓存
读取一个图片时,如果每次使用都从磁盘读取会影响性能,如果一次全部读取又有可能内存溢出。如何既能提升效率,又不会使得内存溢出呢?这时就要将内存利用最大化,在内存空间不足快要发生OOM时,将加载到内存的图片释放。如果运行期间内存充足,则可以一直使用内存中的图片。 HashMap<String, Reference<Byte>> hashMap = new HashMap<>()

虚引用与引用队列

虚引用的使用要配合着引用队列,没有引用队列使用虚引用将无意义。

引用队列

在引用的对象被销毁前会先放入引入队列中,另一端监听这个队列可以进行一些关于即将销毁的对象的善后处理
软引用,弱引用,虚引用都可以在构建时指定引用队列,这里的虚引用在构建时必须要使用指定引用队列。
下面虚引用结合引用队列的使用:

public class Application {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
        User u1 = new User("张三");
        WeakReference<Object> weakReference = new WeakReference<>(u1, referenceQueue);
        u1=null;
        new Thread(()->{
    
    
            while (referenceQueue.poll()==null){
    
    
            }
            System.out.println("对象被销毁");
        }).start();

        System.gc();
        TimeUnit.SECONDS.sleep(1);
    }
}

很少会使用到

虚引用 PhantomReference

phantom:幽灵,幻觉,虚引用无法获取引用对象,它唯一的用处就是和引用队列结合,使得被引用的对象在销毁前能够加入引用队列,这样在引入队列的另一头可以做一些善后工作。使用虚引用可以不对类产生任何额外的影响,极少会用到。

public class Application {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        User user = new User("张三");
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
        PhantomReference<User> reference = new PhantomReference<>(user, referenceQueue); // 在创虚引用对象时就要指定消息队列
        System.out.println(reference.get()); //尝试获取虚引用引用的对象
        user=null;
        new Thread(()->{
    
    
            while (referenceQueue.poll()==null){
    
    

            }
            System.out.println("虚引用引用的对象被销毁");
        }).start();
        System.gc();
        TimeUnit.SECONDS.sleep(1);
    }
}

结果:
在这里插入图片描述

思考:其他引用队列可以在销毁前可以加入引用队列吗?
可以只是很少用到,只是虚引用只有这个功能,所以特别介绍


总结:至于对象是否会被回收,只需要查看引用此对象的个数和引用的方式。


上面已经介绍了如何识别垃圾,接下来就是发现垃圾后如何处理呢?

垃圾回收算法

引用计数

计数为0的就是垃圾,有一次引用次数就+1,有一个引用失效次数就-1。
这种算法是结合扫描垃圾的引用计数法,但这种方法存在循环引用问题,已经很少使用,所以引用计数这种算法方法也不会使用。

复制 Copying

复制A区不是垃圾部分到B区,然后清空A区。这种方式适用于堆的年轻代,因为年轻代不是垃圾占少数,移动量相对较少。将Eden区和幸存者from区不为垃圾的部分复制到幸存者to区,然后清空Eden区和幸存者from区。
这种方式效率较高,而且在复制是从to区全为空的地方开始复制,清除是将Eden区和幸存者from区全部清除,所以不会产生内存碎片。
这种方式的缺点

  • 需要额外的空间,如幸存者to区,就需要和from区一样大小,在任何时刻在幸存区中都有一半的内存被浪费
  • 如果遇到极端的情况,如Eden区100%全不为垃圾,这是复制有用部分耗时不说,还会将幸存者to区撑爆。所以适合存活率低的地方(在默认的情况下,Eden区:幸存者from区:幸存者to去=8:1:1)

标记 Mark-Sweep

标记清除:分为两部分 1. 遍历所有GC roots标记哪一部分是垃圾 2. 遍历整个堆清除垃圾。在标记清除垃圾和清除垃圾时都需要STW(stop the world)暂停整个应用。发生在老年代,因为老年代绝大多数对象都是经历过15次GC的,再次成为垃圾的概率较小。

在这里插入图片描述
它有两大缺点:

  • 效率低,需要暂停整个应用
  • 清除使得内存不连续,造成内存碎片过多。JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候(大对象在老年区)需要一大块连起来的空间,寻找连续的内存空间会不太好找。

标整 Mark-Compact

标记整理:分为三部分,1. 遍历所有GC roots标记哪一部分是垃圾 2. 遍历整个堆清除垃圾。3.将内存进行整理,减少碎片的产生。相较于标记算法多了整理一步,这就使得内存碎片问题得到解决,但也带来了新的问题:整理时花费时间和耗费CPU。
标记清除和标记整理都发生在老年代,在使用时常常会将二者结合,在进行多次标记清除后,进行一次整理。这样既避免了频繁标记整理耗费时间和性能又能使得内存碎片在合理的范围内。


这三个算法各有各的优势,各有各的劣势。没有完美的算法,要根据合适的长江选择合适的算法。


上面的是哪个算法是一种解决垃圾回收的思路,具体的实现工具就是下面的垃圾回收器。

垃圾回收器种类

真正将根据算法实现的工具,垃圾回收器总的来说回收的方式分为5类。

串行回收器

只有一个线程在进行回收,在回收垃圾时会暂停所有用户线程。适合单线程的场景。就好比,上课时,只有一个保洁阿姨进来要打扫卫生。想要继续上课只能等到保洁阿姨打扫完卫生。所有用户线程都要暂停等待这一个线程GC完毕,效率较低。
具体实现有 Serial(用于年轻代),Serial Old(用于老年代)

并行回收器

相较于串行回收器,在回收时不再只有一个线程,而是多个线程一起参与。这样就不再等待一个人干活,任务量不变,多个人一起干活相较于一个人干活能够减少用户线程的等待时间。
具体实现有 ParNew(用于年轻代),Parallel Scavenge(用于年轻代),Parallel Old(用于老年代)

并发回收器

用户线程和回收线程可以一起工作(虽然有暂停但时间较短),它的最大特点就是没有长时间暂停,适合用于于用户对交互请求强的场景。因为用户在交互时肯定不希望突然长时间的暂停,使用并发垃圾回收器可以减少响应时间。
具体的实现有 CMS(用于老年代)

G1

ZGC

思考:什么是STW?
STW:Stop The Word,是在进行垃圾回收时,会暂停所有用户线程,造成卡顿的现象。
思考:复制算法也会到导致STW吗?为什么要GC时要STW?
所有垃圾回收器都会导致STW,只是时间长短问题。因为在最终确认垃圾时,一定要确保一致性,否则程序不断运行,引用关系不断变化,分析的结果会不准确。如果在复制时,没有暂停,导致在这期间创建的对象就不会被标记为存活对象,就会导致幸存者没被移动到幸存者区而被全部清除,强引用被清除,程序会出错。且在复制算法和标记整理算法中,会导致原引用对象的地址会发生变化。防止引用混乱。

常用垃圾回收器实例

新生代

Serial

只有一个线程在年轻代中使用复制算法将伊甸园中与幸存者From区所有幸存的对象复制到幸存者To区。在GC时需要STW。可以与Serial Old垃圾回收器一起搭配工作。

Parallel Scavenge

开启多个线程在年轻代中使用复制算法将伊甸园中与幸存者From区所有幸存的对象复制到幸存者To区。在GC时需要STW,由于采用多线程一起工作,STW的时间可能会短些。
可以通过参数 XX:MaxGCPauseMillis及直接控制吞吐量的参数-XX:GCTimeRatio设置吞吐量。吞吐量=程序运行时间/(程序运行时间+GC时间)。提升吞吐量能够提升CPU的利用率,但与响应速度无关,并不一定能提升用户体验。

可以与CMS垃圾回收器Parallel Old垃圾回收器一起搭配工作。

ParNew

同样是多个线程一起GC,与Parallel略有不同(不能设置吞吐量)。可以与CMS垃圾回收器一起工作

老年代

Serial Old

采用单一线程的方式回收垃圾,在回收垃圾时会暂停所有的用户线程。与上面青年代的Serial类似,但在老年代采用的是标记整理算法,不会产生内存碎片。

Parallel Old

并发线程的回收,与年轻代的Parallel 类似,但在老年代采用的标记整理算法,不会产生内存碎片。

CMS

CMS全称:concurrent mark sweep,翻译过来就是并发清除。

CMS四个阶段
  • 初始标记:在老年区将所有GC roots即根节点全部扫描出来。在这期间在暂停根节点的变化,所以是STW的,但由于GC roots相对较少,所以STW时间相对较短
  • 并发标记:由于在初始阶段已经将根节点全部扫描出来,在这个阶段只需要顺着根节点扫描出无法引用到的对象并标记为垃圾。在这期间,是与用户线程并行执行的。由于通常根节点下面的节点要比根节点要多,所以是相对耗时的,但由于是与用户线程并行执行,并没有STW。缺点就是这节点比较耗费性能。
  • 重新标记:由于用户线程一边执行一边扫描垃圾,垃圾可能会新增。这是暂停所有用户线程,来个最终的大扫除。将这一过程新增垃圾再次进行标记,确保打扫彻底,不能由于并发产生的垃圾不知道。虽然这个阶段是STW,但由于新增的垃圾比较少,所以时间很短。
  • 并发清除:到这里就已经标记完所有的垃圾,剩余的就是清除(清除算法采用的是标记清除)。清除的线程可以与用户线程一起执行。

CMS最大的特点就是STW较短,在交互中系统卡顿的时间较短,适用于与用户交互时使用。
但CMS有两大的缺点:

  • 在并发清除的过程中,由于用户线程没有暂停,这是可能有新的数据加入老年代(像大对象直接放入老年代),所以并不是满了再去清除,而是预留一定的空间。比如10%,然而如果在并发清除过程中新进入老年代的数据超过预留的10%,则此时老年代没有空间存放,会触发Concurrent Mode Failure,然后CMS将退化为Serial Old垃圾回收器。暂停所有用户线程,采用单线程的方式进行回收。造成严重卡顿。预留空间大会造成频繁Full GC,预留空间小会造成CMS回收失败。
  • CMS采用的算法是并发清除算法,所以会产生内存碎片。碎片过多时,就会触发Serial GC,采用单线程标记整理算法进行清除和碎片整理。

思考:CMS为什么不使用标记整理算法来解决碎片问题?
如果使用 整理算法,那么在并发清除时对象的地址就可能发生变化,需要暂停所有用户线程。

年轻代+老年代

G1

推荐阅读这篇博客

至于GC垃圾回收器的配置,搭配使用,以及JVM的一些参数、命令,JVM结合Linux的调优等会在后续的博客中介绍。如果发现错误的地方欢迎一起探讨。

猜你喜欢

转载自blog.csdn.net/m0_52889702/article/details/128900768