GC垃圾回收

JVM的垃圾回收机制,在内存充足的情况下,除非你显式调用System.gc(),否则它不会进行垃圾回收;在内存不足的情况下,垃圾回收将自动运行

判断对象是否要回收的方法

引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器减1。当计数器为0的时候,对象就可以被回收。

缺点:存在循环引用的情况,导致2个循环引用对象的内存得不到释放。目前没有一个JVM的垃圾回收实现是使用这个算法的。

主流的Java虚拟机没有使用引用计数算法来管理内存,因为它很难解决循环引用的问题。

可达性分析算法

思路是:通过一系列“GC Roots”对象作为起点,从这些节点开始向下进行搜索,搜索所走过的路径被称为“引用链”。当一个对象到GC Roots没有任何引用链相连,也就是说从GC Roots到这个对象不可达,则证明此对象是不可用的。如图object5、object6、object7虽然互相关联,但是他们到GC Roots是不可达的,所以他们将被判断为可回收的对象。

image-20191026100934969

(把一些对象当做root对象,JVM认为root对象是不可回收的,并且root对象引用的对象也是不可回收的)

在Java语言中,可作为GC Roots的对象包含下面几种:

  • 虚拟机栈(栈帧中本地变量表)中引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中Native方法引用的对象

即时在可达性分析法中不可达的对象,也并非是非死不可,要真正宣告一个对象的死亡,只要要经历2次标记的过程

  • 如果对象在进行可达性分析之后,发现没有与GC Roots相连的引用链,那它将会被第一次标记。
  • 判断该对象是否有必要执行finalize(),如果对象没有覆盖finalize方法或者finalize已经被覆盖过了,虚拟机将这两种情况视为”没有必要执行“。

    • 如果这个对象被判断为有必要执行finalize()方法,那么这个对象将会被放置在一个F-Queue的队列中,并在稍后由虚拟机建立的、低优先级的Finalizer线程去执行。这里的”执行“指的是虚拟机会触发这个方法,但是不承诺等待到该方法执行完毕。这样做的原因是:

      • 如果一个对象的finalize()方法执行缓慢,甚至发生了死循环,那么将导致F-Queue队列中其他对象永久等待下去,甚至导致整个内存回收系统奔溃,因为在F-Queue中的对象无法进行垃圾回收。
    • finalize()方法是对象最后一次逃脱死亡命运的机会,如果对象在finalize()方法中成功拯救自己——和引用链上任何一个对象关联起来,比如把自己(this)赋值给某个类变量或者成员变量,那么在第二次标记时,它将被移除”即将回收“的集合。
    • 如果对象没有成功逃脱,那么基本上它就真的被回收了(第二次标记)。

任何一个对象的finalize方法只会被系统自动调用一次,如果对象面临下一次回收,它的finalize方法不会被再次执行。尽量避免使用finalize方法,因为它只是为了使C/C++程序员更容易接收Java所作出的一个妥协,它的运行代价高昂,不确定性达,无法保证各个对象的调用顺序。

public class HYFinalize {


    public static void main(String[] args) {
        Book book = new Book(true);

        book.checkIn();

        // 每一本书都应该进行checkIn操作,从而释放内存。
        // 这本书没有进行 checkIn操作,因此,没有执行清理操作(没有输出finalize execute)。也就是利用finalize方法进行终结验证,从而找出没有释放对象的内存。
        new Book(true);

        // 手动调用垃圾回收
        System.gc();
    }

}

class Book {
    boolean checkOut;

    public Book(boolean checkOut) {
        this.checkOut = checkOut;
    }

    void checkIn() {
        checkOut = false;
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();

        if (checkOut) {
            System.out.println("finalize execute");
        }
    }
}

方法区中的垃圾回收

Java虚拟机规范确实说过可以不在方法区中实现垃圾收集,方法区的垃圾收集效率也非常低,因为条件苛刻。

方法区(在HotSpot虚拟机中称为永久代)主要回收的内容有:废弃常量和无用的类

对于废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串”abc“已经进入常量池中,但是当前系统没有任何一个String对象是叫做”abc“的,换句话说,已经没有任何String对象引用常量池中的”abc“常量,也没有其他地方引用了这个常量,如果这个时候发生内存回收,并且必要的话,这个”abc“常量就会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

对于无用的类则需要同时满足下面3个条件:

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

这里解释下为什么需要回收该类的ClassLoader?

