java中的垃圾收集器和内存分配策略

27 篇文章 0 订阅
4 篇文章 0 订阅

引用计数法

概述:在对象中添加一个引用计数器,每当有一个地方引用它时候,计数器值就加1,当引用失效时候,计数器值就减一,任何计数器为0的对象就是不可能被使用了。

引用计数器的缺点:当两个对象相互引用的时候,引用计数器就无法回收他们。

 

可达性分析算法

思路:通过一系列称为“GC Roots”的根节点作起始点集,从这些节点开始,根据引用向下搜索,搜索的过程称为引用链,如果某个对象到GC Roots间没有任何引用链相连,或者用图论来说就是GC Roots到这个对象不可达时候,则证明这个对象是不可再使用的,因此将他们判定为可回收的对象。

可固定为GC Roots的对象包括以下几种

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 在本地方法栈中JNI所引用的对象
  • 在方法区中类静态属性引用的对象
  • 在方法区中常量引用的对象
  • Java虚拟机内部的引用,如基本类型的Class对象,一些常驻的异常对象
  • 所有被同步锁持有的对象
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调等

GC Roots集合并不是固定的,根据用户所选用的垃圾回收器以及当前回收的内存区域不同,还有其他对象的临时性地加入,共同构成完整的GC Roots集合。

 

 

引用

上述两种算法无一例外都提到引用,判断对象是否存活和引用离不开关系。

强引用:只要强引用关系还在,垃圾收集器就永远不会回收掉被引用的对象。

软引用(SoftReference):只要被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。

弱引用(WeakReference):用来描述那些非必须对象,被弱引用关联的对象只能活到下一次垃圾收集发生为止。

虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间造成影响。

 

 

生存还是死亡?

死亡需要两次标记:

1、可达性分析后发现没有与GC Roots相连接的引用链。

2、有没有必要执行finalize()方法(判断有没有必要执行的依据:对象有没有覆盖此方法,此方法是否已经被虚拟机调用过)。

finalize()方法如果被判定为要执行,那么它就会被放入F-Queue队列中,并在稍后一条由虚拟机自动建立的,低度优先级的Finalizer线程去执行它们的finalize()方法。finalize是对象唯一能自救的方法,这种自救方法只有一次,因为一个对象的finalize()方法最多只会被系统调用一次。不建议使用这个方法。

 

 

回收方法区

方法区的垃圾收集比较苛刻。

方法区的垃圾收集主要回收两部分:废弃的常量和不再使用的类型。

回收常量和回收java堆对象类似,没有任何字符串对象引用常量池中的常量,那么这个常量就会被移出常量池。

判断一个类型属于不再被使用的类:该类的所有实例已经被回收、该类的类加载器已经被回收、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

 

 

 

垃圾收集算法

 

分代收集理论

三个分代假说

(1)弱分代假说:绝大多数的对象都是朝生夕灭

(2)强分代假说:熬过越多次垃圾收集过程的对象就越难消亡

(3)跨代引用假说:跨代引用相对于同代引用来说仅占少数

在新生代上建立一个全局数据结构,这个结构把老年代分为若干小块,标识出老年代哪一块内存会存在跨代引用。

 

一些关键词:

部分收集(Partial GC)

新生代收集(Mijor GC/Young GC)

老年代收集(Mijor GC/Old GC)

混合收集(Mixed GC)

整堆收集(Full GC)

 

标记-清除算法

缺点:执行效率不稳定,Java堆中包含大量对象,而且其中大部分是需要被回收的,标记和清除这两个过程效率随着对象数量增长而降低。内存空间碎片化问题。

 

标记-复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块用完了,就将还存活的对象复制到到另一块上面,然后把使用过的内存空间一次清理掉,而且每次针对整个半区进行内存回收,分配内存的时候也就不需要考虑有空间碎片的复杂情况

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

应用:新生代的回收

更优化的半分区复制分代策略,称为Apple式回收。做法是将新生代分为一块较大的Eden空间和两块较小的Survior空间,每次发生垃圾收集的时候,将Eden和Survior中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清除掉Eden和已用过的Survivor区。HotSpot虚拟机默认Eden和Survivor的大小比列为8:1,所以新生代的每次可用内存空间是整个新生代容量的90%。但是没法保证每次回收只有少于10%的对象存活。所以Apple回收有种逃生门的安全设计,当Survivor空间不够容纳存活的对象,需要依赖其他内存区域(老年代)进行分配担保。

 

