参考文献

概念

  • 内存泄漏: 该释放的没释放,该回收的没回收.
  • 内存溢出: 内存不够用

垃圾回收

哪些内存需要回收?

  • JVM 的内存区域中,程序计数器、虚拟机栈和本地方法栈这 3 个区域是线程私有的,随着线程的创建而创建,销毁而销毁;栈中的栈帧随着方法的进入和退出进行入栈和出栈操作,每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知的.
  • 垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收.

什么时候回收?

  • 对象的状态:

    • live: 被引用的对象
    • dead: 不再被引用的对象,也称为垃圾(garbage)
  • 垃圾回收: 寻找和释放(也称为回收)这些对象所使用的空间的过程

  • 判断对象已死的算法

    • 引用计数算法
    • 可达性分析算法

如何回收?

  • JVM 垃圾回收遵循以下两个特性:
    • 自动性: Java 提供了一个系统级的线程来跟踪每一块分配出去的内存空间,当 JVM 处于空闲循环时,垃圾收集器线程会自动检查每一块分配出去的内存空间,然后自动回收每一块空闲的内存块.
    • 不可预期性: 一旦一个对象没有被引用了,该对象是否立刻被回收呢?答案是不可预期的.我们很难确定一个没有被引用的对象是不是会被立刻回收掉,因为有可能当程序结束后,这个对象仍在内存中.
    • 垃圾回收线程在 JVM 中是自动执行的,Java 程序无法强制执行.我们唯一能做的就是通过调用 System.gc 方法来"建议"执行垃圾收集器,但是否可执行,什么时候执行?仍然不可预期.

判断对象已死的算法

  • 垃圾-就是无用对象所占用的堆内存空间

引用计数算法

  • 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器就减一;任何时刻计数器为零的对象就是不可能在被使用.
  • 优点:
    • 虽然占用了额外的内存空间来计数,但是原理简单,判断效率也很高.
  • 缺点:
    • 单纯的引用计数很难解决对象之间的互相循环引用的问题.

可达性分析算法

  • 基本思路:
    • 通过一系列称为GC Roots的跟对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链"(Reference Chain),如果某个对象GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的.
    • 在 Java 中,垃圾回收器并不是依靠 Reference Chain 来判断对象是否存活的,而是依靠对象是否被活动线程所引用.只有当一个对象不再被任何活动线程所引用时,它才会被垃圾回收器回收
GC Roots
  • 在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

    • Class - class loaded by system class loader. Such classes can never be unloaded. They can hold objects via static fields. Please note that classes loaded by custom class loaders are not roots, unless corresponding instances of java.lang.Class happen to be roots of other kind(s).
    • Thread - live thread
    • Stack Local - local variable or parameter of Java method
    • JNI Local - local variable or parameter of JNI method
    • JNI Global - global JNI reference
    • Monitor Used - objects used as a monitor for synchronization
    • Held by JVM - objects held from garbage collection by JVM for its purposes. Actually the list of such objects depends on JVM implementation. Possible known cases are: the system class loader, a few important exception classes which the JVM knows about, a few pre-allocated objects for exception handling, and custom class loaders when they are in the process of loading classes. Unfortunately, JVM provides absolutely no additional detail for such objects. Thus it is up to the analyst to decide to which case a certain “Held by JVM” belongs.

    from https://www.yourkit.com/docs/java/help/gc_roots.jsp

    • Class - 由Bootstrap ClassLoad加载的类.这样的类永远无法卸载.它们可以通过静态字段保存对象.请注意,自定义类加载器加载的类不是根,除非 java.lang.Class 的相应实例恰好是其他类型的根.
    • Thread - 正在运行的线程
    • Stack Local - 当前所有正在被调用的方法的引用类型参数、局部变量(在线程栈内存中)、临时值等,也就是与栈帧相关的各种引用
    • JNI(Java Native Interface) Local - JNI方法的局部变量或参数
    • JNI Global - 全局JNI引用
    • Monitor Used - 用作同步监视器的对象
    • 由 JVM 持有 - JVM出于其目的从垃圾收集中持有的对象.实际上,此类对象的列表取决于 JVM 实现.可能的已知情况有:系统类加载器,JVM知道的一些重要异常类,一些用于异常处理的预分配对象,以及在加载类的过程中的自定义类加载器.不幸的是,JVM 完全没有为这些对象提供额外的细节.因此,由分析人员决定某个“由 JVM 持有”属于哪种情况.

    Garbage Collection Roots

    A garbage collection root is an object that is accessible from outside the heap. The following reasons make an object a GC root:

    • System Class

      Class loaded by bootstrap/system class loader. For example, everything from the rt.jar like java.util.* .

    • JNI Local

      Local variable in native code, such as user defined JNI code or JVM internal code.

    • JNI Global

      Global variable in native code, such as user defined JNI code or JVM internal code.

    • Thread Block

      Object referred to from a currently active thread block.

    • Thread

      A started, but not stopped, thread.

    • Busy Monitor

      Everything that has called wait() or notify() or that is synchronized. For example, by calling synchronized(Object) or by entering a synchronized method. Static method means class, non-static method means object.

    • Java Local

      Local variable. For example, input parameters or locally created objects of methods that are still in the stack of a thread.

    • Native Stack

      In or out parameters in native code, such as user defined JNI code or JVM internal code. This is often the case as many methods have native parts and the objects handled as method parameters become GC Roots. For example, parameters used for file/network I/O methods or reflection.

    • Finalizable

      An object which is in a queue awaiting its finalizer to be run.

    • Unfinalized

      An object which has a finalize method, but has not been finalized and is not yet on the finalizer queue.

    • Unreachable

      An object which is unreachable from any other root, but has been marked as a root by MAT to retain objects which otherwise would not be included in the analysis.

    • Java Stack Frame

      A Java stack frame, holding local variables. Only generated when the dump is parsed with the preference set to treat Java stack frames as objects.

    • Unknown

      An object of unknown root type. Some dumps, such as IBM Portable Heap Dump files, do not have root information. For these dumps the MAT parser marks objects which are have no inbound references or are unreachable from any other root as roots of this type. This ensures that MAT retains all the objects in the dump.

    –from https://help.eclipse.org/latest/index.jsp?topic=%2Forg.eclipse.mat.ui.help%2Fconcepts%2Fgcroots.html

  • 有两个注意点:

    • 这里说的是活跃的引用,而不是对象,对象是不能作为 GC Roots 的.
    • GC 过程是找出所有活对象,并把其余空间认定为“无用”;而不是找出所有死掉的对象,并回收它们占用的空间.所以哪怕 JVM 的堆非常的大,基于 tracing 的 GC 方式,回收速度也会非常快.

