Java与C++之间有一堵由内存动态分配和垃圾收集围城的高墙,墙外面的人想进来,墙里面的人却想出去.

一、概述

垃圾收集需要完成的三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

内存的动态分配和内存回收技术已经相当成熟,为什么还需要去了解GC和内存分配呢?

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

Java运行时区域的各个部分中,程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每个栈帧中分配多少内存基本上是在类结构确定下来时就是已经知道的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内不需要过多考虑回收问题,因为在方法结束或线程结束的时候,内存自然就跟着回收了。

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

二、对象已死?

堆中几乎存放这Java世界中所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是要确定这些对象有哪些还“活着”,哪些已经“死去”。

1、引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用消失时,计数器值就减1;任何时刻计数器都为0的对象就是不可能再被使用的。

Java语言中没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引发的问题。

例:当两个对象互相引用对方,除此之外两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是他们因为互相引用着对方,导致他们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。

2、根搜索算法

在主流的商用程序语言中(Java和C#)都使用根搜索算法(GC Roots Tracing)判断对象是否存活的。这个算法的基本思路就是通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所有走过的路径成为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

在Java语言里,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中的引用的对象
  • 方法区中的类静态属性引用的对象
  • 方法区中的常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)的引用的对象
跟搜索算法判定对象是否可回收
跟搜索算法判定对象是否可回收

3、引用

无论是通过引用计数算法判断对象的引用数量,还是 通过根搜索算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。

在JDK1.2之前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

这种定义很存粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存在进行垃圾收集后还是非常进场,则可以抛弃这些对象。

Java堆引用的概念进行了扩充,将引用分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种,这四种引用的强度一次组建减弱。

  • 强引用:在程序代码之间普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收调被引用的对象。
  • 软引用:用来描述一些还用,但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。
  • 弱引用:用来描述非必须的对象的,它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • xu虚引用:又称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时受到一个系统通知。

4、生存还是死亡

在跟搜索算法中不可达的对象,也并非时“非死不可”的,这个时候他们处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程: 如果对象在进行跟搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件时此对象是否有必要执行finalize()方法。

当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行。

任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会再次执行

finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时。完全可以忘掉Java语言中还有这个方法存在。

5、回收方法区(永久代)

Java虚拟机规范中说过可以不要求虚拟机再方法区实现垃圾收集,而且再方法区进行垃圾收集的“性价比”一般比较低:再堆中,尤其是再新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要满足3个条件才能算是“无用的类:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以堆满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而不是和对象一样,不使用了就必然会被回收。

在大量使用反射、动态代理、CGLib等bytecode框架的场景,以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

三、垃圾收集算法

介绍几种算法的思想及其发展过程。

1、标记-清除算法

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

"标记-清除"算法

2、复制算法

为了解决效率问题,于是就有了“复制(Copying)”算法。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是堆其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动栈顶指针,按顺序分配内存即可,实现简单,运行高效。但是这种算法的代价时将内存缩小为原来的一般。

复制算法
复制算法

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM的专门研究表明,新生代中的对象98%是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还活着的对象一次性拷贝到另外一块Survivor空间上,最后清理调Eden和刚才用过的Survivor的空间。

Hotspot虚拟机默认Eden和Survivor的大小比例时8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存是会被“浪费”的。

3、标记-整理算法

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,提出了”标记-整理(Mark-Compact)“算法,标记过程仍然与”标记-清除“算法一样,但后续步骤不是直接堆可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理调端边界意外的内存。

标记-整理(Mark-Compact)
标记-整理(Mark-Compact)

4、分代收集算法

当前商业虚拟机的垃圾收集都采用”分代收集“(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。

  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存货对象的复制成本就可以完成收集。
  • 在老年代中因为对象存活率高、没有额外空间堆它进行分配担保,就必须使用”标记-清理“或”标记-整理“算法来进行回收。

四、内存分配与回收策略

Java计数习题中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:给对象分配内存以及回收分配给对象的内存。

对象的那内存分配,往大方向上讲,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的时哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

1、对象优先在Eden分配

大多数情况下, 对象字新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

Minor GC和Full GC的区别?

  • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC / Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的)。Major GC的速度一般会比Minor GC慢10杯以上。

2、大对象直接进入老年代

所谓大对象是指,需要大量连续内存空间的Java对象,最经典的大对象就是那种很长的字符串及数组。

大对象堆虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

3、长期存活的对象将进入老年代

虚拟机既然采用了分代收集的思想来管理内存,那内存回收时就必须能识别哪些对象应当放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话, 将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(15)时,就会被晋升到老年代中。

4、动态对象年龄判定

为了能更好的地适应不同程序的内存状况,虚拟机并不能总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

5、空间分配担保

在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的生于空间大小,如果大于,则改为直接进行一次Full GC。如果小于,则查看HandlePromotionFailure设置是否允许担保时报;如果允许,那指挥进行Minor GC;如果不允许,则也要改为进行一次Full GC。

小结

内存回收与垃圾收集器在很多时候都时影响系统性能、并发能力的主要因素之一,虚拟机之所以提供多种不同的垃圾收集器及大量的调节参数,是因为只有根据实际应用需要、实现方式选择最优的收集方式才能获取最好的性能。

没有固定收集器、参数组合,也没有最优的调优方法,虚拟机也没有什么必然的内存回收行为。

因此学习虚拟机内存只是,如果要到实践调优阶段,必须了解每个具体收集器的行为,优势和劣势、调节参数。

参考