深入理解JVM-第三章 垃圾收集器与内存分配策略

概述

程序计数器,虚拟机栈,本地方法栈随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期间会由JIT编译器做一些优化),因此这几个区域的内存分配和回收都具有确定性,不需要过多考虑。

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

判断对象是否死亡

1.引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它引用计数器就加1,当引用失效时计数器就减1,计数器值为0的对象就应当是死亡的。

引用计数算法很难解决对象之间相互循环引用的问题。

2.可达性分析算法:通过一系列的称为“GC Root”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到“GC Root”没有任何引用链相连时,则证明此对象是不可用的。

在主流的商用语言的主流实现中都是通过可达性分析算法来判断对象是否存活的。

Java中可作为GC Root的对象:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI(即Native方法)引用的对象

引用:

判断对象是否存活的算法都与引用有关

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

Jdk1.2以后java对引用进行了扩充,将引用分为强引用,软引用,弱引用,虚引用,强度递减:

强引用:就是普通引用,只要强引用存在,垃圾收集器就不会回收被引用的对象。

软引用:用来描述一些还有用但并非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围中进行第二次回收,如果第二次回收还没有足够的内存再抛出内存溢出异常。

弱引用:被弱引用关联的对象只能生存到下一次垃圾收集发生之前。

虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过一个虚引用来获得对象实例,为一个对象设置虚引用的目的仅仅是为了在这个对象被回收时收到一个系统通知。

 

生存还是死亡:

即使在可达性分析算法中不可达的对象,也并非”非死不可”的,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析算法后是不可达的,那么它将被第一次标记,并进行一次筛选。当对象没有覆盖finalize()方法,或者finalize()方法被虚拟机调用过,虚拟机判定没必要执行finalize()方法。如果对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在F-Queue队列中,并在稍后由一个虚拟机自动建立的,低优先级的Finalizer线程去执行它。(并不承诺会等待它运行结束)。稍后GC将对F-Queue中的对象第二次进行小规模的标记,finalize()方法是对象逃脱的唯一机会,如果finalize()方法执行后对象仍不可达,基本上这个对象就真的被回收了。

 

建议完全可以忘掉finalize()方法的存在。

回收方法区:

在方法区中进行垃圾收集性价比一般较低。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收Java堆中对象类似,没有任何String对象引用常量池中的某一常量,也没有期它地方引用了这个字面量,这个常量就会被清理出常量池。

类需要同时满足下面三个调价才可以被回收:

  1. 该类所有的实例都被回收
  2. 加载该类的ClassLoader对象已被回收
  3. 该类的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

具体是否会回收还需要取决于虚拟机是否开启了类卸载的功能。

垃圾回收算法

1.标记-清除算法

过程:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

缺点:标记和清除两个过程效率都不高;容易产生大量不连续的内存碎片。

2.复制算法

过程:将可用内存容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存在着的对象复制到另一块上面,然后再把已使用过的内存一次性清理掉。

缺点:将内存缩小为原来的一半。

改进:根据对象朝生夕死的原则,将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor空间,当回收时,将还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和Survivor空间。当Survivor空间不够用时,需要依赖其他内存进行分配担保。

3.标记-整理算法

过程:标记过程与标记-清除算法一样,回收时让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

4.分代收集算法

根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,对象朝生夕死,选用复制算法,而老年代中对象存活率较高,没有额外空间对其担保,就必须采取标记-清理或标记-整理算法来进行回收。

HotSpot的算法实现

枚举根节点:

可作为GC Roots的节点主要在全局性的引用(例如常量或静态属性)与执行上下文(例如栈帧中的本地变量表)中。

 

GC停顿:可达性分析工作必须在一个能确保一致性的快照中进行,整个分析期间整个执行系统不可以出现分析过程中对象引用还在不断变化的情况。这是导致GC进行时必须停顿所有Java线程的一个重要原因。

虚拟机应当有办法直接得知哪些地方存放着对象引用,在HotSpot实现中,是用一组称为OopMap的数据结构来达到这个目的的。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样GC扫描时就可以直接得知这些信息了。

安全点:

HotSpot没有为每条指令都生成OopMap,只是在特定位置记录了这些信息,这些位置称为安全点,程序并非在任何地方都能停顿下来GC,只有在到达安全点才能暂停。安全点的选定基本上是以”是否具有让程序长时间执行的特征“为标准进行选定的,例如方法调用,循环跳转,异常跳转等,功能才会产生Safepoint。

让GC发生时所有线程都”跑“到最近的安全点上再停顿下来的方法:

  1. 抢先式中断:在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它”跑“到安全点上 。
  2. 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动区轮询这个标志,发现中断标志为真时就自己中断挂起。(轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方)

 

安全区域:

对于处于sleep或blocked状态的线程,这时候它们无法响应JVM的中断请求,对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码之中,引用关系不会发生改变,在这个区域中任何地方开始GC都是安全的。

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

 