两次标记

  • 即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
    • 如果对象在进行可达性分析后发现没有与GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法.假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种 情况都视为“没有必要执行”.
  • 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer 线程去执行它们的finalize() 方法.这里所说的“执行”是指虚拟机会触发这个方 法开始运行,但并不承诺一定会等待它运行结束.这样做的原因是,如果某个对象的 finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue 队列中的 其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃.finalize()方法是对象逃 脱死亡命运的最后一次机会,稍后收集器将对F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建 立关联即可,譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它.
    就真的要被回收了.

判断是否是垃圾的步骤

  1. GC Roots算法判断不可用
  2. 看是否有必要执行finalize方法,若只执行finalize方法,表示该对象可能会被"自救"
    • 如果对象在可达性分析中被判断为不可达,但该对象覆盖了finalize()方法且还未执行过一次,那么对象会进入一个**“队列”**,等待执行它的finalize()方法.
    • 这给了对象一次”自救”的机会:如果对象在finalize() 方法中重新建立与 GC Roots 之间的引用链,那么它就会被重新标记为可达,避免被回收.
    • 如果finalize()方法没有重新让对象变为可达,对象将会在下一轮GC中被判定为不可达,进入回收过程.
  3. 两个步骤走完后对象任然没有使用,那就属于垃圾

方法区回收

  • 方法区的垃圾收集主要回收两部分内容:废弃的常量不再使用的类.
  • 判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了.需要同时满足下面三个条件:
    • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例.
    • 加载该类的类加载器(ClassLoader)已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP 的重加载等,否则通常是很难达成的.
    • 该类对应的java.lang.Class对象没有在任何地方被引用
    • 无法在任何地方通过反射访问该类的方法.
  • Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收.关于是否要对类型进行回收, HotSpot 虚拟机提供了-Xnoclassgc 参数进行控制,还可以使用-verbose:class 以及-XX: +TraceClass-Loading-XX:+TraceClassUnLoading 查看类加载和卸载信息,其中-verbose:class-XX:+TraceClassLoading 可以在Product 版的虚拟机中使用,-XX:+TraceClassUnLoading 参数需要FastDebug 版的虚拟机支持.
  • 在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力.

引用

  • 无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判断对象是否存活都和"引用"离不开关系.
  • JDK1.2版本之后,Java对引用的概念进行了扩充,将引用分为
    • 强引用(Strongly Reference)
    • 软引用(Soft Reference)
    • 弱引用(Weak Reference)
    • 虚引用(Phantom Reference)
  • 四种引用强度依次逐渐减弱

强引用(Strongly Reference)

  • 当内存空间不足,系统撑不住了,JVM 就会抛出OutOfMemoryError 错误.即使程序会异常终止,这种对象也不会被回收.这种引用属于最普通最强硬的一种存在,只有在和GC Roots 断绝关系时,才会被消灭掉.

  • 强引用是最传统的"引用"的定义,是指在程序代码中普遍存在的引用赋值,即类似Object obj=new Object().无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收被引用的对象.

软引用(Soft Reference)

  • 软引用用于维护一些可有可无的对象.在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常.

  • 可以看到,这种特性非常适合用在缓存技术上.比如网页缓存、图片缓存等.

  • GuavaCacheBuilder,就提供了软引用和弱引用的设置方式.在这种场景中,软引用比强引用安全的多.

  • 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java 虚拟机就会把这个软引用加入到与之关联的引用队列中.

    1
    2
    3
    // 伪代码
    Object object = new Object();
    SoftReference<Object> softRef = new SoftReference(object);

弱引用(Weak Reference)

  • 弱引用对象相比较软引用,要更加无用一些,它拥有更短的生命周期.

  • 当 JVM 进行垃圾回收时,无论内存是否充足,一个对象只被弱引用关联时,每次垃圾回收时都会回收被弱引用关联的对象.弱引用拥有更短的生命周期,在 Java 中,用 java.lang.ref.WeakReference 类来表示

    1
    2
    3
    4
    5
    // 伪代码

    Object object = new Object();

    WeakReference<Object> softRef = new WeakReference(object);
  • 需要注意的是,弱引用只能保证被关联对象在垃圾回收时被回收掉,不能保证何时被回收,也不能保证在回收之前是否会被其他引用类型关联.因此,在使用弱引用时需要仔细考虑其生命周期和使用场景.

虚引用(Phantom Reference)

  • 这是一种形同虚设的引用,在现实场景中用的不是很多.虚引用必须和引用队列(ReferenceQueue)联合使用.如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收.

  • 虚引用主要用来跟踪对象被垃圾回收器回收的活动.

  • 实际上,虚引用的get,总是返回 null.

    1
    2
    3
    4
    5
    6
    7
    Object  object = new Object();

    ReferenceQueue queue = new ReferenceQueue();

    // 虚引用,必须与一个引用队列关联

    PhantomReference pr = new PhantomReference(object, queue);
引用类型 被垃圾回收时间 用途 生存时间
强引用 从来不会 对象的一般状态 JVM停止运行时终止
软引用 当内存不足时 对象缓存 内存不足时终止
弱引用 正常垃圾回收时 对象缓存 垃圾回收后终止
虚引用 正常垃圾回收时 跟踪对象的垃圾回收 垃圾回收后终止

垃圾收集算法

  • 从如何判定对象消亡的角度出发,垃圾收集算法可以划分为"引用计数式收集"(Reference Counting GC)和"追踪式垃圾收集"(Tracing GC)两大类,这两类也经常被称为"直接垃圾收集"和"间接垃圾收集".
  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC/Young GC): 指目标只是新生代的垃圾收集.
    • 老年代收集(Major GC/Old GC): 指目标只是老年代的垃圾收集.目前只有CMS收集器会有单独收集老年代的行为.
    • 混合收集(Mixed GC): 指目标是收集整个新生代以及部分老年代的垃圾收集.目前只有G1收集器会有这种行为.
    • 整堆收集(Full GC): 收集整个Java堆和方法区的垃圾收集.

