论如何初步理解GC--极度枯燥,生人退避

什么是垃圾?

在Java堆中,没有被引用、没有在任何地方被使用、再也不会用到的对象就是垃圾。而在方法区中,假如某个类是无用的类,那么相应的类的instanceKlass也要被卸载回收。无用的类定义如下:

  • 该类所有的实例对象都被回收
  • 加载该类的ClassLoader被回收(暂时无法找到关于回收类加载器的资料)
  • 该类的java.lang.Class对象无用,即没有任何地方在用反射机制去访问这个类

怎么判断无用对象

无用类的判断标准我们已经知道了,那么Java堆中的无用对象如何判断呢?

引用计数法

引用计数法是指如果有一个地方引用了该对象,引用计数器就+1;一个地方放弃了引用,引用计数器就-1。最后如果引用计数器为0则代表该对象无用。

这在一般的情况下都是效率挺高的办法,但是很少有虚拟机会采用这种办法,因为它无法解决循环引用的问题。

可达性分析算法

可达性分析算法的是指选取一些对象作为GC Roots,对象之间的引用关系可以看做一条引用链,假如从GC Roots开始沿着引用链查找,查找不到的对象就是无用对象,即从GC Roots到该对象不可达。

可见这个算法的关键就在于如何选择GC Roots:

  • 虚拟机栈中局部变量引用的对象
  • 方法区中静态变量、常量所引用的对象
  • 本地方法栈引用的对象
  • 加锁的对象

个人对于为什么选择这些作为GC Roots的一些思考:

对象创建出来就是要使用的,那么我们一般在哪里使用对象呢?

  1. 在Java中,所有的语句执行都依靠方法调用来运行,比如最基础的就是main方法,那么毫无疑问,在方法中引用的对象都会被默认看做是需要被使用的,不能被回收,以其为GC Roots十分合适;
  2. 在方法中,我们可能使用类名.变量名的方式直接使用某个类的静态成员,那么假如这个静态成员引用了某个对象,那么这个对象也不应该被当作无用对象,我们默认静态变量都会被使用,所以方法区中静态变量引用的对象可以作为GC Roots;
  3. 被设置为常量的对象引用是不可变的,并且常量必须赋初始值,所以常量引用的对象从编译一直到程序结束都不能被回收,所以适合作为GC Roots;
  4. 被锁的对象明显不能被回收,不然锁就没了。其实大多数情况下我们都是使用静态变量来充当锁,也就暗合了2中的情况。

谈谈引用

引用到底是什么呢?Java中有4种引用方式。

强引用
强引用就是我们最常接触的引用:

Object obj = new Object();

在上面的例子中,我们new了一个Object对象,并把这个对象赋值给了obj变量,即obj变量强引用了Object对象。只要强引用还在,那么引用的对象就永远不会被回收。但是,其他引用就不一定了。

软引用

软引用可以用来引用一些有用但是非必需的对象。当将要发生内存溢出时,虚拟机会把软引用的对象加进可回收范围内,即便软引用依旧存在,虚拟机也会为了避免发生内存溢出而回收掉软引用。Java提供了SoftReference类来实现软引用。

弱引用

弱引用的对象更糟糕,它只能存活一次,等到GC的时候无论内存够不够它们都会立马被回收。Java提供了WeakkReference类来实现弱引用。

虚引用

最惨还得数虚引用,它的存在不会对对象的存活时间造成任何影响,唯一的用处就是在虚引用对象被回收时可以收到通知。Java提供了PhantomReference类来实现虚引用。

那么问题来了,有了强引用为什么还要其他引用呢?

看一下下面这个例子:

import java.util.ArrayList;
import java.util.List;

//-Xmx20m -XX:+PrintGCDetails -verbose:gc
public class test {
    public static final int _4MB = 4*1024*1024;

    public static void main(String[] args) {
        List<byte[]> ls = new ArrayList<>();
        for (int i = 0;i<5;i++){
            ls.add(new byte[_4MB]);//这里ls强引用了byte[]数组
        }
    }
}
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at test.main(test.java:10)

我们设置了虚拟机的堆内存为20M,可以看到创建五个4M的对象程序就会OOM。

我们改用软引用试试

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

//-Xmx20m -XX:+PrintGCDetails -verbose:gc
public class test {
    public static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) {
        List<SoftReference<byte[]>> ls = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            SoftReference<byte[]> srf = new SoftReference<>(new byte[_4MB]);//通过srf去软引用数组
            //ls跟SoftReference是强引用,srf跟byte[]是软引用
            ls.add(srf);
        }
    }
}
null
null
null
null
[B@1b6d3586

可以看到,此时并未抛出OOM异常,因为在创建第五个对象时发现将要发生OOM异常,JVM就把前面四个软引用对象全都回收了,然后再创建第一个对象,所以集合里面只有第五个对象。

当然我们还可以优化一下代码,毕竟既然软引用对象已经被回收了,就没必要放在集合里了。

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 test {
    public static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) {
        List<SoftReference<byte[]>> ls = new ArrayList<>();
        //定义引用队列
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
        for (int i = 0; i < 5; i++) {
            //关联了引用队列,当软引用连接的byte[]被回收时,srf就会被加入到这个queue中
            SoftReference<byte[]> srf = new SoftReference<>(new byte[_4MB], queue);//通过srf去软引用数组
            //ls跟SoftReference是强引用,srf跟byte[]是软引用
            ls.add(srf);
        }
        //我们获取queue中的元素
        Reference<? extends byte[]> poll = queue.poll();
        while (poll != null) {
            ls.remove(poll);//如果有元素就把它从ls里面移除
            poll = queue.poll();//再获取一次元素
        }
        for (SoftReference<byte[]> s : ls
        ) {
            System.out.println(s.get());
        }
    }
}

所以,当保存一些可以有但没必要的数据时(即不是很常用,
可以在需要的时候再创建的数据),为了防止OOM,可以采用其他引用方式,增加系统的鲁棒性,下次需要这些数据的时候再创建一次就好了。

那么怎么回收对象呢?

回收对象有多种算法。

标记-清除算法

这个很简单,就分为标记和清除两个步骤。标记就是根据可达性分析算法去把所有的无用对象标记出来;但是清除不是将内存清空,而是把标记出来的内存地址放在一个Java堆维护的空闲列表中,下次有对象需要分配内存空间就在这个表上面找大小合适的空间。

在这里插入图片描述

标记-清除算法是最基础的算法,所以问题也很多,最主要有两个:

  1. 标记和清除的效率都不高
  2. 清除之后会产生大量的空间碎片,下次分配要找到合适大小的空间不容易,可能全都很小,然后找不到合适的内存,那么即便还有内存也会造成内存溢出

复制算法

复制算法是在标记-清除算法的基础上优化得来的。我们这里直接以Java堆新生代为例进行介绍。

Java新生代按照8:1:1的比例(经研究,新生代98%的对象都会被回收)将新生代内存空间划分为一块Eden区和两块Survivor区(分别叫toSurvivor和fromSurvivor),当给对象分配空间时会优先在Eden区分配。当Eden区满的时候,就会标记Eden区和fromSurvivor区里面的有用对象,然后把所有的有用对象移到toSurvivor区,再把Eden区和fromSurvivor区清除,最后还要把fromSurvivor和toSurvivor区角色互换。

在这里插入图片描述

上面的过程看官可以细品一下。这样就可以保证内存规整没有空间碎片,给对象分配内存的时候就可以使用指针碰撞的简单形式,并且只有10%的空间是空闲的(即toSurvivor空间),内存利用率达到90%。

空间分配担保
虽说一般情况下新生代98%的对象都会被清除,但是只留10%的空间用于接收存活对象也并不保证,万一就有超过10%的对象存活了呢?

这时候就需要用老年代的空间作为担保,假如toSurvivor装不下了,就将对象全部移到老年代去。

对象何时进入老年代?

我们已经知道对象会优先分配到Eden区,那么老年代仅仅用来存放新生代放不下的对象吗?并不是,以下对象也会进入老年代:

  • 大对象在直接分配在老年代
  • 新生代中,年龄(每度过一次GC加一岁)默认超过15岁的对象会被移入老年代
  • 如果新生代中某一年龄的对象占Eden区内存的一半以上,那么那个年龄及以上年龄的对象会被移入老年代

标记-整理算法

复制算法香啊,但是也不是谁用都香。新生代能用是因为那里的存活对象少,碰到存活对象多的老年代就不合适了。所以老年代一般使用标记-整理算法。

标记-整理算法简单粗暴,标记好对象后,将对象移到一端,除此之外的内存空间全都记录在空闲列表中。

在这里插入图片描述

进入正题,虚拟机的垃圾回收过程

枚举GC Roots

我们已经知道,所有的垃圾回收算法的第一步都是标记无用对象,具体实现就是要枚举GC Roots。

