前言
java 其实对垃圾回收已经是自动化管理,我们为什么还要了解GC和内存分配呢? 其实答案很简答:当你的系统发生各种内存溢出、内存泄露的问题时,或者说当垃圾收集成为了系统瓶颈时,我们就需要对这些看起来不需要管的事,来实施一些必要的手段了。 我们在了解了JVM内存区域划分,需要来了解几个问题:
- 哪些内存需要回收?
- 是什么时候回收?
- 怎么回收?有哪些方法?
1.哪些内存需要回收?
程序计数器、栈、本地方法栈会随着线程生生灭灭,但Java堆和方法区则不一样,这部分的内存是需要回收的。
2.什么时候回收?
没有任何引用的对象会被回收,通常有两种判定方式。
引用计数算法
* 给对象添加一个引用计数器,一个地方引用则计数器+1;当引用失效计数器-1;
* 当计数器为0则对象可被回收。但这个算法有个严重的缺陷,可能导致互相循环引用。A对象引用B,B引用A。
可达性分析法
* 通过一系列称为'GC Root'的对象作为起始点,从这些节点向下搜索,走过的路径称为引用链。
* 当一个对象到 GC Root没有任何引用链时,则对象不可达,可被内存回收。
在Java中,可作为GC Root的对象有以下几种:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象
一个对象真正已死,需要经历2次标记过程。
- 通过可达性分析法判定若对象没有引用链,它会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法。当对象没有重写finalize方法或已经被被JVM调用过一次,则此对象没必要执行。
- 如果此对象有必要执行finalize方法,那么这个对象会被放在一个叫F-Queue的队列中,稍后由JVM的一个线程去执行它。执行后GC会对队列中的对象进行第二次标记,如果被标记了,则对象必然被回收。
3.怎么回收?
在垃圾回收时,通常有如下几种算法思想。
1. 标记清除算法(Mark-Sweep)
- 这个算法是最基础的收集算法,后续的收集算法都是根据这个不断的改进。
- 标记清除算法分为“标记”和“清除”2个阶段:首先标记处需要回收的对象,标记完成后统一回收。
- 它的不足有2个:效率问题,标记和清除的效率都不高;
- 第二个问题是空间问题,通过该算法垃圾收集后,会产生大量的不连续的空间碎片,若之后需要分配大的对象,无法找到足够内存,不得不触发再一次的GC。
2. 复制收集算法(copying)
为了解决效率问题设计了复制算法,它会把可用内存分为大小相等的两块,每次只使用其中的一块,当一块用完了,就把存活的对象复制到另一块上,再把其他对象清除掉。这种算法简单高效,也不存在碎片的问题,但代价是缩小了一半的内存空间。不过很多商业虚拟机都采用这种算法来回收新生代,例如Hotspot虚拟机就是采用这种算法来回收新生代。复制收集算法在对象存活率较高的区域复制会大大降低效率,所以及其不适合在老年代使用这种算法。
3. 标记整理算法(Mark-Compact)
标记整理算法跟标记清除算法类似,只不过第二次标记时不会直接回收对象,而是让所有存活的对象都向一个方向移动,然后直接清理另一端的内存。
4. 分代收集算法(Generational Collection)
分代收集算法的主要思想是根据对象的存活周期将内存分为几块。一般将Java堆分为新生代和老年代,这样就可以根据不同区域特点采用最适合的收集算法。对于新生代中这种对象存活率低的特点采用复制算法,老年代存活率高的区域采用标记清理或标记整理。
4.什么是Stop The World?
以可达性分析法中从GC Root查找引用链这个操作为案例,如果要逐个检查这些引用必然会消耗很多时间,另外有一点,可达性分析工作必须在分析时对象引用关系不可以再变化,如果对象引用关系还在发生变化,那分析的准确性是无法保证的。所以为了保证可达性分析法的准确性,GC时会停止所有的Java执行线程,sun公司成这个事件为“Stop The World”。即使在号称几乎不会发生GC停顿的CMS收集器,枚举根节点时也是必须要停顿的。
5.垃圾收集器
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。JVM规范没有对垃圾收集器如何实现有具体规定,因此不同厂商、不同版本的垃圾收集器会有很大差别,一般情况下,用户要根据自身应用特点去组合选择各个年代所使用的收集器。
上图展示了7中垃圾收集器,如果有两条连线说明两种收集器时可以搭配使用的。 绿色区域是新生代的收集器,黄色区域为老年代的收集器。
Serial(单线程收集器)
Serial收集器时最历史最悠久的收集器,它是一个单线程收集器,它在执行GC的时候必须暂停其他所有工作线程,直到它收集结束。“Stop The World”是在用户不可见的情况下自动发起和自动完成的。对于“Stop The World”带给用户的不良体验,我们的确要理解,这就像你妈妈在打扫房间的时候,肯定也会让你在椅子上乖乖待着,如果她边打扫,你边制造垃圾,那是打扫不完了!从Serial收集器到Parallel收集器,再到CMS,乃至G1(Garbage First)收集器,越来越优秀的收集器出现,用户线程暂停的时间也在不断的缩短,但是仍然没有办法完全消除。 Serial收集器对于其他收集器简单而高效,因为没有线程交互的开销。所以对于收集几十兆升值200兆左右的新生代内存,停顿时间大概能控制在最多100毫秒以内,只要不频繁GC,Serial收集器对于新生代来说,还是一个不错的选择。
ParNew(多线程收集器)
ParNew收集器其实就是serial收集器的多线程版本,除了使用多条线程进行垃圾回收,其他都与serial收集器完全一样。 该收集器在JVM Server模式下是首选新生代收集器。因为除了Serial收集器,只有它才可以与CMS配合使用。
Parallel Scavenge(并行收集器)
[ˈpærəlel][ˈskævɪndʒ]
该收集器是一个采用复制算法的、并行的、新生代的收集器,并行不意味着并发,还会存在阻塞用户线程。看上去parallel收集器和parnew收集器没太大区别,其他收集器都在考虑如何缩短GC的停顿时间,但parallel的关注点在于达到一个可控制的吞吐量,所谓的吞吐量就是:CPU运行用户代码的时间与CPU总耗时的比值。比如,用户代码运行时间/用户代码运行时间+垃圾收集时间。这其中有两点需要我们注意,GC停顿时间越短的收集器,越适合与用户交互多的应用程序,而高吞吐量更适合后台运算服务。
Serial Old (单线程收集器)
Serial Old收集器是Serial收集器的老年代版本,使用标记整理算法。它存在的意义在于适合client模式下的JVM使用。
Parallel Old(并行收集器)
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记整理算法。在它出来之前,如果新生代采用Parallel Scavenge,老年代就只能使用Serial Old,但Serial Old又是个单线程的收集器,在JVM的server模式下并不能提高GC效率,所以Parallel Old的出现,终于打破了尴尬的局面。从此“吞吐量优先”收集器终于有了名副其实的组合,在注重CPU应用效率和吞吐量的应用,优先考虑Parallel Scavenge和Parallel Old组合。
CMS(并发收集器)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的GC收集器。目前很多大型互联网应用都采用CMS收集器,来获得快速的响应速度,给用户带来良好的体验。从它的名字就可以看出CMS使用的是“标记-清除”算法实现的,它工作的流程可能会稍微复杂一下,一共分为4步:
- 初始标记(CMS initial mark):初始标记仍然会触发“Stop The World”,这个步骤仅标记GC Roots能关联的对象,速度很快。
- 并发标记(CMS concurrent mark):标记GC Roots追踪(Tracing)的过程。
- 重新标记(CMS remark):为了修正并发标记期间用户程序继续运行导致的标记记录变动的记录。GC停顿时间大于初始标记阶段,小于并发标记阶段。
- 并发清除(CMS concurrent sweep)
CMS有几个缺点: (1)对CPU资源依赖较大 在并发收集阶段,虽然不会影响用户访问,相比其他收集器CMS会占用更多CPU资源,导致用户应用程序吞吐量相对降低。 (2)无法收集浮动垃圾 在并发清理阶段,因为其他应用程序线程还在运行,还会有新的垃圾产生,CMS无法在本次GC时清理它们,可能会出现“Concurrent Mode Failure”。因为在垃圾收集时还要保证正常的用户线程工作,所以跟别的收集器相比,CMS无法在老年代都被占满才进行收集,需要预留一部分内存供并发收集的程序使用。如果应用老年代的空间占用率不会增长太快,可以适当地调高参数-XX:CMSInitialtingOccupancyFraction的值,来提高触发百分比,以便降低内存回收次数从而获得更高的性能。 (3)产生空间碎片 因为CMS采用的标记清除算法,产生空间碎片是必然结果,会给大对象分配空间无法找到足够的空间,从而导致触发再一次的GC。 为了解决这个问题,CMS提供了参数可以使发生FullGC时,开启内存碎片的整理过程,整理过程是无法并发的。由于这个整理过程导致GC整体时间边长,所以CMS还提供一个参数来控制,进行多少次不整理的FullGC后,才执行一次带整理的FullGC。
G1(并行并发收集器)
G1(Garbage-First)收集器是当今收集器里技术中最前沿的技术成果之一。它有如下特性:
- 并行与并发:G1能充分利用多核CPU来缩短“Stop The World”的时间,G1可利用并发的特性在GC的同时让Java线程继续执行。
- 分代收集:G1可以不和其他收集器配合就可以完成分代收集的工作
- 空间整合:G1与CMS不同,从整体上来看G1采用的是“标记-整理”的算法,从局部(两个Region间)来看G1采用的是“复制算法”。不会产生碎片空间。
- 可预测的停顿:G1除了追求低停顿之外,还能建立可预测的停顿时间模型;能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在GC的时间不得超过N毫秒。之所以能做到这点,是因为G1可以在GC时可以避免在整个Java堆中全区域的垃圾收集。它在垃圾收集时会跟踪各个Region里哪些Region值得回收。怎么判断是 否值得回收 ?G1在后台会维护一个优先列表,每次根据允许的收集时间,优先回收优先级高的区域,这样就保证了在有限的时间内尽可能高的回收率。
除了G1之外的其他收集器在GC时收集范围都是整个年轻代或者老年代,但G1不是;G1在内存布局上有了很大的不同,它把整个内存划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但它们已经不是物理隔离了,它们都是一部分Region的集合。
G1是如何来避免全堆扫描的?G1如何避免一个对象在不同的region之中?
G1是使用RememberSet来避免全堆扫描的。G1中每个region都有一个与之对应的RememberedSet,JVM发现程序对Reference类型的数据进行写操作时,会临时产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否存在于不同的region之中,如果是,便通过CartTable把相关引用信息记录到该对象所属的RememberSet之中。当内存回收时,在GC根节点的枚举范围内加入RememberSet即可保证部队全堆扫描也不会遗漏。
G1的运作步骤
- 初始标记(initial marking):仅仅是只标记GCRoots能直接关联到的对象,停顿线程,耗时很短。
- 并发标记(concurrent marking):从GCRoots开始对堆中对象进行可达性分析,找出存活对象,耗时较长,可与用户线程并发执行。
- 最终标记(final marking):在此阶段G1会将这段时间的对象变化记录记录到Remember Set Logs中,同时需要把Remember Set Logs中的数据合并到RememberSet里面,本阶段需要停顿线程,但停顿时间很短。
- 筛选回收(live data counting and evacuation):本阶段首先对各个region回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,可以做到和用户线程并发执行。
最后的想法,没有一开始就选中完美的垃圾回收算法,只有在实际过程中结合应用业务特点慢慢优化,才能找到最适合的。