public Class<?> getDeclaringClass() throws SecurityException {
        final Class<?> candidate = getDeclaringClass0();
  /*
  * 反射里面使用到ClassLoader,因此要把ClassLoader干掉,才能保证没有地方可以通过反射调用到Class类。
  * 然后当类的实例都会被回收了,并且该类没有在任何地方被引用到了,那么这个类就可以被回收了
  */
  if (candidate != null)
  candidate.checkPackageAccess(
  ClassLoader.getClassLoader(Reflection.getCallerClass()), true);
  return candidate;
}

可以通过虚拟机参数控制类是否被回收 -Xnoclassgc。

在大量使用反射、动态代理、GCLib等ByteCode框架、动态生成JSP 这类频繁自定义ClassLoader的场景,都需要虚拟机具备类卸载功能,以保证永久代不会溢出。

常见的垃圾回收算法

标记-清除算法

思想

算法分为标记、清除两个阶段:首先标记处所有需要回收的对象,在标记完成后,统一回收所有被标记的对象。

它的标记过程,使用的是可达性分析算法。

它是最基础的算法,因为后面的垃圾回收算法都是基于标记-清除算法进行改进。标记-清除也是最简单的算法。

image-20191026104917493

优点

实现简单

缺点
  • 一个是效率问题,标记和清除过程,两个效率都不高
  • 另外一个是空间问题,标记-清除之后,会产生大量不连续的内存碎片。空间内存碎片太多,那么需要给较大的对象分配内存空间的时候,无法找到足够内存空间,而不得不提前触发一次垃圾回收。

复制收集算法

思想

将可用内存划分为大小相等的两块,每次只使用其中一块。当这一块内存用完了,就将还活着的对象复制到另外一块上,然后再把已使用过的内存空间一次性清理掉。

优点

这样每次都是对整个半区进行回收,内存分配时也不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

缺点

只是这种算法的代价将内存缩小为原来的一半,代价太高了。

image-20191026105459849

现在商业虚拟机都采用这种方法来回收新生代,IBM公司研究表明,新生代中的对象98%都是朝生暮死的,所以并不需要按照1:1来划分内存空间,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。

当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一个Survivor空间上,最后清理Eden和刚才使用过的Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存为整个新生代的90%,只有10%的内存会被浪费。

当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%内存大小的对象存活,当Survivor空间不够时,需要依赖其他内存进行分配担保

分配担保:如果另外一块Survivor空间没有足够空间存放上一次新生代回收存活下来的对象时,这些对象将直接进入老年代。

Mark-Compact(标记-整理算法)

思想

复制收集算法在对象存活率较高时,就要进行较多的复制操作,导致效率变低。由于老年代存活率较高,所以一般不采用这种算法。

根据老年代的特点,有人提出了”标记-整理“算法,标记过程仍然使用”可达性分析算法“,然后让所有的存活对象向一端移动,然后直接清理掉端边界以外的内存。

image-20191026112718120

优点
  • 不容易产生内存碎片
  • 内存利用率高
缺点
  • 存活对象多并且分散的时候,移动次数多,效率低下
  • 程序暂停

分代收集算法

思想:

只是根据对象的存活周期的不同把堆分成新生代和老年代(永久代指的是方法区),这样就可以根据各个年代的特点采用最适当的收集算法。

“分代收集”是目前大部分JVM的垃圾收集器所采用的算法。

在新生代中,每次垃圾收集都有大量对象死去,只有少了存活,那就采用复制算法,只需付出少量对象的复制成本就可以完成收集。

而老年代中对象存活率高,并且没有其他空间对它进行分配担保,就必须使用标记-清理 或者 标记-整理算法进行回收。

新生代
  • 在新生代里面存放的是存活时间比较短的对象,如某一个方法的局域变量、循环内的临时变量等等
  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 新生代里面分成一份较大的Eden空间和两份较小的Survivor(存活)空间。每次只使用Eden和其中一块Survivor空间,然后垃圾回收的时候,把存活对象放到未使用的Survivor空间中,清空Eden和刚才使用过的Survivor空间。
  • 一块Eden和一块Survivor区,比值为8:1。这样子的设置是有原因的。新生代采用复制算法,如果单纯的把内存分为2块,由于存活对象很少,那么存放存活对象的那块堆内存,会有很多内存浪费。因此,使用两块10%的内存作为空闲和活动区间(两块Survivor区),而另外80%的内存(Eden区),则是用来给新建对象分配内存的。一旦发生GC,将10%与另外80%的活动区间 中存活的对象转移到10%的空闲区间,接下来,将之前90%的内存全部释放。
  • 绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快

