《深入理解java虚拟机》——垃圾收集器与内存分配策略

2019-11-23  本文已影响0人  李die喋

既然要探究GC机制,那么必须要明确几个问题:

  1. 哪些内存需要回收?
  2. 如何判断当前对象是否满足回收的标准?
  3. 如何回收?

在开始学习jvm的时候,最先要接触的就是jvm的内存管理。jvm的内存管理主要分为两大部分:一部分是内存是线程私有的一部分是线程共享的。线程私有的内存分为三大类,分别是程序计数器、java虚拟机栈、本地方法栈。线程共有的内存分为两大类,分别是java堆和方法区。线程私有的3个区域会随着线程的消亡而被回收,他们内存的分配和回收具备确定性,但是线程共有的2个区域中堆主要存放的是new出来的Java对象,只有在程序的运行期间才会知道要创建那些对象,这部分内存的分配和回收都是动态的,所以我们关注于这部分内存。

判断对象已死的方法

引用计数法

此方法是给对象中添加一个引用计数器,每当有一个地方引用它,计数器值就加一;当引用失效时就减一。任何时刻计数器为0的对象就是不可再被使用的。但是它最大的缺点就是很难解决对象之间相互引用的问题。

可达性分析法

基本思想是通过一系列的称为“GC Roots”的对象作为起点,从这些起点开始向下搜索,搜索走过的路成为引用链,当一个对象到GC Roots没有任何引用链相连时,此对象是不可用的。

可以作为GC Roots的对象有四种,在上篇文章有提到过,我觉得找例子来记理解的比较清楚。

四大引用

出现了这四种引用是有原因的。无论是引用计数法还是可达性分析,判断对象是否存活都和对象的引用有关。如果只是将引用定义为有一块引用,存放的是另一块内容的起始地址,这就略显狭隘了。我们希望出现这样的对象:当内存空间还足够时,则能保存在内存中;如果内存空间在垃圾收集机制后还是很紧张,则可以抛弃这些对象。像很多系统的缓存功能都符合这样的场景。

引用分类:

生存还是死亡

即使在可达性分析中不可达的对象,也并非是非死不可的,这时候它们都处于缓刑阶段,真正宣告一个对象的死亡,至少需要经历两次标记过程:

当对象没有覆盖finalize()方法,或者finalize()方法已被虚拟机调用过,虚拟机都把这两种情况视为没有必要执行。

什么是F-Queue队列呢?对象还可以自救么?在这之中到底发生了什么?

答:如果一个对象被判定为有必要执行finalize()方法,那么这个对象会被放在F-Queue队列中,并在稍后由一个由虚拟机自动建立的、优先级低的Finalizer线程去执行。这里说的执行是指虚拟机会触发这个方法,但并不承诺会等待它的结束。原因是这个finalize可能会发生死循环的情况或者缓慢执行,将可能会导致F-Queue队列中其他对象处于等待的状态,甚至导致整个回收系统瘫痪。(看了后面的垃圾收集器的类型,有一个垃圾收集器的类型是Serial,是单线程处理gc的,并且它执行的时候用户线程必须Stop The World,如果这时队列中的finalize方法发生了死循环怎么办,查了一些资料没有发现是怎么解决的)。

还有一个神奇的就是finalize方法是对象逃脱死亡命运的最后一次机会,如果要拯救自己只要重新与引用链的任何一个对象相关联即可。

一个对象的finalize方法都只会被系统自动调用一次,如果对象面临下次回收,它的finalize方法不会被再次执行。

垃圾收集算法

1. 标记 - 清除算法

最基础的收集算法,分为两个阶段——标记和清除。首先会标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

不足:

  1. 效率问题。标记和清除的效率都不太高。
  2. 空间问题。标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序的运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2. 复制算法

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

这是对整个半区进行内存回收,内存分配时不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。这种算法的代价是内存缩减为原来的一半。

由于新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor空间。当回收时,将Eden区和Survivor中存活下来的对象一次性的复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

当Survivor空间不够用时,需要依赖其它内存(老年代)进行分配担保。

3. 标记 — 整理算法

出现的原因:

  1. 复制算法在对象存活率较高的情况下就要进行较多的复制操作,效率会降低。
  2. 如果不想浪费50%的空间,就需要有额外的空间进行担保,以应对可能100%对象都存活下来的情况。所以在老年代中不用复制算法。

标记整理算法其实是标记出要回收的对象,然后让所有存活的对象都移向另一端,然后直接清理掉端边界以外的内存。

意思就是修改存活对象的引用,把存活对象都赶到了集中的一端区域,对端的就是需要回收的对象。

4. 分代收集算法

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

在新生代:每次收集都有大量的对象死去,只有少量的存活下来,一般选择复制算法。

在老年代:对象存活率高、没有额外的空间担保,必须使用“标记——清理”或者“标记——整理”算法来进行回收。

内存分配与回收策略

java技术体系中的自动内存管理可以归纳出解决了两个问题:

  1. 给对象分配内存
  2. 回收分配给对象的内存

对象内存的分配在大的方向上讲就是在堆上分配内存。对象主要是分配在新生代的Eden区,如果启动了本地线程分配缓冲(TLAB),将按线程优先在TLAB上分配。少数情况下也可能直接分配在老年代中,分配的规则不是确定的。产生分配不同的原因是:

内存分配策略有一下几种:

1. 对象优先在Eden上分配

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

新生代(Minor GC):指发生在新生代的垃圾收集动作,因为java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

老年代GC(Full GC/Major GC):指发生在老年代的GC,经常会伴随至少一次的Minor GC(并非是绝对的,在Parallel Scavenge收集器的收集策略中就有直接进行Major GC的策略选择过程)。老年代GC比新生代GC慢10倍以上。

大对象直接进入老年代

大对象是指需要大量连续内存空间的java对象。典型的是很长的字符串以及数组(例如:byte[]数组)。

经常出现大对象很容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来安置它们。

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

虚拟机采用了分代收集的思想来管理内存,内存回收时就必须要分清哪些对象在新生代哪些对象应放在老年代。为此虚拟机的解决方法是:给每个对象定义了一个对象年龄计数器

如果对象在Eden区出生并且经过第一次Minor GC后仍然存活,并且能被Survivor区容纳的话就放入Survivor区中,并且将年龄设为1。对象在Survivor区中熬过一次Minor GC年龄就加一,当年龄增加到一定程度(默认为15),就会被晋升到老年代中。

对象晋升到老年代的阈值,可以通过参数-XX:MaxTenuringThreshold设置

动态对象年龄判断

虚拟机并不是必须要求对象的年龄达到默认年龄(一般为15)才晋升老年代。

出现的原因:更好地适应不同程序的内存状况。

执行方式:如果在Survivor区空间中相同年龄所有对象大小的中和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

空间分配担保,在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。接下来的流程如图所示:


image

HandlePromotionFailure值代表是否允许担保失败,也就是老年代的内存空间是否可以保证再有对象放入自己的内存时是否会出现溢出。

取平均值进行比较其实是一种动态概率的手段,就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会担保失败。如果不允许担保失败的话,那就只能在重新发起一次Full GC。

虽然担保失败绕的圈子比较大,但是大部分情况还是允许担保失败的,是为了避免Full GC过于频繁。

垃圾收集器

上一篇下一篇

猜你喜欢

热点阅读