分代收集理论

  • 分代收集(Generational Collection)名为理论,实质上是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
    • 弱分代假说(Weak Generational Hypothesis): 绝大多数对象都是朝生夕灭的.
    • 强分代假说(Strong Generational Hypothesis): 熬过越多次垃圾收集过程的对象就越难以消亡.
  • 这两个分代假说共同奠定了很多常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同区域之中存储.
    • 显而易见,通过一个区域中大多数对象都是朝生夕死,难以熬过垃圾收集过程,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低的代价回收到大量的空间.
    • 如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用.
  • Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域.顾名思义,在新生代中,每次垃圾收集时都发现大批量两对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放.分代收集并非只是简单的划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立,对象之间存在跨代引用.
    • 假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代引用的,为了找出该区域中的存货对象,不得不在固定的GC Roots之外,在额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样.这样无疑会给内存回收带来很大的性能负担.为了解决这个问题,就需要分代收集理论添加第三条经验法则
  • 跨代引用假说(Intergenerational Reference Hypothesis): 跨代引用相对同代引用来说仅占极少数.
    • 这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的.举个例子,如果某个新生代对象存在跨代 引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了.
    • 根据跨代引用假说,不应该再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在哪些跨代引用,只需要在新生代上建立一个全局的数据结构(该结构被称为"记忆集",Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用.

标记-清除(Mark-Sweep)算法

  • 最早出现也是最基础的垃圾收集算法是"标记-清除"(Mark-Sweep)算法.在1960年由有Lisp之父John McCarthy所提出.
  • 标记—清除算法(Mark and Sweep algorithm),来跟踪所有的可达对象(即存活对象),确保所有不可达对象(non-reachable objects)占用的内存都能被重用.其中包含两步:
    • Marking(标记): 遍历所有的可达对象,并在本地内存(native)中分门别类记下.
    • Sweeping(清除): 这一步保证了,不可达对象所占用的内存,在之后进行内存分配时可以重用.
  • 优点:
    • 标记清除算法最重要的优势,就是不再因为循环引用而导致内存泄露.
  • 缺点:
    • 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
    • 内存空间的碎片化问题,标记,清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作.

复制(Copying)算法

  • 为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为"半区复制"(Semispace Copying)的垃圾收集算法:
    • 它将可用内存按容量划分为大小相等的两块,每次只使用其中一块.当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉.如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只需要移动堆顶指针,按顺序分配即可.
    • 代价:将可用的内存缩小为原来的一半,空间浪费太多了.研究表明新生代中的对象有98%熬不过第一轮收集.因此并不需要按照 1∶1 的比例来划分新生代的内存空间.
  • 在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel 式回收”.
    • Appel 式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor.发生垃圾搜集时,将EdenSurvivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间.
    • HotSpot虚拟机默认EdenSurvivor的大小比例是 8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的.当Survivor 空间不足以容纳一次Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion).

标记-压缩(Mark-Compact)算法

  • 标记-复制算法在对象存活率较高时就是进行较多的复制操作,效率将会降低.更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100%存活的极端情况,所以在老年代一般不能直接选用这种算法.
  • 针对老年代对象的存亡特征,1974 年Edward Lueders提出了另外一种有针对性的“标记-压缩”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存.
  • 标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的.是否移动回收后的存活对象是一项优缺点并存的风险决策:
    • 进行移动存活对象操作,必须全程停止用户应用程序才行,即"Stop The World".
    • 不进行移动存活对象,则需要考虑离散的存活对象导致空间碎片化问题,从而只能依赖更为复杂的内存分配器和内存访问器来解决.(分区空闲分配链表),内存访问是用户程序最频繁的操作,这样影响应用程序的吞吐量.
    • HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的 CMS收集器则是基于标记-清除算法的.

汇总对比

回收算法类型 优点 缺点
标记-清除(Mark-Sweep)算法 不需要移动对象,简单高效 标记-清除过程效率低,GC产生内存碎片
复制(Copying)算法 简单高效,不会产生内存碎片 内存使用率低,且有可能产生频繁复制问题
标记-压缩(Mark-Compact)算法 综合了前两种算法 仍需要移动局部对象
分代收集(Generational Collection) 分区回收 对于长时间存活对象的场景回收不明显,甚至起到反作用

分代假设

弱代假设(Weak Generational Hypothesis)

  • 程序中的大多数可回收的内存可归为两类:
    • 大部分对象很快就不再使用,生命周期较短;
    • 还有一部分不会立即无用,但也不会持续太长时间.
  • 基于这一假设,JVM中的内存被分为年轻代(Young Generation)和老年代(Old Generation).老年代有时候也称为年老区(Tenured)
  • 年轻代又分为新生代(Eden Space),S0,S1三个部分.

img

新生代

  • Eden Space,也叫伊甸区,是内存中的一个区域,用来分配新创建的对象.通常会有多个线程同时创建多个对象,所以Eden区被划分为多个 线程本地分配缓冲区(Thread Local Allocation Buffer,简称TLAB).通过这种缓冲区划分,大部分对象直接由JVM在对应线程的TLAB中分配,避免与其他线程的同步操作.
  • 如果TLAB中没有足够的内存空间,就会在共享Eden区(Shared Eden space)之中分配.如果共享Eden区也没有足够的空间,就会触发一次年轻代GC来释放内存空间.如果GC之后Eden区依然没有足够的空闲内存区域,则对象就会被分配到老年代空间(Old Generation).
  • Eden区进行垃圾收集时,GC 将所有从Root可达的对象过一遍,并标记为存活对象.
  • 对象间可能会有跨代的引用,所以需要一种方法来标记从其他分代中指向Eden的所有引用.JVM在实现时采用了一些绝招:卡片标记(card-marking).从本质上讲,JVM只需要记住Eden区中“脏”对象的粗略位置,可能有老年代的对象引用指向这部分区间.
  • 标记阶段完成后,Eden区中所有存活的对象都会被复制到存活区(Survivor spaces)里面.整个Eden区就可以被认为是空的,然后就能用来分配新对象.这种方法称为“标记—复制”(Mark and Copy):存活的对象被标记,然后复制到一个存活区(注意,是复制,而不是移动).