20160730141640502

新生代垃圾回收流程

  • 当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的)
  • 此后,每次Eden区满了,就执行一次Minor GC,并将Eden剩余的存活对象都添加到Survivor0
  • 当Survivor0也满的时候,将其中仍然活着的对象直接复制到Survivor1,然后清理掉Survivor0区。之后Eden区执行Minor GC后,就将剩余的对象添加Survivor1(此时,Survivor0是空白的)。重复上述步骤,只不过这次是Eden区和Survivor1区配合。

Eden区是连续的空间,且Survivor总有一个为空。经过一次GC和复制,一个Survivor中保存着当前还活 着的对象,而Eden区和另一个Survivor区的内容都不再需要了,可以直接清空,到下一次GC时,两个Survivor的角色再互换。因此,这种方 式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”法),这不代表着停止复制清理法很高效,其实,它也只在这种情况下高效,如果在老年代采用停止复制,则挺悲剧的。

老年代(tenured)
  • 存放的是存活时间比较长的对象,如缓存对象、数据库连接对象、单例对象等等
  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就只能使用“标记-清除”或“标记-整理”算法来进行回收。
  • 在新生代里的每一个对象,都会有一个年龄,当这些对象的年龄到达一定程度时(年龄就是熬过的GC次数,每次GC如果对象存活下来,则年龄加1),则会被转到年老代,而这个转入年老代的年龄值,一般在JVM中是可以设置的。
永久代
  • 在堆区外有一个永久代,
  • 对永久代的回收主要是无效的类和常量,并且回收方法同老年代

HotSpot的GC算法实现

枚举根节点

可作为GC Roots的节点主要在全局性的引用(例如常量或者静态属性)、执行上下文中(例如栈帧中的本地变量表),现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,找出GC Roots节点,那么必然会消耗很多的时间,

另外,可达性分析对执行时间的敏感还提现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里”一致性“的意思是整个系统看起来像被冻结在某个时间点上,不可以出现分析过程中引用关系还在变化的情况,该点不满足的话,分析结果的准确性将无法保证。这点是导致GC进行时必须停顿所有Java执行线程的其中一个重要原因。即时是在号称几乎不会停顿的CMS收集器中,枚举根节点时也是要停顿的。

准确式内存管理

准确式内存管理,又称为“准确式GC”。

虚拟机可以知道内存中某个位置的数据具体是什么类型。比如内存中有一个32位的整数123456,它到底是一个引用类型,指向123456的地址,还是一个数值为123456的整数,虚拟机将由能力辨别出来,这样子才能在GC的时候,准确判断堆上的数据是否还可能被使用。

由于使用准确式内存管理,Exact VM抛弃了基于handler的对象查找方式(原因是GC后对象可能被移动位置,比如对象的地址原本为123456,然后该对象被移动到654321的地址,在没有明确信息表明内存中的哪些数据是引用的前提下,虚拟机是不敢把内存中所有123456的值改为654321的,因为不知道这个值是整数还是指向另外一块内存的地址,因此有些虚拟机使用句柄来保持引用的稳定),通过准确式内存管理,能快速判断该数据是否引用,就可以避免使用句柄,从而减少一次查找地址的开销,提高执行性能。

由于目前主流的Java虚拟机都是采用准确式GC,所以当执行系统停顿下来后,并不需要一个不漏的检查完执行上下文和全局的引用位置,虚拟机应当有办法直接指导哪些地方存放着对象引用。

HotSpot怎么快速找到可达对象

在HotSpot实现中,使用一组称为OopMap的数据结构来达到这个目的。

在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是引用。这样子,在GC扫描的时候,就可以直接知道哪些是可达对象了。

安全点

在OopMap的协助下,HotSpot可以快速准确完成GC Roots枚举。可能导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那么将会需要大量的额外空间,这样GC的成本将会变得很高。

实际上,HotSpot也没有为所有指令生成OopMap,只有在特定位置生成这些信息,这个位置称为“安全点”。

