垃圾收集器与内存分配器

3.1概述

第二章介绍了java内存运行时区域的各个部分,其中程序计数器,虚拟机栈,本地方法栈三个区域岁线程而生,随线程而灭,栈中的栈帧随着方法进行入栈和出栈操作。每一个栈帧中分配多少内存基本上是类结构确定下来就已知的(尽管在运行期会由及时编译器进行一些优化,单在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这个几个区域,在这个几个区域内就不需要考虑过多的如何回收的问题,当方法结束时,内存自然而然就跟随着回收了。

而java堆和方法区有很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的的内存也可能不一样,只有分配和回收是动态的。垃圾收集器算关注的正式这部分内存该如何管理,本位后续讨论的内存分配与回收也仅仅特指这一部分内存。

3.2对象已死

如何确定对象存活着还是死去

3.2.1引用计数算法

很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用的时候,计数器就加1,当引用失效的时候,计数器就-1,任何时刻计数器为0的对象就是不可能再被使用的。

客观的说,引用计数器算法虽然占用了一些额外空间来进行技术,但它原理简单,判定效率也很高,在大多数情况下,它都是一个不错的算法(pytion)但是在java中,至少主流的虚拟机都没有使用引用计数法来计算内存,主要原因是,这个看似简单的算法有很多例外的情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间循环引用的问题。

3.2.1可达性分析算法

当前主流的商用语言,内存管理思路,都是通过可达性分析算法来判定对象是否存活的。这个算法的基本思路就通过一些列称为GCRoots的跟队形作为起始节点集,从这些接地那开始,根据引用关系向下搜索,搜索过程中所走过的路径为引用链,如果某个对象到GCRoots间没有任何引用链相连,或说GC Roots不可达时,它们将会判定为可回收对象

在java技术体系里,规定作为GCRoots的对象包括以下几种:

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个现线程被调用的方法堆栈中使用到的参数,局部变量表,临时变量等。
  2. 在方法区中静态属性引用的对象,譬如字符串处理成String Table中的引用
  3. 在本地方法栈JNI(即通常说的native方法)引用的对象
  4. java虚拟机内部的引用,如基本数据类型对应的class对象,一些常驻的异常对象(比如nullPointException,OutOfMemoryError)等 ,还有系统类加载器
  5. 所有被同步锁(synchronized)持有对象
  6. 反应java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等

除了这些固定的GCRoots集合以外,根据用户所选的垃圾收集器当前回收的内存区域不同,还有其他对象临时性地加入,共同完成GCRoots集合。

3.2.3再谈引用

无论是通过引用计数法判断对象里的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判断对象存活都和引用脱不开关系,在jdk1.2版之前,java引用的定义很传统:如果reference数据是代表另一块内存的起始地址,就称该reference类型数据是代表某块内存,某个对象的引用。

在jdk1.2版本中,java对引用的概念进行了扩充,将引用分为强引用(Stongly reference)软引用(Soft Reference),弱引用(Weak Reference)和虚引用4种,这4种强度依次逐渐减弱。

  • 强引用是最传统的引用定义,值程序代码中普遍存在的引用赋值,即类似Object object=new Object()这种引用关系。无论何种情况之下, 只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
  • 软引用是用来描述一些还有用,但非必须的对象。只被软用关联着的对象,在系统要发生内存溢出前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出异常
  • 在1.2之后的版本提供了SoftReference类来实现软引用
  • 弱引用也是用来描述那些非必须的对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集器发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉被弱引用来关联的对象。在jdk1.2版之后WeakReference类来实现弱引用
  • 虚引用也称“幽灵引用”, 他是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。为一个对下设置虚引用唯一目的是为了能在这个对象被收集器 回收时 收到一个系统通知。在jdk1.2版之后提供了PhatomReference类来实现虚引用

3.2.4生存还是死亡

几遍是在可达性分析算法中判定为不可达的对象,也不是非死不可的。真正要宣告一个对象死亡,至少要经历俩次标记过程:如果对象杂进行可达性分析后发现没有GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,加入对象没有覆盖finalize方法,或者finalize方法已经被调用过,那么虚拟机将这俩种情况都视为没有必要执行。

如果这个对象呗判定为确定有必要执行finalze方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条虚拟机自动建立的,低调度优先等级的finallizer线程去执行它的finalize方法。这里所说的执行,是指虚拟机会出发曾方法开始运行,但不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize方法执行缓慢,或者更极端地发生了死循环,很可能直接导致F-queue队列中的其他对象永久处于等待,甚至导致整个内存回收系统崩溃我。finalize()方法是对象逃脱死亡命运的最后一次机会。稍后收集器将对F-Queue中的对象进行 第二次小规模 标记,如果对象要在finalize中成功拯救自己,只要诚心与引用链上的任何一个对象进行关联即可,譬如把this关键字赋值给某个变量或对象的成员变量,那在第二次标记时它将被移出即将回收的集合;如果对象这时候还没有逃脱,那基本上就真的要被回收了。

还有一点要特别加以说明,上面关于对象死亡时的finalize()方法并不鼓励大家使用,它不等同于c和c++语言中的析构函数,而是java刚诞生时为了传统c/c++程序员更容易接受所做的妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已经被官方明确声明为不推荐使用的语法。有些教材中描述它适合做”关闭外部资源”之类的清理工作,使用try-finally或者其他方式都可以做的更好,更及时,所以希望大家都忘掉java语言中的这个语法

3.2.5回收方法区

(方法去用于存储被加载类的信息,常量,静态变量,即编译器编译后的代码缓存等数据)

有些人认为方法区是没有垃圾收集行为的,jvm规范中提到过可以不要求虚拟机在方法去实现垃圾收集,方法区垃圾收集的性价比也是比较低的:在java堆中尤其是新生代中,对常规引用一次垃圾收集通常可回收70%-99%的内存空间,相比之下,方法去取的回收严苛的判定条件,其垃圾收集回收的成果往往远低于此。

方法区的垃圾收集主要分配俩部分内容:废弃的常量和不再使用类型。回收废弃的常量与java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但当前没有任何字符串对象引用常量池中的java常量,并且虚拟机中也没有其他地方使用这个字面量。如果在这是发生内存回收,而且垃圾收集器判断有必要的话,这个java常量就会被系统清理出常量池。常量池中的其他类,接口,方法,字段符号的引用也类似如此。

判定一个常量是否废弃还是相对简单,要判定一个类型是否属于不再被使用的类,条件就比较严苛了。同时要满足以下三个条件

  • 该类的所有实例都已经被回收,也就是java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是结果精心设计的可替换类加载器的场景,如OSGi,JSP的重加载等,否则通常也是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

java虚拟机允许对满足上述三个条件的无用类进行回收。关于是否要对类进行回收,hotspot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading,-XX:+TraceClassUnLoading查看类的加载和卸载信息,其中-Verbose:class和-XX+TraceClassLoading可以在product版的虚拟机使用,-XX+TraceClassUnLoading参数需要FastDebug版虚拟机支持

在大量使用反射,动态代理,CGlib等字节码框架,动态生成JspOSGi这类频繁自定义类加载器的场景中,通常都需要java虚拟机具备类型自卸载的能力,以保证不会对方法去造成过大的内存压力.

3.3垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为引用计数式和追踪式垃圾收集,俩大类,这俩类也常被称作直接垃圾收集和间接垃圾收集.由于引用计数式垃圾收集算法在本书讨论到主流的java虚拟机规范中未涉及,我们暂时不把它作为主要内容来讲,本节介绍的所有算法均属于追踪式垃圾收集的范畴

3.3.1分代收集理论

当前商业虚拟机的垃圾收集器,大多都遵循了"分代收集"的理论进行设计,实质是一套符合绝大多数程序运行实际情况的经验法则,它建立在俩个分代假说之上

  • 弱分代假说:绝大多数对象都是朝生夕死的
  • 强分代假说:熬过越多次垃圾回收的对象就越是难以消亡

这俩个分代假说共同奠定了多款常用垃圾收集器的一致原则 :收集器应该将java堆划分出不同的区域,如何将回收对象依据年龄,分配到不同的区域之中存储.显而易见,如果一个区域中大多数对象都是朝生夕死,难以熬过垃圾收集过程的话,那么它们集中放在一起,每次回收只关注如何保留少量存活而不是去标记哪些大量将要被回收的对象,就能以较低的代价回收到较大的空间;如果剩下的都是消亡的对象,那么把它们集中放在一块,虚拟机便可使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销内存的空间有效利用

在java堆中换分出不同的区域之后,垃圾收集器才可以回收其中一个或者某些区域,因此才有了minorGC majorGC FullGC这样的回收类型划分;也才能针对不同的区域安排里面存储对象死亡特征相匹配的算法

分代收集理论具体放到现在商用java虚拟机里,设计者一般至少会把java堆分为新生代,老年代俩个区域。顾名思义。在新生代中,每次垃圾收集时都发现有道理的对象四驱,而每次回收存活的少量对象,将会逐渐晋升到老年代中去。

加入要现在进行一次只局限于新生代的手机(MinorGC)但在新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不固定的GC Roos之外,再额外便利整个老年代中所有的对象来确保可达性分析的正确性,反过来也是一样。便利整个老年代所有对象的方案理论上虽然可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集添加第三条经验法则。

跨带引用假说:跨带引用相对于同带引用仅占极少数

这其实是根据俩条逻辑推理得出的隐含结论:存在相互引用关系的俩个对象是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨带引用,由于老年代的对象难以消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,而在年龄增长后到老年代中后,这跨带引用也随机被消除了。

依据这条假说,我们就不应该再为了少量的跨带引用去扫描整个老年代,也不必浪费空间记录每一个对象是否存在哪些跨带引用,只需要在新生代中建立一个全局的数据结构(该结构被称为记忆集,remembered Set)这个结构把老年代新生代划分成若干小块,表示出老年的哪一个内存存在跨带引用。从此当发生MinorGC时,只有包含了跨带引用关系的小内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如果自己活着某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代任然是划算的。

3.3.2标记-清除算法

最早出现也是最基础的垃圾收集算法是标记-清除(Mark-Sweep)算法,首先标记出需要回收的对象,在标记完成后,统一回收掉被标记的对象。标记过程就是对象是否属于垃圾的判定过程。之所以是最基础,因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进得到的。主要缺点有俩个:第一个是执行效率不稳定,如果java堆中包含大量对象,而且其中大部分对象是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除俩个过程的执行效率都随着对象数量的增长而降低;第二是内存空间碎片化的问题,标记清除过后会产生大量不连续的内存碎片,空间碎片太多可能会导致用户程序运行过程中分配较大对象不得不提前触发一次垃圾收集动作。

3.3.3标记-复制算法

标记-复制算法啊常常被简称为复制算法。为了解决标记清除算法面对大量可回收对象时执行效率低的问题,他们将可用内存划分为俩大小相等的区块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,如何再把已经使用过内存空间一次性清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存复制开销,但对于多数对象都是可回收的情况,复制算法需要复制的就是少数存活的对象。而且每次移动都是针对整个半区进行回收,分配内存的时候就不用考虑有空间碎片的复杂情况,只有移动堆顶指针,按需分配 即可1.这样实现简单,运行高效,不过缺陷也很易见,这种复制算法的代价是将可用内存缩小为了原来的一半,空间浪费的太多了

现在商用虚拟机大多都是优先采用了这种算法去回收新生代。hotspot虚拟机的Serial,parNew等新生代收集器均采用了这种策略来设计新生代的内存布局。appel回收的具体做法是吧新生代分为较大的eden区和俩个较小的survivor空间,每次分配内存只使用eden区和一块survivor。发生垃圾收集的时候,将Eden和Survivor中任然存活的对象一次性复制到另一块survivor空间上,如何直接清理掉Eden和已用过的那块Survivor空间,即10%的新生代是会被浪费的。当然98%的对象可被回收仅仅是普通常见下测得的数据,任何人都没有办法保证每次回收都只有不多余10%的对象的存活,因此appel式回收还有一个罕见的情况,逃生门的设计,当survivor空间不足以容纳一次minorGC之后存活的对象的时候,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保

3.3.4标记整理算法

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

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

标记-清除算法与标记整理算法的本质差异在于前者是一项非移动式回收算法,后者是移动式的。是否移动回收后的对象存活是一项优缺点并存的风险决策:

如果移动对象,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,折旧更加让使用者不得不小心翼翼的权衡利弊了,像这样的停顿被虚拟机设计者亲切的称为stop the world。

但如果跟标记-清除算法哪有完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致我的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过”分区空闲分配链表“来解决内存分配问题(计算机磁盘存储大文件就不要求物理连续的磁盘空间,能够碎片化的存储和访问就是通过磁盘分区表实现的)。内存的访问是用户程序 最频繁的操作,没有之一,加入在这个环节上增加了额外的负担,是势必会直接影响吞吐量。

基于以上俩点,是否移动对象都会存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至不需要停顿,但从整个程序的吞吐量来看,移动对象会更划算。此语境中,吞吐量的实质是赋值器与收集器的效率总和。

另外还有一种解决方案可以不在内存分配和访问上增加太大的额外负担,做法是让虚拟机平时多数时间采用标记清除算法,暂时容忍内存碎片的存在,知道内存空间的碎片化程度已经大道影响对象分配时,再采用标记整理算法手机一次,以获得规整的内存空间。前面提到的基于标记清除算法的cms收集器棉量空间碎片过多时采用的就是这种办法.

Last modification:July 4, 2023
如果觉得我的文章对你有用,请随意赞赏