存活区(Survivor Spaces)
  • Eden区的旁边是两个存活区(Survivor Spaces),称为from空间和to空间.需要着重强调的的是,任意时刻总有一个存活区是空的empty.
  • 空的那个存活区用于在下一次年轻代 GC 时存放收集的对象.年轻代中所有的存活对象(包括 Eden 区和非空的那个“from”存活区)都会被复制到 ”to“ 存活区.GC 过程完成后,“to”区有对象,而“from”区里没有对象.两者的角色进行正好切换,from 变成 to,to 变成 from.
  • 存活的对象会在两个存活区之间复制多次,直到某些对象的存活时间达到一定的阀值.分代理论假设,存活超过一定时间的对象很可能会继续存活更长时间.
  • 这类“年老”的对象因此被提升(promoted)到老年代.提升的时候,存活区的对象不再是复制到另一个存活区,而是迁移到老年代,并在老年代一直驻留,直到变为不可达对象.
  • 为了确定一个对象是否“足够老”,可以被提升(Promotion)到老年代,GC 模块跟踪记录每个存活区对象存活的次数.每次分代 GC 完成后,存活对象的年龄就会增长.当年龄超过提升阈值(tenuring threshold),就会被提升到老年代区域.
  • 具体的提升阈值由 JVM 动态调整,但也可以用参数 -XX:+MaxTenuringThreshold 来指定上限.如果设置 -XX:+MaxTenuringThreshold=0 ,则 GC 时存活对象不在存活区之间复制,直接提升到老年代.现代 JVM 中这个阈值默认设置为 15 个 GC 周期.这也是 HotSpot JVM 中允许的最大值.
  • 如果存活区空间不够存放年轻代中的存活对象,提升(Promotion)也可能更早地进行.

img

老年代(Old Gen)

  • 老年代一般使用“标记-清除”、“标记-压缩”算法,因为老年代的对象存活率一般是比较高的,空间又比较大,拷贝起来并不划算,还不如采取就地收集的方式.

  • 对象进入老年代途径:

    • 提升(Promotion): 如果对象够老,会通过“提升”进入老年代.关于对象老不老,是通过它的年龄(age)来判断的.每当发生一次 Minor GC,存活下来的对象年龄都会加 1.直到达到一定的阈值,就会把这些“老顽固”给提升到老年代.这些对象如果变的不可达,直到老年代发生 GC 的时候,才会被清理掉.

    • 分配担保: 看一下年轻代的图,每次存活的对象,都会放入其中一个幸存区,这个区域默认的比例是 10%.但是我们无法保证每次存活的对象都小于 10%,当 Survivor 空间不够,就需要依赖其他内存(指老年代)进行分配担保.这个时候,对象也会直接在老年代上分配.

      • 首先,在执行任何一次Minor GC之前,JVM会先检查一下老年代可用的可用内存空间,是否大于新生代所有对象的总大小.

        在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的.如果不成立,则虚拟机会先查看-XX: HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者-XX:
        HandlePromotnFailure 设置不允许冒险,那这时就要改为进行一次 Full GC.

        JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行Full GC.

        ​ 引用自周志明<<深入理解Java虚拟机(第3版)>>

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      bool TenuredGeneration::promotion_attempt_is_safe(size_t max_promotion_in_bytes) const {
      // 老年代最大可用的连续空间
      size_t available = max_contiguous_available();
      // 每次晋升到老年代的平均大小
      size_t av_promo =(size_t)gc_stats()->avg_promoted()->padded_average();
      // 老年代可用连续空间是否大于平均晋升大小或者老年代可用连续空间是否大于当此GC时新生代所有对象总容量
      bool res =(available >= av_promo) ||(available >= max_promotion_in_bytes);

      log_trace(gc)("Tenured: promo attempt is%s safe: available(" SIZE_FORMAT ") %s av_promo(" SIZE_FORMAT "), max_promo(" SIZE_FORMAT ")",
      res? "":" not", available, res? ">=":"<", av_promo, max_promotion_in_bytes);

      return res;
      }
      • OpenJDK 8
    • 大对象直接在老年代分配: 超出某个大小的对象将直接在老年代分配.这个值是通过参数 -XX:PretenureSizeThreshold进行配置的.默认为 0,意思是全部首选 Eden 区进行分配.

    • 动态对象年龄判定: 有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法.比如,如果幸存区中相同年龄对象大小的和,大于幸存区的一半,大于或等于 age 的对象将会直接进入老年代.

      • 如果一次新生代gc过后,发现Survivor区域中的几个年龄的对象加起来超过了Survivor区域的50%,比如说年龄1+年龄2+年龄3的对象大小总和,超过了Survivor区域的50%,此时就会把年龄3以上的对象都放入老年代.
      • 每次Minor GC过后就会触发动态年龄判定机制
    • 老年代如果使用CMS收集器: 老年代已用空间达到CMSInitiatingOccupancyFaction设置比例自动触发

  • 触发Full GC的原因有很多:

    • 当年轻代晋升到老年代的对象大小比目前老年代剩余的空间大小还要大时,此时会触发Full GC;
    • 当老年代的空间使用率超过某阈值时,此时会触发Full GC;
    • 当元空间不足时(JDK1.7永久代不足),也会触发Full GC;
    • 当调用System.gc()也会安排一次Full GC;
  • 对老年代触发垃圾回收的时机,一般就是两个:

    • 要不然是在Minor GC之前,一通检查发现很可能Minor GC之后要进入老年代的对象太多了,老年代放不下,此时需要提前触发Full GC然后再带着进行Minor GC
    • 要不然是在Minor GC之后,发现剩余对象太多放入老年代都放不下了

永久代(Perm Gen Java 8已删除)

  • 在 Java 8 之前有一个特殊的空间,称为“永久代”(Permanent Generation).这是存储元数据(metadata)的地方,比如class信息等.此外,这个区域中也保存有其他的数据和信息,包括内部化的字符串(internalized strings)等等.

  • 实际上这给 Java 开发者造成了很多麻烦,因为很难去计算这块区域到底需要占用多少内存空间.预测失败导致的结果就是产生 java.lang.OutOfMemoryError: Permgen space 这种形式的错误.除非OutOfMemoryError 确实是内存泄漏导致的,否则就只能增加permgen的大小,例如下面的示例,就是设置perm gen最大空间为 256 MB:

    1
    -XX:MaxPermSize=256m