程序在执行过程中,并非在所有地方都可以停顿下来进行GC,只有在到达安全点时才能暂停。安全点的选定既不能太以至于让GC等待太长的时间,也不能过多以至于增大运行时的负荷。所以安全点的选定是以“是否让程序长时间运行”为标准进行选定的。长时间运行最明显的特征是指令复用,比如说方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。

对于安全点,另外一个需要考虑的问题,是如何让所有线程跑到最近的安全点再停顿下来,这里有2种方案可供选择:抢占式中断、主动式中断。

抢占式中断

抢占式中断不需要线程的执行代码主动去配合。

在GC发生时,首先把所有线程中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。

现在几乎没有虚拟机采用抢占式中断来暂停线程。个人觉得是太粗暴了,比如直接中断线程。

主动式中断

主动中断的思想是:当GC需要中断线程时,不直接对线程操作,仅仅设置一个标志,各个线程执行时主动去轮询这个标志,当发现中断标记为真就自己中断挂起。轮询标记的位置就是安全点的位置。

安全区域

使用安全点是否已经完美解决什么时候进入GC的问题。但是假如程序不执行呢?所以的程序不执行就是没有分配CPU时间片,最典型的就是线程处于sleep或者阻塞状态,这时候线程无法执行到安全点,并且响应中断挂起。JVM也不太可能等待线程重新获得CPU时间片,这时候就需要安全区域来解决。

安全区域指在一段代码片段中,引用关系不会发生变化。这个区域任务地方开始GC都是安全的。我们可以把安全区域看做是扩展的安全点。

在线程执行到安全区域时,首先标识自己已经进入安全区域了,那样,当这段时间内发生GC时,就不用管那些标识为安全区域状态的线程了。

在线程要离开安全区域时,它首先检查系统是否已经完成根节点枚举,如果完成,线程就继续执行,否则,它就继续等待直到收到可以离开安全区域的信号。

垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

image-20191026130140758

上图展示了不同分代的垃圾收集器,如果两个收集器之间存在连线,那么说明它们可以搭配使用。

垃圾收集器所处的区域,则表明它是新生代收集器,还是老年代收集器。

Serial收集器

Serial收集器是最基本,发展历史最悠久的收集器。Serial是一个单线程收集器。是新生代收集器。

Serial收集器在进行垃圾收集的时候,必须暂停其他所有的线程,直到它收集结束。“Stop the World”暂停线程 这个工作是后台自动发起和完成的,在用户不可见的情况下把用户正常工作的线程停掉,这对于很多应用来说是很难接受的。假如你的计算机每运行1个小时就要停顿5分钟,你会有怎样的心情?下图展示了Serial收集器的运行过程:

image-20191026131046698

“Stop the World”是没有办法避免的,举个简单例子:你妈妈在打扫房间的时候,你还一遍扔垃圾,这怎么打扫的完?。目前之间尽量减少停顿线程的时间。

serial收集器仍然是虚拟机运行在client模式下的默认新生代垃圾收集器。它也有由于其他收集器的地方:简单而高效。对于单CPU的环境来说,Serial收集器由于线程交互的开销,专心做垃圾收集,自然可以获得最好的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代,停顿时间可以控制在几十毫秒甚至一百毫秒以内,只要不是频繁发生,还是可以接受的。

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本。除了多线程进行垃圾收集之外,其他都和Serial一样。

是新生代收集器。

ParNew收集器的工作过程如图:

image-20191026135929901

ParNew收集器是许多运行在Server模式下的虚拟机首选的新生代收集器。因为除了Serial收集器外,只有它能和CMS收集器配合工作。

Parallel Scavenge收集器

Parallel Scavenge是新生代收集器。它也是使用复制算法的收集器,又是并行的多线程收集器。看上去了ParNew一样,那么它有什么特别之处呢?

Parallel Scavenge是为了达到一个可控制的吞吐量。吞吐量=运行用户代码的时间 / (运行用户代码的时间 + 垃圾收集的时间)。高吞吐量表明CPU时间被有效的利用,尽快完成程序的运算任务。

Parallel Scavenge收集器提供了参数控制最大垃圾收集停顿时间,虚拟机将尽可能保证垃圾回收的时间不超过该值。不过大家不要任务把这个参数的值设小一点就可以使垃圾收集速度加快,GC停顿时间缩短,是以牺牲吞吐量和新生代空间来换取的,系统会把新生代调小一些,收集300MB的新生代肯定比收集500MB的快,但这也导致垃圾收集更频繁一些。原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿时间是下降了,但是系统吞吐量下来了。

