java(9)-深入浅出GC垃圾回收机制

2022-04-14 16:55:01 浏览数 (1)

java8内存模型有所变化,本博客更新2019年6月。参考原文:http://www.importnew.com/1993.html和《Java性能优化权威指南》。

1、本文了解GC垃圾回收机制,深入理解GC后才明白,为啥FGC会导致stop-the-world。 2、了解GC算法。

Stop-the-world会在任何一种GC算法中发生。Stop-the-world意味着 JVM 因为要执行GC而停止了应用程序的执行。当Stop-the-world发生时,除了GC所需的线程以外,所有线程都处于等待状态,直到GC任务完成。GC优化很多时候就是指减少Stop-the-world发生的时间。

我们垃圾回收,就是对java的占用内存的垃圾信息进行回收,再了解垃圾回收机制之前,先了解内存分布。

一、JVM堆内存结构

1、堆(Heap)内存划分策略

在堆的管理上,Sun JDK从1.2版本开始引入了分代管理的方式。Java虚拟机根据对象存活的周期不同,JVM堆内存被分为两部分——年轻代(Young Generation)和老年代(Old Generation)。其中Young空间,又被分为2个部分和3个板块,分别是1个Egen区,和2个Survivor区。

分代管理的好处:分代方式大大改善了垃圾收集的效率。

堆内存是虚拟机管理的内存中最大的-块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中。给堆内存分代是为了提高对象内存分配和垃圾回收的效率。这是基于类似28原理的统计,90%多的对象都是很快成为垃圾的对象。所以化为成两个区域,分别使用不同的收集算法,年轻代的收集频率更高,所需空间也相对较小。 试想-下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率。 有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代中回收效果太差, 一般不进行垃圾回收,还可以根据不同年代的特点,采用不同的垃圾收集算法。分代垃圾收集大大提升了垃圾收集效率,这些都是JVM分代的好处。

2、堆(Heap)和非堆(Non-heap)内存

  按照官方的说法:“Java 虚拟机具有一个堆,堆是运行时数据区域,所有类实例和数组的内存均从此处分配。堆是在 Java 虚拟机启动时创建的。”“在JVM中堆之外的内存称为非堆内存(Non-heap memory)”。可以看出JVM主要管理两种类型的内存:堆和非堆。简单来说堆就是Java代码可及的内存,是留给开发人员使用,一般存放对象以及数组。

非堆就是JVM留给 自己用的,所以方法区、JVM内部处理或优化所需的内存(如JIT编译后的代码缓存)、每个类结构(如运行时常数池、字段和方法数据)以及方法和构造方法 的代码都在非堆内存中。

方法区的Class信息,又称为永久代,是否属于Java堆? 方法区(method area)只是JVM规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,具体放在哪里,不同的实现可以放在不同的地方。而永久代Hotspot虚拟机特有的概念,是方法区的一种实现,别的JVM都没有这个东西。 在Java 6中,方法区中包含的数据,除了JIT编译生成的代码存放在native memory的CodeCache区域,其他都存放在永久代; 在Java 7中,Symbol的存储从PermGen移动到了native memory,并且把静态变量从instanceKlass末尾(位于PermGen内)移动到了java.lang.Class对象的末尾(位于普通Java heap内); 在Java 8中,永久代被彻底移除,取而代之的是另一块与堆不相连的本地内存——元空间(Metaspace),‑XX:MaxPermSize 参数失去了意义,取而代之的是-XX:MaxMetaspaceSize。 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。 对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如BEA JRockit、IBM J9等)来说是不存在永久代的概念的。即使是HotSpot虚拟机本身,根据官方发布的路线图信息,现在也有放弃永久代并“搬家”至Native Memory来实现方法区的规划了。 Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。在Sun公司的BUG列表中,曾出现过的若干个严重的BUG就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。 根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。 以上描述截取自:《深入理解Java虚拟机:JVM高级特性与最佳实践》 作者: 周志明

二、JVM内存泄露情况

有时候程序会碰到java.lang.OutOfMemoryError,这个主要是JVM堆参数没有配好引起的。

OutOfMemoryError分两种:java.lang.OutOfMemoryError: Java heap space和java.lang.OutOfMemoryError: PermGen space。

Java heap space是有关堆内存的内存溢出,可以通过配置-Xms和-Xmx参数来解决。

PermGen space是有关非堆内存的内存溢出,这个主要是java8之前遇到的问题,可以通过配置-XX:PermSize和-XX:MaxPermSize来设置。

1、堆内存:年轻代和年老代

我们先看看错误:java.lang.OutOfMemoryError: Java heap space例子:

代码语言:javascript复制
package com.demo.test.web;
import java.util.ArrayList;
import java.util.List;
public class HeapOom {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<byte[]>();
        int i = 0;
        boolean flag = true;
        while (flag){
            try {
                i  ;
                list.add(new byte[1024 * 1024]);//每次增加一个1M大小的数组对象
            }catch (Throwable e){
                e.printStackTrace();
                flag = false;
                System.out.println("count=" i);//记录运行的次数
            }
        }
    }
}

我们设置堆内存的大小为16M,当运行到第15次,当无法申请空间时会抛出OutOfMemoryError:

1)整个堆内存分配: -Xms和-Xmx

-Xms:JVM初始分配的堆内存。默认是物理内存的1/64;

-Xmx :JVM最大分配的堆内存,默认是物理内存的1/4。默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制;空余堆内存大于70%时,JVM会减少堆直到- Xms的最小限制。因此服务器一般设置-Xms、-Xmx相等以避免在每次GC 后调整堆的大小。

如果-Xmx不指定或者指定偏小,应用可能会导致java.lang.OutOfMemory错误,此错误来自JVM,不是Throwable的,无法用try...catch捕捉。

-Xmn256m:年轻代内存大小:注意:此处的大小是(eden 2 survivor space).与jmap -heap中显示的New gen是不同的。 整个堆大小=年轻代大小 年老代大小 持久代大小. 增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8

2)、年轻代堆内存分配 -Xmn

年轻代是所有新对象产生的地方。当年轻代内存空间被用完时,就会触发垃圾回收。这个垃圾回收叫做Minor GC。年轻代被分为3个部分——Enden区和两个Survivor区。 年轻代空间的要点: 1、大多数新建的对象都位于Eden区。 2、当Eden区被对象填满时,就会执行Minor GC。并把所有存活下来的对象转移到其中一个survivor区。 3、Minor GC同样会检查存活下来的对象,并把它们转移到另一个survivor区。这样在一段时间内,总会有一个空的survivor区。 4、经过多次GC周期后,仍然存活下来的对象会被转移到年老代内存空间。通常这是在年轻代有资格提升到年老代前通过设定年龄阈值来完成的。

-XX:NewRatio:年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)

-XX:NewRatio=4表示年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5,Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。

Eden和Survivor(S0和S1)参数设置: 一般来说很小,Survivor与Young差不多相差一倍的比例,设置的的参数主要有两个: -XX:SurvivorRatio=8

是Eden和Survivor区域比重,注意是一个Survivor的的大小,如果将其设置为8,则说明Eden区是一个Survivor区的8倍,换句话说S0或S1空间是整个Young空间的1/10,剩余的80%由Eden区域来使用。 -XX:InitialSurvivorRatio=8 是Young/S0的比值,当其设置为8时,表示s0或s1占整个Young空间的12.5%。

3)、年老代Old Generation堆内存