元数据区(Metaspace)

  • 既然估算元数据所需空间那么复杂,Java 8 直接删除了永久代(Permanent Generation),改用Metaspace.从此以后,Java 中很多杂七杂八的东西都放置到普通的堆内存里.

  • 当然,像类定义(class definitions)之类的信息会被加载到Metaspace中.元数据区位于本地内存(native memory),不再影响到普通的Java对象.默认情况下,Metaspace的大小只受限于Java进程可用的本地内存.这样程序就不再因为多加载了几个类/JAR 包就导致 java.lang.OutOfMemoryError: Permgen space..注意,这种不受限制的空间也不是没有代价的 —— 如果Metaspace失控,则可能会导致严重影响程序性能的内存交换(swapping),或者导致本地内存分配失败.

  • 如果需要避免这种最坏情况,那么可以通过下面这样的方式来限制Metaspace的大小,如 256 MB:

    1
    -XX:MaxMetaspaceSize=256m

空间担保

什么是空间分配担保?

  • 在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,
    • 如果大于,则此次Minor GC是安全的
    • 如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败.如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC.

为什么要进行空间担保?

  • 是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代.老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考.使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间.

垃圾收集类型

  • 串行收集: GC单线程内存回收,会暂停所有用户线程,如Serial
  • 并行收集: 多个GC线程并发工作,此时用户线程是暂停的,如Parallel
  • 并发收集: 用户现场和GC线程同时执行(不一定并行,可能交替执行),不需要停顿用户线程,如CMS

GC全流程

Minor GC(新生代GC)

  • Minor GC是针对新生代的垃圾回收操作.
  • 通常发生在新生代空间不足时,会回收Eden区和Survivor区的垃圾对象.
  • Minor GC触发频率较高,因为新生代中的对象生命周期较短.
  • 对于大多数应用程序,Minor GC的停顿时间较短.

触发时机

  • 新生代Eden区内存空间不足,采用复制算法进行回收垃圾.

Old GC

  • Old GC主要是清理老年代(Tenured区)的垃圾对象.
  • Old GC 通常伴随着至少一次Minor GC,因为新生代的垃圾对象可能晋升到老年代.
  • Old GC 的停顿时间较长,因为老年代中的对象生命周期相对较长.

触发时机

  • Minor GC之前检查"老年代的可用连续内存空间大于新生代所有对象大小"或"老年代的可用空间大于历次晋升到老年代的对象的平均大小"都不满足.

  • 执行Minor GC之后有一批对象需要放入老年代,此时老年代就是没有足够的内存空间存放这些对象了,此时必须立即触发一次Old GC

  • 老年代内存使用率超过了92%,也要直接触发Old GC,当然这个比例是可以通过参数调整的

垃圾收集器

  • 垃圾收集器职责:
    • 分配内存
    • 确保被引用的对象留在内存中
    • 回收在执行代码中不再可达引用的对象所使用的内存
      • 执行代码中是为了排除对象相互引用的情况,如A,B相互引用,但是没有执行中的代码引用它们,则表示A,B应该被回收.

经典垃圾收集器

Serial Collector(串行收集器)

  • 单线程工作的收集器,说明它只会使用一个处理器或者一条收集线程去完成垃圾收集工作,年轻代和年老代都是串行收集的,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束.
对年轻代和老年代的收集过程
  • 对年轻代:

    • 在伊甸区(Eden)的存活对象(除了那些太大而不适合放入的对象)都复制到标记为To的生还者区,那些太大的对象直接复制到老年代,标记为From的生还者区中较为年轻的对象也复制到标记为To的生还者区中,较为年老的对象则复制到老年代.
      • 需要注意的是如果To区满了,从Eden区或者From区存活的对象就不再复制到To区了,而是直接晋升到老年代,而不管这些对象在年轻代垃圾收集中存活了几次了.
    • 年轻代的收集完成之后,Eden区和以前使用的生还者区(From区)都被置空,只有之前空闲的生还者区(To区)保存存活着的对象.这时,生还者去交换角色(原来的From区变为To区,原来的To区变为From区)
  • 对老年代: 使用标记-清除-压缩算法(mark-sweep-compact算法)

    • 标记阶段,收集器识别哪些对象任然活着
    • 清除阶段,清扫整个代,识别垃圾
    • 压缩阶段,收集器执行平移压缩(sliding compaction),将存活的对象平移到代的前端,相应的在尾部留下一整块连续的空闲空间.
优点
  • 简单而高效(相对于其他收集器的单线程相对),对于内存资源受限的环境,它是所有收集器里面额外内存消耗(Memory Footprint)最小的;
  • 对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率.

Parallel Collector(并行收集器)

对年轻代和老年代的收集过程
  • 对年轻代: 并行收集器是使用一个串行收集器以及使用年轻代收集算法的并行版本.它仍然是一个Stop The World的拷贝算法,但使用多个CPU并行运行的,减少了垃圾收集器的开销,因此增加了吞吐量.
  • 对老年代: 在老年代使用的与串行收集器相同的算法: 标记-清除-压缩收集算法(mark-sweep-compact算法)

ParNew

  • ParNewParallel并没有本质上的区别,其主要是为了配合CMS的垃圾收集而提供的年轻代的垃圾收集器,其只有年轻代的收集版本,垃圾收集上与Parallel相同. 目前仅有SerialParNew 可与CMS 进行配合垃圾收集.
  • 常用对应JVM参数:-XX:+UseParNewGC启用ParNew收集器,只影响新生代的收集,不影响老年代.
  • 开启上述参数后,会使用:ParNew(Young区用) + Serial old的收集器组合,新生代使用复制算法,老年代采用标记-整理算法。

Parallel Scavenge Collector收集器

  • Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器.

  • Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput).所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

    吞吐量=运行用户代码时间运行用户代码时间+运行垃圾收集时间吞吐量=\frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间}

  • Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX: GCTimeRatio 参数.

    • -XX:MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽力保证 内存回收花费的时间不超过用户设定值.不过大家不要异想天开地认为如果把这个参数 的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是 以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接导致垃圾收集发生得更频繁,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、每次停顿 70 毫秒.停顿时间的确在下降,但吞吐量也降下来了.
    • -XX:GCTimeRatio 参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数.譬如把此参数设置为 19,那允许的最大垃圾收集时间就占总时间的 5%(即 1/(1+19)),默认值为 99,即允许最大 1%(即1/(1+99))的垃圾收集时间.
  • Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”.-XX: +UseAdaptiveSizePolicy 虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量.这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics).

  • 只需要把基本的内存数据设置好(如-Xmx 设置最大堆),然后使用-XX:MaxGCPauseMillis 参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了.自适应调节策略也是 Parallel Scavenge 收集器区别于 ParNew 收集器的一个重要特性.