垃圾收集器

 

连线表示可以搭配使用

新生代收集器:

1.Serial收集器:

这是一个单线程的收集器,在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

截至jdk1.7它依然是虚拟机运行在Client模式下的默认新生代收集器。

2.ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数,收集算法,Stop the World,对象分配规则,回收策略都与Serial收集器完全一样。

除了Serial收集器外目前只有它能与CMS收集器配合工作。

ParNew收集器在单Cpu的环境中绝对不会有比Serial收集器更好的效果

它默认开启的收集线程数与Cpu总数量一致,可以通过参数控制垃圾回收线程数。

3.Parallel Scavenge收集器

它也是使用复制算法的收集器,又是并行的多线程收集器

Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量。高吞吐量可以高效率的利用Cpu时间,尽快完成程序的运算任务,适合在后台运算等不需要太多交互的任务。

老年代收集器:

1.Serial Old收集器

Serial Old 是Serial收集器的老年代版本,它同样是一个单线程收集器,使用”标记-整理“算法。

这个收集器的主要意义也是在于给Client模式下的虚拟机使用。

如果在Server模式下,在jdk1.5版本之前中可以与Parallel Scavenge收集器搭配使用,还有一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

2.Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年版本,使用多线程和标记-整理算法。

在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge + Parallel Old收集器。

3.CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器。它是使用标记-清除算法实现的,运作过程如下:

初始标记

并发标记

重新标记

并发清除

其中,初始标记,重新标记这两个步骤仍然需要”Stop the World”。

初始标记仅仅只标记一下GC Root 能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。

 

CMS的缺点:

CMS收集器对CPU资源非常敏感。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当前CPU在4个以上时,并发回收时垃圾收集线程占用不少于25%的CPU资源,并且随着CPU数量的增加而下降。但当CPU不足4个时CMS对用户程序的影响就很大。

 

CMS收集器无法处理浮动垃圾,可能出现”Concurrent Mode Failure“ 而导致另一次Full GC的产生。并发清理阶段用户线程产生的垃圾称为浮动垃圾,CMS无法在当次收集处理掉他们,只好留待下一次GC时再清理掉。

收集结束时会有大量空间碎片产生,这一点是由CMS使用”标记-清除“算法实现的。

 

 

G1收集器:

特点:

并行与并发:G1能充分利用多CPU,多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

分代收集:分代概念在G1中仍然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是它能够采用不同的方式去处理新创建的对象和旧对象以获取更好的收集效果。

空间整合:G1从整体上看是使用”标记-整理“算法实现的收集器,从局部上看是基于”复制“算法实现的。这两种算法都不会产生内存空间碎片。

可预测停顿:G1的可停顿模型能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。

 

G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代,但新生代和老年代不再是物理隔离的了,他们都是一部分Region的集合。

 

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

 

由于一个对象分配在某个Region中,它并非只能被Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系,但是又不可能进行全堆扫描来判断对象的存活性。

所以在G1收集器中,虚拟机采用Remembered Set来避免全堆扫描,其他收集器的新生代和老年代之间也可能存在引用关系,他们也是采用这种方法避免全堆扫描的。

G1中的每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region中,若是,便通过CardTable把相关信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证即使不对全堆扫描也不会遗漏。

G1收集器运作过程:

初始标记

并发标记

最终标记

筛选回收(对各个Region的回收价值和成本排序,根据用户所期望的GC停顿时间来制定回收计划)

对象内存分配与回收策略

内存分配,往大方向上讲,就是在堆上分配(也有可能经过JIT编译后被拆散为标量类型并间接的栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下可能会直接分配在老年代中。

 

对象优先在Eden分配:

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

虚拟机提供了-XX:+PrintGCDetials这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出时输出当前内存各区域分配情况。

大对象直接进入老年代:

所谓的大对象,是指需要大量连续存储空间的Java对象,最典型的就是那种很长的字符串及数组。经常出现大对象容易导致内存中还有不少空间时就触发垃圾收集以获取足够内存空间。

虚拟机提供了一个-XX:PretenureSizeThreShold参数,令大于这个设置值的对象直接在老年代分配。这样做避免了在Eden区以及两个Survivor区之间发生大量的内存复制。

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

虚拟机给每个对象定义了一个年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,就将被移动到Survivor空间,并且对象年龄设为1,。对象在Survivor区中每”熬过“一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度,就将会被晋升到老年代中。可以通过-XX:MaxTenuringThreshold设置对象晋升至老年代的年龄阈值。

动态对象年龄判断:

虚拟机并不是永远地要求对象年龄必须达到MaxTrnuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保:

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的,如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,则继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果是,就尝试进行一次Minor GC(失败进行Full GC),如果不是,或者不允许担保失败,改为进行一次Full GC。

 

 

 

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值