标记-整理算法

标记-复制算法在对象存活率较高的时候要进行较多的复制操作,效率会降低。如果不想浪费50%的空间,就需要额外的空间进行分配担保。老年代的存活率都很高,很明显在老年代是不适合使用标记-复制算法的。

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。

移动存活对象,在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方是一种极为负重的操作。

但是不移动对象的话,又会导致空间的碎片化问题。这样又得依赖更为复杂的内存分配器和内存访问器。

基于上述两点,是否移动对象都存在弊端,移动则内存回收会更复杂,不移动则内存分配会更复杂。

标记-整理算法会使得垃圾收集的时候时间停顿时间变长,而标记-清除算法会使得整个系统的吞吐量下降。

和稀泥法:平时先使用标记清除算法,先容忍碎片空间的存在,到一定程度的时候再调用标记整理算法。例如 CMS收集器遇到碎片过多的时候就是采用这种操作。

 

 

HotSpot的算法细节

 

根节点枚举

到现在为止所有收集器再根节点枚举这一步骤都是必须暂停用户线程的。根节点枚举始终必须在一个能保障一致性的快照才能得以进行。

现在主流Java虚拟机使用的都是准确式垃圾收集,所以不需要一个不漏地检查完所有执行上下文和全局的引用位置。

HotSpot的解决方案,是一组使用称为OopMap的数据结构来达到这个目的。类加载动作完成,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来。在即时编译中也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器就并不需要一个不漏地从方法区等GC Roots开始查找

 

 

安全点

OopMap可以准确快速地完成GC Roots枚举。引用关系如果发生变化,如果为每一条指令都生成OopMap,那么需要大量的额外空间。

安全点:就是在特定的位置记录这些对象引用信息。安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。

安全点需要考虑的问题是如何在垃圾收集的时候让所有的线程都跑到最近的安全点,有两种方法

1、抢先式中断:抢先式中断不需要线程执行代码主动去配合,在垃圾收集发生时,系统先把所有的用户线程全部中断,如果发现有用户线程中断的位置不在安全点上,就恢复这条线程执行,让它跑到安全点再中断(注:没有任何虚拟机采用此方法)

2、主动式抢断:当垃圾收集需要中断线程的时候,不直接对线程操作,而是设置一个标志位,各个线程再执行过程中会不断地去轮询这个标志,如果发现中断标志为真,就自己再最近安全点上主动中断挂起。

 

安全区域

使用安全点设计的问题:安全点机制保证程序执行时在不太长的时间内就会遇到可进入垃圾收集的安全点。但是程序不执行的时候呢?所谓程序不执行就是没有分配处理器时间,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己。在这种情况下,就必须引入安全区域来解决

安全区域指能够确保在某一段代码片段中,引用关系不会发生变化,在这个区域中任意地方开始垃圾收集都是安全的。

 

 

记忆集和卡表

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

一些可供记忆集选择的记录的精度:

1、字长精度:每个记录精确到一个机器字长,该字包含跨代指针

2、对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针

3、卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针

上述第三种卡精度的方式被称为卡表,这是目前最常用的一种记忆集实现方式

记忆集只是一种抽象的数据结构,而卡表是记忆集的一种具体实现,卡表最简单的形式可以是一个字节数组,实际上HotSpot虚拟机也确实是这样做的

注:上图来自《深入理解Java虚拟机》 中的图片

 

 

 

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个对象的字段存在跨代指针,就将对应卡表的数组元素的值标识为1,称这个元素变脏(Dirty),没有则标识为0.在垃圾收集的时候,只需要筛选出卡表中变脏的元素。

 

写屏障

如何维护卡表的数据呢?如何在对象赋值的那一刻去更新维护卡表呢?

HotSpot虚拟机中是通过写屏障技术来维护卡表状态的。

写屏障可以看作在虚拟机层面对引用类型字段赋值这个动作的AOP切面,在引用对象赋值的时候会产生一个环形通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。