由于和吞吐量关系密切,Parallel Scavenge也被称为吞吐量优先收集器

Parallel Scavenge还有一个参数,这个参数打开以后,就不需要手工指定新生代大小、Eden和Survivor比例等参数,虚拟机会根据运行情况,动态调整这些参数,已提供最适合的停顿时间,这种调节方式成为GC自适应调节策略。自适应调节策略也是Parallel Scavenge收集器和ParNew收集器的一个重要区别。

Parallel Scavenge无法和CMS配合工作。

Serial Old收集器

Serial Old是Serial收集器的老年代版本。它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义是给Client模式下的虚拟机器使用,工作过程如下:

image-20191026142719613

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。

这个收集器是JDK1.6之后才开始提供的,在此之前,Parallel Scavenge收集器一直处于比较尴尬的位置,因为如果新生代选择了Parallel Scanvenge收集器,老年代除了Serial Old收集器之外别无选择。老年代Serial Old收集器在服务端的拖累,使用Parallel Scavenge收集器未必能在整体应用上获得吞吐量最大化的效果。由于单线程的老年代收集,无法充分利用服务端多CPU的能力,在老年代很大而且硬件比较高级的环境,这种组合的吞吐量甚至还不如ParNew + CMS组合给力。

直到Parallel Old收集器出现后,Parallel Scavenge才有了比较名副其实的应用组合。在注重吞吐量与多CPU的场景,可以优先考虑Parallel Scavenge 和 Parallel Old收集器。Parall Old工作状态如图:

image-20191026143854952

CMS收集器

概念

CMS是一种以获取最短停顿时间为目标的收集器。互联网应用就非常注重服务器的响应速度,希望系统停顿时间最短,已给用户带来最好的体验。CMS收集器就非常符合这类应用的需求。

CMS收集器基于标记-清除算法实现的。它的运作过程分为4个步骤:初始标记、并发标记、重新标记、并发清除

其中,初始标记、重新标记两个步骤仍然需要暂停用户线程。

  • 初始标记仅仅是标记一下GC Roots能够直接关联的对象,速度很快。
  • 并发标记就是进行GC Roots 向下查找过程,也就是从GC Roots开始,对堆中对象进行可达性分析。这时候用户线程还可以继续执行。
  • 重新标记阶段是为了修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象标记记录

    • 这个阶段的标记时间一般比初始标记稍长一点,但远比并发标记时间短。
  • 并发清除是GC垃圾收集线程 和 用户线程并行的,清理被回收的对象。

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

image-20191026145340936

优点

减少了GC停顿时间

缺点
  • 对CPU资源敏感,在并发阶段,因为占用一部分CPU资源,因此会导致程序变慢。当CPU个数比较少的时候,对用户影响可能很大。

    • 为了因对这种情况,虚拟机提供了一种增量式并发收集器,是CMS收集器的变种。在并发标记、并发清除阶段,让GC线程、用户线程交替运行,尽量减少GC线程独占资源的时间,这样一来,整个垃圾收集的时间更长,但对用户的影响就少一些。实践证明,增量式并发收集器的效果很一般,已经不提倡用户使用了。
  • 无法处理浮动垃圾。在并发清除阶段,用户线程还在运行着,还会产生新的垃圾。这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留到下一次GC的时候再清理掉。这一部分垃圾就称为浮动垃圾

    • 由于垃圾收集阶段,用户线程还在运行,因此需要预留足够的内存空间给用户使用。 因此CMS收集器不能像其他收集器一样,等到老年代几乎被填满了再进行收集,需要预留一部分内存空间提供用户线程使用。在JDK1.6中,CMS的启动阈值为92%,也就是老年代使用了92%之后,CMS收集器就会进行垃圾回收。
    • 如果CMS运行期间预留的内存不够用户线程使用,就会出现一次“Concurent Mode Fail”失败,这时虚拟机临时启用Serial Old收集器来重新进行老年代的垃圾回收,这样的停顿时间就长了。因此,如果启动阈值设置得太高,容易导致“Concurrent Mode Fail”,性能反而降低。
  • CMS是一块基于标记-清除实现的垃圾收集器,那么在收集结束时会有大量内存碎片产生。当内存碎片过多的时候,如果要对大对象进行内存分配,但是无法找到足够大的连续内存空间进行分配,就会触发一次Full GC。

    • 为了解决这个问题,CMS提供一个开关,默认是开启的,表示CMS要进行Full GC的时候,开启内存碎片的整理合并过程,并该过程是无法并发的,因此停顿时间就变长了。