年老代内存里包含了长期存活的对象和经过多次Minor GC后依然存活下来的对象。通常会在老年代内存被占满时进行垃圾回收。老年代的垃圾收集叫做Major GC。Major GC会花费更多的时间。 Stop the World事件: 所有的垃圾收集都是“Stop the World”事件,因为所有的应用线程都会停下来直到操作完成(所以叫“Stop the World”)。 Minor GC 不会Stop the World:因为年轻代里的对象都是一些临时(short-lived )对象,执行Minor GC非常快,所以应用不会受到(“Stop the World”)影响。 Major GC会Stop the World:由于Major GC会检查所有存活的对象,因此会花费更长的时间。应该尽量减少Major GC。因为Major GC会在垃圾回收期间让你的应用反应迟钝,所以如果你有一个需要快速响应的应用发生多次Major GC,你会看到超时错误。 垃圾回收时间取决于垃圾回收策略。这就是为什么有必要去监控垃圾收集和对垃圾收集进行调优。从而避免要求快速响应的应用出现超时错误。

full gc是对新生代,旧生代,以及持久代的统一回收,由于是对整个空间的回收,因此比较慢,系统中应当尽量减少full gc的次数。

3、java8之前永久代Perm Gen非堆内存分配 -XX:PermSize

永久代是一片连续的堆空间。在JVM启动之前通过在命令行设置参数-XX:MaxPermSize来设定永久代最大可分配的内存空间,java8的元空间(Metaspace)后面有详细说明

-XX:PermSize:设置JVM非堆内存初始值,默认是物理内存的1/64; -XX:MaxPermSize:设置最大非堆内存的大小,默认是物理内存的1/4。 (还有一说:MaxPermSize缺省值和-server -client选项相关,-server选项下默认MaxPermSize为64m,-client选项下默认MaxPermSize为32m)

永久代或者“Perm Gen”包含了JVM需要的应用元数据,这些元数据包括类的版本、字段、方法、接口等描述信息,还有运行时常量池,用于存放编译器生成的各种字面量和符号引用。注意,永久代不是Java堆内存的一部分。class文件中包括 永久代存放JVM运行时使用的类。永久代同样包含了Java SE库的类和方法。永久代的对象在full GC时进行垃圾收集。

在jdk7设置-XX:MaxPermSize过小会导致java.lang.OutOfMemoryError: PermGen space,原因如下:PermGen space用于存放Class和Meta的信息,GC不会对PermGen space进行处理,所以如果Load很多Class的话,就会出现上述Error。这种Error在web服务器对JSP进行pre compile的时候比较常见。

方法区 方法区是永久代空间的一部分,并用来存储类型信息(运行时常量和静态变量)和方法代码和构造函数代码。 内存池 如果JVM实现支持,JVM内存管理会为创建内存池,用来为不变对象创建对象池。字符串池就是内存池类型的一个很好的例子。内存池可以属于堆或者永久代,这取决于JVM内存管理的实现。 运行时常量池 运行时常量池是每个类常量池的运行时代表。它包含了类的运行时常量和静态方法。运行时常量池是方法区的一部分。 Java栈内存: -Xss Java栈内存用于运行线程。它们包含了方法里的临时数据、堆里其它对象引用的特定数据。 -Xss:每个线程的堆栈大小, JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.更具应用的线程所需内存大小进行 调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右 一般小的应用, 如果栈不是很深, 应该是128k够用的 大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。(校长) 和threadstacksize选项解释很类似,官方文档似乎没有解释,在论坛中有这样一句话:"”-Xss is translated in a VM flag named ThreadStackSize” 一般设置这个值就可以了。

动态生成类的情况比较容易出现永久代的内存溢出。最典型的场景就是,在 jsp 页面比较多的情况,容易出现永久代内存溢出。我们现在通过动态生成类来模拟 “PermGen space”的内存溢出:

代码语言:javascript复制
package com.demo.test;
public class TestClass {
}

动态加载类com.demo.test.TestClass:

代码语言:javascript复制
package com.demo.test.web;

/**
 * Created by huangguisu on 2020/7/10.
 */

import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
 import java.util.List;

public class PermGenOom{
    public static void main(String[] args) {
        URL url = null;
        List<ClassLoader> classLoaderList = new ArrayList<ClassLoader>();
        try {
            url = new File("/tmp").toURI().toURL();
            URL[] urls = {url};
            while (true){
                ClassLoader loader = new URLClassLoader(urls);
                classLoaderList.add(loader);
                loader.loadClass("com.demo.test.TestClass");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

使用JDK1.7执行,指定的 PermGen 区-XX:MaxPermSize的大小为 8M:

大家都知道JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分

三、垃圾回收机制及相关算法

1、何时进行收集和触发条件

一般来说,当某个区域内存不够的时候就会进行垃圾收集。如当Eden区域分配不下对象时,就会进行年轻代的收集。还有其他的情况,如使用CMS收集器时配置CMSInitalize 。

2、哪些垃圾需要回收

JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区(包含常量池)。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

3、判断对象是否垃圾的算法

垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法。

1)、引用计数( Reference Counting ):当有对象引用自身时,就会计数器加1,删除一个引用就减一,当计数为0时即可判断为垃圾

比较古老的回收算法。这是早起jvm使用的算法。原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数。垃圾回收时,只用收集计数为0的对象。此算法最致命的是无法处理循环引用的问题。看下面的例子。

代码语言:javascript复制
public class ReferenceFindTest{

    public static void main(String[] args){
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
        object1.object = object2;
        object2.object = object1;
        object1 = null;
        object2 = null;
    }

}

这段代码是用来验证引用计数算法不能检测出循环引用。最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

3) 、可达性分析(对象引用遍历)算法::通过一些根节点开始,分析引用链,没有被引用的对象都可以被标记为垃圾对象。

现在大多数JVM采用可达性分析算法,可达性分析算法是从离散数学中的图论引入的,程序把所有的对象引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的对象引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

对象引用遍历从一组对象开始,沿着整个对象图上的每个链接,递归确定可达到的对象,如果某个对象不能从这些根对象的一个(至少)到达,则将它作为垃圾收集。

在Java语言中,可作为GC Roots的对象包括下面几种:本地变量引用,操作数栈引用,PC寄存器,本地方法栈引用等这些都是根。 a) 虚拟机栈中引用的对象(栈帧中的本地变量表,即本地变量引用); b) 方法区中类静态属性引用的对象; c) 方法区中常量引用的对象; d) 本地方法栈中JNI(Native方法)引用的对象。

3)方法区如何判断是否需要回收

方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:

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

4、常用垃圾回收算法

1)标记-清除(Mark Sweep) :

对非垃圾对象进行标记,清除其他的对象。

是JVM垃圾回收算法中最古老的一个,该算法共分成两个阶段,第一阶段从引用根节点开始标记所有被引用的对象(即可达性分析标记非垃圾对象),第二阶段遍历整个堆,清除未被标记的对象。

该算法的缺点: 1、需要暂停整个应用, 2、产生内存碎片:我们可以看到在回收以后未使用的空间是不连续的,不连续的空间也就是内存碎片。最终导致有空余空间,但没有连续的足够大小的空间分配内存。但在存活对象比较多的情况下极为高效。

2) 标记整理(Mark Compact) :

标记非垃圾对象后,将这些对象整理好,排列到内存的开始位置。这样内存就是整齐的了。但是因为会造成对象移动,所以效率会有降低

此算法结合了标记-清楚算法和复制算法的优点,也分为两个阶段,第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题,按顺序排放,同时解决了复制算法所需内存空间过大的问题。

3)标记清除整理(Mark Sweep Compact) :

即组合两种方式,在若干次清除后进行一次整理。

4) 复制(Copy) :

划分成两个相同大小的区域,垃圾收集时,将第一个区域的活对象复制到另一个区域,这样不会有内存碎片问题。当然,此算法的缺点也是很明显的,就是需要两倍内存空间。这个就是用在堆分代回收那里的算法。

四、按代的垃圾回收机制

在Java程序中不能显式地分配和注销内存。有些人把相关的对象设置为null或者调用System.gc()来试图显式地清理内存。设置为null至少没什么坏处,但是调用System.gc()会显著地影响系统性能,必须彻底杜绝(还好,我还没有见到NHN的哪个开发者调用这个方法)。

在Java中,开发人员无法直接在程序代码中清理内存,而是由垃圾回收器自动寻找不必要的垃圾对象,并且清理掉他们。垃圾回收器会在下面两种假设(hypotheses)成立的情况下被创建(称之为假设不如改为推测(suppositions)或者前提(preconditions))。

这些假设我们称之为弱年代假设( weak generational hypothesis)。为了强化这一假设,HotSpot虚拟机将其物理上划分为两个–新生代(young generation)和老年代(old generation)。

分代垃圾收集的优点:每一代都根据自身特性选择最合适的收集算法。

1、新生代(Minor GC)和GC机制

绝大多数最新被创建的对象会被分配到这里,由于新生代的大部分对象在创建后会很快变得不可到达(存活时间很短),然后消失。对象从这个区域消失的过程我们称之为”minor GC“。 1)与整个java堆相比,所占用的空间小,收集频繁。 2)minor GC关注小并且有大量垃圾回收的对象,所以收集效率高

新生代是用来保存那些第一次被创建的对象,他可以被分为三个空间:块较大的Eden空间和两块较小的Survivor空间,默认比例为8: 1: 1。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。

一共有三个空间,其中包含两个幸存者空间。每个空间的执行顺序如下:

1)Eden区域: 绝大多数刚刚被创建的对象会存放在Eden空间。是用来存放使用new或者newInstance等方式创建的对象,默认都是存放在Eden区,除非这个对象太大,或者超过了设定的阈值-XX:PretenureSizeThresold,这样的对象会被直接分配到Old区域。 2)2个Survivor(幸存)区,一般称S0,S1,理论上他们是一样大的。

3)工作原理:

(1) 第一次GC:在不断创建对象的过程中,Eden区会满,这时候会开始做Young G也叫Minor GC,而Young空间的第一次GC就是找出Eden区中,幸存活着的对象,然后将这些对象,放到S0,或S1区中的其中一个。

(2) 此后,在Eden空间执行GC之后,存活的对象会被堆积在同一个Survivor空间: 假设第一次选择了S0,它会逐步将活着的对象拷贝到S0区域,但是如果S0区域满了,剩下活着的对象只能放old区域了,接下来要做的是,将Eden区域 清空,此时时候S1区域也是空的。

(3) 当一个Survivor空间饱和,还在存活的对象会被移动到另一个Survivor空间。之后会清空已经饱和的那个Survivor空间。

当第二次Eden区域满的时候,就将Eden区域中活着的对象 S0区域中活着的对象,迁移到S1中,如果S1放不下,就会将剩下的部门,放到Old区域中,只是这次对象来源区域增加了S0,最后会将Eden区 S0区域,清空 (4)第三次和第四次依次类推,始终保证S0和S1有一个是空的,用来存储临时对象,用于交换空间的目的,反反复复多次没有被淘汰的对象,将会放入old区域中,默认是15次。具体的交换过程就和上图中的信息相似。

如果你仔细观察这些步骤就会发现,其中一个Survivor空间必须保持是空的。如果两个Survivor空间都有数据,或者两个空间都是空的,那一定标志着你的系统出现了某种错误。 通过频繁的minor GC将数据移动到老年代的过程可以用下图来描述:

图 3: GC执行前后对比

在Minor GC过程中,如果老年代的满了无法容纳更多对象,Minor GC之后就会Full GC. 这将导致遍历整个JAVA 堆。导致严重影响应用性能。

2、老年代(Old generation)

对象没有变得不可达,并且从新生代中存活下来,会被拷贝到老年代。其所占用的空间要比新生代多。也正由于其相对较大的空间,发生在老年代上的GC要比新生代少得多。对象从老年代中消失的过程,我们称之为”major GC“(或者”full GC“)

1)与整个java堆相比,所占用的空间大,收集频率小,一旦收集,执行时间比较长。

请看下面这个图表。

图1 : GC 空间 & 数据流

3、java8之前的永久代 permanent generation

永久代( permanent generation )也被称为方法区(method area)。他用来保存类常量以及字符串常量。因此,这个区域不是用来永久的存储那些从老年代存活下来的对象。这个区域也可能发生GC。并且发生在这个区域上的GC事件也会被算为major GC。

有些人可能会问: 如果老年代的对象需要引用一个新生代的对象,会发生什么呢? 为了解决这个问题,老年代中存在一个”card table“,他是一个512 byte大小的块。所有老年代的对象指向新生代对象的引用都会被记录在这个表中。当针对新生代执行GC的时候,只需要查询card table来决定是否可以被收集,而不用查询整个老年代。这个card table由一个write barrier来管理。write barrier给GC带来了很大的性能提升,虽然由此可能带来一些开销,但GC的整体时间被显著的减少。

图 2: Card Table 结构

3、GC触发条件总结

针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

  • Partial GC(部分gc):并不收集整个GC堆的模式
    • Young GC:只收集young gen的GC:
    • Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式
    • Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式
  • Full GC:收集整个堆,包括整个新生代young gen、老生代old gen、元空间(metaspace,java8以上版本取代perm gen)的全局范围的GC。

Minor GC和Major GC是俗称。在Hotspot JVM实现的Serial GC, Parallel GC, CMS, G1 GC中大致可以对应到某个Young GC和Old GC算法组合; Major GC通常是跟full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“major GC”的时候一定要问清楚他想要指的是上面的full GC还是old GC。

触发条件:

最简单的分代式GC策略,按HotSpot VM的serial GC的实现来看,触发条件是:

  • young GC:当young gen中的eden区分配满的时候触发。注意young GC中有部分存活对象会晋升到old gen,所以young GC后old gen的占用量通常会有所升高。
  • full GC:当准备要触发一次young GC时,如果发现统计数据说之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发full GC(因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集old gen的GC都会同时收集整个GC堆,包括young gen,所以不需要事先触发一次单独的young GC);或者,如果有perm gen的话,要在perm gen分配空间但已经没有足够空间时,也要触发一次full GC;或者System.gc()、heap dump带GC,默认也是触发full GC。

按算法来区分: Serial GC算法:Serial Young GC + Serial Old GC (实际上它是全局范围的Full GC);

Parallel GC算法:Parallel Young GC + 非并行的PS MarkSweep GC / 并行的Parallel Old GC(这俩实际上也是全局范围的Full GC),选PS MarkSweep GC 还是 Parallel Old GC 由参数UseParallelOldGC来控制;

CMS算法:ParNew(Young)GC CMS(Old)GC (piggyback on ParNew的结果/老生代存活下来的object只做记录,不做compaction)+ Full GC for CMS算法(应对核心的CMS GC某些时候的不赶趟,开销很大);

G1 GC:Young GC mixed GC(新生代,再加上部分老生代)+ Full GC for G1 GC算法(应对G1 GC算法某些时候的不赶趟,开销很大);

各类GC算法的触发条件。

简单说,触发条件就是某GC算法对应区域满了,或是预测快满了。比如,

1、各种Young GC的触发原因都是eden区满了;

2、Serial Old GC/PS MarkSweep GC/Parallel Old GC的触发则是在要执行Young GC时候预测其promote的object的总size超过老生代剩余size;

3、CMS GC的initial marking的触发条件是老生代使用比率超过某值;

4、G1 GC的initial marking的触发条件是Heap使用比率超过某值,跟4.3 heuristics 类似;

5、Full GC for CMS算法和Full GC for G1 GC算法的触发原因很明显,就是4.3 和 4.4 的fancy算法不赶趟了,只能全局范围大搞一次GC了;

gc耗时问题:

  • YGC耗时:一般来说,YGC的总耗时在1~100ms是比较正常的,虽然会引起系统卡顿(stop the world),这种情况几乎对用户无感知,对程序的影响可以忽略不计。但是如果YGC耗时达到了1秒甚至几秒(都快赶上FGC的耗时了),那卡顿STW时间就会增大,加上YGC本身比较频繁,就会导致比较多的服务超时问题。即使YGC不会引起服务超时,但是YGC过于频繁也会降低服务的整体性能,对于高并发服务也是需要关注的。
  • FGC耗时:FGC通常是比较慢的,少则几百毫秒,多则几秒,正常情况FGC每隔几个小时甚至几天才执行一次,对系统的影响还能接受。但是,一旦出现FGC频繁(比如几十分钟就会执行一次),这种肯定是存在问题的,它会导致工作线程频繁被停止,让系统看起来一直有卡顿现象,也会使得程序的整体性能变差。

问题1:对象进入Old区域有什么坏处? old区域一般称为老年代,老年代与新生代不一样,年轻代,我们可以认为存活下来的对象很少,而老年代则相反,存活下来的对象很多,所以JVM的 堆内存,才是我们通常关注的主战场,因为这里面活着的对象非常多,所以发生一次FULL GC,来找出来所有存活的对象是非常耗时的,因此,我们应该尽量避免FULL GC的发生。 问题2;一个对象每次Minor Gc时,活着的对象都会在s0和s1区域转移,经过经过Minor GC多少次后,会进入Old区域呢? 默认是15次,参数设置-XX:MaxTenuringThreshold=15,计数器会在对象的头部记录它交换的次数。

五、快速内存分配

对象内存分配器的操作和GC器紧密配合,GC记录回收的空间,分配器需要寻找满足内存分配的空间。

HotSpot虚拟机使用了两种技术来加快内存分配。他们分别是是指针碰撞”bump-the-pointer“和线程本地分配缓冲区“TLABs(Thread-Local Allocation Buffers)”。

Bump-the-pointer技术跟踪在Eden 空间创建的最后一个对象。这个对象会被放在Eden 空间的顶部。如果之后再需要创建对象,只需要检查Eden 空间是否有足够的剩余空间。如果有足够的空间,对象就会被创建在Eden 空间,并且被放置在顶部。这样以来,每次创建新的对象时,只需要检查最后被创建的对象。这将极大地加快内存分配速度。

但是,如果我们在多线程的情况下,事情将截然不同。如果想要以线程安全的方式以多线程在Eden 空间存储对象,不可避免的需要加锁,而这将极大地的影响性能。TLABs 是HotSpot虚拟机针对这一问题的解决方案。该方案为每一个线程在伊甸园空间分配一块独享的空间,这样每个线程只访问他们自己的TLAB空间,再与bump-the-pointer技术结合可以在不加锁的情况下分配内存。

以上是针对新生代空间GC技术的简要介绍,你不需要刻意记住我刚刚提到的两种技术。不知道他们不会对你产生什么影响,但是请务必记住在对象刚刚被创建之后,是保存在伊甸园空间的。那些长期存活的对象会经由幸存者空间转存在老年代空间。

六、常见的垃圾收集器


oracle公司提供的hotspot中7中垃圾收集器(有箭头指向的,是可以结合使用的)

新生代收集器使用的收集器:Serial、PraNew、Parallel Scavenge

老年代收集器使用的收集器:Serial Old、Parallel Old、CMS

jdk1.7 默认垃圾收集器Parallel Scavenge(新生代) Parallel Old(老年代)

jdk1.8 默认垃圾收集器Parallel Scavenge(新生代) Parallel Old(老年代)

jdk1.9 默认垃圾收集器G1

下面给出配置回收器时,经常使用的参数:

-XX: UseSerialGC:在新生代和老年代使用串行收集器 -XX: UseParNewGC:在新生代使用并行收集器 -XX: UseParallelGC :在新生代使用并行收集器,老年代使用串行收集器(jdk1.8默认收集器) -XX: UseParallelOldGC:新生代和老年代都使用并行收集器 -XX:ParallelGCThreads:设置用于垃圾回收的线程数 -XX: UseConcMarkSweepGC:老年代使用CMS收集器 -XX:ParallelCMSThreads:设定CMS的线程数量 -XX: UseG1GC:启用G1垃圾收集器(jdk1.9默认收集器) -XX:MaxGCPauseMillis:控制收集的暂停时间(ms),谨慎使用

查看jvm参数默认值:

$ java -XX: PrintFlagsFinal

$ java -XX: PrintFlagsInitial

-XX: PrintCommandLineFlagsjvm参数可查看默认设置收集器类型

-XX: PrintGCDetails亦可通过打印的GC日志的新生代、老年代名称判断

1、新生代收集器使用的收集器:

(1). Serial GC 也叫串行收集器(复制算法-XX: UseSerialGC)

新生代单线程的收集器,是Client模式默认的垃圾收集器,在JDK1.3之前唯一一个次收集器,单线程收集器,标记和清理都是单线程,优点是简单高效,缺点是在进行垃圾收集时必须暂停所有其他线程(包括用户线程)即也存在stop the world。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单 线程收集效率。

适合场景:Client模式应用。

(2). ParNew GC (也叫并行收集器-XX: UseParNewGC 停止-复制算法):

ParNew收集器其实是前面Serial 的多线程版本,除使多条线程进行GC外,包括Serial可用的所有控制参数、收集算法、STW(stop the world)、对象分配规则、回收策略等都与Serial完全一样(当老年代启用CMS收集器XX: UseConcMarkSweepG时,ParNew是默认新生代收集器).ParNew缩短了在垃圾回收时其他线程的暂停时间,但不能完全消除。

适合场景:这种GC在内存充足以及多核的情况下比Serial更好的表现,因此我们也称之为”throughput GC“。

(3)、Parallel Scavenge (XX: UseParallelGC 停止-复制算法, 并行收集器,追求高吞吐量,高效利用CPU)

与ParNew类似Parall Scavenge也是使用复制算法,也是并行多线程收集器.但与其他收集器关注尽可能缩短垃圾收集时间不同,Parallel Scavenge更关注系统吞吐量。

系统吞吐量 = 运行用户代码时间/ (运行用户代码时间 垃圾收集时间)。并行收集器,追求高吞吐量,高效利用CPU,吞吐量一般为99%,

停顿时间越短就越适用于用户交互的程序良好的响应速度能提升用户的体验;而高吞吐量则适用于后台运算而不需要太多交互的任务. 可以最高效率地利用CPU时间尽快地完成程序国的运算任务.

适合场景:适合后台应用等对交互相应要求不高的场景。

Parall Scavenge提供了如下参数设置系统吞吐量:

2、老年代收集器使用的收集器:Serial Old、Parallel Old、CMS

(4). Serial Old GC 老年代单线程收集器(标记-清除-压缩算法)

在老年代空间中的GC采取称之为标记-清除-压缩“mark-sweep-compact“的算法。