除了写屏障的开销外,卡表在高并发场景下还面临伪共享问题。何为伪共享,现代中央处理器的缓存系统中是以缓存行为单位存储的,当多线程修改互相独立的变量的时候,如果这些变量恰好共享同一个缓存行,就会彼此影响,使得效率较低。

为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有卡表当该卡表元素未被标记过的时候才将其变脏。

 

并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能进行分析,这意味着必须全程冻结用户线程的运行

白色:表示对象尚未被垃圾收集器访问过

黑色:表示这个对象已经被垃圾收集器访问过,且这个对象的所有引用都已经被扫描过了

灰色:表示对象已经被垃圾收集器访问过,但这个对象至少存在一个引用还没被扫描

注:上图来自《深入理解Java虚拟机》 中的图片

 

解决并发扫描时候对象消失问题有两种方案:增量更新和原始快照

当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

*赋值器插入了一条或多条从黑色对象到白色对象新引用

*赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

增量更新破环的是当黑色对象插入新的指向白色对象的引用关系的时候,将这个新插入的引用记录下来。等扫描结束后,再将这些记录过的引用关系中黑色对象为根,再扫描述。

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

 

 

经典的垃圾收集器

概念了解

并行:描述的是多条垃圾收集线程之间的关系。通常默认此时的用户线程是出于等待状态

并发:描述的是垃圾收集器线程与用户线程之间的关系,同一时间内垃圾收集器线程和用户线程都在运行。

 

Serial收集器

背景:最基础,历史最悠久。

工作要求:进行垃圾收集时,必须暂停其他所有的工作线程,直到他收集结束

社会地位:依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单高效。额外内存消耗最小

 

ParNew收集器

背景:是Serial收集器的多线程并行版本,除了使用多条线程进行垃圾收集之外,其余的基本和Serial收集器一致。

社会地位:除了Serial收集器之外,目前只有它能与CMS收集器配合工作。现如今ParNew和CMS彼此之间只能相互搭配使用了,换句话说ParNew合并入了CMS.

可以通过-XX:ParallelGCThreads参数来限制垃圾收集的线程数

 

CMS收集器

背景:真正意义上支持并发的垃圾收集器,让垃圾收集线程和用户线程能够同时工作。

社会地位:作为老年代的收集器 无法和新生代收集器Parallel Scavenge配合工作。

 

G1收集器

社会地位:是一个面向全堆的收集器,不需要其他新生代收集器的配合工作,它的出现使得从JDK9 ParNew和CMS就不再是官方推荐的服务端模式下收集器的解决方案了。

 

Parallel Scavenge收集器

背景:新生代收集器,基于标记-复制算法,也是能够并行收集的多线程收集器。

特点:关注的点和其他收集器不同。CMS等收集器关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而此收集器的目标是达到一个可控制的吞吐量。

吞吐量 = 运行用户代码时间/(运行用户代码时间+运行垃圾收集时间)

Parallel Scavenge收集器有两个参数用于精确控制吞吐量

1、-XX:MaxGCPauseMillis参数:控制最大垃圾收集停顿时间

2、-XX:GCTimeRatio参数:直接设置吞吐量大小

3、-XX:+UseAdaptiveSizePolicy,这是一个开关 打开Parallel Scavenge的自适应调节策略开关

 

Serial Old收集器

背景:Serial Old是Serial收集器的老年代版本,是一个单线程收集器。

存在意义:供客户端模式下的HotSpot虚拟机使用。服务端下作为CMS收集器的后备预案

 

Parallel Old收集器

背景:Parallel Old 是 Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

意义:此收集器化解了Parallel Scavenge收集器的尴尬,它的出现使得“吞吐量优先”的收集器名副其实

 

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS非常符合基于浏览器的B/S系统的应用

背景:CMS收集器是基于标记-算法实现的。

工作四部曲:

  1. 初始标记:需要Stop the World,仅仅标记下GC Roots能直接关联到的对象
  2. 并发标记:从GC Roots直接关联对象开始遍历整个对象图的过程,这个时耗较长,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  3. 重新标记:需要Stop the World,是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。在这里的停顿时间通常会比初始标记时间稍长一些
  4. 并发清除:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以和用户线程同时并发的

 

