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

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

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

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

判断对象已死的方法

引用计数法

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

可达性分析法

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

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

四大引用

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

引用分类:

  • 强引用:是指在程序代码中普遍存在的,类似Object object = new Object()的这类引用。只要强引用在,垃圾收集器就不会回收这些对象
  • 软引用:用来描述一些还有用但是又不是必须的对象。对于软引用关联地对象,在系统要发生内存溢出之前,将会把这些对象列进回收范围进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。SoftReference实现。
  • 弱引用:也用来描述非必须对象。被弱引用关联的对象只能存活到下次垃圾收集之前。提供了WeakReference实现。
  • 虚引用:最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间造成影响,也无法通过一个虚引用来获得一个对象实例。为一个对象设置虚引用就是为了能在这个对象被收集器回收时收到一个系统通知——PhantomReference。(可用来跟踪对象的垃圾回收)

生存还是死亡

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

  • 第一次标记:当对象没有在GC Roots的引用链中,将会被第一次标记并且进行第一次筛选。筛选的条件是:此对象是否有必要执行finalize()方法。

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

  • 第二次标记:对象被放在F-Queue队列中,且没有自救成功,被标记肯定会被回收。

什么是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过于频繁。

垃圾收集器

  • Serial收集器
    • 是一个单线程收集器。
    • 在进行垃圾收集的时候,必须暂停其他所有的工作线程,直到它收集结束。
    • 简单高效:对于限定单个CPU环境来说,没有线程交互的开销。
  • ParNew收集器
    • 使用多条线程进行垃圾收集(Serial收集器多线程版本),使用复制算法
    • 运行在Server模式下的虚拟机中首选的新生代收集器
    • 可以和CMS收集器配合工作
    • 在多CPU的环境下,在GC时系统资源的利用率还是很高的。默认开启的收集线程数与CPU的数量相同。
  • Parallel Scavenge收集器
    • 是新生代收集器,使用复制算法。
    • 目标:达到一个可控制的吞吐量。吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
    • 自适应调节策略。意思就是Parallel Scavenge收集器在打开了-XX:+UseAdaptiveSizePolicy值后,就不需要手工指定新生代大小、Eden和Survivor区的比例、晋升老年代对象大小等细节参数了。虚拟机会根据当前运行的情况动态调整。很厉害的亚子。。。
  • Serial Old收集器
    • 是Serial收集器的老年代版本
    • 是一个单线程收集器,使用“标记——整理”算法
    • 主要意义是在于给Client模式下的虚拟机使用
    • 在Server模式下,有两大用途:在jdk1.5之前的版本中与Parallel Scavenge收集器搭配使用,另一种就是作为CMS收集器的后备预案。
  • Parallel Old收集器
    • Parallel Scanvenge收集器的老年代版本
    • 使用多线程和“标记——整理”算法
  • CMS收集器(Concurrent Mark Sweep)
    • 是一种以获取最短回收停顿时间为目标的收集器
    • 基于“标记——清除”算法,整个过程分为四步:
      • 初始标记:需要Stop The World,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
      • 并发标记:进行GC Roots Tracing过程
      • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。比初始标记时间长,比并发标记时间短。
      • 并发清除
    • 内存回收过程是与用户线程一起并发执行的
    • 三个明显的缺点:
      • 对CPU资源非常敏感。就是说cpu的个数越多开启的回收线程就越多,就会导致用户线程个数减少。CMS默认启动的回收线程数是(CPU数量+3)/4,当cpu的个数大于4以上,并发回收时垃圾收集线程不少于25%的cpu资源。
      • 无法处理浮动垃圾。浮动垃圾就是因为CMS并发清理阶段用户线程还在运行着,伴随着程序的运行就会有新的垃圾产生,这些在当前是无法清理的,只能等到下次垃圾回收再清理掉。
      • CMS是基于“标记——清除”算法实现的,收集结束会有大量碎片出现。
  • G1收集器
    • 是一款面向服务端应用的垃圾收集器
    • 特点:
      • 并行与并发
      • 分代收集
      • 空间整合
      • 可预测的停顿
    • 步骤:
      • 初始标记
      • 并发标记
      • 最终标记
      • 筛选回收

你可能感兴趣的:(《深入理解java虚拟机》——垃圾收集器与内存分配策略)