Serial GC的minor gc和full gc都是“stop-the-world”运行,只有等gc结束后,应用程序才能执行。适合大多数对停顿要求不高和客户端运行的应用。 Serial GC不应该被用在服务器上。这种GC类型在单核CPU的桌面电脑时代就存在了。使用Serial GC会显著的降低应用的性能指标。

(5). Parallel Old GC(老年代并行收集器-XX: UseParallelOldGC) 停止-复制算法

Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先,使用停止-复制算法。

(6) CMS GC (-XX: UseConcMarkSweepGC):并发收集器 低延迟为先

先了解并行和并发 并行是几个人同一时间干一件事,比如上面的Parallel Old收集器在垃圾回收时是几个线程一起做的。 并发是几个人在同一时间能做不同的事,也就是说前面几个垃圾收集器中,用户线程可以不暂停,系统一边回收垃圾,一边执行用户线程。

CMS(Concurrent Mark Sweep)收集器是一款具有划时代意义的收集器,一款真正意义上的并发收集器,虽然现在已经有了理论意义上表现更好的G1收集器,但现在主流互联网企业线上选用的仍是CMS。

CMS GC比我之前解释的各种算法都要复杂很多。CMS是一-种以获取最短回收停顿时间为目标的收集器(CMS又称多并发低暂停的收集器,基于”标记清除”算法实现,整个GC过程分为以下4个步骤:

1.初始标记(CMS initial mark) 比较简单,单线程运行:。这一步骤只是查找那些距离类加载器最近的幸存对象。因此,停顿的时间非常短暂。

2.并发标记(CMS concurrent mark: GC Roots Tracing过程)无停顿,和用户线程同时运行:从GC Roots直达对象开始遍历整个对象图,所有被幸存对象引用的对象会被确认是否已经被追踪和校验。

3.重新标记(CMS remark)多线程运行,需要Stop The World:标记并发标记阶段产生对象,会再次检查那些在并行标记步骤中增加或者删除的与幸存对象引用的对象。

4.并发清除(CMS concurrent sweep:无停顿,和用户线程同时运行:清理掉标记阶段标记的死亡的对象。。

其中1和3两个步骤(初始标记、重新标记仍需STW但初始标记仅只标记一下GC Roots能直接关联到的对象,速度很快;而重新标记则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那-部分对象的标记记录,虽然般比初始标记阶段稍长,但要远小于并发标记时间)

适合场景:并发收集、低停顿。对于响应时间要求十分苛刻的应用之上。一旦采取了这种GC类型,由GC导致的暂停时间会极其短暂。CMS GC也被称为低延迟GC。它经常被用在那些对于响应时间要求十分苛刻的应用之上。高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择

缺点:CMS同样有三个明显的缺点。

  • Mark Sweep算法会导致内存碎片比较多
  • CMS的并发能力比较依赖于CPU资源,并发回收时垃圾收集线程可能会抢占用户线程的资源,导致用户程序性能下降。
  • 并发清除阶段,用户线程依然在运行,会产生所谓的理“浮动垃圾”(Floating Garbage),本次垃圾收集无法处理浮动垃圾,必须到下一次垃圾收集才能处理。如果浮动垃圾太多,会触发新的垃圾回收,导致性能降低。如果因为内存碎片过多而导致压缩任务不得不执行,那么stop-the-world的时间要比其他任何GC类型都长,你需要考虑压缩任务的发生频率以及执行时间。

CMS触发条件 1.如果没有设置 UseCMSInitiatingOccupancyOnly,虚拟机会根据收集的数据决定是否触发(线上环境建议带上这个参数,不然会加大问题排查的难度) 2.老年代使用率达到阈值 CMSInitiatingOccupancyFraction,默认92% 3.永久代的使用率达到阈值 CMSInitiatingPermOccupancyFraction,默认92%,前提是开启 CMSClassUnloadingEnabled 4.新生代的晋升担保失败

3、使用cms的日志分析

-XX: UseConcMarkSweepGC -XX: CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=70 -XX: PrintGCDetails (参数打印gc信息)

其他对应的参数列表

代码语言:javascript复制
-XX: PrintGC 输出GC日志
-XX: PrintGCDetails 输出GC的详细日志
-XX: PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX: PrintGCDateStamps 输出GC的时间戳(以日期的形式,如2019-05-04T21:53:59.234 0800)
-XX: PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径

[GC (Allocation Failure) [ParNew: 449976K->40388K(460800K), 0.0308434 secs] 7340588K->7092465K(15840564K), 0.0311817 secs] [Times: user=0.84 sys=0.00, real=0.03 secs]

具体含义 [GC (表示Young GC)(Allocation Failure) [ParNew(ParNew垃圾收集器): 449976K(年轻代垃圾回收前的大小)->40388K(年轻代回收后的大小)(460800K)年轻代总大小), 0.0308434 secs(本次回收的时间)] 7340588K(整个堆回收前的大小)->7092465K(整个堆回收后的大小)(15840564K(堆总大小)), 0.0311817 secs(回收时间)] [Times: user=0.84(用户耗时) sys=0.00,(系统耗时) real=0.03 secs(实际耗时)]

GC:

表明进行了一次垃圾回收,前面没有Full修饰,表明这是一次Minor GC ,注意它不表示只GC新生代,并且现有的不管是新生代还是老年代都会STW。

Allocation Failure:

一次分配失败GC:表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了。

ParNew:

表明本次GC发生在年轻代并且使用的是ParNew垃圾收集器。ParNew是一个Serial收集器的多线程版本,会使用多个CPU和线程完成垃圾收集工作(默认使用的线程数和CPU数相同,可以使用-XX:ParallelGCThreads参数限制)。该收集器采用复制算法回收内存,期间会停止其他工作线程,即Stop The World。

449976K->40388K(460800K=):单位是KB

三个参数分别为:GC前该内存区域(这里是年轻代)使用容量,GC后该内存区域使用容量,该内存区域总容量。即gc处理新生代从449976K40388K空间(新生代共460800K空间)

在ygc之前young generation = eden S1;ygc之后可能,young generation = eden S0。

该次GC新生代减少了449976K-40388K=409 588K。该次Young GC有409 588K的内存被移动到了Old Gen。

0.0308434 secs:

该内存区域GC耗时,单位是秒

7340588K->7092465K(15840564K):

三个参数分别为:堆区垃圾回收前的大小,堆区垃圾回收后的大小,堆区总大小。

0.0311817 secs:

该内存区域GC耗时,单位是秒

[Times: user=0.84 sys=0.00, real=0.03 secs]:

分别表示用户态耗时,内核态耗时和总耗时

分析下可以得出结论:

该次GC新生代减少了YongM:449976K-40388K=409 588K

Heap区总共减少了HeapM:7340588K-7092465K=248 123K

该算法是我自己理解,代验证: 如果YongMHeapM < 0 说明该次共有(YongMHeapM)内存被释放掉. 如果YongMHeapM > 0 说明该次共有(YongMHeapM)内存从年轻代移到了老年代. YongM(409 588) – (HeapM)248 123 =-161 465K(小于0),说明该次gc有161 465k内存被释放掉了。

七、垃圾收集器G1

由于g1比较重要,所以单独一章讨论。

1、 G1 GC(Garbage first GC -XX: UseG1GC) :cms的替代者

Garbage First Garbage Collector 是个并发,并行和增量式压缩低停顿的GC.

Garbage First(简称G1)收集器是垃圾收集器的一个颠覆性的产物,它开创了局部收集的设计思路和基于Region的内存布局形式。

虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异。以前的收集器分代是划分新生代、老年代、持久代等。G1引入分区的思路,弱化了分代的概念。

G1收集与上面提到的收集器有很大不同:

1、G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大; 2、G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩); 3、G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换; 4、G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。

