每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
而
Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才
能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理
在堆里面存放着
Java
世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就
是要确定这些对象之中哪些还
“
存活
”
着,哪些已经
“
死去
”
(
“
死去
”
即不可能再被任何途径使用的对
象)了。
引用计数算法
判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
可达性分析算法当前主流的商用程序语言
(Java、C#,上溯至古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
对象
object 5
、
object 6
、
object 7
虽然互有关联,但是它们到
GC Roots
是不可达的,
因此它们将会被判定为可回收的对象。
在
Java
技术体系里面,固定可作为
GC Roots
的对象包括以下几种:
--
在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的
参数、局部变量、临时变量等。
--
在方法区中类静态属性引用的对象,譬如
Java
类的引用类型静态变量。
--
在方法区中常量引用的对象,譬如字符串常量池(
String Table
)里的引用。
·
在本地方法栈中
JNI
(即通常所说的
Native
方法)引用的对象。
--Java
虚拟机内部的引用,如基本数据类型对应的
Class对象,一些常驻的异常对象(比如 NullPointExcepiton
、
OutOfMemoryError
)等,还有系统类加载器。
--
所有被同步锁(
synchronized
关键字)持有的对象。
--
反映
Java
虚拟机内部情况的
JMXBean
、
JVMTI
中注册的回调、本地代码缓存等。
除了这些固定的
GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象
“
临时性
”
地加入,共同构成完整
GC Roots集合。譬如后文将会提到的分代收集和局部回收(
Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入
GC Roots集合中去,才能保证可达性分析的正确性。
目前最新的几款垃圾收集器
无一例外都具备了局部回收的特征,为了避免
GC Roots
包含过多对
象而过度膨胀,它们在实现上也做出了各种优化处理。
希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象
——很多系统的缓存功能都符合这样的应用场景。
在
JDK 1.2
版之后,
Java
对引用的概念进行了扩充,将引用分为强引用(
Strongly Re-ference)、软引用(
Soft Reference
)、弱引用(
Weak Reference
)和虚引用(
Phantom Reference
)
4
种,这
4种引用强度依次逐渐减弱。
--
强引用是最传统的
“
引用
”
的定义,是指在程序代码之中普遍存在的引用赋值,即类似
“Object
obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
--
软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内
存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在
JDK 1.2
版之后提供了
SoftReference
类来实现软引用。
--
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只
能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在
JDK 1.2
版之后提供了
WeakReference
类来实现弱引用。
--
虚引用也称为
“
幽灵引用
”
或者
“
幻影引用
”
,它是最弱的一种引用关系。一个对象是否有虚引用的
存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在
JDK 1.2版之后提供了
PhantomReference
类来实现虚引用。
要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与
GC Roots
相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是
否有必要执行finalize()
方法。假如对象没有覆盖
finalize()
方法,或者
finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为
“
没有必要执行
”
。
如果这个对象被判定为确有必要执行
finalize()
方法,那么该对象将会被放置在一个名为
F-Queue的
队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer
线程去执行它们的finalize()方法。这里所说的
“
执行
”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的
finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致
F-Queue
队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对
F-Queue中的对象进行第二次小规模的标记,如果对象要在
finalize()
中成功拯救自己
——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(
this
关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出
“
即将回收
”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。从代码清单
3-2中我们可以看到一个对象的
finalize()
被执行,但是它仍然可以存活。
方法区垃圾收集的“性价比”
通常也是比较低的:在
Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收
70%
至
99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收
Java
堆中的对象非常类似。
判定一个常量是否
“
废弃
”
还是相对简单,而要判定一个类型是否属于
“
不再被使用的类
”
的条件就
比较苛刻了。需要同时满足下面三个条件:
·
该类所有的实例都已经被回收,也就是
Java
堆中不存在该类及其任何派生子类的实例。
·
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如
OSGi
、
JSP
的重加载等,否则通常是很难达成的。
·
该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方
法。
分代收集理论
建立在两个分
代假说之上:
1
)弱分代假说(
Weak Generational Hypothesis
):绝大多数对象都是朝生夕灭的。
2
)强分代假说(
Strong Generational Hypothesis
):熬过越多次垃圾收集过程的对象就越难以消
亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将
Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区
域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
在
Java
堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域
——
因而才有了
“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法
——
因而发展出了
“
标记
-
复制算法
”“
标记
-清除算法
”“
标记
-
整理算法
”等针对性的垃圾收集算法。
三种垃圾清理机制
1. 标记-清除算法(Mark-Sweep)
2. 标记-复制算法
3. 标记-整理算法(Mark-Compact)
1. 标记-清除算法
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2. 标记-复制算法
标记
-
复制算法常被简称为复制算法。为了解决标记
-
清除算法面对大量可回收对象时执行效率低
的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%
的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都
100%存 活的极端情况,所以在老年代一般不能直接选用这种算法。
现在的商用Java
虚拟机大多都优先采用了这种收集算法去回收新生代,
IBM
公司曾有一项专门研
究对新生代
“
朝生夕灭
”
的特点做了更量化的诠释
——
新生代中的对象有
98%熬不过第一轮收集。因此并不需要按照
1
∶
1
的比例来划分新生代的内存空间。
在
1989
年,
Andrew Appel
针对具备
“
朝生夕灭
”
特点的对象,提出了一种更优化的半区复制分代策
略,现在称为
“Appel
式回收
”
。
HotSpot
虚拟机的
Serial
、
ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局
。
Appel
式回收的具体做法是把新生代分为一块较大的
Eden空间和两块较小的Survivor
空间,每次分配内存只使用
Eden
和其中一块
Survivor
。发生垃圾搜集时,将
Eden
和
Survivor中仍然存活的对象一次性复制到另外一块
Survivor
空间上,然后直接清理掉
Eden
和已用过的那块
Survivor
空
间。
HotSpot
虚拟机默认
Eden
和
Survivor
的大小比例是
8
∶
1,也即每次新生代中可用内存空间为整个新生代容量的
90%
(
Eden
的
80%
加上一个
Survivor
的
10%
),只有一个
Survivor
空间,即
10%的新生代是会被
“
浪费
”
的。当然,
98%
的对象可被回收仅仅是
“
普通场景
”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于
10%
的对象存活,因此
Appel
式回收还有一个充当罕见情况的
“
逃生门
”的安全设计,当
Survivor
空间不足以容纳一次
Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(
Handle Promotion
)。
内存的分配担保好比我们去银行借款,如果我们信誉很好,在
98%
的情况下都能按时偿还,于是
银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款
时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块
Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。
3. 标记-整理算法
针对老年代对象的存亡特征,
1974
年
Edward Lueders
提出了另外一种有针对性的
“
标记
-整理
”
(
Mark-Compact
)算法,其中的标记过程仍然与
“标记-清除”算法一样,但后续步骤不是直接对可
回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内
存,“标记-整理”算法的示意图如图所示。
标记
-
清除算法与标记
-
整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动
式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新
所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行
,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为
“Stop The World”
。
但如果跟标记
-
清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的
空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过
“分区空闲分配链表
”
来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整
个程序的吞吐量来看,移动对象会更划算。此语境中,吞吐量的实质是赋值器(
Mutator,可以理解为使用垃圾收集的用户程序,本书为便于理解,多数地方用
“
用户程序
”
或
“
用户线程”代替)与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。
HotSpot
虚拟机里面关注吞吐量的Parallel Scavenge
收集器是基于标记
-
整理算法的,而关注延迟的
CMS
收集器则是基于标记
-清除算法的,这也从侧面印证这点。
另外,还有一种
“
和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚
拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。