3、JVM--垃圾回收期和内存分配策略

前言:

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

3.1、概述

垃圾收集(Garbage Collection,GC),大部分人都把这项技术当做Java语言的伴生产
物。事实上,GC的历史比Java久远,1960年诞生于MIT的Lisp是第一门真正使用内存动态分
配和垃圾收集技术的语言。当Lisp还在胚胎时期时,人们就在思考GC需要完成的3件事情:
1、哪些内存需要回收?
2、什么时候回收?
3、如何回收?

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

答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并

发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节

Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,

随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈入栈操作。

每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由JIT编译器
进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的),因此
这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问
题,因为方法结束或者线程结束时,内存自然就跟随着回收了。

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

3.2、对象已经死了吗

里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一
件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径
使用的对象)。

3.2.1、引用计数算法

多数情况下:

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;

当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

客观情况下:

引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部
分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如微软公司的
COM(Component Object Model)技术、使用ActionScript 3的FlashPlayer、Python语言和在游
戏脚本领域被广泛应用的Squirrel中都使用了引用计数算法进行内存管理。

但是,至少主流的Java虚拟机里面没有选用引用计数算法来管理内存,

其中最主要的原因是它很难解决对象之间相互循环引用的问题

3.2.2、可达性分析算法

在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,
都是称通过可达性分析(Reachability Analysis)来判定对象是否存活的。

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,

从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),

当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,

则证明此对象是不可用的。

对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达
的,所以它们将会被判定为是可回收的对象。

在Java语言中,可作为GC Roots的对象包括下面几种:
1、虚拟机栈(栈帧中的本地变量表)中引用的对象。
2、方法区中类静态属性引用的对象。
3、方法区中常量引用的对象。
4、本地方法栈中JNI(即一般说的Native方法)引用的对象。

3.2.3、在谈引用

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

在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值

代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,

但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描

述一些“食之无味,弃之可惜”的对象就显得无能为力。

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存
空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这
样的应用场景。

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong
Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom
Reference)4种,这4种引用强度依次逐渐减弱。


强引用就是指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引
用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。


软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将
要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回
收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实
现软引用。


弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的
对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,
都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引
用。


虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引
用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一
个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在
JDK 1.2之后,提供了PhantomReference类来实现虚引用。

3.2.4、生存还是死忙

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

1、如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,

那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。

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


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

这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做
的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情
况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统
崩溃。

finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象
进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链
上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的
成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃
脱,那基本上它就真的被回收了。

另外一个值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃
脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,
如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行
动失败了。

需要特别说明的是,上面关于对象死亡时finalize()方法的描述可能带有悲情的艺术色
彩,笔者并不鼓励大家使用这种方法来拯救对象。相反,笔者建议大家尽量避免使用它,因
为它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做出的
一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中描
述它适合做“关闭外部资源”之类的工作,这完全是对这个方法用途的一种自我安慰。
finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,所以笔
者建议大家完全可以忘掉Java语言中有这个方法的存在。

3.2.5、回收方法区

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

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收
Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了
常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说,就是没有任何
String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时发生内
存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接
口)、方法、字段的符号引用也与此类似。

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


该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。


加载该类的ClassLoader已经被回收。


该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该
类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是
和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc
参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:
+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:
+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要
FastDebug版的虚拟机支持。

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

3.3、垃圾回收算法

3.3.1、标记-清除算法 

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同它的名字一样,算法分
为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有
被标记的对象,它的标记过程其实在前一节讲述对象标记判定时已经介绍过了。之所以说它
是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到
的。它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是
空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程
序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾
收集动作。

3.3.2、复制算法

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

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生
代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存
分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor

当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最
后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是
8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%
的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每
次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里
指老年代)进行分配担保(Handle Promotion)。


内存的分配担保就好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时
偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保
证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也
一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,
这些对象将直接通过分配担保机制进入老年代。关于对新生代进行分配担保的内容,在本章
稍后在讲解垃圾收集器执行规则时还会再详细讲解。


这里需要说明一下,在HotSpot中的这种分代方式从最初就是这种布局,与IBM的研究并
没有什么实际联系。本书列举IBM的研究只是为了说明这种分代布局的意义所在

3.3.3、标记-整理算法

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


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

3.3.4、分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算
法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆
分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代
中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付
出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间
对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

3.4、HotSpot算法实现

3.4.1、枚举节点

从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在
全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现
在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时
间。

另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一
个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看
起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情
况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有
Java执行线程(Sun将这件事情称为“Stop The World”)的其中一个重要原因,即使是在号称
(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

由于目前的主流Java虚拟机使用的都是准确式GC(这个概念在第1章介绍Exact VM对
Classic VM的改进时讲过),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有
执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在
HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的
时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也
会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知
这些信息了。下面的代码清单3-3是HotSpot Client VM生成的一段String.hashCode()方法的
本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中
偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer)的引用,有效范
围为从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录的偏移
量)=0x026eb7be,即hlt指令为止。

3.4.2、安全点

在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问
题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一
条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很
高。

实际上,HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的
位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都
能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以致于让
GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基
本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行
的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间
执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有
这些功能的指令才会产生Safepoint。

对于Sefepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行
JNI调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式
中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),其中抢先式中断不需
要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程
中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采
用抢先式中断来暂停线程从而响应GC事件

而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设
置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。
轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。下面代码清
单3-4中的test指令是HotSpot生成的轮询指令,当需要暂停线程时,虚拟机把0x160100的内存
页设置为不可读,线程执行到test指令时就会产生一个自陷异常信号,在预先注册的异常处
理器中暂停线程实现等待,这样一条汇编指令便完成安全点轮询和触发线程中断

3.4.3、安全区域

使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际情况却并不一定。
Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,
程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处
于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去
中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全
区域(Safe Region)来解决。


安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方
开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。


在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当
在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离
开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完
成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为
止。


到此,笔者简要地介绍了HotSpot虚拟机如何去发起内存回收的问题,但是虚拟机如何
具体地进行内存回收动作仍然未涉及,因为内存回收如何进行是由虚拟机所采用的GC收集
器决定的,而通常虚拟机中往往不止有一种GC收集器。下面继续来看HotSpot中有哪些GC收
集器。

猜你喜欢

转载自www.cnblogs.com/Mrchengs/p/10746632.html
今日推荐