2、G1的内存结构

1)分区region:

G1虽然也把内存分成了这三大类,但是在G1里面这三大类不是的三大块内存,而是把连续的Java堆划分为多个大小相等的独立区域块(Region),每个Region大小为2的幂,范围在1MB-32MB之间。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小,默认将整堆划分为2048个分区。

例如-Xmx16g –Xms16g,设置16GB的堆大小,2000个Regions,则每个Region=16GB/2048=8MB。如果堆大小很大,而每个Region的大小很小,则Region数量可能会超过2048个。同样地,很小的堆大小会导致Region数量很少。

每一个Region都可以根据需要会被标记为Eden/Survivor/Old中的一个,各个区块region逻辑上并不是连续的Region是实现G1算法的基础,每个Region的大小相等,可以通过-XX:G1HeapRegionSize参数可以设置Region的大小。

G1对于Region的几个定义:

1、Available Region:可用的空闲Region

2、Eden Region : 年轻代Eden空间

3、Suivivor Region:年轻代Survivor空间

所有Eden和Survivor的集合=整个年轻代

4、Humongous Region:大对象Region

Humongous Region:大对象是指占用大小超过一个Region 50%空间的对象,这个大小包含了Java对象头。对象头大小在32位和64位HotSpot VM之间有差异,可以使用Java Object Layout工具确定头大小,简称JOL。

这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

2)每个区块标记不是固定的:

一个Region在某一个时刻是Eden,在另一个时刻就可能属于Old.

3) 不同的收集策略:

G1收集器能够对不同角色(Eden/Survivor/Old)的Region采用不同的策略去处理。G1在进行垃圾清理的时候就是将一个Region的对象拷贝到另外一个Region中。

每个对象被分配到不同的格子,随后GC执行。当一个区域装满之后,对象被分配到另一个区域,并执行GC。这中间不再有从新生代移动到老年代的三个步骤。这个类型是为了替代CMS GC而被创建的,因为CMS GC在长时间持续运作时会产生很多问题。

3、G1收集器的运行过程

G1收集器的运行过程大致可划分为以下四个步骤:

  • 初始标记(initial mark),标记了从GC Root开始直接关联可达的对象。短暂的时间段(Inital Mark)会停止应用(stop-the-world)。
  • 并发标记(concurrent marking),和用户线程并发执行,从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象、
  • 最终标记(Finalize Remark),再次停止应用(stop-the-world),修正并发标记阶段中因程序运行导致标记发生变化的那部分对象。 由于在并发标记阶段,垃圾回收线程和用户线程并发执行,因此在这一过程中,可能会由于用户线程改变了对象的引用关系,造成对象”消失“,因此还需要重新处理 SATB(原始快照)记录下在并发阶段有引用关系改动的对象,这一过程就是在最终标记阶段完成的,会造成 STW,否则如果用户线程还一直进行,就会不停地造成对象引用关系的改变,我们就得不停的处理 SATB 记录。
  • 筛选回收(Live Data Counting And Evacuation),根据时间来进行价值最大化回收。制定回收计划,选择多个Region 构成回收集,把回收集中Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。需要STW。

蓝色条形:

从日志就能清楚看出四个阶段:

G1和CMS收集器比较

只从内存的角度来看,与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。

同CMS相比G1有以下优势: 1、可预测的停顿模型 2、避免了CMS的垃圾碎片 3、超大堆的表现更出色

4、G1 垃圾回收触发条件

1)、新生代 GC触发条件

在 G1 中,Eden、Survivor、老年代的大小是在动态变化的。在初始时,新生代占整个堆内存的 5%,可以通过参数「-XX:G1NewSizePercent」设置,默认值为 5。

在 G1 中,虽然进行了 Region 分区,但是新生代依旧可以被分为 Eden 区和 Survivor 区,参数 SurvivorRatio 依旧表示 Eden/Survivor 的比值。

随着系统的运行,Eden 区的对象越来越多,当达到 Eden 区的最大大小时,就会触发 Minor GC。新生代的最大大小默认为整个堆内存的 60%,可以通过参数「-XX:G1MaxNewSizePercent」控制,默认值为 60。

G1 垃圾回收器在进行新生代的垃圾回收时,会采用复制算法来回收垃圾,不用考虑并发的场景,全程都是 STW,它会根据设置的停顿时间,尽可能的最大效率的回收新生代区域。

这个可以通过arthas工具能清楚的看到heap变化情况,然后看看gc.g1_young_generation.count值,heap使用率小于60%之前gc.g1_young_generation.count值一直都是161,超过60%后触发gc(gc.g1_young_generation.count=162)

2)、 什么时候进入到老年代

新生代的对象要进入老年代,需要达到以下两个条件中的其中之一即可。

(1)多次躲过新生代的回收:对象年龄达到「MaxTenuringThreshold」,该参数默认值为 15。 在每次 Minor GC 时,新生代的对象如果存活,会被移动到 Survivor 区中,同时会将对象的年龄加 1,当对象的年龄达到 MaxTenuringThreshold 后,就被被移到老年代中。

(2)符合动态年龄判断规则: 如果某次 Minor GC 过后,发现 Survivor 区中相同年龄的对象达到了 Survivor 的 50%,那么该年龄及以上的对象,会被直接移动到老年代中。 例如某次 Minor GC 过后,Survivor 区中存在年龄分别为 1、2、3、4 的对象,而年龄为 3 的对象超过了 Survivor 区的 50%,那么年龄大于等于 3 的对象,就会被全部移动到老年代中。

3)、 什么时候触发混合 Mixed GC

在 G1 中,「不存在单独回收老年代的行为,而是当要发生老年代的回收时,同时也会对新生代以及大对象进行回收,因此这个阶段称之为混合回收(Mixed GC)」。

当老年代对堆内存的占比达到 45%时,就会触发混合回收。可以通过参数「-XX:InitiatingHeapOccupancyPercent」来设置当堆内存达到多少时,触发混合 GC,该参数的默认值为 45。

当触发混合 GC 时,会依次执行初始标记(在 Minor GC 时完成)、并发标记、最终标记、筛选回收这四个过程。最终会根据设置的最大停顿时间,来计算对哪些 Region 区域进行回收带来的收益最大。

实际上,在筛选回收阶段,可以分多次回收 Region,具体多少次可以通过参数「-XX:G1MixedGCCountTarget」控制,默认值为 8 次。具体什么意思呢?

假如现在有 80 个 Region 需要被回收,因为筛选回收阶段会造成 STW,如果一下子全部回收这 80 个 Region,可能造成的停顿时间较长,因此 JVM 会分 8 次来回收这些 Region,每次先回收 10 个 Region,然后让用户线程执行一会,接着再让 GC 线程回收 10 个 Region,直至回收完这 80 个 Region,这样尽可能的降低了系统的暂停时间。

G1 垃圾回收器的回收思路是:不需要对整个堆进行回收,只需要保证垃圾回收的速度大于内存分配的速度即可。因此在每次进行 Mixed GC 时,虽然我们设置了停顿时间,但是当回收得到的空闲 Region 数量达到了整个堆内存的 5%,那么就会停止回收。可以由参数「G1HeapWaterPercent」控制,默认值为 5%。

另外,在混合回收的过程中,由于使用的是复制算法,因此当一个 Region 中存活的对象过多的话,复制这个 Region 所耗费的时间就会较多,因此 G1 提供了一个参数,用来控制当存活对象占当前 Region 的比例超过多少后,就不会对该 Region 进行回收。该参数为「G1MixedGCLiveThresholdPercent」,默认值为 85%。 4)、 触发 Full GC