Serial Old收集器

  • Serial OldSerial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法.这个收集器的主要意义也是供客户端模式下的 HotSpot 虚拟机使用.如果在服务端模式下,它也可能有两种用途:一种是在JDK 5 以及之前的版本中与Parallel Scavenge 收集器搭配使用,另外一种就是作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用.

Paralled Old收集器

  • Parallel Old 是Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现.这个收集器是直到JDK 6 时才开始提供的,在此之前,新生代的Parallel Scavenge 收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge 收集器,老年代除了Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS 无法与它配合工作.由于老年代Serial Old 收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge 收集器也未必能在整体上获得吞吐量最大化的效果.同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew 加 CMS 的组合来得优秀.
  • 直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge 加Parallel Old 收集器这个组合.

CMS收集器

  • CMS GC的官方名称为**Mostly Concurrent Mark and Sweep Garbage Collector**(最大并发—标记—清除—垃圾收集器).

  • 对年轻代采用并行STW方式的mark-copy(标记—复制)算法

  • 对老年代主要使用并发mark-sweep(标记—清除)算法,在大部分的老年代收集工作与应用系统并行的执行

  • CMS GC 的设计目标是避免在老年代垃圾收集时出现长时间的卡顿,主要通过两种手段来达成此目标:

    • 第一: 不对老年代进行整理(CMS收集器是仅有的无压缩的收集器),而是使用空闲列表(free-lists)来管理内存空间的回收.
    • 第二: 在 mark-and-sweep(标记—清除)阶段的大部分工作和应用线程一起并发执行.
  • 也就是说,在这些阶段并没有明显的应用线程暂停.但值得注意的是,它仍然和应用线程争抢 CPU 时间.默认情况下,CMS 使用的并发线程数等于 CPU 核心数的 1/4.

  • 运行步骤:

    • CMS收集器的一个收集周期从一个短暂的暂停开始,称为**Initial Mark(初始标记)**
      • 该阶段会进入STW状态,标出所有GC Roots直接引用的对象集合
    • 然后进入**Concurrent Marking Phase(并发标记)**,收集器从上阶段标记出的对象集合中开始标记所有的间接引用的活动对象.由于在标记的同时,应用程序依然在运行,在不断更新引用字段,所以在并发标记结束时无法保证所有的活动对象都被标记.
    • 为了解决这个问题,应用程序会二次暂停,这个阶段为**Remark(重新标记)**,重新访问所有在并发标记期间更改过的对象作为最终标记.重新标记的暂停时间通常比初始标记的暂停时间长很多,因此会使用多线程来提高效率.
    • 在重新标记阶段的最后,保证所有堆中的活动对象都已经标记,随后进入**Concurrent Sweep(并发清除)**阶段回收所有识别的垃圾.
  • 优点:并发收集,低停顿.

  • -XX:+UseConcMarkSweepGC

CMS收集器收集完整步骤
  • Phase1 : Initial Mark【初始标记】

    • 这个是CMS两次stop-the-world事件的其中一次,这个阶段的目标是: 标记那些直接被GC root引用或被年轻代存活对象所引用的所有对象
  • Phase2 : Concurrent Mark 【并发标记】

    • 在这个阶段Garbage Collector会遍历老年代,然后标记所有存活的对象,它会根据上个阶段找到GC Roots遍历查找.并发标记阶段,它会与用户的应用程序并发运行.并不是老年代所有的存活对象都会被标记,因为在标记期间用户的程序可能会改变一些引用
  • Phase3 : Concurrent Preclean【并发预先清除】

    • 这也是一个并发阶段,与应用的线程并发运行,并不会stop应用的线程.在并发运行的过程中,一些对象的引用可能会发生变化,但是这种情况发生时,JVM会将包含这个对象的区域(Card)标记为Dirty,这也就是Card Marking.
    • 在pre-clean阶段,那些能够从Dirty对象到达的对象也会被标记,这个标记做完之后,dirty card标记就会被清除了
  • Phase4 : Concurrent Abortable Preclean【并发可能失败的预先清除】

    • 这也是一个并发阶段,但是同样不会影响用户的应用线程,这个阶段是为了尽量承担STW(stop-the-world)中最终标记阶段的工作.这个阶段持续时间依赖于很多的因素,由于这个阶段是在重复做很多相同的工作,直接满足一些条件(比如: 重复迭代的次数、完成的工作量或者时钟时间等)
  • Phase5 : Final Remark【最终重新标记】

    • 这是第二个STW阶段,也是CMS中的最后一个,这个阶段的目标是标记老年代所有的存活对象,由于之前的阶段是并发执行的,GC线程可能跟不上应用程序的变化,为了完成标记老年代所有存活对象的目标,STW就非常有必要了.
    • 通常CMS的Final Remark阶段会在年代代尽可能干净的时候运行,目的是为了减少连续STW发生的可能性(年轻代存活对象过多的话,也会导致老年代涉及的存活对象会很多).这个阶段会比前面的几个阶段更复杂一些
    • 经历过以上五个阶段之后,老年代所有存活的对象都被标记过了,现在可以通过清除算法去清理那些老年代不再使用的对象.可见其实CMS是将一个标记阶段细分成五个子阶段了.
  • Phase6 : Concurrent Sweep【并发清除】

    • 这里不需要STW,它是与用户的应用程序并发运行,这个阶段是:清除那些不再使用的对象,回收它们的占用空间为将来使用
  • Phase7 : Concurrent Reset【并发重置】

    • 这个阶段也是并发执行的,它会重设CMS内部的数据结构,为下次的GC做准备.
存在的问题
  • CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生,可能引发串行Full GC
  • 空间碎片,导致无法分配大对象,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开发参数(默认就是开启的),用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长;
  • 对于堆比较大的应用,GC的时间难以预估.

Garbage First收集器

  • G1垃圾回收器是可以同时回收新生代和老年代的对象的,不需要两个垃圾回收器配合使用.

  • 最大的特点是将Java堆内存拆分为了多个相等的Region

    • JVM最多可以有2048个Region,Region的大小必须是2的倍数,如1MB,2MB.
  • G1 是一款主要面向服务端应用的垃圾收集器.

  • G1 GC 最主要的设计目标是: 将 STW 停顿的时间和分布,变成可预期且可配置的.

  • -XX:+UseG1GC

