垃圾回收器和内存分配策略

本文作者:李敏,叩丁狼高级讲师。原创文章,转载请注明出处。

4. 垃圾回收器和内存分配策略

GC(Garbage Collection)的历史比java久远.1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言.GC一直致力于解决的问题:

哪些内存需要回收(what)?
什么时候回收(when)?
如何回收(how)?

在虚拟机运行时数据区中,最频繁使用的是堆,管理的内存最大的一块,还是堆.JVM堆是垃圾收集器管理的主要区域.因此很多时候也被称作"GC堆".

4.1 为什么要了解垃圾回收

经过半个多世纪的发展,目前内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了"自动化"时代,那为什么我们还要去了解GC和内存分配呢?

当需要排查各种内存溢出,内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些"自动化"的技术实施必要的监控和调节.

程序计数器、虚拟机栈、本地方法栈 3 个区域随线程生灭(因为是线程私有),栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。所以这三块区域的内存,我们不需要过多考虑回收问题.

Java堆以及方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收器所关注的就是这部分内存。

4.2 对象存活判断

垃圾回收器,会针对没有任何引用的”死去”的对象进行回收.垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之后哪些还"存活",哪些已经变成垃圾"死去".

4.2.1 引用计数算法

给对象添加一个引用计数器.有一个地方引用时,计数器+1,失去引用,计数器-1.为0表示就是垃圾.但是难以解决循环引用问题.比如A只有B,B只有A.主流的java虚拟机没有选用引用计数算法来判定对象的生死.

测试代码:

public class ReferenceCountingGC {
      public Object instance = null;
      private static final int _1MB = 1024 * 1024;
      // 使用该变量表示在内存中占用空间,以便可以方便观察日志.
      private byte[] bigSize = new byte[2 * _1MB];
      public static void main(String[] args) {
          ReferenceCountingGC objA = new ReferenceCountingGC();
          ReferenceCountingGC objB = new ReferenceCountingGC();
          objA.instance = objB;
          objB.instance = objA;
          // 设置两个变量为null,那么对象理论上来说就是垃圾.
          objA = null;
          objB = null;
          // 假设这里发生GC,查看日志,观察两个对象是否被回收.
          System.gc();
  }
}

运行参数:-XX:+PrintGCDetails

测试结果:

[GC [PSYoungGen: 5427K->584K(38400K)] 5427K->584K(124416K), 0.0010157     secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC [PSYoungGen: **584K->0K**(38400K)] [ParOldGen: 0K->465K(86016K)] 584K->465K(124416K) [PSPermGen: 2557K->2556K(21504K)], 0.0076511 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
Heap
 PSYoungGen      total 38400K, used 998K [0x00000007d5e80000,     0x00000007d8900000, 0x0000000800000000)
  eden space 33280K, 3% used [0x00000007d5e80000,0x00000007d5f79a60,0x00000007d7f00000)
  from space 5120K, 0% used [0x00000007d7f00000,0x00000007d7f00000,0x00000007d8400000)
  to   space 5120K, 0% used [0x00000007d8400000,0x00000007d8400000,0x00000007d8900000)
 ParOldGen       total 86016K, used 465K [0x0000000781c00000, 0x0000000787000000, 0x00000007d5e80000)
  object space 86016K, 0% used [0x0000000781c00000,0x0000000781c74508,0x0000000787000000)
 PSPermGen       total 21504K, used 2563K [0x000000077ca00000, 0x000000077df00000, 0x0000000781c00000)
  object space 21504K, 11% used [0x000000077ca00000,0x000000077cc80f38,0x000000077df00000)

可以先尝试性的看一下GC的日志,可以发现,在年轻代,发生了GC之后,年轻代的空间使用为0,的的确确表示对象被GC了.所以从这个案例我们可以证明,JVM虚拟机没有使用引用计数算法.

4.2.2 可达性算法

通过一系列的 ‘GC Roots’ 的对象作为起始点,从这些节点出发所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连的时候说明对象不可用,如下图,GC Roots对象不可达的对象,就是可以回收的垃圾对象.


垃圾回收器和内存分配策略_第1张图片
20.png

可作为 GC Roots 的对象:
1.虚拟机栈(栈帧中的本地变量表)中引用的对象
2.方法区中类静态属性引用的对象
3.方法区中常量引用的对象
4.本地方法栈中JNI(即一般说的 Native 方法) 引用的对象

4.2.2.1 HotSpot的可达性分析

HotSpot虚拟机在实现上面的算法的时候,必须要经过更严格的考量,才能保证虚拟机高效运行.比如,在上面的可达性分析中,就存在执行效率的问题:

1.从GC Roots节点找引用链,可是现在很多应用的引用比较复杂,比如方法区就有数百兆,如果要逐个检查这里面的引用,必然消耗很多的时间.
2.为了保证整个分析期间整个执行系统被冻结,而不产生新的引用,会导致java执行线程停顿(stop the world).

为了解决上面的两个问题:

1.枚举根节点,使用一组OopMap的数据结构来存放对象引用,这个数据结构在类加载完成的时候,就已经计算出来了,GC在扫描的时候就可以得知这些信息,从而降低GC Roots时间以及减少停顿时间.

2.OopMap中的引用关系可能会变化.或者OopMap的指令太多,反而需要更多的空间.此时解决方案是,OopMap会根据虚拟机选定的安全点(safepoint,可以简单理解为执行到哪一行),在这个安全点内去生成指令的OopMap.在GC 的时候,驱使所有的线程都"跑"到最近的安全点,STW才发生,应用才停顿.

3.对于挂起的线程来说,比如处于sleep或者blocked状态的,是不能"跑"到安全点的,那么此时解决方案就是,增大安全域(Safe Region).如果线程已经达到安全域,做一个标记,GC就不需要管这些线程.

4.2.3 对象自我拯救

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时出于“缓刑”阶段,一个对象的真正死亡至少要经历两次标记过程:

1.如果对象在进行中可达性分析后发现没有与 GC Roots 相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,这种情况就活不了。

2.如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象竟会放置在一个叫做 F-Queue 的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会出发这个方法,并不承诺或等待他运行结束。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象简历关联即可。
finalize() 方法只会被系统自动调用一次。

建议大家尽量避免使用它,因为他不是c/c++的析构函数,而是java刚诞生的时候为了使c/c++程序员更容易接受它所作出的一个妥协.代价高昂,不确定性大,无法保证各个对象的调用顺序.大家完全可以忘掉java语言中有这个方法的存在.

4.2.4 再谈引用

无论引用计数算法或者是可达性分析算法,都是用的是引用.在JDK1.2之前,java中的引用定义为,如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用.在JDK1.2之后,java对引用的概念进行了扩充,

1.强引用:类似于 Object obj = new Object(); 创建的,只要强引用在就不回收。

2.软引用:SoftReference 类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。

3.弱引用:WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。

4.虚引用:PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

我们可以使用上面四种引用,根据内存是足够或者紧张的应用场景,具体的来选择使用何种引用方式.

4.3 方法区回收

方法区(Hotspot中的永久代),也是可以存在垃圾回收的,尽管在java虚拟机规范中确实表示可以不要求虚拟机在方法区实现垃圾回收.且性价比比较低.我们可以通过配置相关参数,在何时的时候来回收永久代,以保证不会溢出.

1.永久代回收信息:废弃的常量和无用的类.
废弃的常量:没有任何地方使用常量引用,也没有任何地方使用常量对应的字面量.
无用的类:该类所有的实例被回收,加载该类的类加载器被回收.该类的字节码对象没有被任何地方引用.

2.在大量使用反射,动态代理,CGLib等ByteCode框架,动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能.

4.4 垃圾收集算法

知道了要回收哪些对象之后,就是具体的垃圾回收了.虚拟机规范中没有限定只能使用何种方式去清理垃圾,所以,不同的虚拟机可以使用不同的回收算法或者组合使用.

4.4.1 标记-清除

标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

垃圾回收器和内存分配策略_第2张图片
21.png

它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

此算法需要暂停整个应用(Stop The World).

4.4.2 复制

“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

垃圾回收器和内存分配策略_第3张图片
22.png

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。

4.4.3 标记-整理

“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存.

垃圾回收器和内存分配策略_第4张图片
23.png

4.4.4 分代收集

GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短, 并且不同的对象的生命周期是不一样的。

“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

4.4.4.1 为什么要分代

为什么需要把堆分代?不分代不能完成他所做的事情么?其实不分代完全可以,分代的唯一理由就是优化GC性能。

JVM在程序运行过程当中,会创建大量的对象,这些对象,大部分是短周期的对象,小部分是长周期的对象,对于短周期的对象,需要频繁地进行垃圾回收 以保证无用对象尽早被释放掉,对于长周期对象,则不需要频率垃圾回收以确保无谓地垃圾扫描检测。如果没有分代,那我们所有的对象都在一块,GC的时候我们要找到哪些对象没用,这样就会对堆的所有区域进行扫描.

为解决这种矛盾,Sun JVM的内存管理采用分代的策略。如果分代的话,我们把新创建的对象放到某一地方,当GC的时候先把这块存“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

垃圾回收器和内存分配策略_第5张图片
24.png

新生代和老年代存在于堆中,而永久代是方法区的实现方式(java7逐步取代永久代,在java8之后,完全去掉永久代,使用Meta space元数据区).

4.4.4.2 新生代

新生代/年轻代(Young Gen):年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成1个Eden Space和2个Survivor Space。

当对象在堆创建时,将进入年轻代的Eden(伊甸园) Space。垃圾回收器进行垃圾回收时,扫描Eden Space和A Survivor(幸存区) Space,如果对象仍然存活,则复制到B Survivor Space,如果B Survivor Space已经满,则复制到Old Gen。同时,在扫描A Survivor Space时,如果对象已经经过了几次的扫描仍然存活,JVM认为其为一个持久化对象,则将其移到Old Gen。扫描完毕后,JVM将Eden Space和A Survivor Space清空,然后交换A和B的角色(即下次垃圾回收时会扫描Eden Space和B Survivor Space。这么做主要是为了减少内存碎片的产生。

Young Gen垃圾回收时,采用将存活对象复制到到空的Survivor Space的方式来确保尽量不存在内存碎片,采用空间换时间的方式来加速内存中不再被持有的对象尽快能够得到回收。

4.4.4.3 老年代

老年代/年老代(Tenured Gen):年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁(譬如可能几个小时一次)。年老代主要采用压缩的方式来避免内存碎片 (将存活对象移动到内存片的一边,也就是内存整理)。当然,有些垃圾回收器(譬如CMS垃圾回收器)出于效率的原因,可能会不进行压缩。

4.4.4.4 虚拟机如何管理新生代和老年代

虚拟机一般是这样管理新生代和老年代的:

1.当一个对象被创建的时候(new)首先会在年轻代的Eden区被创建,直到当GC的时候,根据可达性算法,看一个对象是否消亡,没有消亡的对象会被放入新生代的Survivor区,消亡的直接被Minor GC(次要的,普通的GC) Kill掉.

2.进入到Survivor区的对象也不是安全的,当下一次Minor GC来的时候还是会检查Enden和Survivor存放对象区域中对象是否存活,存活放入另外一块Survivor区域.

3.当2个Survivor区切换几次以后,会直接进入老年代,当然进入到老年代也不是安全的,当老年代内存空间不足的时候,会触发Major GC(主要的,全局的GC),已经消亡的依然还是被Kill掉.

4.4.4.5 按系统线程分

串行收集:串行收集使用单线程处理所有垃圾回收工作, 因为无需多线程交互,实现容易,而且效率比较高。但是,其局限性也比较明显,即无法使用多处理器的优势,所以此收集适合单处理器机器。当然,此收集器也可以用在小数据量(100M左右)情况下的多处理器机器上。

并行收集:并行收集使用多线程处理垃圾回收工作,因而速度快,效率高。而且理论上CPU数目越多,越能体现出并行收集器的优势。

并发收集:相对于串行收集和并行收集而言,前面两个在进行垃圾回收工作时,需要暂停整个运行环境(STW),而只有垃圾回收程序在运行,因此,系统在垃圾回收时会有明显的暂停,而且暂停时间会因为堆越大而越长。

你可能感兴趣的