G1收集器

G1收集器是当前收集器发展最前沿的成果之一。G1收集器是一款面向服务端应用的垃圾收集器。Hotspot团队希望G1收集器未来能替换掉CMS收集器。

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。G1将堆分成许多大小相同的区域单元,每个单元称为Region。Region是一块地址连续的内存空间,G1模块的组成如下图所示:

image-20191026170627820

G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。Region的大小是一致的,数值是在1M到32M字节之间的一个2的幂值数,JVM会尽量划分2048个左右、同等大小的Region。

G1收集器具备如下特点

  • 并发与并行:G1能充分利用CPU、多核环境的硬件优势。使用多个CPU缩短Stop the world,也就是工作线程的暂停时间。在执行GC动作的同时,G1收集器仍然能够通过并发的方式让Java程序执行。
  • 分代收集:与其他收集器一样,分代概念在G1收集器中仍然得以保留。虽然G1收集器不用其他收集器配合技能管理整个GC堆,但他依然能够采用不同方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象 以取得更好的收集效果。
  • 空间整合:G1整体上看是基于标记-整理算法实现的收集器,从局部看(两个Region之间),是基于复制算法实现的。使用这两种算法,在G1运行期间不会产生内存碎片,垃圾收集后,能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
  • 可预测的停顿:G1除了追求减低停顿以外,还建立了可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
停顿时间模型

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划的避免对整个堆进行垃圾收集。G1维护了一份优先列表,每次根据允许的收集时间,优先回收价值最大的region。(怎样才是价值大?region采用复制算法,那么如果一个region中垃圾很多,存活对象很少,那么这个迁移存活对象的工作就很少,并且收集完之后,能够得到的内存空间很多,这种就是价值大的region)。

这种使用region划分内存空间,并且有优先级的区域回收方式,保证G1收集器在有限的时间内,可以获取尽可能高的效率。

避免全堆扫描

一个对象分配在某个Region中,但是它可以与整个Java堆中任意对象发生引用关系。那么做可达性分析法判断对象是否存活的时候,岂不是扫码怎么java堆才能确保准确性?这个问题其实并非G1才有,只是G1更加突出而已。如果回收新生代不得不扫描老年代的话,那么Minor GC的 效率可能下降不少。

在G1收集器中,Region之间的引用 和 其他收集器中新生代和老年代之间的引用,虚拟机都是使用Remembered Set来避免全堆扫描。在G1中,每一个Region都有一个Remembered Set。当对引用进行写操作的时候,G1检查该引用的对象是否在别的region中,是的话,则通过CardTable把相关引用信息存到被引用对象的Remembered Set中。当进行内存回收时,把RememberSet加入到GC Roots根节点的枚举范围。这样就可以保证不全堆扫描也不会有遗漏。

工作流程

如果不计算Remembered Set的操作,G1收集器的运作大致分为如下操作:初始标记、并发标记、最终标记、筛选回收。

  • 初始标记阶段:仅仅只是标记一下GC Roots直接关联的对象。并且修改TMAS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建对象。这阶段需要停顿线程,但耗时很短。
  • 并发标记阶段:从GC Roots开始,对堆中对象进行可达性分析,找出存活对象。这阶段耗时较长,但可与用户程序并发执行
  • 最终标记阶段:修复在并发标记阶段因用户程序运行导致标记发生变化的那一步部分标记记录。虚拟机将这段时间对象变化记录到Remembered Set Log中,在最终标记阶段把Remembered Set Log合并到Rmembered Set中。这阶段需要停顿线程。
  • 筛选回收阶段:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划。这个阶段其实也可以做到和用户线程一起并发执行,但因为只回收一部分Region,时间是用户可控制的,而且停顿线程能大幅提高收集效率。因此没有实现为和用户线程并发执行。

image-20191026182758933

内存分配与回收策略

对象的分配,主要在新生代的Eden区上,如果启动了本地线程分配缓存,那么将优先在TLAB上分配。少数情况下,也可能直接分配在老年代中。

对象优先在Eden区分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代

