垃圾收集器与内存分配策略
垃圾回收算法需要考虑三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
对象已死?
哪些对象需要回收,就是那些死了的对象,即不可能再被任何途径所使用的对象。有两种主流的方法可以判断对象是否需要回收,引用计数法和可达性分析法。
引用计数法
引用计数法的实现是简单的,高效的。算法为每个对象添加一个引用计数器,每当一个地方引用它时,计数器的值就加一,当引用失效时,就减一。
但这个算法无法解决两个对象循环引用的情况。如下代码,当方法执行结束时,a 和 b 所指向的两个对象都已经不可能再被访问了,但由于a和b互相引用对方,引用计数器都不为0,因此引用计数法无法通知GC收集器回收它们。
1 | public void makeFriend() { |
可达性分析法
可达性分析法是jvm虚拟机所使用的的算法。算法的基本思路就是通过一系列称为“GC Roots”的对象作为起始点,从这些结点向下搜索,搜索所走过的路径称为引用链条,当一个对象到GC Roots没有任何引用链时,该对象就是可以回收的。例如下图中 object5、object6、object7虽然互相引用,但从GC Roots 是不可达的,因此判定为可回收对象。
在java中,GC Roots的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即一般说的Native方法) 引用的对象。
- 跨代引用(见 记忆集)。
四种引用类型
在JDK1.2之后,java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用 4 种。一个对象是可以有多种引用的。
强引用
强引用(Strong Reference)就是在代码中普遍存在的,类似 Object obj = new Object();
这类的引用,只要强引用还在,垃圾回收器永远不会回收掉被引用的对象。
软引用
软引用(Soft Reference)用来描述一些还有用但非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。使用场景是在内存中做缓存,如果缓存将导致内存不足了,将会对只被软引用所关联的对象进行垃圾回收。
弱引用
弱引用(Weak Reference)也是用来描述非必需对象的,它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。弱引用也可以用来在内存中做缓存,当下一次GC时自动清除缓存。还可以用于Map中key引用丢失的问题,如下代码,放入map的这个key的对象是不可能获取到的了,但由于HashMap有对这个key的强引用,所以永远无法回收这个key和相应的value。java中WeakHashMap
的实现方式就是让key使用弱引用,当key无法被引用回收的时候,WeakHashMap会自动的清除相应的Entry,感兴趣可以查看其源码。
1 | private Map map = new HashMap(); |
虚引用
虚引用也称幽灵引用或者幻影引用,它是最弱的一种引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个通知。使用较少,暂没有想到使用场景。
方法区的回收
永久代与元空间
方法区是虚拟机中的一个规范定义,在HotSpot中,方法区使用永久代来实现,因此Hotspot的垃圾收集器可以像管理java堆那样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。其它虚拟机是不存在永久代的概念的,且在JDK1.8之后,Hotspot移除了方法区,使用元空间来代替方法区。两者最大的区别是元空间并不在虚拟机,而在本地内存中。
使用元空间替换永久代有以下几个原因:
- 字符串存在永久代中,容易出现性能问题与内存溢出。
- 类及方法等信息的大小难以确认,无法很好确定永久代的大小。太小容易溢出,太大容易导致老年代溢出(堆的大小是恒定的,此消彼长)。
- 永久代会为GC带来不必要的复杂度,并且回收效率偏低。
永久代的回收
永久代的回收主要有两部分:废弃常量和无用的类。如果当前系统没有任何一个变量引用常量池的某个常量,那么这个废弃常量可以被回收。
但判断一个类是否是“无用的类”条件就比较苛刻,需要满足三个条件:
- 该类的所有实例都已经被回收。
- 加载该类的ClassLoader已经被回收。
- 该类对象的Class对象没有被任何对象引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足以上三个条件的类进行回收,但仅仅是“可以”,而不是不使用了一定会回收。因此,永久代的回收性价比是比较低的。
垃圾收集算法
标记-清除算法(清除算法)
标记清除算法是最基础的算法,算法分为两个阶段 “标记” 和 “清除” 两个阶段。这个算法有两个不足点:一是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。
复制算法
复制算法解决了效率问题,它将可用内存分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后把已使用的过的内存空间一次清理掉。这种算法的缺点就是将内存缩小为原来的一半了。
据研究,新生代中的对象98%都是朝生夕死的,所以并不需要按照 1 : 1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块比较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当发生回收时,将 Eden 和 Survivor 中还存活着的对象复制到另外一块 Survivor 空间上,然后清理掉 Eden 和刚使用过的 Survivor 空间。HotSpot 默认的分配比例是 8 : 1 : 1,每次只会有10%的内存会被浪费掉。
但我们没法保证每次回收都只有不多于10%的对象存活,所以当 Survivor 空间不够时,需要依赖老年代进行分配担保,无法容纳的对象将直接进入老年代。
复制算法在对象存活率较高时需要进行较多的复制,效率将会降低。更关键的是如果不想浪费 50% 的空间,就需要额外的空间进行分配担保,以应对所有对象都 100% 存活的情况。所以在老年代中不能直接选用这种算法。
标记-整理算法(压缩算法)
根据老年代的特点,有人提出了标记-整理算法。和标记-清除算法类似,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后清理掉边界以外的内存。
分代收集算法
当前商业虚拟机都使用分代收集算法,分代收集算法没什么新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾回收时都会有大量对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就完成收集。而老年代中因为对象存活率高、没有额外空间对它进行担保,就必须使用标记-整理算法或者标记-清理算法来进行回收。
HotSpot的算法实现
通过OopMap枚举根节点
总所周知,虚拟执行垃圾回收时必须停顿所有的java线程,也就是 Stop The World ,这是因为在执行可达性分析的时候,必须保证对象的引用关系不能再发生变化了,必须冻结在某个时间点上。因此,停顿的时间也是非常重要的一个参数。
当 GC 时,收集线程会对栈上的内存进行扫描,看看哪些位置是 Reference 类型,如果是 Reference 类型,那么其指向的对象不能被回收。但栈中会有大量的非引用类型的数据,它们对 GC 没有用处。 如果通过遍历栈的方式来枚举根节点,那么将非常耗时。于是就有了空间换时间的策略,HotSpot 使用 OopMap存储栈上的所有代表引用的位置,当发生 GC 时,直接读取 OopMap 就可以了。
使用 记忆集(Remembered Set) 避免全堆扫描
在新生代的垃圾收集中,仅仅遍历 GC Roots 是不够的,还需要遍历老年代中的对象引用,因为老年代可能也会持有新生代中某些对象的引用。
可能你会疑惑,为什么还需要额外遍历老年代中的引用,如果从 GC Roots 开始,不是也能通过 GC Roots -> Old -> Young 完成遍历吗?
文档 Garbage collection in the HotSpot JVM 提到了这个问题,其实是一个剪枝。
A generational tracing collector starts from the root set, but does not traverse references that lead to objects in the older generation, which reduces the size of the object graph to be traced.
这个剪枝有效的原理就是跨代引用其实很少,大部分老年代中的对象都是引用了老年代对象,也就是 GC Roots -> Old -> Old -> Old -> Old -> Old ,因此不遍历老年代中的对象能大大降低要跟踪的对象的数量,提高系统吞吐量。
因此这就带了一个问题,如果老年代中的对象引用了新生代中的对象,而新生代中的对象又不能通过 GC Roots 到达,如果不遍历老年代的话,它就会被认定为垃圾,该怎么办?
还是使用空间换时间的解决方案,记忆集是记录从非收集区域指向收集区域的指针的指针集合的抽象数据结构,在对象层面来说就是非收集区域对象对收集区域对象引用的记录。它存放在收集区域,比如在新生代里存放老年代对新生代对象的每一个引用,这样在收集新生代的时候,我们就可以根据记忆集知道哪些对象被老年代所引用,不能回收,这样就解决了跨代引用的问题。
记忆集根据记录的精度分为三类:
- 字长精度:记录的是老年代指向新生代的地址。
- 对象精度:记录的是老年代引用的新生代对象。
- 卡精度:记录的是新生代的一段地址是否被老年代引用的记录。
HotSpot 的解决方案是卡表(Card Table),是以第三种卡精度的方式实现的记忆集,也是最常用的方式。记忆集是抽象的概念,卡表是记忆集一种具体的实现。该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每个卡的的一个标识位。每修改堆中对象的指针时,都会将该对象所在的卡设置为 dirty 。当新生代垃圾回收时,就可以在卡表中寻找脏卡,并把脏卡中的对象添加到 GC Roots 中,完成脏卡的扫描后,再将标志位清零。
下图中箭头表示堆中对象之间的引用。红色箭头表示老年代对象到新生代对象的引用,必须将这个老年代对象添加到 GC Roots 中。蓝色箭头表示从根集或从年轻代到老年代对象的引用,仅收集年轻代时无需跟踪。
安全点(SafePoint)
另外一个问题是,很多代码操作都会导致 OopMap 变化,如果每次都生成 OopMap,那也是需要很大开销的。 HotSpot 只会在特定的位置才会生成 OopMap,这些位置被称作是安全点。程序也不是在任何时候都能停下来开始 GC,只有到达安全点的时候才能暂停,并生成 OopMap。
安全点的设置也是有讲究,如果两个安全点太远,会导致 GC 停顿的时间过长,这里就不细说了。
安全点的引入导致了另外一个问题,如何让所有的线程都跑到最近的安全点上再停顿下来。现在主流的虚拟机都采用主动式中断的思想。当 GC 需要中断线程的时候,不直接对线程操作,只是简单地设置一个标志,各个线程到达安全点时,都会检查这个标志,如果检查到了,就自己中断挂起。
安全区域(Safe Region)
安全区域可以看做是扩展了安全点。安全区域是指在一段代码片段中,引用关系不会发生变化,在这个区域的任何地方开始 GC 都是安全的。
既然有了安全点,为什么还需要安全区域?假如某个线程一直没有分配到 CPU 时间,处于Sleep 或者 Blocked 状态,该线程迟迟无法走到安全点,这种情况就需要安全区域来解决。
当线程执行到安全区域的代码时,首先会标识自己已经进入了安全区域。当发起 GC 时,虚拟机就不需要管那些已经进入安全区域的线程了。 当线程要离开安全区域的时候,它会检查系统是是否已经完成了根节点的枚举或者是整个GC过程,如果完成了,就线程继续执行,否则它必须等待直到收到可以安全离开 Safe Region 的信号为止。
垃圾收集器
我们可以使用 java -XX:+PrintCommandLineFlags -version
命令来查看默认的垃圾回收器,在 Service 模式下,有 -XX:+UseParallelGC 默认参数,即默认使用 Parallel Scavenge + Serial Old 两个收集器。在 Client 模式下,有 -XX:+UseSerialGC 参数,即默认使用 Serial + Serial Old 两个收集器,下图中还展示了其余组合及参数。
这里也有比较官方的参考 标准版HotSpot虚拟机垃圾收集调优指南-收集器
Serial 收集器
Serial 收集器是最基本、发展历史最悠久的收集器。Serial是串行的意思,所以它只会用一个 CPU 或一条手机线程去完成垃圾收集工作。因此,对于单个 CPU 的环境来说,它比其它收集器更简单而高效。Serial 收集器对于运行在 Client 模式下的虚拟机是一个不错的选择。
ParNew 收集器
ParNew 收集器就是 Serial 收集器的多线程版本,其余地方并没有多大创新。但它是许多运行在 Server 模式下的虚拟机中的首选的新生代收集器,是因为除了 Serial 收集器外, 目前只有它能与 CMS 收集器配合工作。当CPU数量较多时,性能是比较好的。
Parallel Scavenge 收集器
Parallel Scavenge 是 “并行 清除” 的的意思。这是新生代收集器,它适用于注重吞吐量以及 CPU 敏感的场合,它不在意停顿时间,它的目标是达到一个可控制的吞吐量,就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值, $ 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)$。
该收集器可以使用两个参数来控制: -XX:MaxGCPauseMillis 接收一个大于 0 的毫秒数,收集器将尽可能地保证内存花费的时间不超过设定值,这个参数也不是越小越好。 -XX:GCTimeRatio 的值是一个大于 0 且小于 100 的整数,也就是垃圾收集时间占总时间的比例,相当于是吞吐量的倒数,默认值是 99 ,就是允许最大 1% (1 / ( 1+ 99)) 的垃圾收集时间。
该收集器还有一个参数 -XX:UseAdaptiveSizePolicy 值得关注,当这个参数打开后,就不需要手动指定新生代的大小、Eden 与 Survivor 区的比例、晋升老年代的大小等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或最大的吞吐量,这叫GC自适应的调节策略,这也是 和 ParNew 收集器的一个重要区别。
Serial Old 收集器
Serial Old 收集器 是 Serial 收集器的老年代版本,同样是一个单线程收集器,使用”标记-整理“算法,这个收集器的意义也是给 Client 模式下的虚拟机使用。
Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理“算法。
CMS 收集器
CMS (Concurrent Mark Sweep) 收集器是一种一种以获得最短回收停顿时间为目标的收集器。该收集器非常适合 B/S 系统的服务端上,因为这类应用尤其重视服务的响应速度。GC停顿时间的缩短是以牺牲吞吐量和新生代空间换来的:系统把新生代调小一点,收集300M 肯定比 500M 快一些,这也导致垃圾收集更加频繁,原来每次 10 秒收集一次,每次停 100 毫秒,现在每 5 秒收集一次,每次停顿 70 毫秒,停顿时间确实在下降,但吞吐量也降下来了。
从名字可以看出,该收集器是基于“标记-清除”算法的,其运作过程更复杂些,分为四个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅是记录 GC Roots 能直接关联到的对象,速度很快,并发标记就是进行 GC Roots Tracing(追踪)的过程,而重新标记阶段则是为了修正并发标记期间因用户线程继续运行而导致的标记产生变动的记录,这个阶段的停顿时间一般比初始标记稍微长些,但远比并发标记的时间短,之后就开始并发清理阶段,如下图所示。
CMS是十分优秀的收集器,优点是并发收集、低停顿。但它也有三个明显确点:
- CMS收集器对 CPU 资源非常敏感,默认启动的回收线程数是 (CPU 数量 + 3)/ 4,当 CPU 不足4个时, CMS 对用户程序的影响很大。
- CMS 无法处理浮动垃圾,在并发清理阶段用户进程还会继续,在这个时间段产生的垃圾无法进行回收。因此 CMS 无法像其他收集器一样能等到老年代机会完全被填满的时候再收集,需要预留一部分空间提供并发清理时的程序的运行使用。在 JDK 1.6 中,该启动阈值提升到了 92%,要是垃圾回收期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机会启用后背方案:临时启用 Seria Old 收集器来重新进行老年代的垃圾回收,这样停顿时间就更长了。 当老年代的增长很快时,应该适当降低该参数。
- 这个缺点是因为 CMS 是基于“标记-清除”算法实现的,会产生大量空间碎片。当分配大对象时候,往往老年代还有很多对象,却会因为无法找到足够大的连续空间而不得不提前出发一次 Full GC。 为了解决这个问题,CMS收集器提供了-XX:+UseCMSCompactAtFullCollection 开关参数(默认开启的),用于 CMS 在 Full GC 时开启内存碎片的合并整理过程,并可以使用参数 -XX:CMSFullGCBeforeCompaction 设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认为0,表示每次 Full GC 时都进行碎片整理)。
由于 G1 的出现, CMS 在 Java9 中已经被废弃, G1 也是一款低停顿的收集器,其目的就是代替 CMS。
G1 收集器
ZGC 收集器
内存分配策略
当调用 new
指令时,它会在 Eden 区划分一块作为对象存储的内存,由于堆是线程共享的,所以直接在里面划空间需要进行同步。通常有两种方案解决这个问题,一种是采用CAS配上失败重试的方式保证原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程需要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。虚拟机是否使用 TLAB ,可以通过 -XX:+/-UseTLAB
参数设定,默认是开启的, -XX:+PrintTLAB
可以跟踪TLAB的使用情况,还可以手动调整 TLAB 的大小,但不建议,虚拟机默认会自适应调整其大小。
对象优先在 Eden 分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
老年代GC(Major GC / Full GC)一般会比 Minor GC 慢 10 倍以上,出现了 Major GC,经常会伴随至少一次的 Minor GC。
大对象直接进入老年代。大对象是对于虚拟机来说一个坏消息,更坏的消息是一群“朝生夕死”的“短命大对象”。出现大对象容易导致内存中还有不少空间时就提前出发垃圾收集以获得足够的连续空间来“安置”它们。
长期存活的对象进入老年代。Survivor 区的对象中每“熬过”一次 Minor GC,年龄就增加一岁,当年龄到达一个数值,时就就会晋升到老年代中,这个值默认为 15, 且最大也为 15,这是因为 Mark Word 中,只分配了 4 bit 用于存储对象分代年龄。最大也就 1111b 。
动态对象年龄判断。为了使用更多情况,虚拟机并不是永远要求对象的年龄达到 MaxTenuringThreshold
才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一般,年龄大于或等于该年龄的对象就可以直接进入老年代。