枚举GC Roots需要STW(Stop the World,即停止所有正在运行的线程,以防止引用关系被修改)。可作为GC Roots的节点都在全局性引用(常量和静态变量引用)和执行上下文(局部变量表),如果要逐个检查这些地方(堆栈)的引用是十分耗费时间的,那么STW的时间就会很长,程序性能就很差。

目前主流的虚拟机都采用准确式GC,即执行到某个地方时,就使用一组OopMap的数据结构用来记录堆栈中哪些地方是引用,然后虚拟机直接找这些地方就可以了。

安全点

上面提到只有执行到某个地方才会生成一组OopMap用来记录当前线程的引用记录(假如每执行一条语句就用一组OopMap把当前引用记录下来,那么OopMap就会十分多,占用大量内存,生成OnpMap也需要时间,增加了程序运行的压力,而且也没必要),这个地方就是安全点。

程序并不是在任何地方都可以随意停止线程然后开始GC的,而是必须等所有线程都运行到安全点。安全点的选择十分讲究,各个安全点之间既不能间隔太远以至于让虚拟机等很久才能GC,也不能间隔太近而生成太多OopMap。一般选择标准都是在“能让程序长时间执行”的指令上,包括方法调用、循环跳转、异常跳转等。

另一个需要考虑的问题是,如何在发送GC时等到所有线程都运行到安全点再STW呢?

  • 抢先式中断:先停顿所有线程,然后检查发现有还没到安全点的线程就恢复它们,让它们跑到安全点再次停顿它们(现在几乎没有虚拟机使用这种方法);
  • 主动式中断:当需要GC时,不直接去中断线程,而是在安全点设置一个标志,所有线程都会去轮询这个标志,发现了这个标志就停下来

看似到了这里,枚举GC Roots似乎已经可以完美地实现了,但还有个问题,处于Blocked、Waiting、TimeWaiting的线程无法轮询标志(因为它们并未执行),那它们怎么办呢?

安全区域

安全区域就是对象的引用关系不会发送改变的某一段代码块,这些代码块会被标记为Safe Region,在这些区域的任何地方GC都不会有问题。当线程进入Safe Region时,就给自己打上一个标记,这个时候GC就不用管打上标记的线程了;当线程离开Safe Region时,就需要检查枚举GC Roots(或者整个GC)是否结束,没有结束就得等待,直到收到结束的通知才能继续运行。

所以其实安全区域就可以直接搞定枚举GC Roots的问题。嗐,搞了那么多乱七八糟的东西,增加学习负担。

垃圾收集器(GC策略)

我们扯了那么多,那肯定得有东西去执行垃圾回收啊。垃圾回收的工作就需要垃圾收集器来完成了。

在这里插入图片描述

注意!垃圾收集器其实就是JVM的守护线程,分为单线程和多线程,它们按照不同的回收策略和算法去回收垃圾,就相当于不同的垃圾收集器。

3.5.1 Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器,曾经(在JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。这个收集器是一个单线程的收集器,只使用一个CPU或一条收集线程去完成垃圾收集工作,在垃圾收集时会“Stop The World”,即在用户不可见的情况下把用户正常工作的线程全部停掉。
在这里插入图片描述
虽然有缺点,但是它依然是虚拟机运行在Client模式下的默认新生代收集器。它相对其他收集器的单线程更加简单高效,虽然会有点停顿,但是在新生代内存不是很大的情况下完全是可以接受的。

3.5.2 ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,是Server模式下的虚拟机中首选的新生代收集器。

ParNew收集器多线程收集、多运行在Server模式下的虚拟机中首选的新生代收集器。我们知道服务端如果接收的请求多了,响应时间就很重要了,多线程可以让垃圾回收得更快,也就是减少了 STW 时间,能提升响应时间,所以是许多运行在 Server 模式下的虚拟机的首选新生代收集器,另一个与性能无关的原因是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作,CMS 是一个划时代的垃圾收集器,是真正意义上的并发收集器,它第一次实现了垃圾收集线程与用户线程(基本上)同时工作,它采用的是传统的 GC 收集器代码框架,与 Serial,ParNew 共用一套代码框架,所以能与这两者一起配合工作,而后文提到的 Parallel Scavenge 与 G1 收集器没有使用传统的 GC 收集器代码框架,而是另起炉灶独立实现的,另外一些收集器则只是共用了部分的框架代码,所以无法与 CMS 收集器一起配合工作。

3.5.3 Parallel Scavenge收集器(默认开启)

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。 [ˈskævɪndʒ]清除污物,打扫;

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
在这里插入图片描述

3.5.4 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下可以作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
在这里插入图片描述

3.5.5 Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
在这里插入图片描述

3.5.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC RootsTracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

初始标记和重新标记两个阶段会发生 STW,造成用户线程挂起,不过初始标记仅标记 GC Roots 能关联的对象,速度很快,并发标记是进行 GC Roots Tracing 的过程,重新标记是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短

整个过程中耗时最长的是并发标记和标记清理,不过这两个阶段用户线程都可工作,所以不影响应用的正常使用,所以总体上看,可以认为 CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS有以下3个明显的缺点:

  1. CMS 收集器对 CPU 资源非常敏感 原因也可以理解,比如本来我本来可以有 10 个用户线程处理请求,现在却要分出 3 个作为回收线程,吞吐量下降了30%,CMS 默认启动的回收线程数是 (CPU数量+3)/ 4, 如果 CPU 数量只有一两个,那吞吐量就直接下降 50%,显然是不可接受的
  2. CMS 无法处理浮动垃圾(Floating Garbage),可能出现 「Concurrent Mode Failure」而导致另一次 Full GC 的产生,由于在并发清理阶段用户线程还在运行,所以清理的同时新的垃圾也在不断出现,这部分垃圾只能在下一次 GC 时再清理掉(即浮云垃圾),同时在垃圾收集阶段用户线程也要继续运行,就需要预留足够多的空间要确保用户线程正常执行,这就意味着 CMS 收集器不能像其他收集器一样等老年代满了再使用,JDK 1.5 默认当老年代使用了68%空间后就会被激活,当然这个比例可以通过 -XX:CMSInitiatingOccupancyFraction 来设置,但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure 失败,这时会启用 Serial Old 收集器来重新进行老年代的收集,而我们知道 Serial Old 收集器是单线程收集器,这样就会导致 STW 更长了。
  3. CMS 采用的是标记清除法,上文我们已经提到这种方法会产生大量的内存碎片,这样会给大内存分配带来很大的麻烦,如果无法找到足够大的连续空间来分配对象,将会触发 Full GC,这会影响应用的性能。当然我们可以开启 -XX:+UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理会导致 STW,停顿时间会变长,还可以用另一个参数 -XX:CMSFullGCsBeforeCompation 用来设置执行多少次不压缩的 Full GC 后跟着带来一次带压缩的。
3.5.7 G1收集器

G1 收集器是面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器,主要有以下几个特点

  • 像 CMS 收集器一样,能与应用程序线程并发执行。
  • 整理空闲空间更快。
  • 需要 GC 停顿时间更好预测。
  • 不会像 CMS 那样牺牲大量的吞吐性能。
  • 不需要更大的 Java Heap

与 CMS 相比,它在以下两个方面表现更出色

  1. 运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
  2. 在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。

为什么G1能建立可预测的停顿模型呢,主要原因在于 G1 对堆空间的分配与传统的垃圾收集器不一器,传统的内存分配就像我们前文所述,是连续的,分成新生代,老年代,新生代又分 Eden,S0,S1,如下

除了和传统的新老生代,幸存区的空间区别,Region还多了一个H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动。那么 G1 分配成这样有啥好处呢?

传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。

G1 收集器的工作步骤如下

  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 筛选回收

G1垃圾回收阶段:
在这里插入图片描述
三者是循环过程:

  1. 首先进行新生代回收(将新生代存活对象复制进To-Survivor空间,年龄足够的就晋升到老年代,然后回收Eden和From-Survivor空间),并对GC Roots进行初始标记(会STW)
  2. 当老年代内存超过阈值(可以通过参数来控制,默认45%),会在新生代收集的同时进行并发标记(不会STW)
  3. 然后会进行混合收集,将回收价值最大的一块Region(同时包含新生代和老年代)回收掉(最终标记(因为之前是并发标记,程序还在运行,可能产生新的死亡对象),并进行垃圾收集,会STW)

何时发生GC?

新生代GC(Minor GC)

当Eden区没有空间分配给对象时,就会发生Minor GC,所以Minor GC十分频繁,创建对象越多越快,GC越频繁。

老年代GC(Full GC)

①当老年代无法分配内存的时候,会导致MinorGC;

②当发生Minor GC的时候可能触发Full GC,由于老年代要对年轻代进行担保,由于进行一次垃圾回收之前是无法确定有多少对象存活,因此老年代并不知道自己要担保多少空间,因此采取采用动态估算的方法:也就是以之前回收时晋升到老年代的对象容量的平均值作为阈值,假如老年代当前容量比这个阈值小,就会发生Full GC。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值