大对象是指需要大量连续内存空间的对象。最典型的就是很长的数组或者字符串。经常出现大对象,就容易导致内存还有不少空间的时候,就触发GC来获取足够的连续内存空间来放置这些大对象。

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

虚拟机怎么识别哪些对象应该存放到新生代,哪些对象应该存放到老年代?为了做到这点,虚拟机给每个对象定义了一个对象年龄计数器(放在对象头的Mark World中)。

如果对象在Eden区出生,经历了第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间,然后年龄计数器设置为1。

对象在Survivor中每熬过一次Minor GC,年龄就增加1岁。当它的年龄到达一定程度(默认是15岁),就会被移动到老年代。

动态年龄判断

如果Survivor中相同年龄的对象大小总和 大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以进入老年代。无需等到阈值(比如15岁)。

空间分配担保

新生代使用复制算法,但是为了保证内存利用率,因此只用其中一块Survivor区保存存活对象。因此当发生大量对象在Minor GC后仍然存活的情况,就需要老年代进行分配担保,就是指把Survivor无法容纳的对象直接进入老年代。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象能够存活下来,在实际内存回收完成之前是无法知道的,只好取之前成功晋升老年代的对象容量大小的平均值,与老年代剩余空间进行比较。决定是否进行Full GC以腾出更多的空间。

具体操作为:

在进行Minor GC之前,虚拟机将会检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果这个条件成立,则认为Minor GC是安全的(老年代可以担保成功)。

如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,将检查老年代最大可用连续空间是否 大于 之前成功晋升老年代的对象容量大小的平均值。如果大于,则尝试进行一次Minor GC。如果小于,或者HandlePromotionFailure设置为不允许担保失败,则进行一次Full GC。

HandlePromotionFailure一般打开,避免频繁Full GC。

在JDK6之后,已经不再使用HandlePromotionFailure这个参数了,JDK6之后的规则变为:

  • 只要老年代的连续内存空间 大于新生代对象总大小 或者 大于历次晋升的对象平均大小,就进行minor GC,否则进行Full GC。

几种不同的垃圾回收类型

Minor GC

Minor GC又称为新生代GC。指发生在新生代的垃圾收集动作。因为Java对象大多很快死亡,所以Minor GC非常频繁,一般回收速度也比较快。

Major GC/Full GC

又称为老年代GC。指发生在老年代的GC。出现了Major GC,经常伴随着一次Minor GC。Major GC一般速度比Minor GC慢10倍以上。

其他内存

除了Java堆和永久代之外,还有一些区域会占用比较多的内存,这里所有内存总和受到操作系统进程最大内存的限制。

  • Direct Memory:可用过-XX: MaxDirectMemorySize调整大小,内存不足时,会抛出OutOfMemoryError 或者 OutOfMemoryError : Direct buffer memory
  • 线程堆栈:可通过-Xss调整大小。内存不足时抛出StackOverflowError(即无法分配新的栈帧)或者OutOfMemoryError: unable to create new native thread(无法建立新的线程)
  • Socket缓冲区:每个Socket连接都有Send和Receive两个缓冲区,分别占大约37KB和25KB内存,连接多的话,这两块内存占用也比较可观。如果无法分配,则可能抛出 IOException : Too many open files异常。
  • JNI代码:如果代码中使用JNI调用本地库,那本地库使用的内存也不在堆中
  • 虚拟机和GC:虚拟机、GC的代码执行也要消耗一定的内存

问题

为什么要划分成年轻代和老年代?

为了针对不同的内存区域采用不同垃圾收集算法,从而提高效率

年轻代为什么被划分成eden、survivor区域?

通过划分eden、survivor区,能够提高年轻代的内存使用率。因为年轻代的大部分对象都会很快死去,因此只需要使用少部分的内存来保留存活对象。

参考

https://blog.csdn.net/mccand1...

重点: https://www.cnblogs.com/aspir...、https://www.cnblogs.com/aspirant/category/1195271.html

https://www.cnblogs.com/1024Community/p/honery.html#25-%E6%96%B9%E6%B3%95%E5%8C%BA%E5%A6%82%E4%BD%95%E5%88%A4%E6%96%AD%E6%98%AF%E5%90%A6%E9%9C%80%E8%A6%81%E5%9B%9E%E6%94%B6

[https://www.cnblogs.com/heyon...


Huangy远
530 声望63 粉丝

« 上一篇
MySQL-锁总结
下一篇 »
Dubbo负载均衡