触发新生代+老年代的混合垃圾回收的时机
  • -XX:+InitiatingHeapOccupancyPercent,默认为堆内存的45%,意思是当老年代占据了堆内存的45%的Region的时候,此时就触发一个新生代+老年代一起回收的混合回收(Mixed GC)阶段.
    • 堆内存有2048个Region,如果老年代占据了其中45%的Region,也就是接近1000个Region的时候开始触发一个混合回收.
G1垃圾回收的过程
  • 首先触发一个"初始标记"的操作,这个过程是需要进入Stop the World的,仅仅只是标记一下GC Roots直接能引用的对象这个过程速度是很快的.
  • 接着回进入"并发标记"的阶段,这个阶段回允许系统程序的运行,同时进行GC Roots追踪,从GC Roots开始追踪所有的存活对象.
    • 这个并发标记阶段还是很耗时的,因为要追踪全部的存活对象.但这个阶段是可以跟系统程序并发运行的,所以对系统程序的影响不太大
  • 下一个阶段是最终标记阶段,这个阶段会进入Stop the World,系统程序是禁止运行的,但是会根据并发标记阶段记录的那些对象修改,最终标记一下有哪些存活对象,哪些是垃圾对象.
  • 最后一个阶段就是,混合回收阶段,这个阶段会计算老年代中每个Region中的存活对象数量,存活对象的占比,还有执行垃圾回收的预期性能和效率.
    • 会停止系统程序,然后全力以赴尽快进行垃圾回收,此时会选择部分Region进行回收,因为必须让垃圾回收的停顿时间控制在我们指定的范围内(-XX:MaxGCPauseMills)
回收失败是的Full GC
  • 如果在进行Mixed回收的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个Region的存活对象拷贝到别的Region
    里去
  • 此时万一出现拷贝的过程中发现没有空闲Region可以承载自己的存活对象了,就会触发 一次失败.
  • 一旦失败,立马就会切换为停止系统程序,然后采用单线程进行标记、清理和压缩整理,空闲出来一批Region,这个过程是极慢极慢
G1 GC 常用参数设置
  • -XX:+UseG1GC:启用 G1 GC,JDK 7 和 JDK 8 要求必须显示申请启动 G1 GC;
  • -XX:G1NewSizePercent:初始年轻代占整个 Java Heap 的大小,默认值为 5%;
  • -XX:G1MaxNewSizePercent:最大年轻代占整个 Java Heap 的大小,默认值为 60%;
  • -XX:MaxGCPauseMills:预期 G1 每次执行 GC 操作的暂停时间,单位是毫秒,默认值是 200ms,G1 会尽量保证控制在这个范围内.
  • -XX:+InitiatingHeapOccupancyPercent(简称 IHOP):G1 内部并行回收循环启动的阈值,默认为 Java Heap 的 45%.这个可以理解为老年代使用大于等于 45% 的时候,JVM 会启动垃圾回收.这个值非常重要,它决定了在什么时间启动老年代的并行回收.
  • -XX:G1HeapWastePercent:G1 停止回收的最小内存大小,默认是堆大小的 5%.
    • 在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉
    • GC 会收集所有的 Region 中的对象,但是如果下降到了 5%,就会停下来不再收集了.就是说,不必每次回收就把所有的垃圾都处理完,可以遗留少量的下次处理,这样也降低了单次消耗的时间.
  • -XX:G1MixedGCCountTarget:设置并行循环之后需要有多少个混合 GC 启动,默认值是 8 个.老年代 Regions 的回收时间通常比年轻代的收集时间要长一些.所以如果混合收集器比较多,可以允许 G1 延长老年代的收集时间.
  • -XX:G1MixedGCLiveThresholdPercent: 默认值为85%,意思是确定要回收的Region的时候,必须是存活对象低于85%的Region才可以回收.
  • -XX:G1HeapRegionSize:设置每个 Region 的大小,单位 MB,需要为 1、2、4、8、16、32 中的某个值,默认是堆内存的 1/2048.如果这个值设置比较大,那么大对象就可以进入 Region 了.
  • -XX:ConcGCThreads:与 Java 应用一起执行的 GC 线程数量,默认是 Java 线程的 1/4,减少这个参数的数值可能会提升并行回收的效率,提高系统内部吞吐量.如果这个数值过低,参与回收垃圾的线程不足,也会导致并行回收机制耗时加长.
  • -XX:+G1PrintRegionLivenessInfo:这个参数需要和 -XX:+UnlockDiagnosticVMOptions 配合启动,打印 JVM 的调试信息,每个 Region 里的对象存活信息.
  • -XX:G1ReservePercent:G1 为了保留一些空间用于年代之间的提升,默认值是堆空间的 10%.因为大量执行回收的地方在年轻代(存活时间较短),所以如果你的应用里面有比较大的堆内存空间、比较多的大对象存活,这里需要保留一些内存.
  • -XX:+G1SummarizeRSetStats:这也是一个 VM 的调试信息.如果启用,会在 VM 退出的时候打印出 RSets 的详细总结信息.如果启用-XX:G1SummaryRSetStatsPeriod参数,就会阶段性地打印 RSets 信息.
  • -XX:+G1TraceConcRefinement:这个也是一个 VM 的调试信息,如果启用,并行回收阶段的日志就会被详细打印出来.
  • -XX:+GCTimeRatio:大家知道,GC 的有些阶段是需要 Stop—the—World,即停止应用线程的.这个参数就是计算花在 Java 应用线程上和花在 GC 线程上的时间比率,默认是 9,跟新生代内存的分配比例一致.这个参数主要的目的是让用户可以控制花在应用上的时间,G1 的计算公式是 100/(1+GCTimeRatio).这样如果参数设置为 9,则最多 10% 的时间会花在 GC 工作上面.Parallel GC 的默认值是 99,表示 1% 的时间被用在 GC 上面,这是因为 Parallel GC 贯穿整个 GC,而 G1 则根据 Region 来进行划分,不需要全局性扫描整个内存堆.
  • -XX:+UseStringDeduplication:手动开启 Java String 对象的去重工作,这个是 JDK8u20 版本之后新增的参数,主要用于相同 String 避免重复申请内存,节约 Region 的使用.
  • 这里面最重要的参数,就是:

    • -XX:+UseG1GC:启用 G1 GC;

    • -XX:+InitiatingHeapOccupancyPercent:决定什么情况下发生 G1 GC;

    • -XX:MaxGCPauseMills:期望每次 GC 暂定的时间,比如我们设置为 50,则 G1 GC 会通过调节每次 GC 的操作时间,尽量让每次系统的 GC 停顿都在 50 上下浮动.如果某次 GC 时间超过 50ms,比如说 100ms,那么系统会自动在后面动态调整 GC 行为,围绕 50 毫秒浮动.