优点:CMS是一款优秀的收集器,最主要的优点是:并发收集和低停顿

缺点:

1、在并发阶段,虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量

2、CMS无法处理浮动垃圾。在CMS的并发标记和并发清理阶段。用户线程还在继续运行,程序在运行自然就会伴随新的垃圾对象不断产生,但这一部分垃圾是出现在标记过程结束以后,CMS无法在当次收集中处理掉他们,这一部分就成为浮动垃圾。

因为在垃圾收集阶段用户线程还在继续运行,那就还需要预留足够的内存空间提供给用户线程使用。不能等到老年代完全被填满再进行垃圾收集。可以通过设置-XX:CMSInitiatingOccu-pancyFraction这个参数来提高CMS的触发百分比,但是这个值也不能设得太高,太高的话会有可能导致并发失败,这个时候虚拟机会冻结用户线程执行,采用Serial Old收集器来重新进行老年代的垃圾收集。

3、标记-清除算法的通病,就是会导致大量空间碎片的产生。

 

Garbage First收集器(G1)

背景:开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,主要是面向服务端应用的垃圾收集器。

特色:跳出局限,可以面向对内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。

关键:Region堆内存布局,G1把连续的Java堆划分成多个大小相等的独立区域,每个Region都可以根据需要扮演新生代的Eden、Survivor空间、或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。

技能:Region中的Humongous区域,专门用来存储大对象,只要超过region容量一半的对象就被判定为大对象。region的大小可以通过-XX:G1HeapRegionSize来设定。

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。

在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法笔者已经抽出独立小节来讲解过(见3.4.6节):CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的,原始快照搜索能够减少并发标记和重新标记阶段的消耗。

跨Region引用对象如何解决?

使用记忆集避免全堆作为GC Roots扫描,G1收集器上的每个Region都维护有自己的记忆集。记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在那些卡页的范围内。G1记忆集在存储结构上本质是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。

垃圾收集的时候,G1怎么处理对新创建对象的分配?

G1为每个Region设计了两个名为TAMS的指针,新分配的对象地址都需要在着两个指针的位置上。如果内存回收的速度赶不上内存分配的速度,那么G1收集器也要被迫冻结用户线程执行。

G1收集器的运作过程大致可划分为以下四个步骤:

  1. 初始标记:仅仅标记下GC Roots能关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个时候需要停顿线程。
  2. 并发标记:从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆的对象图,找出要回收的对象。此过程和用户线程并发执行。扫描完成后还需要重新处理STAB记录下在有并发时引用变动的对象
  3. 最终标记:对用户线程做另外一个短暂的暂停,用于处理并发阶段结束后仍遗留下的少量SATB记录
  4. 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分的Region的存活对象复制到空的Region,再清理掉整个旧Region的空间,这里涉及到存活对象的移动,所以必须暂停用户线程。

缺点:

1、G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外负载都要比CMS高。

2、G1的卡表实现复杂,而且每个region都有一份卡表,这导致G1的记忆集可能会占整个堆容量的20%乃至更多的内存空间。

3、G1的写屏障操作比较复杂,其实现类似于消息队列的结构,把写前屏障和写后屏障要做的事放到队列中,再异步处理

 

内存分配策略

 

对象优先在Eden分配

当Eden没有足够空间进行分配的时候,虚拟机将发起一次Minor GC

 

大对象直接进入老年代

-XX:PretenureSizeThreshold参数,大于此设置值的对象直接在老年代分配

 

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

-XX:MaxTenuringThreshold参数设置年龄

 

动态对象年龄判定

并不是年龄到达-XX:MaxTenuringThreshold设置的参数才能到达老年代,如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代。

 

空间分配担保

发生Minor GC之前,虚拟机需要检查老年代最大连续可用空间是否大于新生代所有对象总空间。条件成立的话,那么这一次的Minor GC则是安全的。

条件不成立的话就需要老年代担保了,把Survivor无法容纳的对象直接送入老年代,前提是老年代还有足够的空间去容纳这些,如果没有的话将进入Full GC

 

注:参考《深入理解java虚拟机》第三版

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值