在进行混合回收时,使用的是复制算法,如果当发现空闲的 Region 大小无法放得下存活对象的内存大小,那么这个时候使用复制算法就会失败,因此此时系统就不得不暂停应用程序,进行一次 Full GC。进行 Full GC 时采用的是单线程进行标记、清理和整理内存,这个过程是非常漫长的,因此应该尽量避免 Full GC 的触发。

5、G1收集器的优点和缺点:

优点:

1、G1最大的好处是性能:由于它高度的并行化,因此它在应用停止时间(Stop-the-world)这个指标上比其它的GC算法都要好。他比我们在上面讨论过的任何一种GC都要快。但是在JDK 6中,他还只是一个早期试用版本。在JDK7之后才由官方正式发布。目前JDK9已经是默认收集器了。

2、G1能让用户设置应用的暂停时间: G1回收的第4步“筛选回收”,而不是整代内存来回收,这是G1跟其它GC非常不同的一点,其它GC每次回收都会回收整个Generation的内存(Eden, Old), 而回收内存所需的时间就取决于内存的大小,以及实际垃圾的多少,所以垃圾回收时间是不可控的;而G1每次并不会回收整代内存,到底回收多少内存就看用户配置的暂停时间,配置的时间短就少回收点,配置的时间长就多回收点,伸缩自如。

3、可以有效的规避内存碎片的问题:由于内存被分成了很多小块,又带来了另外好处,由于内存块比较小,进行内存压缩整理的代价都比较小,相比其它GC算法,可以有效的规避内存碎片的问题。

G1收集器的缺点:

如果应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个Heap的回收,那么G1要做的工作量就一点也不会比其它垃圾回收器少,而且因为本身算法复杂了一点,可能比其它回收器还要差。

6、建议使用G1收集器的场景

G1收集器首要关注的是为用户运行着需要大堆空间、限制的垃圾回收延迟的应用提供一个解决方案。这意味着堆大小为6GB左右或者更大,稳定的、可预言的暂停时间小于0.5秒。

如果应用有以下一个或多个特点,当下运行着CMS或ParallelOldGC垃圾收集器的应用把收集器切换到G1收集器的话,会从中受益的:

  • Full GC持续时间太长或者太频繁
  • 对象分配比率或者提升有显著的变化
  • 不期望的长时间垃圾收集或者压缩暂停(大于0.5到1秒)

因此G1比较适合内存稍大一点的应用(一般来说至少4G以上),小内存的应用还是用传统的垃圾回收器比如CMS比较合适。

「G1 的内存占用相对而言,比较大」。G1 堆内存采用 Region 分区设计,每个 Region 中都存在一个记忆集,而其他传统的垃圾回收器中,整个堆内存只需要维护一份记忆集即可,因此 G1 中记忆集所占用的内存相比传统的垃圾回收器而言,会大很多。「加上其他内存消耗,G1 所占用的内存空间可能达到堆内存的 20%,甚至更多」。(这个数据参考自周志明《深入理解 Java 虚拟机》第三版)。

7、G1常用参数

参数/默认值和调优

-XX: UseG1GC

使用 G1 垃圾收集器

-XX:MaxGCPauseMillis=200

设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)

-XX:InitiatingHeapOccupancyPercent=45

启动并发mix ed GC周期时的堆内存占用百分比. G1之类的垃圾收集器用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比. 值为 0 则表示”一直执行GC循环”. 默认值为 45.

-XX:NewRatio=n

新生代与老生代(new/old generation)的大小比例(Ratio). 默认值为 2.

-XX:SurvivorRatio=n

eden/survivor 空间大小的比例(Ratio). 默认值为 8.

-XX:MaxTenuringThreshold=n

提升年老代的最大临界值(tenuring threshold). 默认值为 15.

-XX:ParallelGCThreads=n

设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台不同而不同. 设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。 如果逻辑处理器不止八个,则将 n 的值设置为逻辑处理器数的 5/8 左右。这适用于大多数情况,除非是较大的 SPARC 系统,其中 n 的值可以是逻辑处理器数的 5/16 左右。

-XX:ConcGCThreads=n

并发垃圾收集器使用的线程数量. 默认值随JVM运行的平台不同而不同. 将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。

-XX:G1ReservePercent=n

设置堆内存保留为假天花板的总量,以降低提升失败的可能性. 默认值是 10.

-XX:G1HeapRegionSize=n

使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小. 默认值将根据 heap size 算出最优解。region值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。

-XX:G1NewSizePercent

在初始时,新生代占整个堆内存默认为 5%。

-XX:G1MaxNewSizePercent

新生代的最大大小默认为整个堆内存的 60%,超过就触发ygc

7、G1优化喝最佳实践:

G1收集器时你应该遵守的一些最佳实践

1)不要设置年轻代大小 :避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。

  • 收集时G1收集器将不再遵照暂停时间指标。所以本质上,设置年轻代大小将不会启用暂停时间目标。
  • G1收集器将不能按需扩张、收缩年轻代空间。自从大小被固定之后,大小将不再会被改变。

2)响应时间指标:代替使用平均响应时间(ART)做为指标,来设置XX:MaxGCPauseMillis=,考虑设置值将会符合这个时间的90%或者更高比例。这意味着90%的用户发出一个请求将不会经历高于这个目标的时间。记住,暂停时间只是一个目标,不保证总是能够达到。

该值MaxGCPauseMillis设置的太小,虽然在 GC 回收时停顿时间较短,但是每次回收的 Region 也会较少,如果内存分配速度过快,就需要频繁的进行 GC,当回收速度跟不上内存分配速度时,会造成 Full GC。

如果设置MaxGCPauseMillis得过大,那么虽然每次回收可以获得的空闲 Region 较多,但是系统停顿时间会过长,也不好。因此需要结合系统的实际情况,通过相关的工具,实时查看系统的内存情况,从而决定如何调整该参数。

3)尽量「减少 Mixed GC 发生的次数」。触发 Mixed GC 的条件是老年代占用堆内存到达 45%时,因此可以适当地调大该值。不建议使用,尽量使用默认值即可。

触发混合 GC 是因为老年代对象过多,而老年代的对象从哪儿来的?当 Survivor 区中的对象年龄达到阈值或者存活的对象数量太多,导致 Survivor 无法容纳下,最终使对象进入到老年代。

如果 MaxGCPauseMillis 设置得过大,会导致很久才进行一次新生代回收,由于新生代的对象积攒过多,存活的对象数量也可能比较多,当 Survivor 无法存放下时,可能触发动态年龄判断条件,从而导致对象直接进入到老年代中,进而导致 Mixed GC。

如果 MaxGCPauseMillis 设置得过小,导致新生代回收频繁,存活对象的年龄增长过快,从而进入到老年代,又会造成 Mixed GC。

因此想要减少 Mixed GC 发生的次数,其核心也是需要控制 MaxGCPauseMillis 参数的大小。

G1常用参数删除重复字符串对象

JAVA应用的字符串对象很容易就占据了约至少 30% 的内存。而这些 String 对象中的大多数都是重复的,这些字符串的重复浪费了大量内存。因此,优化重复字符串对象浪费的内存是 Java 非常受欢迎的功能之一。在 G1 中,Java 就对此功能做了支持。

G1 GC 算法运行时,它将从内存中删除垃圾对象。它还从内存中删除重复的字符串对象(字符串重复数据删除)。可以通过设置以下 Java8 JVM 参数来激活此功能:

代码语言:javascript复制
-XX: UseG1GC -XX: UseStringDeduplication

注意1:为了使用此功能, 需要在 Java 8 update 20 或更高版本上运行。 注意2:为了使用 -XX: UseStringDeduplication,您需要使用 G1 GC 算法。

七、Java四种引用类型和垃圾回收

引用与对象

每种编程语言都有自己操作内存中元素的方式,例如在 C 和 C 里是通过指针,而在 Java 中则是通过“引用”。 在 Java 中一切都被视为了对象,但是我们操作的标识符实际上是对象的一个引用(reference)。

代码语言:javascript复制
//创建一个引用,引用可以独立存在,并不一定需要与一个对象关联
String s;

通过将这个叫“引用”的标识符指向某个对象,之后便可以通过这个引用来实现操作对象了。

代码语言:javascript复制
String str = new String("abc");
System.out.println(str.toString());

在 JDK1.2 之前,Java中的定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称为这块内存代表着一个引用。 Java 中的垃圾回收机制在判断是否回收某个对象的时候,都需要依据“引用”这个概念。 在不同垃圾回收算法中,对引用的判断方式有所不同:

  • 引用计数法:为每个对象添加一个引用计数器,每当有一个引用指向它时,计数器就加1,当引用失效时,计数器就减1,当计数器为0时,则认为该对象可以被回收(目前在Java中已经弃用这种方式了)。
  • 可达性分析算法:从一个被称为 GC Roots 的对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连时,则说明此对象不可用。

JDK1.2 之前,一个对象只有“已被引用”和"未被引用"两种状态,这将无法描述某些特殊情况下的对象,比如,当内存充足时需要保留,而内存紧张时才需要被抛弃的一类对象。

四种引用类型

所以在 JDK.1.2 之后,Java 对引用的概念进行了扩充,将引用分为了:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4 种,这 4 种引用的强度依次减弱。

一,强引用

Java中默认声明的就是强引用,比如:

代码语言:javascript复制
Object obj = new Object(); //只要obj还指向Object对象,Object对象就不会被回收
obj = null;  //手动置null

只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足时,JVM也会直接抛出OutOfMemoryError,不会去回收。如果想中断强引用与对象之间的联系,可以显示的将强引用赋值为null,这样一来,JVM就可以适时的回收对象了

二,软引用

软引用是用来描述一些非必需但仍有用的对象。在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。这种特性常常被用来实现缓存技术,比如网页缓存,图片缓存等。 在 JDK1.2 之后,用java.lang.ref.SoftReference类来表示软引用。

下面以一个例子来进一步说明强引用和软引用的区别: 在运行下面的Java代码之前,需要先配置参数 -Xms2M -Xmx3M,将 JVM 的初始内存设为2M,最大可用内存为 3M。

首先先来测试一下强引用,在限制了 JVM 内存的前提下,下面的代码运行正常

代码语言:javascript复制
public class TestOOM {
	
	public static void main(String[] args) {
		 testStrongReference();
	}
	private static void testStrongReference() {
		// 当 new byte为 1M 时,程序运行正常
		byte[] buff = new byte[1024 * 1024 * 1];
	}
}

但是如果我们将

代码语言:javascript复制
byte[] buff = new byte[1024 * 1024 * 1];

替换为创建一个大小为 2M 的字节数组

代码语言:javascript复制
byte[] buff = new byte[1024 * 1024 * 2];

则内存不够使用,程序直接报错,强引用并不会被回收

接着来看一下软引用会有什么不一样,在下面的示例中连续创建了 10 个大小为 1M 的字节数组,并赋值给了软引用,然后循环遍历将这些对象打印出来。

代码语言:javascript复制
public class TestOOM {
	private static List<Object> list = new ArrayList<>();
	public static void main(String[] args) {
	     testSoftReference();
	}
	private static void testSoftReference() {
		for (int i = 0; i < 10; i  ) {
			byte[] buff = new byte[1024 * 1024];
			SoftReference<byte[]> sr = new SoftReference<>(buff);
			list.add(sr);
		}
		
		System.gc(); //主动通知垃圾回收
		
		for(int i=0; i < list.size(); i  ){
			Object obj = ((SoftReference) list.get(i)).get();
			System.out.println(obj);
		}
		
	}
	
}

打印结果:

我们发现无论循环创建多少个软引用对象,打印结果总是只有最后一个对象被保留,其他的obj全都被置空回收了。 这里就说明了在内存不足的情况下,软引用将会被自动回收。 值得注意的一点 , 即使有 byte[] buff 引用指向对象, 且 buff 是一个strong reference, 但是 SoftReference sr 指向的对象仍然被回收了,这是因为Java的编译器发现了在之后的代码中, buff 已经没有被使用了, 所以自动进行了优化。 如果我们将上面示例稍微修改一下:

代码语言:javascript复制
	private static void testSoftReference() {
		byte[] buff = null;

		for (int i = 0; i < 10; i  ) {
			buff = new byte[1024 * 1024];
			SoftReference<byte[]> sr = new SoftReference<>(buff);
			list.add(sr);
		}

        System.gc(); //主动通知垃圾回收
		
		for(int i=0; i < list.size(); i  ){
			Object obj = ((SoftReference) list.get(i)).get();
			System.out.println(obj);
		}

		System.out.println("buff: "   buff.toString());
	}

则 buff 会因为强引用的存在,而无法被垃圾回收,从而抛出OOM的错误。

如果一个对象惟一剩下的引用是软引用,那么该对象是软可及的(softly reachable)。垃圾收集器并不像其收集弱可及的对象一样尽量地收集软可及的对象,相反,它只在真正 “需要” 内存时才收集软可及的对象。

三,弱引用

弱引用的引用强度比软引用要更弱一些,无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。在 JDK1.2 之后,用 java.lang.ref.WeakReference 来表示弱引用。 我们以与软引用同样的方式来测试一下弱引用:

代码语言:javascript复制
	private static void testWeakReference() {
		for (int i = 0; i < 10; i  ) {
			byte[] buff = new byte[1024 * 1024];
			WeakReference<byte[]> sr = new WeakReference<>(buff);
			list.add(sr);
		}
		
		System.gc(); //主动通知垃圾回收
		
		for(int i=0; i < list.size(); i  ){
			Object obj = ((WeakReference) list.get(i)).get();
			System.out.println(obj);
		}
	}

打印结果:

可以发现所有被弱引用关联的对象都被垃圾回收了。

四,虚引用

虚引用是最弱的一种引用关系,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收,在 JDK1.2 之后,用 PhantomReference 类来表示,通过查看这个类的源码,发现它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。

代码语言:javascript复制
public class PhantomReference<T> extends Reference<T> {
    /**
     * Returns this reference object's referent.  Because the referent of a
     * phantom reference is always inaccessible, this method always returns
     * <code>null</code>.
     *
     * @return  <code>null</code>
     */
    public T get() {
        return null;
    }
    public PhantomReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }
}

那么传入它的构造方法中的 ReferenceQueue 又是如何使用的呢?

五,引用队列(ReferenceQueue)

引用队列可以与软引用、弱引用以及虚引用一起配合使用,当垃圾回收器准备回收一个对象时,如果发现它还有引用,那么就会在回收对象之前,把这个引用加入到与之关联的引用队列中去。程序可以通过判断引用队列中是否已经加入了引用,来判断被引用的对象是否将要被垃圾回收,这样就可以在对象被回收之前采取一些必要的措施。

与软引用、弱引用不同,虚引用必须和引用队列一起使用。

0 人点赞