type
status
date
slug
summary
tags
category
icon
password
一、垃圾回收简介
1、什么是垃圾回收
垃圾(Garbage)是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。如果不对垃圾对象进行回收,那么这些垃圾占用的内存空间会一直保留到应用程序结束,这些内存无法被其它对象使用,甚至会导致内存溢出。
回收垃圾的过程被称为 Garbage Collection,简称 GC,分为标记和清理两个阶段。
- 标记阶段:通过一系列算法标准来判断一个对象是否为垃圾,标记阶段在执行时会暂停用户线程,俗称 Stop The World,简称 STW。
- 清理阶段:通过垃圾收集器执行回收。
2、标记算法
常用的标记算法有两种:
- 引用计数器算法
- 可达性分析算法
引用计数法
引用计数法的原理是在对象中添加一个引用计数器,每当有一个地方引用它时,该对象的引用计数器就加 1;当引用失效的时候,引用计数器就减 1。当对象的引用计数器为 0,说明对象没有被引用,可进行回收。
优点:
实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:
- 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。 缺点3:引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。
注意只是没有在 Java 中使用引用计数法,不代表这个算法没有实用价值。引用计数算法是很多语言的资源回收选择,例如因人工智能而更加火热的 Python,它更是同时支持引用计数和垃圾收集机制。Python如何解决循环引用?
- 手动解除:很好理解,就是在合适的时机,解除引用关系。
- 使用弱引用weakref, weakref是Python提供的标准库,旨在解决循环引用。
可达性分析算法
可达性分析算法的原理:
- 以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
- 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。这点也是导致 GC 进行时必须 Stop The World 的一个重要原因。即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举 GC Roots 时也是必须要停顿的。
哪些对象可以作为 GC Roots 的对象:
- Class – 由系统类加载器(system class loader)加载的对象,这些类是不能够被回收的,例如基本数据类型对应的 Class 对象、一些常驻的异常对象(如:NullPointerException、OutOfMemoryError) 、系统类加载器。他们可以以静态字段的方式保存持有其它对象。我们需要注意的一点就是,通过用户自定义的类加载器加载的类,除非相应的 java.lang.Class 实例以其它的某种(或多种)方式成为 roots,否则它们并不是 roots。
- Thread – 活着的线程
- Stack Local – Java 方法的 local 变量或参数
- JNI Local – JNI 方法的 local 变量或参数
- JNI Global – 全局 JNI 引用
- Monitor Used – 所有被同步锁synchronized持有的对象
- JMXBean、JVMTI中注册的回调、本地代码缓存等
由于 Root 采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个 Root。
Stop The World
Stop-the-World,简称 STW,指的是 GC 事件发生过程中,会产生引用程序的停顿。停顿产生时整个应用程序都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。
- 可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。
- 分析工作必须在一个确保一致性的快照进行。
- 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上。
- 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。
被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所有我们需要减少 STW 的发生。
STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。哪怕是 G1 也不能完全避免 Stop-the-world 情况发生,只能说垃圾回收器越来越优秀,回收效率越高,尽可能地缩短了暂停时间。
3、垃圾回收算法
垃圾回收算法 | 算法 | 优点 | 缺点 |
标记-清除算法(mark-sweep) | 当堆中的有效空间(available memory)被耗尽时,就会停止整个程序(STW),然后执行标记和清除的操作。
1、标记:采用可达性分析算法,从引用根节点遍历,标记所有被引用的对象,一般是在对象的对象头(Header)中记录为可达对象。
2、清除:对堆内存中的所有对象进行从头到尾的线性遍历,如果发现某个对象在其对象头中没有被标记为可达对象,则就将该对象所占用的内存回收。
注意:这里的清除,并不是真的把对应的内存置空,而是把需要清除的对象地址保存在空闲的地址列表中,等有新对象需要分配内存空间时,会判断垃圾对象的位置空间是否足够。若足够,则分配给新对象。 | 简单易实现 | 1、效率不高。因为标记和清除都要进行遍历,这也意味着标记和清除两个过程都会因为对象的增加而效率下降。
2、空间碎片化。这种方式清理出来的空闲空间是不连续的,产生了内存碎片问题。故需要维护一个空闲列表,才能知道新对象该如何分配内存。而碎片问题可能会导致,即使内存空间足够,大对象依然有可能无法存放的问题。 |
复制算法(copying) | 将内存空间分为大小相等的两块,每次只使用其中的一块。在垃圾回收时,将正在使用的内存块中标记为存活的对象复制到未被使用的内存块中,然后一次性清理正在使用的内存块中的所有对象,交换两个内存块的角色,完成垃圾回收。 | 1、实现比较简单,不需要空闲链表的存在,直接移动指针分配内存,所以效率很高。
2、复制过去后保证了空间的连续性,不会出现“碎片问题”。 | 1、可用内存空间缩小了一半,浪费了原来的内存。
2、由于需要复制对象至另一半空间,故有一定的性能开销。
3、因为对象地址空间被改变,所以在复制过去后,还用花费一定的时间开销来维护对象之间的引用关系。比如,如果栈中的引用指向了堆中某块内存,经过复制算法后,还要把这个引用进行修改才行。 |
标记-压缩算法(mark-compact) | 标记-清除算法在在标记-清除算法上进行改进:
1、标记:与标记-清除算法一样,从根节点开始标记所有被引用的对象。
2、压缩:将所有存活对象压缩(移动)到内存的一端,按顺序排放。
3、清理边界外所有的空间。
标记-整理算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片的整理。二者的本质差异在于,标记-清除算法是非移动式的回收算法,而标记-整理算法是移动式的。 | 1、消除了标记-清除算法中产生的碎片问题。我们需要给新对象分配内存时,只需要一个内存的起始地址即可。
2、消除了复制算法中,内存减半的高额代价。 | 1、从效率上看,标记-整理算法要低于复制算法和标记-清除算法。
2、移动对象的同时,如果对象被其他对象引用,则还要调整引用地址
3、移动过程中,需要全程暂停用户的应用程序(STW) |
分代收集算法 | 根据不同对象的生命周期不同,采用不同的收集方式分代收集算法,严格来说分代收集算法应该是一种垃圾收集的理论。
分代收集算法一般把堆内存分成新生代和老年代:
1、新生代:区域相对老年代较小,对象生命周期短,存活率低,垃圾回收频繁。使用复制算法,通常一次可以回收70%-99%的内存空间,回收性价比很高。
2、老年代:区域较大,对象生命周期长,存活率高,回收不如新生代频繁。使用标记-清除算法或者是由标记-清除算法和标记-整理算法混合实现。 | 回收性价比最高,目前,几乎所有的垃圾收集器都采用了分代收集算法执行垃圾回收。 | 长时间STW可能影响用户体验 |
增量收集算法 | 上述的算法在垃圾回收过程中都不可避免的处于一种Stop The World 的状态。在STW状态下,程序所有的用户线程都会挂起,暂停一切正常工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序被挂起很久,将严重影响用户体验或者系统的稳定性。
增量收集算法的思想:如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么可以让垃圾收集线程和应用线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。如此反复,直到垃圾收集完成。
增量收集算法的基础仍然是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的处理,允许垃圾收集线程以分阶段的方式完成垃圾标记、清理或者复制工作。 | 减少了系统的停顿时间,优化了用户体验。 | 因为线程切换和上下文转换的消耗,会降低系统整体吞吐量 |
下面介绍下垃圾回收的相关概念
4、四种对象引用
引用一共分为四种类型:
- 强引用(StrongReference):无法被回收。最传统的 “引用” 的定义,是指在程序代码之中普遍存在的引用赋值,即类似
object obj = new object ( )
这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用(SoftReference):内存不足即回收。在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
- 弱引用(WeakReference) :发生垃圾收集(full gc)即回收。被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
- 虛引用(PhantomReference):不影响回收,对象回收跟踪。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虛引用来获得一个对象的实例。为一个对象设置虛引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
强引用
在 Java 程序中,最常见的引用类型是强引用(普通系统 99% 以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。当在 Java 语言中使用 new 操作符创建一个新的对象, 并将其赋值给一个变量的时候, 这个变量就成为指向该对象的一个强引用。
对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。
测试代码:
输出如下:
可以看到即使发生了 OOM,垃圾回收器也不会回收强引用关联的对象
软引用
软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存
垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)
类似弱引用,只不过 Java 虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理
测试代码:
输出如下:
可以看到第一次手动调用
System.gc()
触发 full gc 的时候,因为内存充足,所以不会回收软引用的对象;第二次因为内存不足触发 full gc 的时候,软引用对应的对象就被回收放到了引用队列。弱引用
弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统 GC 时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象
但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间
弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况
软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
测试代码:
输出如下:
可以看到在内存充足的时候发生 full gc,弱引用的对象也会被回收。
虚引用
也称为 “幽灵引用” 或者 “幻影引用” ,是所有引用类型中最弱的一个。
一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收
无法通过虚引用来获取被引用的对象。当试图通过虚引用的 get() 方法取得对象时,总是 null。
虚引用不能单独使用,必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。
为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。因此,也可以将一些资源释放操作放置在虛引用中执行和记录。
测试代码:
输出如下:
可以看到不管虚引用的对象是否被回收,都无法通过
get()
来获取虚引用的对象。5、finalize机制
当垃圾回收器发现没有引用指向一个对象,在垃圾回收此对象之前会先调用这个对象的
finalize()
方法。 finalize()
方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。注意
- 永远不要主动调用某个对象的
finalize()
方法,应该交给垃圾回收机制调用。理由包括下面三点: - 在
finalize()
时可能会导致对象复活。 finalize()
方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()
方法将没有执行机会。- 一个糟糕的
finalize()
会严重影响 GC 的性能。
- 如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是"非死不可"的,这时候它们暂时处于"缓刑"阶段。一个无法触及的对象有可能在某一个条件下"复活"自己。如果这样,那么对它的回收就是不合理的。以下 3 种状态是由于
finalize()
方法的存在进行的区分。只有在对象不可触及时才可以被回收。 - 可触及的:从根节点开始,可以到达这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在
finalize()
中复活。 - 不可触及的:对象的
finalize()
被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
- 判定一个对象 objA 是否可回收,至少要经理两次标记过程:
- 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
- 进行筛选,判断此对象是否有必要执行
finalize()
方法。 - 如果对象 objA 没有重写
finalize()
方法,或者finalize()
方法已经被虚拟机调用过,则虚拟机视为"没有必要执行",objA 被判定为不可触及的。 - 如果对象 objA 重写了
finalize()
方法,且还未执行过,那么 objA 会被插入到F-Queue
队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize() 方法。 - finalize()方法是对象逃脱死亡的最后机会。稍后 GC 会对
F-Queue
队列中的对象进行第二次标记。如果 objA 在finalize()
方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出"即将回收"集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()
方法不会被再次调用,对象会直接编程不可触及的状态,也就是说一个对象的finalize()
方法只会被调用一次。
6、GC的安全点和安全区域
OopMap
JVM 采用的可达性分析法有个缺点,就是从
GC Roots
找引用链耗时。都说他耗时,他究竟耗时在哪里?GC 进行扫描时,需要查看每个位置存储的是不是引用类型,如果是,其所引用的对象就不能被回收;如果不是,那就是基本类型,这些肯定是不会引用对象的;这种对 GC 无用的基本类型的数据非常多,每次 GC 都要去扫描,显然是非常浪费时间的。
而且迄今为止,所有收集器在
GC Roots
枚举这一步骤都是必须暂停用户线程的。那有没有办法减少耗时呢?
一个很自然的想法,能不能用空间换时间,把栈上的引用类型的位置全部记录下来,这样到 GC 的时候就可以直接读取,而不用一个个扫描了。Hotspot 就是这么实现的,这个用于存储引用类型的数据结构叫
OopMap
。OopMap(Ordinary Object Pointer Map)
用来存储栈上的对象引用的信息。OopMap 中存储了两种对象的引用:- 栈里和寄存器内的引用。在即时编译中,在特定的位置记录下栈里和寄存器里哪些位置是引用。在 JVM中,一个线程为一个栈,一个栈由多个栈桢组成,一个栈桢对应一个方法,一个栈帧可能有多个 OopMap。
- 对象内的引用。比如一旦类加载动作完成的时候, HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来。注:把存储单元的实际地址与其所在段的段地址之间的距离称为段内偏移,也称为有效地址或偏移量,因此,实际地址 = 所在段的起始地址 + 偏移量。
假设,这两个方法都只有一个 OopMap,并且是在方法返回之前:
那么 testMethod1() 和 testMethod2() 的 OopMap 如下图所示:
安全点
在程序执行的过程中,对象之间的引用关系随时都会发生改变,这意味着对应的
OopMap
需要同步进行更新。如果每一条指令的执行,都生成(或更新)对应的 OopMap
,那么将会占用大量的内存空间,增加了 GC 的空间成本。因此,针对这个问题,JVM 引入了 Safe Point 的概念,只有在 Safe Point 才会生成(或更新)对应的 OopMap
。有了安全点,当 GC 回收需要停止用户线程的时候,将设置某个中断标志位,各个线程不断轮询这个标志位,发现需要挂起时,自己跑到最近的安全点,更新完
OopMap
才能挂起。安全点不是任意的选择,既不能太少以至于让收集器等待时间过长,也不能过多以至于过分增大运行时的内存负荷。那么,哪些地方适合放置 Safe Point 呢
- 所有的非计数循环的末尾(防止循环体的执行时间太长,一直进入不了 Safe Point)。
- 所有方法返回之前。
- 每条 Java 编译后的字节码的边界。
例如,在方法返回之前插入 Safe Point,那么栈帧 8 只有一个
OopMap
:除此之外,Safe Point 的数量不能太少,太少会导致进入 Safe Point 的前置时间过长,以至于垃圾回收线程等待的时间太长。
Safe Point 的数量也不能太多,太多意味着将会频繁生成(或更新)OopMap ,会有性能损耗。
当所有线程都到达 Safe Point,有两种方法中断线程:
- 抢占式中断(Preemptive Suspension)。JVM 会中断所有线程,然后依次检查每个线程中断的位置是否为 Safe Point,如果不是则恢复用户线程,让它执行至 Safe Point 再阻塞。目前很少 JVM 采用这种方案,
- 主动式中断(Voluntary Suspension)。大部分 JVM 实现都是采用主动式中断,需要阻塞用户线程的时候,首先做一个标志,用户线程会主动轮询这个标志位,如果标志位处于就绪状态,就自行中断。
安全区域
然而,实际情况中 Safe Point 仍然存在缺陷,线程执行的过程中,Safe Point 可以发挥很好的作用。
可如果线程没有执行呢?即线程没有分配到 CPU 片,例如:线程处于 Sleep 状态或者 Blocked 状态,那么线程就无法达到 Safe Point。
因此,针对这个问题,JVM 引入了 Safe Region 的概念。Safe Region 是一片区域,在这个区域的代码片段,引用关系不会发生变化,因此,在 Safe Region 中任意地方开始垃圾收集都是安全的。
可以理解为 Safe Region 就是 Safe Point 的扩展,点动成线。
线程执行到 Safe Region 时,首先标记线程已经进入 Safe Region,当线程将要离开 Safe Region 时,线程需要检查 JVM 是否已经完成 GC Roots 枚举。如果尚未完成,则需要一直等待,直到 GC Roots 枚举完成。
例如,下面这个方法的:
总结
- HotSpot 使用
OopMap
把引用类型的指针记录下来,让 GC Roots 的枚举变得快速准确。
- 为了减少更新 OopMap 的开销,引入了
安全点
。GC STW 时,线程需要跑到距离自己最近的安全点,更新完 OopMap 才能挂起。
- 处于 Sleep 或者 Blocked 状态的线程无法跑到安全点,需要引入
安全区域
。GC 的时候,不会去管处于安全区域的线程,线程离开安全区域的时候,如果处于 STW 则需要等待直至恢复。
7、吞吐量和暂停时间
吞吐量:运行用户代码的时间占总运行时间的比例,即用户线程运行时间 / (用户线程运行时间 + 垃圾收集时间),高吞吐量可能对每次 STW 时长没有要求,只要求在单位时间内 STW 总时长最短。
暂停时间:执行垃圾回收时用户线程被暂停的时间,即每次 STW 的时长。
高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做 “生产性” 工作。直觉上,吞吐量越高程序运行越快。
低暂停时间(低延迟)较好因为从最终用户的角度来看不管是 GC 还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的 200 毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。
不幸的是 “高吞吐量” 和 “低暂停时间” 是一对相互竞争的目标(矛盾)
- 如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致 GC 需要更长的暂停时间来执行内存回收。
- 如果选择以低延迟优先,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。
8、串行、并行和并发
- 串行:单个垃圾收集线程处于工作状态,此时用户线程仍然处于等待状态。
- 并行:多个垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发:垃圾回收线程和用户线程同时执行(但不一定是并行的,可能会交替执行),此时垃圾回收线程可以是单线程或多线程。
二、垃圾回收器
1、垃圾回收器介绍
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。
下面是七种常用的垃圾回收器:
垃圾收集器 | 分代 | 算法 | 执行模式 | 使用场景 | 特点 |
Serial GC | 新生代 | 复制 | 串行 | Client 模式下的默认新生代收集器,适用于单CPU场景 | 没有线程切换,简单高效 |
ParNew GC | 新生代 | 复制 | 并行 | 很多 JVM 在Server模式下的默认新生代收集器,适用于多 CPU 场景。唯一能和 CMS 搭配使用的并行收集器。 | Serial GC的多线程版本,其他几乎没区别,在多CPU情况下比Serial GC高效,但是单CPU情况下可能更慢。 |
Parallel Scavenge GC | 新生代 | 复制 | 并行 | Java8 的默认新生代收集器,适用于后台计算等不需要太多交互的场景。 | 高吞吐量,尽快完成计算任务 |
Serial Old GC | 老年代 | 标记-压缩 | 串行 | Client 模式下的默认老年代收集器,Server 模式下和 Parallel Scavenge GC 搭配使用,或者作为 CMS 的后备方案。 | 没有线程切换,简单高效 |
Parallel Old GC | 老年代 | 标记-压缩 | 并行 | Java8 的默认老年代收集器,主要是为了和Parallel Scanvenge GC 搭配使用。 | 同Parallel Scanvenge GC |
CMS GC | 老年代 | 标记-清除 | 并发 | 适用于 B/S 等追求高响应速度的场景,只能和 Serial GC 或 ParNew GC 搭配使用。 | 低延迟,第一款并发收集器 |
G1 GC | 新生代 + 老年代 | ㅤ | 并发 + 并行 | 主要面向服务端,针对多 CPU 和大内存的机器,在尽量满足 GC 停顿时间的同时提高吞吐量。 | 延迟可控的情况下尽可能提高吞吐量 |
从串行、并行、并发角度分类
- 串行回收器:Serial、Serial Old
- 并行回收器:ParNew、Parallel Scavenge、Parallel Old
- 并发回收器:CMS、G1
从垃圾分代角度分类
- 新生代收集器:Serial、ParNew、Parallel Scavenge
- 老年代收集器:Serial Old、Parallel Old、CMS
- 整堆收集器:G1
垃圾回收器搭配使用关系
- 两个收集器间有连线,表明它们可以搭配使用: Serial/Serial Old、 Serial/CMS、ParNew/Serial Old、ParNew/CMS 、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1。
- 其中 Serial Old 作为 CMS 出现 “Concurrent Mode Failure" 失败的后备预案。
- (红色虚线)由于维护和兼容性测试的成本,在 JDK 8 时將 Serial + CMS、ParNew + Serial Old这两个组合声明为废弃(JEP 173),并在 JDK 9 中完全取消了这些组合的支持(JEP214),即移除。
- (绿色虚线)JDK 14中,弃用Parallel Scavenge和SerialOld GC组合(JEP 366 )
- (蓝色虚线)JDK 14中,删除CMS垃圾回收器 (JEP 363)
搭配使用的场景:
- java8默认使用:Parallel Scavenge GC + Parallel Old GC
- java9默认使用:G1
- client模式、单CPU等硬件较差:Serial GC + Serial Old GC
- 追求高吞吐量:ParNew GC + CMS GC
- java 14之后只剩下 Serial GC + Serial Old GC、Parallel Scavenge GC + Parallel Old GC 和 G1 这三种组合。
2、Serial GC 和 Serial Old GC
Serial 收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3 之前回收新生代唯一的选择。Serial 收集器作为 HotSpot 中 client 模式下的默认新生代垃圾收集器。Serial 收集器采用复制算法、串行回收和 "Stop-The-World" 机制的方式执行内存回收。
除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old 收集器同样也采用了串行回收和 "Stop-The-World" 机制,只不过内存回收算法使用的是标记-压缩算法。
Serial Old 在 Server 模式下主要有两个用途:
- 与新生代的 Parallel Scavenge 配合使用。
- 作为老年代 CMS 收集器的后备垃圾收集方案。
优势:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本不会再大了),停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不频繁发生,这点停顿时间可以接收。
所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。
JVM 参数:
-XX:+UseSerialGC
同时设置新生代收集器为 Serial GC,老年代收集器为Serial Old GC。
3、ParNew GC
ParNew 收集器就是 Serial 收集器的多线程版本,它也是一个新生代收集器。除了使用多线程进行垃圾收集外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与Serial收集器完全相同,两者共用了相当多的代码。
ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器。
由于ParNew收集器是基于并行回收,那么是否可以断定 ParNew 收集器的回收效率在任何场景下都会比Serial收集器更高效?
- ParNew 收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
- 但是在单个CPU的环境下,ParNew收集器不比Serial 收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
此外新生代收集器除 Serial GC 外,目前只有 ParNew GC 能与 CMS 收集器配合工作。
JVM 参数:
-XX:+UseParNewGC
设置新生代收集器为 ParNew GC
-XX:ParallelGCThreads
设置并行收集线程数
4、Parallel Scavenge GC + Parallel Old GC
HotSpot 的年轻代中除了拥有 ParNew 收集器是基于并行回收的以外,Parallel Scavenge 收集器同样也采用了复制算法、并行回收和 ”Stop the World” 机制。
那么 ParNew 收集器和 Parallel Scavenge 收集器的区别是什么?
- 和 ParNew 收集器不同,Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),也被称为吞吐量优先的垃圾收集器。高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
- 自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。
Parallel Old 收集器类似于 Parallel Scavenge 收集器,但是用于收集老年代的垃圾,采用了标记-压缩算法,但同样也是基于并行回收和 ”Stop-the-World” 机制。
在程序吞吐量优先的应用场景中, Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。在 Java8 中,默认是 Parallel Scavenge GC + PS MarkSweep(Serial Old GC) 的组合。
Parallel Scavenge 收集器有时候会使用 PS 前缀,即命名为 PS Scavenge,本身包含了老年代收集器,被命名为 PS MarkSweep。但由于 PS MarkSweep 与 Serial Old 实现非常接近,基本是使用同一份代码,因此官方的许多资料都直接以 Serial Old 代替 PS MarkSweep 进行讲解。
JVM 参数
-XX:+UseParallelGC
设置新生代回收器为 Parallel Scanvenge GC,老年代回收器为 PS MarkSweep(Serial Old GC)。
-XX:+UserParallelOldGC
设置新生代回收器为 Parallel Scanvenge GC,老年代回收器为 Parallel Old GC。
-XX:ParallelGCThreads
设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。在默认情况下,当CPU 数量小于8个, ParallelGCThreads 的值等于CPU 数量。当CPU数量大于8个,ParallelGCThreads 的值等于3+[5*CPU_Count]/8] 。
-XX:MaxGCPauseMillis
设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒。为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。该参数使用需谨慎。
-XX:GCTimeRatio
垃圾收集时间占总时间的比例(= 1 / (N + 1))。用于衡量吞吐量的大小。取值范围(0,100)。默认值99,也就是垃圾回收时间不超过1%。 与前一个-XX:MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
-XX:+UseAdaptiveSizePolicy
设置Parallel Scavenge收集器具有自适应调节策略。在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。
5、CMS GC
在 JDK 1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS (Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。
在 G1 出现之前,CMS 使用还是非常广泛的。一直到今天,仍然有很多系统使用 CMS GC。
JVM 参数:
-XX:+UseConcMarkSweepGC
设置老年代为 CMS GC,同时自动打开-XX:+UseParNewGC
,设置新生代为ParNew GC
-XX:+UseCMSCompactAtFullCollection
用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生,默认开启。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
-XX:CMSFullGCsBeforeCompaction
设置在执行多少次Full GC后对内存空间进行压缩整理。
-XX:ParallelCMSThreads
设置 CMS 的线程数,默认为(ParallelGCThreads + 3)/4
-XX:CMSInitiatingOccupancyFraction
设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
- JDK5 及以前版本的默认值为68,即当老年代的空间使用率达到 68% 时,会执行一次 CMS 回收;JDK6及以上版本默认值为92%。
- 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低 CMS 的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC 的执行次数。
-XX:+UseCMSInitiatingOccupancyOnly
关闭动态检查,动态检查是指 CMS 会根据历史记录,预测内存空间还剩多少时触发 CMS GC,一般和-XX:CMSInitiatingOccupancyFraction
搭配使用
-XX:+CMSScavengeBeforeRemark
在重新标记之前执行一次 Minor GC。
CMS GC的回收过程
CMS的垃圾收集算法本质上仍然是采用标记-清除算法,并且也会 ”Stop-the-World”。但是整个过程比之前的收集器要复杂,主要分为五个阶段(七个步骤):
- 初始标记阶段(Initial-Mark):只标记出 GC Roots 直接可达的老年代对象,java7 单线程,java8 多线程,STW,速度非常快。
- 并发标记阶段(Concurrent Mark):从第一阶段扫描出来的存活的对象开始遍历全部老年代对象,在执行阶段由于新生代晋升到老年代等原因,导致部分老年代引用关系发生变化,把受影响的老年代的对象所在的 Card 标记为 Dirty,和用户线程并发执行,耗时较长。
- 预清理(Concurrent-Preclean):扫描 Dirty 对象,并标记被 Dirty 对象直接或间接引用的对象,然后清除 Card 标识。目的是为了让重新标记阶段的 STW 尽可能短,和用户线程并发执行,可设置参数不执行
3 的引用发生了变化,将 6 标记为存活
- 可被终止的预清理(Concurrent-Abortable-Preclean):有一些老年代对象的存活是因为被新生代引用,为了尽快确定这些老年代对象可以尽量减少新生代的对象数量,可以先进行一次 Minor GC。目的是为了让重新标记阶段的 STW 尽可能短,和用户线程并发执行,可设置参数不执行。
- 重新标记阶段(Remark):遍历整个堆标记老年代存活对象,包括:
- 遍历新生代对象重新标记。
- 遍历 GC Roots 重新标记。
- 遍历老年代的 Dirty Card 重新标记,STW,比初始标记耗时稍微长一点,远比并发标记时间短。
- 并发清除阶段(Concurrent-Sweep):清除标记阶段判断已经死亡的对象,和用户线程并发执行。
- 重置线程阶段(Concurrent-Reset):清除内部状态,为下次回收做准备。
尽管 CMS 收集器采用的是并发回收(非独占式),但是在初始化标记和重新标记这两个阶段中仍然需要执行“Stop-the-World”。这点很重要,面试高频题。
由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。
CMS GC的问题
1、内存碎片问题
CMS 收集器的垃圾收集算法采用的是标记—清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么 CMS 在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer)技术,而只能够选择空闲列表(Free List)执行内存分配。
既然 Mark Sweep 会造成内存碎片,那么为什么不把算法换成 Mark Compact 呢?原因是:当并发清除的时候,用 Compact 整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark-Compact 更适合 “Stop the World” 这种场景下使用。
解决方案:设置参数
-XX:CMSFullGCsBeforeCompaction=n
,该参数的作用是让 CMS 在进行 n 次 Full GC(标记清除)的时候进行一次标记整理算法。2、Promotion Failed 和 Concurrent Mode Failure问题
由于在垃圾收集阶段用户线程没有中断,所以在 CMS 回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。
如果 CMS 在运行期间预留的内存无法满足程序需要,一般有两种错误情况:
- 在 Minor GC 的时候,新生代 Survivor 空间放不下,需要放入老年代,而老年代也放不下而产生的,这时候会报 Promotion Failed 和 Concurrent Mode Failure。通常是因为老年代虽然内存空间充足,但是存在大量内存碎片,而新生代需要晋升的对象又较大,导致因为没有足够的连续可用空间而晋升失败。
- 在 CMS GC 的时候,CMS GC 过程中同时业务线程将对象放入老年代,而此时老年代空间不足,这时候会报 Concurrent Mode Failure。
出现 Concurrent Mode Failure 异常之后,JVM 会使用备用的 Searial Old 垃圾回收器对老年代进行单线程垃圾回收,会导致 STW 的时间非常长。应该尽量避免该异常出现。
常见原因和对应的解决方案如下:
原因 | 问题描述 | 解决方案 |
CMS触发太晚 | CMS的 backgroup GC 触发时机太晚,会导致在 backgroup GC 完成前,年老代剩余可用空间放不下新提升上来的或者直接在年老代分配的对象,从而触发 Full GC 并中断并发过程。 | 设置参数 -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=60 -XX:CMSWaitDuration=1500 ,调低触发 CMS GC执行的阀值,使其更早更快地开始 CMS GC。CMSInitiatingOccupancyFraction 默认值是92%,CMS backgroup GC 扫描间隔默认是:2000
但注意这里并不是调的越小越好,越小年老代GC频率会越高,整体业务暂停的时间有可能会更长。另外,某些情况下,该参数设置太小导致年老代空间触发backgroup GC前可用的空间根本放不下所有晋升上来的长生命周期的对象,从而导致JVM一直不停地做年老代GC,严重影响业务性能。 |
老年代空间碎片太多 | CMS收集器采用的标记-清除算法,并不对年老代进行回收后的内存整理(虽然GC后会进行一些连续空间的合并)。因此多次GC后会存在较多的空间碎片。所以可能导致年老代剩余空间足够,但在大对象提升时由于没有连续的可用空间导致提升失败。 | 通过 -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=5 来让JVM在多少次 Full GC 后进行年老代空间的碎片整理。但是这两个参数起到的是”病后用药“的作用,前提是已经发生了Full GC才触发(所以有一些比较曲线救国的办法就是在凌晨低峰期间:代码中定时调用 System.gc 来触发一次 Full GC 从而进行年老代的空间整理)。
如果碎片问题确实比较严重,可以考虑改用G1垃圾回收器(G1每次GC都会对region进行整理)。 |
年轻代晋升速度过快 | 不管是promotion failed还是concurrent mode failure,都是由于提升时年老代可用空间不够导致的,因此提升速度过快是导致这些问题的直接原因。具体会导致提升过快的非业务原因有几点:
1、年轻代对象晋升年龄阈值太小。
2、eden区太小,触发年轻代GC太容易。
3、survivor空间溢出(未满年龄的对象提前晋升,参考前面的Desired survivor size的计算)。
4、业务中大对象较多,超过阈值直接在年老代中分配。 | 1、调整 -XX:MaxTenuringThreshold (默认15),提高年轻代晋升年龄。注意:这里并不代表真正达到这个年龄才晋升,但JVM计算一个desired survivor size大小,survivor区对象如果累计到某一个age值的对象大小大于desired survivor size,下次晋升时大于等于该年龄的对象就会被提前promote到年老代,这里这个age值就是动态计算出来的。
2、通过 -XX:SurvivorRatio=N 调整 eden 区和 survior 可用区的比例。默认比例是(8:1),survior区分s0和s1两部分,同一时刻只有一个区可用。所以实际可用大小为xmn的1/10。一般可以通过扩大eden区来减少年轻代GC次数,让年轻代对象到达触发年龄的速度慢一点。
3、调整动态年龄计算比例。survivor空间溢出的直接原因是计算的Desired survivor size太小,导致很多未满年龄的对象提前晋升到年老代,可以通过降低 -XX:TargetSurvivorRatio=N (默认50,就是一个s0或者s1的一半),尽量避免提前晋升的溢出问题。
4、针对大对象,可以通过 -XX:PretenureSizeThreshold 来设置直接在年老代分配的对象阈值,默认是0,即:由JVM动态决定(PS:这个参数尽量不要用,除非对自己的业务细节访问模型很清楚)。
如果以上调整都没办法彻底解决GC问题,那么应该考虑在系统内存可用范围内扩大整个heap的大小了。 |
CMS GC回收处理效率太低 | CMS是一个并发的垃圾回收器,用于CMS各个阶段的GC线程数默认值是: ConcGCThreads = (ParallelGCThreads+3)/4。
而这里 ParallelGCThreads 表示的是GC并行时使用的线程数。比如如果新生代使用ParNew,那么ParallelGCThreads也就是新生代GC线程数。默认情况下,当CPU数量小于8时,ParallelGCThreads的值就是CPU的数量,当CPU数量大于8时,ParallelGCThreads的值等于3+5cpuCount/8。
例如,在32核机器上,新生代并行GC线程数为 3 + 532/8 = 23,所以对应的CMS的并发线程数为 (23 +3) / 4 = 6。、 | 通过参数 -XX:ParallelGCThreads=23 -XX:ConcGCThreads=6 来分别增加年轻代GC并行处理和年老代GC并发处理的能力。但这里也并不是越大越好,因为CMS的很多阶段都是和业务线程并发进行的,如果用于GC的线程数太多也会更多抢占业务线程的处理时间片,从而影响业务性能,所以这里需要进行实际场景的验证测试。 |
3、remark 阶段停顿时间长的问题
解决方案:设置参数
-XX:+CMSScavengeBeforeRemark
,该参数的作用是在执行 remark 操作之前先做一次 Minor GC。目的在于减少年轻代对老年代的无效引用,降低 remark 时的开销。6、G1 GC
G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高吞吐量的性能特征。
在 JDK 1.7 版本正式启用,移除了 Experimental 的标识,是 JDK 9 以后的默认垃圾回收器,取代了CMS 回收器以及Parallel + Parallel Old组合。被 Oracle官方称为 “全功能的垃圾收集器”,与此同时,CMS已经在 JDK 9 中被标记为废弃(deprecated)。G1 GC 在 JDK 1.8 中还不是默认的垃圾回收器,需要使用
-XX:+UseG1GC
来启用。与其他 GC 收集器相比,G1使用了全新的分区算法,其特点如下所示:
- 并行 + 并发。
- 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力。此时用户线程 STW。
- 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
- 全新的分代概念。从分代上看,G1 依然属于分代型垃圾回收器。但不要求各代都是连续逻辑空间,将堆空间分为多个 Region,每个 Region 都可能是 Eden、Survivor、Old、Humongous 区其中的一种。Region 的角色可以互换。例如 Eden 区被清空之后变成空白区域,加入空闲列表,后面可能会变成 Old 区用来存放老年代对象。
- 空间整合,避免内存碎片。G1 将内存划分为一个个的 Region,内存的回收是以 Region 作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。尤其是当 Java 堆非常大的时候,G1 的优势更加明显。
- 可预测的停顿时间模型(软实时 soft real-time)。这是 G1 相对于 CMS 的另一大优势,G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
- G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
JVM 参数:
-XX+UseG1GC
设置垃圾回收器为 G1 GC
-XX:G1HeapRegionSize
设置每个 region 的大小,值是 2 的幂,1MB~32MB,默认是堆内存的 1/2000
-XX:MaxGCPauseMills
期望达到的最大 GC 停顿时间,会尽量满足不能保证一定达到,默认是 200ms
-XX:ParallelGCThread
并行执行即 STW 阶段的工作线程数,最多 8
-XX:ConcGCThreads
并发执行的线程数,一般为-XX:ParallelGCThread
的四分之一
-XX:InitiatingHeapOccupancyPercent
设置触发并发GC周期的堆占用率阈值,默认值是45%
-XX:G1MixedGCCountTarget
一次 GC 周期最多执行 Mixed GC 的次数,默认为 8。
-XX:G1HeapWastePercent
默认值 5%,在全局并发标记结束后能够统计出所有可被回收的垃圾占 Heap 的比例值,如果超过5%,那么就会触发之后的多轮 Mixed GC。这个参数可以控制 Mixed GC 触发的时机。
在介绍垃圾回收过程之前我们先来了解几个重要的概念。
Region
- 一个 Region 有可能属于 Eden,Survivor 或者 Old/Tenured 内存区域。但是一个 Region 只可能属于一个角色。图中的 E 表示该 region 属于 Eden 内存区域,S 表示属于 Survivor 内存区域,O 表示属于 Old 内存区域。图中空白的表示未使用的内存空间。
- G1 垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,如图中的 H 块。主要用于存储大对象,如果超过1.5个region,就放到 H。
设置 H 的原因: 对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放大对象。如果一个 H 区装不下一个大对象,那么 G1 会寻找连续的 H 区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。G1 的大多数行为都把 H 区作为老年代的一部分来看待。
Rset(Remember Set)
Rset 是 G1 里面的概念,是指每一个 Region 都会划出一部分内存用来储存记录其他 Region 对当前持有 Rset Region 中 Card 的引用。作用是在分析存活对象的时候,把 Rset 加入GC Roots的枚举范围,这样可以避免全局扫描。有了这个数据结构,在回收某个 Region 的时候,就不必对整个堆内存的对象进行扫描了,它使得部分收集成为了可能。
RSet 通常会占用很大的空间,大约 5% 或者更高(最高可能20%)。不仅仅是空间方面,很多计算开销也是比较大的。
- 每个 Region 都会对应一个 Rset。例如上面的 Region1、Region2、Region3 分别对应一个 Rset。
- Rset 记录了其他 Region 对当前 Region 的引用。例如上面的 Region2 的 Rset 中记录了 Region1 和 Region3 对当前 Region 的引用信息。
- 年轻代的 Region 的 RSet 只保存来自老年代的引用。这是因为年轻代的回收是针对所有年轻代 Region 的,没必要画蛇添足。所以说年轻代 Region 的 RSet 有可能是空的。
- 老年代的 Region 的 RSet 也只会保存老年代对它的引用。这是因为老年代回收之前,会先对年轻代进行回收。这时,Eden 区变空了,而在回收过程中会扫描 Survivor 分区,所以也没必要保存来自年轻代的引用。
RSet 究竟是怎么辅助 GC 的呢?
- 在 Minor GC 的时候,只需要选定年轻代的 RSet 作为 GC ROOTs,这些 RSet 记录了
Old -> Young
的跨代引用,避免了扫描整个老年代。
- 在 Mixed GC 的时候,老年代中记录了
Old -> Old
的RSet,Young -> Old
的引用从 Survivor 区获取(老年代回收之前,会先对年轻代进行回收,存活的对象放在 Survivor 区),这样也不用扫描全部老年代。
所以 RSet 的引入大大减少了 GC 的工作量。
CSet(Collection Set)
CSet 是指一组可被回收的分区 Region 的集合。G1 每次 GC 并不会选择回收所有垃圾对象,而是
- 根据 Region 的垃圾多少来判断与预估回收价值(指回收的垃圾与回收的 STW 时间的一个预估值),将一个或者多个 Region 放到 CSet。
- 对 Cset 的 Region 进行扫描分析。
- 在满足回收条件后,最后将这些 Region 中的存活对象压缩并复制到新的 Region 中,清空原来的 Region。
CSet 有两种类型。
- 在
fully-young generational mode
下,该模式下 CSet 将只包含 Young Region 和 Survivor Region。G1将调整 Young 的 Region 的数量来匹配软实时的目标。
- 在
partially-young mode
下,该模式会选择所有的 Young Region、Survivor Region 和一部分的 Old Region。Old Region 的选择将依据在 Marking cycle phase 中对存活对象的计数。G1 选择存活对象最少的 Region 进行回收。
IHOP
IHOP(InitiatingHeapOccupancyPercent),缺省情况是 Java 堆内存的45%。当老年代的空间超过 45%,G1 会启动一次混合周期收集。
这也是 G1 和 CMS 之间较大的区别,G1 的百分比是相对于整个 Java 堆而言的,CMS 仅仅是针对老年代空间的占比。
为什么 G1 如此设计呢?因为 G1 没有固定物理上分割一块内存作为老年代,而是用了 Region 的思想,这些 Region 可能是 Eden,Survivor、Old 或者 H 区,所以获取针对老年代本身的占用百分比没有意义。
SATB(Snapshot At the Begging)
在说明 SATB 之前,我们先来了解一下三色标记法。
在三色标记法之前的算法叫 Mark-And-Sweep(标记清除)。这个算法会设置一个标志位来记录对象是否被使用。
- 最开始所有的标记位都是 0,如果发现对象是可达的就会置为 1,一步步下去就会呈现一个类似树状的结果。
- 等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位设置成 0 方便下次清理。
这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能实现用户线程和 GC 线程并发执行。因为在不同阶段标记清扫法的标志位0和1有不同的含义,那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。
三色标记法其实就是对标记清除算法的改进版,用来解决 GC 运行时程序长时间挂起的问题,从而可以以中断时间极少的代价或者完全没有中断来进行整个GC。三色标记法将对象用三种颜色表示:
- 白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。
- 灰:对象被标记了,但是但该对象下的属性没有全被标记完。GC 需要从此对象中去寻找垃圾。
- 黑:对象被标记了,且该对象下的属性也全部都被标记过了。这是程序所需要的对象。
三色标记法的标记过程:
- 在 GC 标记开始的时候,所有的对象均为白色。
- 在将所有的 GC Roots 直接引用的对象标记为灰色集合。
- 如果判断灰色集合中的对象不存在子引用,则将其放入黑色集合,若存在子引用对象,则将其所有的子引用对象存放到灰色集合,当前对象放入黑色集合。
- 按照此步骤 3 ,依此类推,直至灰色集合中所有的对象变黑后,本轮标记完成,并且在白色集合内的对象称为不可达对象,即垃圾对象。
- 标记结束后,为白色的对象为 GC Roots 不可达,可以进行垃圾回收。
三色标记法的问题在于,可能会产生漏标问题。
- 对象 A 被标记为了黑色,此时它所引用的两个对象 B、C 都在被标记的灰色阶段。
- 此时用户线程把
B->D
之间的的引用关系删除,并且在A->D
之间建立引用。
- 此时 B 对象依然未扫描结束,而 A 对象又已经被扫描过了,不会继续接着往下扫描了。因此 D 对象虽然是被引用的对象,但是因为仍然被当做垃圾回收掉。
当下面两个条件同时满足,会产生漏标问题:
- 有至少一个黑色对象在自己被标记之后指向了这个白色对象。
- 所有的灰色对象在自己引用扫描完成之前删除了对白色对象的引用。
要解决漏标的问题,只需要破坏这两个条件中的任意一种即可。漏标问题在 CMS 和 G1 收集器中有着不同的解决方案。
CMS 采用的是
增量更新(IncrementalUpdate)
算法。增量更新要破坏的是第一个条件。在并发标记阶段时如果一个白色对象被一个黑色对象引用时,会将黑色对象重新标记为灰色,让垃圾收集器在重新标记阶段重新扫描。可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。G1 采用的是
原始快照(SATB)
算法。原始快照要破坏的是第二个条件。当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。可以简化理解为,当一个灰色对象取消了对白色对象的引用,那么这个白色对象被变灰。- 在开始标记的时候生成一个快照图标记存活对象。
- 在一个引用断开后(例如上面的 B 和 D 之间的引用),要将被断开的引用(D)推到 GC 的堆栈里,保证白色对象还能被 GC 线程扫描到(在write barrier 里把所有旧的引用所指向的对象都变成非白的)。
- 配合 Rset,去扫描哪些 Region 引用到当前的白色对象,若没有引用到当前对象,则回收。
两种漏标解决方案的对比:
- 增量更新算法关注的是引用的增加(
A->C
的引用) - 优点:避免浮动垃圾。
- 缺点:需要再次扫描被重新标记为灰色对象的所有引用,效率低。
- 原始快照算法关注的是引用的删除(
B->C
的引用) - 优点:效率非常高,无需扫描整个引用链
- 缺点:这个白色对象有可能并没有黑色对象去引用它,但是它还是被变灰了,就会导致它和它的引用,本来应该被垃圾回收掉,但是此次 GC 存活了下来,就是所谓的浮动垃圾。其实这样是比较可以忍受的,只是让它多存活了一次 GC 而已,浪费一点点空间,但是会比增量更新更省时间。
G1 GC的回收过程
G1 GC是一个循环的过程,按照下面阶段进行垃圾回收
第一阶段,Young GC。当 Eden 区满了,触发 Young GC,只回收 Eden 区和 Survivor 区。G1 会创建包含 Eden 区和 Survivor 区的 CSet,期间会 STW。
- 扫描根。根是指 static 变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。
- 更新 Rset。处理
dirty card queue
(下面说明)中的 card,更新 RSet。此阶段完成后,RSet 可以准确的反映老年代对所在的内存分段中对象的引用。
- 处理 RSet。识别被老年代对象指向的 Eden 中的的对象,这些被指向的 Eden 中的对象被认为是存活的对象。
- 复制对象。此阶段,对象树被遍历,Eden 区内存段中存活的对象会被复制到 Survivor 区中空的内存分段,Survivor 区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阈值会被复制到 Old 区中空的内存分段。如果 Survivor 空间不够,Eden 区中部分数据会直接晋升到老年代空间。
- 处理引用。处理 Soft,Weak,Phantom,Final,JNI Weak 等引用。最终 Eden 空间的数据为空,GC 停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
第二阶段,并发标记阶段。随着越来越多的对象晋升到老年代中,当老年代占比(相对于Java总堆而言)达到 IHOP 参数之后,那么 G1就会触发并发标记阶段。作用是是为了找出老年代的存活对象,降低下一阶段 Mixed GC 的停顿时间。
- 初始标记阶段(Initial marking phase):扫描 GC Roots 直接可达对象。
这个阶段是 STW
,并且会触发一次 Young GC。
- 根扫描区域(Root region scanning phase):扫描 Survivor Region 直接可达的老年代对象,并标记被引用的对象,这个阶段是并发进行的,要在下一个 Young GC 开始之前结束。
- 并发标记阶段(Concurrent marking phase):标记整个堆的存活对象。该过程是并发进行的,但是可以被 Young GC 所打断。
- 在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。
- 并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
- 并发阶段产生的新的引用(或者引用的更新)会被 SATB 的 write barrier 记录下来。
- 重新标记阶段(Remark phase):也叫 final marking phase。该阶段只需要扫描 SATB,处理在并发阶段产生的新的存活对象的引用,相当于修正上一次的标记结果。
这个阶段是 STW
。
- 独占清理阶段(Cleanup phase):计算各个区域的存活对象和 GC 回收比例,把完全没有存活对象的 Region 直接放到空闲列表中,并进行排序,识别可以混合回收的区域。该阶段还会重置RSet。
- 该阶段不会真的清除,而是为下阶段做铺垫。
该阶段在计算 Region 中存活对象的时候是 STW 的
;而在重置 RSet 的时候是可以并发进行的。
- 并发清理阶段:识别并清理完全空闲的区域。
第三阶段,Mixed GC 阶段。并发标记阶段结束之后,Mixed GC 阶段就启动了。该算法并不是一个 Old GC,除了回收整个 Young Region,还会回收一部分的 Old Region。这里注意是一部分老年代,而不是全部老年代。
- 一个周期里的单次 STW 的 Mixed GC 和 Young GC 是类似的,唯一区别就是在混合收集过程中会包含一部分老年分区。也就是说 Miixed GC 的 CSet 会把一部分 Old Region 放到 Eden Region 和 Survivor Region 的后面一起进行回收。
- G1 GC 会进行多次 Mixed GC,直到收集到足够数量的老年代区域。
- Mixed GC 结束,本次 GC 周期就算完成了。GC 将恢复到 Young GC,开启下一轮 GC 周期。
非必要阶段,Full GC。Mixed GC 不是 Full GC,因为 Mixed GC 只收集部分老年代 Region,如果在 Mixed GC 期间出现老年代被占用完的情况,JVM 会采用 Serial Old 收集器来收集整个 Heap。
因为是 Full GC 是使用 Serial Old 收集器单线程执行,整个过程非常耗时,应该尽量避免 Full GC。可以使用下面方案:
- 加大内存。
- 提高 CPU 性能,加快 GC 回收速度,使对象增加速度赶不上回收速度。
- 降低进行 Mixed GC 触发的阈值,让 Mixed GC 提早发生(默认45%)。
G1 GC的适用场景
- 面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
- 最主要的应用是需要低 GC 延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约 6GB 或更大时,可预测的暂停时间可以低于 0.5 秒;(G1 通过每次只清理一部分而不是全部的 Region 的增量式清理来保证每次 GC 停顿时间不会过长)。
- 用来替换掉 JDK1.5 中的CMS收集器; 在下面的情况时,使用 G1 可能比 CMS 好:
- 超过 50% 的 Java 堆被活动数据占用
- 对象分配频率或年代提升频率变化很大
- GC停顿时间过长(长于0.5至1秒)
- HotSpot 垃圾收集器里,除了 G1 以外,其他的垃圾收集器使用内置的 JVM 线程执行 GC 的多线程操作,而 G1 GC 可以采用应用线程承担后台运行的 GC 工作,即当 JVM 的 GC 线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。
7、ZGC
ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。
ZGC 的工作过程可以分为4个阶段:并发标记-并发预备重分配-并发重分配-并发重映射等。
ZGC 几乎在所有地方并发执行的,除了初始标记的是STW的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。
在 ZGC 的强项停顿时间测试上,它毫不留情的将 Parallel、G1 拉开了两个数量级的差距。无论平均停顿、95%停顿、99%停顿、99.9%停顿,还是最大停顿时间,ZGC 都能毫不费劲控制在 10 毫秒以内。未来将在服务端、大内存、低延迟应用的首选垃圾收集器。
JDK14 新特性 JEP 364:ZGC 应用在 macOS上 JEP 365:ZGC 应用在Windows上。
- JDK14 之前,ZGC 仅 Linux 才支持。
- 尽管许多使用 ZGC 的用户都使用类 Linux 的环境,但在 Windows 和 macOS 上,人们也需要 ZGC 进行开发部署和测试。许多桌面应用也可以从 ZGC 中受益。因此,ZGC 特性被移植到了 Windows和 macOS 上。
- 现在 macOS 或 Windows上也能使用 ZGC了,示例如下:
XX:+UnlockExperimentalVMOptions -XX:+UseZGC
ZGC 在 JDK15 达到production-ready,JDK17 是第一个开始推出成熟的 ZGC 的长期支持的 ZGC 版本。
8、垃圾回收器设置
如果没有在应用启动时指定虚拟机类型,可以使用下面命令来查看当前 JDK 版本的默认垃圾回收器
也可以通过 jmap 命令查看堆信息详情的时, 在内存区域上会体现垃圾回收器类型
常用垃圾回收器设置参数如下
新生代 | 老年代 | JVM参数 |
Serial | Serial Old | -XX:+UseSerialGC |
Parallel Scavenge | Serial Old | -XX:+UseParallelGC |
Parallel Scavenge | Parallel Old | -XX:+UseParallelGC -XX:+UseParallelOldGC |
Parallel New | Serial Old | -XX:+UseParNewGC |
Parallel New | CMS | -XX:+UseParNewGC -XX:+UseConcMarkSweepGC |
G1 | -XX:+UseG1GC |
三、垃圾回收相关问题
1、什么时候会发生 Full GC
- 调用
System.gc()
时(调用后并不会立即发生 FGC,后面会在某个时间点发生),操作系统建议执行 Full GC( -XX:+DisableExplicitGC 可禁用 )。
- 老年代的可用空间不足时。
- 方法区空间不足时,或 Metaspace Space 使用达到 MetaspaceSize 但未达到 MaxMetaspaceSize 阈值,大多情况下扩容都会触发。
- Concurrent Mode Failure。如果老年代使用了 CMS 垃圾回收器,因为 CMS 垃圾回收器的垃圾回收线程和用户线程是并发执行的,在 CMS GC 的过程中,如果新生代 Survivor 空间放不下,需要放入老年代,而老年代也放不下,或者用户直接把对象放入老年代放不下,就会报 Concurrent Mode Failure。
- Promotion Failed:通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存时。由 Eden 区、From Survior 区向 To Survior 区复制时,对象大小大于 To Survior 区可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小时。
- 执行
jmap -histo:live
或者jmap -dump:live
。
2、空间分配担保机制
空间分配担保是指:在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
- 如果大于:则此次 Minor GC 是安全的。
- 如果小于,则虚拟机会查看
HandlePromotionFailure
设置值是否允许担保失败。 - 如果
HandlePromotionFailure=true
,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次 Minor GC,但这次 Minor GC 依然是有风险的。 - 如果小于或者
HandlePromotionFailure=false
,则改为进行一次 Full GC。
为什么需要空间担保?
因为新生代采用复制收集算法,假如大量对象在 Minor GC 后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而 Survivor 空间是比较小的,这时就需要老年代进行分配担保,把 Survivor 无法容纳的对象放到老年代。
老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行 Full GC 来让老年代腾出更多空间。
3、动态年龄判断
动态年龄判断规则是指:Minor GC 时,Survivor 中年龄 1 到 N 的对象大小超过 Survivor 的 50% 时,则将大于等于年龄 N 的对象放入老年代。
相关参数:
- 大对象直接进入老年代。那多大的对象是大对象?这个阈值通过
-XX:PretenureSizeThreshold
参数来配置。
- 年龄大于阈值,进入老年代。这个阈值通过
-XX:MaxTenuringThreshold
来配置,默认是 15。
- 动态年龄判断涉及到一个值,就是 survivor 区域的一半,其实也不一定是一半,可以通过
-XX:TargetSurvivorRatio
来配置。
4、内存溢出和内存泄漏
- 内存溢出:申请内存的时候没有足够的内存可用。
- 内存泄漏:对象不再被使用,但是垃圾收集器又不能回收这些对象。
- 静态集合类。如 HashMap、LinkedList 等等。如果这些容器为静态的,那么它们的生命周期与 JVM 程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。
- 单例模式。和静态集合导致内存泄露的原因类似,因为单例的静态特性,它的生命周期和 JVM 的生命周期一样长,所以如果单例对象如果持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏。
- 内部类持有外部类。如果一个外部类的实例对象的方法返回了一个内部类的实例对象。这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄漏。
- 各种连接,如数据库连接、网络连接和 IO 连接等。在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用 close 方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对 Connection、Statement 或 ResultSet 不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。
- 变量不合理的作用域。一般而言,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为 null,很有可能导致内存泄漏的发生。
- 如上面这个伪代码,通过 readFromNet 方法把接受的消息保存在变量 msg 中,然后调用 saveDB 方法把msg的内容保存到数据库中,此时 msg 已经就没用了,由于 msg 的生命周期与对象的生命周期相同,此时 msg 还不能回收,因此造成了内存泄漏。实际上这个 msg 变量可以放在 receiveMsg 方法内部,当方法使用完,那么 msg 的生命周期也就结束,此时就可以回收了。
- 还有一种方法,在使用完 msg 后,把 msg 设置为 null,这样垃圾回收器也会回收 msg 的内存空间。
- 改变哈希值。当一个对象被存储进 HashSet 集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了。否则,对象修改后的哈希值与最初存储进 HashSet 集合中时的哈希值就不同了,在这种情况下,即使在 contains 方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄漏。这也是 String 为什么被设置成了不可变类型,我们可以放心地把 String 存入 HashSet,或者把String 当做 HashMap 的 key 值。当我们想把自己定义的类保存到散列表的时候,需要保证对象的 hashCode 不可变。
示例
- 缓存泄漏。内存泄漏的另一个常见来源是缓存,一旦把对象引用放入到缓存中,就很容易遗忘。比如:之前项目在一次上线的时候,应用启动奇慢直到夯死,就是因为代码中会加载一个表中的数据到缓存(内存)中,测试环境只有几百条数据,但是生产环境有几百万的数据。对于这个问题,可以使用 WeakHashMap 代替缓存。此种 Map 的特点是,当除了自身有对 key 的引用外,此 key 没有其他引用那么此 map 会自动丢弃此值。
示例
程序运行结果:
上面代码和图示主要演示 WeakHashMap 如何自动释放缓存对象,当 init 函数执行完成后,局部变量字符串引用 weakd1,weakd2 都会消失,此时只有静态 map 中保存中对字符串对象的引用,可以看到,调用 gc 之后,HashMap 的没有被回收,而 WeakHashMap 里面的缓存被回收了。
- 监听器和其他回调。内存泄漏常见来源还有监听器和其他回调,如果客户端在你实现的 API 中注册回调,却没有显示的取消,那么就会积聚。需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存成为 WeakHashMap 中的键。
示例
上面这段代码的主要问题在 pop(),这里只做了指针的移动,但是引用未手动置空,所以是不会释放内存的。可以改成这样。
- Author:mcbilla
- URL:http://mcbilla.com/article/d156e3ea-9ba8-4de9-934a-6880e3a079f5
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts