聊聊JVM垃圾收集机制

650 阅读23分钟
分享者:锐哥

1、运行时数据区域

JVM在执行java程序的过程中会把它所管理的内存划分成若干个不同的数据区域。


(1)程序计数器

        程序计数器(Program Counter Register)是一块比较小的内存区域,它可以看作是当前线程所执行的字节码指令的行号计数器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条要执行的字节码指令。

        由于java虚拟机的多线程是通过线程的轮流切换并分配CPU时间片来实现的,在任何一个确定的时刻,一个核只会执行一条线程中的指令,为了线程切换后能恢复到正确的执行位置,每一条线程都需要有一个独立的程序计数器,因此,程序计数器是线程私有的内存。

        如果线程正在执行的是一个java方法,那么程序计数器中的值是正在执行的虚拟机字节码指令的地址;如果是一个Native方法,这个计数器的值为空(undefined)。此内存区域是java虚拟机规范中唯一一个没有定义任何OutOfMemoryError情况的内存区域。 

(2)虚拟机栈

        虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:每个方法在执行的同时都会创建一个称为栈帧(Stack Frame)的东西,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用开始直至执行完成,都对应着一个栈帧在虚拟机栈中入栈和出栈的过程。

        通常所说的栈,一般指的是虚拟机栈中的局部变量表部分。局部变量表存储了编译期可知的基本数据类型、引用类型和returnAddress类型(指向一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的。

        如果线程请求的栈深度超过了虚拟机的最大深度,那么就会抛出StackOverFlowError异常;如果虚拟机可以动态拓展并且在拓展时无法申请到足够的内存,将抛出OutOfMemoryError异常。 

(3)本地方法栈

        本地方法栈(Native Method Stack)和虚拟机栈一样,都是线程私有的,只不过虚拟机栈为虚拟机执行java方法服务,而本地方法栈则为虚拟机执行native方法服务。

(4)Java堆

        对大多数应用程序来说,java堆(Java Heap)都是java虚拟机所管理的内存区域中最大的一块。java堆是被所有线程所共享的一块内存区域,在虚拟机启动时创建。此内存区域存在的唯一目的就是存放对象实例,几乎所有的对象都在此内存区域上进行分配。

        根据java虚拟机规范的规定,java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可。在实现时,可以实现成固定大小的,也可以实现成可拓展的。当拓展时,如果无法申请到足够的内存,将抛出OutOfMemoryError异常。 

(5)方法区

        方法区(Method Area)也是被各个线程所共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、JIT编译器编译后的代码等数据。

        对于习惯在HotSpot虚拟机上开发、部署程序的的开发者来说,更习惯于把方法区称为”永久代“,但本质上两者并不等价。

         方法区可以处于不连续的内存空间,也可以选择成可拓展,还可以选择不实现垃圾收集。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。 

(6)运行时常量池

        运行时常量池(Runtime Constant Pool)是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载以后进入方法区的运行时常量池中存放。

        运行时常量池除了保存class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中,因此,运行时常量池相对于class文件常量池的一个重要特征是具备动态性。

        符号引用用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义地定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标不一定加载到内存中。例如org.simple.People类引用了org.simple.Language类,在编译时,People类并不知道Language类的实际内存地址,因此只能使用符号来代替,这就是符号引用。 

2、对象的内存布局

        HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

        HotSpot虚拟机的对象头包含2部分的信息。第一部分用于存储对象自身运行时数据如哈希码、GC分代年龄、锁状态标志等等,官方称之为“Mark Word”。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,但并不是所有的虚拟机实现都必须在对象头中保留类型指针。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据中确定数组的大小。

        实例数据部分是对象真正存储的有效信息。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。

       对齐填充并不是必须的。因为HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍(为什么这么要求?),也就是说对象的大小必须是8字节的整数倍。 

3、对象的访问定位

        栈上的reference数据定位和访问堆中的具体对象的方式取决于虚拟机实现,目前主要有句柄和直接指针两种。

        使用句柄访问方式的话,java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例和类型数据各自具体的地址信息。

        如果使用直接指针访问,那么java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的就是对象地址。

        使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问的最大好处是速度更快,节省了一次指针定位的时间开销。HotSpot虚拟机采用的是直接指针方式。 

4、垃圾收集器

        1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期,人们就在思考GC需要完成的三件事:
        (1)哪些内存需要回收
        (2)什么时候回收
        (3)如何回收

        对于第一个问题,哪些内存需要回收,就是哪些对象是不可用的。第二个问题,什么时候回收,一句话概述就是内存不够用的时候进行回收。第三个问题,就涉及到回收的具体实现上了。

4.1 对象的存活判定

(1)引用计数算法

        给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器值就减1;任何时刻,计数器为0的对象就是不可能再被使用的对象。
        引用计数算法无法解决对象之间循环引用的问题。

(2)可达性分析

        这种算法的基本思想就是通过一系列的称为”GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象不可用。

       在Java中,可以作为GC Roots的对象包括下面几种:

        a. 虚拟机栈(栈帧中的本地变量表)中引用的对象

        b. 方法区中类静态变量所引用的对象

        c. 方法区中常量所引用的对象

        d. 本地方法栈中JNI(即Native方法)引用的对象 

        主流的商用程序语言(java、c#等)都是采用的可达性分析算法来判定对象是否存活。

4.2 垃圾收集算法

(1)标记-清除算法

        标记-清除算法(Mark-Sweep)是最基础的收集算法。算法分为”标记”和”清除"两个阶段:首先标记出所有需要回收的对象,在标记完成之后统一回收被标记的对象。

        这种算法存在两个缺点:

        a. 效率问题。”标记”和”清除”两个阶段的效率都不高

        b. 内存碎片问题。标记清除之后会产生大量不连续的内存碎片,当以后要分配较大的对象时,可能会因为找不到足够的连续内存而不得不提前触发另一次垃圾收集动作。 

(2)复制算法

        复制算法(Copying)是为了解决标记清除算法的效率问题而提出。该算法将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块内存使用完毕,就将还存活着的对象复制到另一块内存上面,然后再把已使用过的内存空间一次清理掉,这样使得每次都是对整个半区进行回收,分配内存时,也不用考虑内存碎片的问题,只需要移动堆顶指针即可。

        这种算法的缺点是内存利用率只有50%

        现代的商业虚拟机都采用这种算法来回收新生代。因为新生代中的对象的存活率比较低,所以并不需要按照1:1来划分内存空间,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次只使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性的复制到另一块Survivor空间中,最后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比率是8:1,也就是说每次新生代中可用内存空间为整个新生代的90%,只有10%的内存会被浪费掉。如果某一次回收时,对象的存活率超过了10%,也就意味着to survivor空间不够用,那么此时就需要依赖老年代来进行分配担保(Handle Promotion),也即这些还存活的对象将直接进入老年代。

(3)标记-整理算法

        标记-整理算法(Mark-Compact)是针对老年代的特点(对象存活率较高、没有额外空间进行分配担保)而提出的。这种算法分为标记和整理两个阶段,标记阶段和标记-清除算法的标记阶段一致,但是整理阶段不是直接对标记对象进行回收,而是让还存活的对象向一端移动,然后一次性清理掉端边界以外的内存。

(4)分代收集

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

        新生代中,对象的存活率较低,就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;老年代中因为对象存活率高,也没有额外空间进行分配担保,所以采用标记-清除算法或者标记-整理算法。 

4.3 垃圾收集器

        如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。


(1)Serial收集器

        serial收集器是最基本、发展历史最悠久的收集器。它是一个单线程的收集器,在它工作时,必须暂停所有其他的工作线程,直到它收集结束,这也被称为”Stop The World”。

        它是虚拟机运行在client模式下的新生代默认的收集器:因为它简单而高效,对于限定单个CPU的环境来说,Serial收集器因为没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

(2)ParNew收集器

        ParNew 收集器是Serial收集器的多线程版本。

        ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,一个很重要但是与性能无关的原因是:除了Serial收集器外,只有它能与CMS收集器配合工作。

        ParNew 收集器默认开启的收集线程数与CPU的数量相同,可以使用参数-XX:ParallelGCThreads来控制垃圾收集的线程数量。

(3)Parallel Scavenge收集器

        Parallel Scavenge收集器看起来和ParNew收集器没什么区别,但其实Parallel Scavenge收集器的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短Stop The World的时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),因此Parallel Scavenge收集器也称为“吞吐量优先”的收集器。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。

        停顿时间越短,就越适合需要与用户交互的程序,良好的响应速度能够提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

        Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数和直接设置吞吐量大小的 -XX:GCTimeRatio 参数。

        除上述两个参数之外,Parallel Scavenge还提供一个参数 -XX:UseAdaptiveSizePolicy。如果开启了这个参数,就不需要手动指定新生代的大小(-Xmn)、Eden与Survivor的比率(-XX:SurvivorRatio)、晋升老年代对象的年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 GC自适应调节策略。 

(4)Serial Old收集器

        Serial Old收集器是Serial收集器的老年代版本,使用”标记-整理”算法。这个收集器的主要意义也是给client模式下的虚拟机使用。如果在Server模式下,它还有另外两个作用:一种用途是在JDK 1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在CMS收集器发生Concurrent Mode Failer时使用。

(5)Parallel Old收集器

        Parallel Old收集器是Parallel Scavenge的老年代版本,使用”标记-整理”算法。这个收集器是在JDK 1.6 中提供的。在此之前,Parallel Scavenge一直处在比较尴尬的状态。原因是:如果新生代中选择了Parallel Scavenge收集器,那么老年代只能选择Serial Old收集器,而Serial Old收集器在服务端应用性能上不是很突出,导致使用了Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。
        直到Parallel Old出现,“吞吐量优先”收集器终于有了比较名副其实的应用组合。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑 Parallel Scavenge + Parallel Old 的组合。 

(6)CMS收集器

        CMS收集器(Concurrent Mark Sweep,并发标记清除)是一种以获取最短停顿时间为目标的收集器。基于“标记-清除”算法实现,它的运作过程大致分为4个步骤:

        a. 初始标记

        b. 并发标记

        c. 重新标记

        d. 并发清除

        其中,初始标记和重新标记过程仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。重新标记阶段就是为了修正并发标记阶段因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。

        由于整个过程中耗时最长的并发标记和并发清除两个过程,收集器线程都可以和用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

        这个收集器存在3个明显的缺点:

        a. CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分的线程(或者说是CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS收集器默认开启的回收线程数量是 (CPU数量+3)/4,也就是说当CPU在4个以上时,并发回收时垃圾收集线程至少要占用 25% 的CPU资源。

        b. CMS无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failer”失败而导致另一次Full GC的产生。并发清理阶段,用户程序还在运行着,伴随着就会产生新的垃圾,这一部分的垃圾出现在标记之后,无法在当次收集中处理掉,只好等待下一次收集时处理,这一部分垃圾称之为“浮动垃圾”。也是因为并发清除阶段,用户程序还在运行,那也就必须要预留一部分内存空间给并发收集时用户程序使用。在JDK1.5的默认设置下,CMS收集器在当老年代使用了68%的空间后就会被激活,在JDK1.6种,这个阈值被提升到了92%。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failer”失败,这时虚拟机将启动后背预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集。

        c. 由标记清除算法引起的空间碎片问题 。

(7)G1收集器

        G1(Garbage First)收集器是一款面向服务端应用的垃圾收集器,JDK 1.7中才出现。相比其他的垃圾收集器,G1具有更加明显的优势:

        a. G1中虽然还保留分代的概念,但是G1能独立管理整个Java堆,不再需要像以前那样要多个收集器配合。因为G1将整个堆划分成多个大小相等的独立区域(Region),新生代和老年代的概念虽然保留着,但是不再是物理隔离的了,他们都是一部分Region(不需要连续)的集合。

        b. 空间整合。G1从整体上来看是基于标记-整理算法实现,从局部(两个Region之间)来看是基于复制算法实现,避免了内存碎片的问题。

        c. 可预测的停顿。这是G1相对于CMS的另一大优势。G1除了追求低停顿以外,还能建立可预测的停顿时间模型,能让使用者指明在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

        G1之所以能建立可预测的停顿时间模型,是因为它可以有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪每个Region里面的垃圾堆积的价值大小(回收这块Region所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也是Garbage First名称的由来)。

5、内存分配与回收策略

Java技术体系中所讲的自动内存管理最终可以归结为自动化的解决了两个问题:给对象分配内存以及回收分配给对象的内存。内存的回收上面已经说过原理,下面看一下内存分配。

(1)对象优先在Eden区上分配

        对象在新生代Eden区中分配,当Eden区中没有足够的内存进行分配时,虚拟机将发起一次Minor GC。

(2)大对象直接进入老年代

        虚拟机提供了一个参数 -XX:PretenureSizeThreshold ,大于这个参数设置值的对象将不会在Eden区域上进行分配,而是直接在老年代进行分配。这样做的目的是为了避免在Eden区以及两个Survivor区之间发生大量的内存复制(当大对象的存活率比较高时)。

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

        前面在说对象的内存布局时提到对象头中有一部分存储对象自身运行时所需要的数据,例如哈希码、GC分代年龄,这里面的GC分代年龄指的就是对象在新生代中熬过的GC次数,每熬过一次Minor GC,对象的年龄就增加一岁,当它的年龄增加到一定程度(默认是15岁)就会被晋升到老年代。这个晋升老年代的阈值可以通过参数 -XX:MaxTenuringThreshold 来设置。

(4)动态对象年龄判定

对象的年龄不一定非要达到 MaxTenuringThreshold 设置的值才能晋升老年代。如果在Survivor中相同年龄的对象的大小之和超过了Survivor空间大小的一半,那么年龄大于或等于该年龄的对象将直接进入老年代,无需等到 MaxTenuringThreshold 要求的年龄。

(5)分配担保

在新生代进行Minor GC之前,虚拟机会做如下的事情:

(1)先检查老年代最大可用的连续内存空间是否大于新生代所有对象总空间,如果大于,那么Minor GC就是安全的,因为不会有对象进入老年代。否则进行步骤(2)

(2)查看 HandlePromotionFailure设置值是否允许分配担保失败,如果不允许,那么也要进行一次Full GC。否则进行步骤(3)

(3)检查老年代最大可用连续空间是否大于历次晋升老年代的平均大小,如果大于,会尝试进行一次Minor GC,但是可能会有风险。否则进行步骤(4)

(4)也要进行一次Full GC。

        步骤(3)中提到会尝试进行一次Minor GC,但这次GC会存在风险,这句话是什么意思呢?前面在讲复制算法时提到,新生代在进行GC时,如果Eden空间和From Survivor空间中存活的对象大小之和超过了To Survivor空间的大小,那么这些存活对象将通过分配担保机制进入老年代。因为还没有进行Minor GC,所以虚拟机并不知道本次GC中存活对象的大小,所以只好采用历次晋升老年代对象的平均值来作为经验值,如果老年代可用的最大连续内存空间大于经验值,那么很有可能说明老年代可用的最大连续内存空间也大于本次Minor GC存活的对象大小之和,所以会尝试进行Minor GC。但是如果在进行Minor GC后发现存活对象大小之和大于老年代最大可用连续内存空间,那么这时老年代将无法存放这部分存活对象,这就是风险所在,此时老年代就要来一次Full GC以腾出空间。