从新生代的垃圾回收来看,G1垃圾回收器在新生代垃圾回收过程中,相比之前的ParNew而言,最大的进步在哪里?
  • 最大进步就是STW可控,但是,虽然各个Region所属区域是动态变化的,但不是随意变化的,还是会为Eden、Survivor、 老年代保留各自需要的空间.例如不会让Eden空间的分配超过系统设定的值

GC 选择的经验总结

收集器 串行/并行/并发 新生代/老年代 算法 目标 使用场景
Serial 串行 新生代 标记-复制 响应速度优先 单CPU环境下的Clinet模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单CPU环境下的Clinet模式,CMS的后备预案
ParNew 并行 新生代 标记-复制 响应速度优先 多CPU环境时在Server模式下与CMS配合
Parallel Scavenge 并行 新生代 标记-复制 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记-清除 响应速度优先 集中在互联网站或B/S系统服务端上的Java应用
G1 并发 all 标记-整理+标记-复制 响应速度优先 面向服务端应用,将来替换CMS
  • 针对新生代的垃圾回收器共有三个:Serial,Parallel Scavenge 和 Parallel New.这三个采用的都是标记 - 复制算法.其中,Serial 是一个单线程的,Parallel New 可以看成 Serial 的多线程版本.Parallel Scavenge 和 Parallel New 类似,但更加注重吞吐率.此外,Parallel Scavenge 不能与 CMS 一起使用.
  • 针对老年代的垃圾回收器也有三个:Serial Old 和 Parallel Old,以及 CMS.Serial Old 和 Parallel Old 都是标记 - 压缩算法.同样,前者是单线程的,而后者可以看成前者的多线程版本.
  • 线上使用最多的垃圾回收器.就有 CMS 和 G1.以及 Java8 默认的 Parallel Scavenge.
    • CMS 的设置参数:-XX:+UseConcMarkSweepGC.
    • Java8 的默认参数:-XX:+UseParallelGC.
    • Java13 的默认参数:-XX:+UseG1GC.

GC 性能衡量指标

  • **吞吐量: **这里的吞吐量是指应用程序所花费的时间和系统总运行时间的比值.我们可以按照这个公式来计算 GC 的吞吐量:系统总运行时间 = 应用程序耗时 +GC 耗时.如果系统运行了 100 分钟,GC 耗时1分钟,则系统吞吐量为99%.GC 的吞吐量一般不能低于 95%.

  • 停顿时间: 指垃圾收集器正在运行时,应用程序的暂停时间.对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低.

  • **垃圾回收频率: **多久发生一次指垃圾回收呢?通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间.所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可.

GC 调优策略

  • From Java性能调优实战-刘超-极客时间

降低Minor GC频率

  • 通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁Minor GC,因此我们可以通过增大新生代空间来降低Minor GC的频率.
  • 可能你会有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但不会增加单次Minor GC的时间吗?如果单次Minor GC的时间增加,那也很难达到我们期待的优化效果呀.
  • 我们知道,单次 Minor GC时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象).假设一个对象在 Eden 区的存活时间为500ms,Minor GC的时间间隔是 300ms,那么正常情况下,Minor GC的时间为 :T1+T2.
  • 当我们增大新生代空间,Minor GC的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生 Minor GC的时间为:两次扫描新生代,即 2T1.
  • 可见,扩容后,Minor GC时增加了 T1,但省去了 T2 的时间.通常在虚拟机中,复制对象的成本要远高于扫描成本.
  • 如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC的时间.如果堆中的短期对象很多,那么扩容新生代,单次Minor GC 时间不会显著增加.因此,单次Minor GC时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小.

降低Full GC的频率

  • 通常情况下,由于堆内存空间不足或老年代对象太多,会触发 Full GC,频繁的Full GC会带来上下文切换,增加系统的性能开销.我们可以使用哪些方法来降低Full GC的频率呢?
  • **减少创建大对象:**在平常的业务场景中,我们习惯一次性从数据库中查询出一个大对象用于 web 端显示.例如,我之前碰到过一个一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老年代.这种大对象很容易产生较多的 Full GC.我们可以将这种大对象拆解出来,首次只查询一些比较重要的字段,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段.
  • **增大堆内存空间:**在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低Full GC的频率.

JVM 内存分配的调优

  • 压测+分析GC日志

参考指标

  • **GC 频率:**高频的Full GC会给系统带来非常大的性能消耗,虽然Minor GC相对FullGC来说好了许多,但过多的 Minor GC仍会给系统带来压力.
  • **内存:**这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存.首先我们要分析堆内存大小是否合适,其实是分析年轻代和老年代的比例是否合适.如果内存不足或分配不均匀,会增加Full GC,严重的将导致 CPU持续爆满,影响系统性能.
  • **吞吐量:**频繁的Full GC将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降.
  • **延时: **JVMGC持续时间也会影响到每次请求的响应时间.

具体调优方法

  • 调整堆内存空间减少Full GC

    • 若出现堆内存基本被用完了,而且存在大量 FullGC,这意味着我们的堆内存严重不足,这个时候我们需要调大堆内存空间.
    1
    java -jar -Xms4g -Xmx4g heapTest-0.0.1-SNAPSHOT.jar
  • 调整年轻代减少MinorGC

    • 通过调整堆内存大小,我们已经提升了整体的吞吐量,降低了响应时间.那还有优化空间吗?我们还可以将年轻代设置得大一些,从而减少一些 MinorGC.
    1
    java -jar -Xms4g -Xmx4g -Xmn3g heapTest-0.0.1-SNAPSHOT.jar
  • 设置Eden、Survivor区比例

    • 在 JVM 中,如果开启AdaptiveSizePolicy,则每次GC后都会重新计算Eden、From SurvivorTo Survivor区的大小,计算依据是GC过程中统计的GC时间、吞吐量、内存占用量.这个时候SurvivorRatio默认设置的比例会失效.
    • JDK1.8中,默认是开启AdaptiveSizePolicy的,我们可以通过-XX:-UseAdaptiveSizePolicy关闭该项配置,或显示运行-XX:SurvivorRatio=8Eden、Survivor的比例设置为8:2.大部分新对象都是在 Eden区创建的,我们可以固定Eden区的占用比例,来调优JVM的内存分配性能.