JVM之垃圾收集与内存分配策略

1、垃圾收集器处理的事情:哪些内存需要回收,什么时候需要回收,如何回收。

(1)Java内存运行时的各个部分区域:程序计数器,Java虚拟机栈,本地方法栈,Java堆,方法区。

(2)垃圾收集器不需要考虑的内存区域:程序计数器,虚拟机栈,本地方法栈这三个内存区域随线程而生,随线程而亡;栈中的栈帧随方法的进入和退出而执行入栈和出栈操作,每一个栈帧中分配多少内存基本上是在类结构确定下来就已知的。这几个区域的内存分配和回收都具备确定性。

  1)栈帧结构:栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址。

   a、局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。

   b、操作数栈也成为操作栈,各种字节码指令后入先出,操作数栈的最大深度也在编译的时候就已经确定,不会在运行期间动态变化。

   c、动态链接:符号引用一部分会在类加载阶段(解析阶段)或者第一次使用的时候就转化为直接引用,这种转化成为静态解析,另一部分在没一次运行期间转化为直接引用,这部分成为动态连接。

   d、方法返回地址:正常完成出口和异常完成出口。

(3)不确定性的内存区域Java堆和方法区:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同分支所需要的内存可能也不一样,只有在运行期间,我们才能知道程序究竟会创建哪些对象,创建多少对象,这部分内存的分配和回收是动态的,垃圾收集器所关注的正是这部分内存该如何管理。

2、确定哪些内存需要回收?

(1)回收方法区

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

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

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

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

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

(2)引用的类型

  1)强应用:类似于Object obj=new Object(),这样的引用关系无论任何情况,垃圾收集器永远不会回收掉这样被引用的对象。如果想中断强引用与对象之间的联系,可以将强应用赋值为null即obj=null,gc认为该对象不存在引用,这时就可以回收这个对象

  2)软引用:如果内存空间足够多,一个对象被软引用,则垃圾回收器不会将其回收;如果内存空间不足,这些引用对象就会被回收。应用在缓存技术上,虚拟机可以将软引用比如网页缓存,图片缓存

  3)弱引用:被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。弱引用常用于Map数据结构中,引用占用空间内存较大的对象

  4)虚引用:无法通过虚引用来取得一个对象实例,为一个对象设置虚引用关联的唯一目的是:当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。因此虚引用可以监听重要对象的回收;也可以通过虚引用来判断gc的频率,如果频率过大,内存使用可能存在问题,才导致了系统gc频繁调用。

(3)引用计数法判断对象的状态(死or活)

  1)引用计数法判断存活对象:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减一;任何时刻计数器为0的对象是不可能再被使用的。引用计数法虽然占用了一些额外的内存空间来进行计数,但是它原理简单,判定效率高。但是无法解决循环引用的问题。

  A=B,B=A,此时A,B互相引用对方,导致它们的引用计数都不为0,引用计数法也就无法回收它们。

(4)可达性分析算法判断对象是否存活(不能判断为死)?

  1)可达性分析算法思路:通过一系列称为“GC Roots”的根对象为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程所走的路径称为“引用链”,如果某个对象到GC Roots没有任何引用链相连,则说明此对象是不可能再被引用的。

  2)GC Roots集合为:

   a、虚拟机栈中所引用的对象,如何局部变量,临时变量。

   b、方法区中静态属性引用的对象,如类变量。

   c、方法区中常量引用的对象,如字符串常量池里的引用。

   d、虚拟机内部的引用,如基本数据类型对应的Class对象。

   e、所有被同步锁(synchronized)持有的对象。

  注意:用户所选的垃圾收集器以及当前回收的内存区域不同,所以某个区域里的对象完全有可能被堆中其他区域的对象所引用,此时需要将这些关联区域的对象也加入到GC Roots中。

  3)可达性分析算法如何判断对象生存还是死亡?

   a、即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

   b、第一次标记并进行一次筛选:筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。

   c、第二次标记:如果这个对象被判定为有必要执行finalize()方法(若内存紧张,垃圾收集器会去回收对象,此时会去调用finalize()方法;若内存不紧张,就不会去回收对象,那么finalize()就不会被调用)这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象finalize()方法中执行缓慢,或者发生死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。

  注意:任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象失败面临下一次回收,它的finalize()方法不会被在再次执行。Finalize()方法是对象避免被回收的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功与引用链上的任何的一个对象建立关联如把自己赋值给某个类变量或对象的成员变量,那么在第二次标记时它将被移除出“即将回收”的集合,否则就会被回收;

3、垃圾收集算法(如何回收)

(1)分代收集理论:建立在三个分代假说之上

  1)绝大多数对象都是朝生夕灭的。

  2)熬过越多次垃圾收集过程的对象就越难以消亡。

  3)跨代引用相对于同代引用来说仅占极少数。

  根据1)和2):若一个区域中大多数对象都是朝生夕灭,把它们集中在一起,每次回收只需关注如何保留少量存活的对象;若一个区域剩下的都是难以消亡的对象,把它们集中在一起,便可以较低的频率回收该区域。在分出不同的区域后,垃圾收集器才可以每次回收其中某一个或者某些部分的区域如Minor GC,Major GC,Full GC。

  根据3)可以解决跨代引用问题:依据跨代引用假说,为了解决极少数跨代引用,只需在新生代建立一个“记忆集(Remembered Set)”,把老年代划分为若干小块,标识出哪一块内存会存在跨代引用,此后发生 Minor GC 时,只有包含跨代引用的小块内存中的对象才会被加入到 GC Roots 进行扫描(避免扫描整个老年代)。

(2)垃圾搜集算法

  1)标记-清除算法:垃圾收集算法中最基础的.主要思想是标记哪些要被回收的对象,然后统一回收,主要效率不高即标记和清除的效率都很低,还会造成产生大量不连续的内存碎片.导致以后程序在分配较大的对象时,由于没有充足的连续内存而提前触发一次GC动作.

  2)复制算法:复制算法将内存按容量将内存划分为8:1:1,较大的那份内存称Eden区,其余两块内存叫做survivor区(叫做s1和s2)。每次会优先使用Eden区,若Eden区满,就会进行youngGC就将存活对象复制到s1内存区中,然后清除Eden区(此时,s2是空白的,两个Survivor总有一个是空白的).当s1满时也会进行youngGC,将s1中存活的对象送入到s2区,当然s2区满时也会进行youngGC,将s2中存活的对象送入到s1,此时s1和s2交换角色.当在年轻代中的对象经过15此GC后会将存活的对象送入老年代.在发生youngGC时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次Full GC.

  3)标记-整理:在清除对象时将可回收对象移动到一端,这样就不会产生碎片了.回收内存时会移动对象会更复杂。

(3)新生代与老年代触发GC机制

  1)Minor GC:当年轻代(Eden和from Survivor)满时就会触发Minor GC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。

  2)Major GC又称为Full GC。当年老代空间不够用的时候,虚拟机会使用“标记—清除”或者“标记—整理”算法清理出连续的内存空间,分配对象使用。

   a、调用System.gc时,直接调用System.gc()只会把这次gc请求记录下来,等到runFinalization=true的时候才会先去执行GC,runFinalization=true之后会在允许一次system.gc()。

   b、老年代空间不足,方法区空间不足,通过Minor GC后进入老年代的平均大小大于老年代的可用内存。

   c、由Eden区、survivor space1(From Space)区向survivor space2(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

(4)GC调优的方法

  1)GC调优目的:将转移到老年代的对象数量降到最小;减少GC的执行时间。

   a、OOM:内存溢出,是指程序在申请内存时,没有足够的空间供其使用,出现了Out Of Memory即系统不能满足程序要求分配的内存,于是产生溢出。

   b、OOM的危害:内存泄露会导致内存溢出,所谓内存泄露(memory leak),是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光

  2)内存分配与回收策略

   a、对象优先在Eden分配:对象在新生代Eden区中分配,当Eden区没有足够空间进行内存分配时,虚拟机将会发起一次MinorGC

   b、大对象直接进入老年代:因为大对象容易导致内存明明还有不少空间时就提前触发GC,以获取足够的连续空间才能安置好它们,复制大对象时,大对象意味着高额的内存复制开销,HotSpot虚拟机提供了-XX:PretenureSize参数,指定大于该设置值的对象直接在老年代分配,这样做目的为了避免Eden区及两个Survivor区之间的来回复制,产生大量的内存复制操作

   c、长期存活的对象进入老年代:虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头,对象在Eden区产生,若经过第一次MinorGC后仍然存活,且被Survivor区容纳的话,会被移到Survivor区,并且将对象的年龄设置为1;对象在Survivor区中每熬过一次MinorGC,年龄就会增加1岁,当年龄增加到默认的阈值15,就会被晋升到老年代

   d、动态对象年龄判定:有时并不是等对象的年龄达到默认的阈值15才能进入老年代,而是若在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代。

   e、空间分配担保:在发生MinorGC时,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象空间,若大于,则这一次的MinorGC是安全的。若不大于,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败,若允许则会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,若大于则将尝试进行一次MinorGC,如果小于或者HandlePromotionFailure设置不允许冒险则进行一次FullGC

   冒险指的是:进行MinorGC后会有多少对象存活下来是无法明确知道的,所以只能取之前每次回收晋升到老年代对象容量的平均值作为经验值,与老年代的剩余空间进行比较,决定是否进行FullGC来让老年代腾出更多的空间。

4、垃圾收集器

(1)Serial收集器(新生代):最基础,历史最悠久

  1)单线程收集,且垃圾收集时,必须暂停其他所有的工作线程(Stop The World, STW),直到它收集结束。

  2)HotSpot 虚拟机运行在 Client 模式下默认新生代收集器。

  3)优于其他收集器的地方:简单而高效(与其他收集器的单线程比)。对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

  Serial / Serial Old 收集器运行示意图运行示意图如下:

(2)ParNew收集器(新生代):ParNew 收集器实质上是 Serial 收集器的多线程并行版本。

  除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数(-XX:SurvivorRatio, -XX:PretenureSizeThreshold、-XX:HandlePromotionFailure 等)、收集算法、Stop The World、对象分配原则、回收策略等都与 Serial 收集器完全一致。

  ParNew / Serial Old收集器运行示意图:只有Serial收集器和ParNew收集器这两种收集器与CMS收集器配合使用。

(3)Parallel Scavenge收集器(新生代)

  Parallel Scavenge 收集器是新生代收集器,也是使用标记-复制算法实现的、并行收集的多线程收集器,也称“吞吐量优先收集器”。

  与 ParNew 类似,但关注点不同:

  1)CMS 等收集器:尽可能地缩短垃圾收集时用户线程的停顿时间;

  2)Parallel Scavenge 收集器:达到一个可控的吞吐量(Throughput)

  吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),即运行用户代码时间所占比重。

  Parallel Scavenge/Parallel Old收集器运行示意图:

(4)Serial Old收集器(老年代)

  Serial 收集器的老年代版本,单线程,使用“标记-整理”算法。主要用于客户端模式下的 HotSpot 虚拟机。

  运行示意图:

  

 (5)Parallel Old收集器(老年代)

  Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,使用多线程和“标记-整理”算法实现。

  运行示意图:

  

 

(6)CMS(Concurrent Mark Sweep)收集器

  CMS(Concurrent Mark Sweep)收集器是一种以「获取最短回收停顿时间」为目标的收集器。部署在基于浏览器的B/S系统服务端上,较为关注服务的响应速度,希望系统停顿时间尽可能短,给用户带来较好的交互体验。

  它基于“标记-清除”算法实现,运作过程分为四步:

  1)初始标记:只标记 GC Roots 能直接关联到的对象,速度很快;

  2)并发标记:从 GC Roots 遍历整个对象图,耗时较长,但无需停顿用户线程(可与用户线程并发执行);

  3)重新标记:修正并发标记期间,因用户线程导致标记产生变动的标记记录;

  4)并发清除:清理删除标记阶段判断的已经死亡的对象,可与用户线程并发执行。

   在整个过程中,耗时最长的是并发标记和并发清除阶段中,运行示意图:

  CMS优缺点:

  1)优点:并发收集、低停顿

  2)缺点:

   a、对处理器资源非常敏感,降低吞吐量。

   b、无法处理“浮动垃圾”,有可能出现“Concurrent Mode Failure”失败而导致另一次完全 Stop The World 的 Full GC 的产生。

   c、CMS是一款基于“标记-清除”算法的收集器,就会产生大量空间碎片。导致无法有足够大的内存存储大对象,CMS提供参数,此参数作用要求CMS收集器在执行一定阈值不整理空间的FullGC之后,下次FullGC前会先进行碎片处理。

  CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说如果处理器核心数在4个以上,并发回收时垃圾收集器只占用不超过1/4的处理器运算资源。但是当处理器核心不足4个时,还要分出一半的运算能力去执行收集线程,这样会严重影响用户程序运行速度。

  浮动垃圾:在CMS的并发标记和并发清除阶段,用户线程还是在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生。但是这一部分垃圾是出现在标记过程结束之后的,CMS无法在当次收集中处理掉它们,只好等下一次垃圾收集再清理掉。对于浮动垃圾CMS收集器不能像其他收集器那样等待到老年代几乎填满再来进行收集,而是必须预留一部分供并发收集期间时的程序运行使用。若CMS收集器预留的内存无法满足程序分配新对象的需要,虚拟机只好冻结用户线程,临时启动Serial Old来重新进行老年代垃圾的收集。

 (7)G1(Garbage First)收集器

  G1开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。之前介绍的6个垃圾收集器目标范围要么是整理新生代或者老年代,再或者java堆,而G1可以面向堆内存的任何部分来组成回收集,不再衡量它是属于哪个分代,而是看哪块内存存放的垃圾数量最多。

  1)分区:G1也遵循分代收集理论,不再坚持固定大小和固定数量的分代区域划分,而是把连续的java堆划分为多个大小相等的独立区域,每一个区域根据需要扮演新生代的Eden空间,Survivor空间或者老年代空间。同时还有Humongous(认为老年代的一部分)区域,专门用来存储大对象,G1只要认为大小超过一个Region容量一半的对象即可判定为大对象。 

  2)回收策略:G1虽然保留分代概念,但是新生代和老年代不再是固定,它们都是一系列的动态集合。G1将Region作为单位回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,可以有计划的避免在整个Java堆中进行全区域的垃圾回收。具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的的空间大小及时间),然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region。

  3)需要解决的问题?

   a、Region里存在的跨Region引用对象如何解决:每个Region都会维护自己的记忆集,这些记忆集会记录别的Region指向自己的指针。记忆集实际上是Hash表,Key是别的Region的起始地址,value是一个集合,里面存储的是卡表的索引号。

   b、并发标记阶段如何保证收集线程和用户线程互不干扰:G1通过原始快照(SATB)算法来实现,在回收过程中,G1为每一个Region设计了两个名为TAMS(Top at mark Start)的指针,把Region中的部分空间划分出来用于回收过程中的新对象分配,新对象地址必须在这两个指针之上。如果内存回收速度赶不上内存分配速度,G1也会Full GC。

   c、如何建立可靠的停顿预测模型?

  原始快照搜索算法:不管引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

  4)G1运行步骤

   初始标记:标记GC Roots能直接关联到的对象,并且修改TAMS指针的位置,需要短暂的停顿线程。

   并发标记:从 GC Roots 遍历整个对象图,耗时较长,但无需停顿用户线程(可与用户线程并发执行),还要处理STAB记录下来的在并发时有引用变动的对象。

   最终标记:对用户线程做另外一个短暂的暂停,处理并发阶段结束后遗留下来的最后那少量的STAB记录。

   筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,然后根据用户期望的停顿时间来制定回收策略,可以选择多个Region构成回收集,然后把决定回收的那一部分的存活对象复制到空的Region中,在清理旧Region空间。涉及到存活对象的移动会暂停用户程序。

   以上四个步骤处理并发标记,其余阶段也是要完全暂停用户进程的。

   5)CMS与G1的比较

   G1可指定最大停顿时间,分Region的内存分布,按收益动态确定回收集;

   CMS是“标记-清除”,G1整体上是基于“标记-整理”,但是从局部即两个Region之间又是基于“标记-复制”,G1不会产生内存空间碎片。

   G1和CMS都是使用卡表来处理跨代指针,G1每个Region都有卡表。

   都使用到写后屏障来更新维护卡表,G1还使用写前屏障跟踪并发时的指针变化情况实现原始快照搜索算法。

posted on 2020-03-21 16:41  hdc520  阅读(169)  评论(0编辑  收藏  举报

导航