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

319 阅读15分钟

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

JVM垃圾收集能够让开发者不用再关系内存的占用与释放,专注于代码的编写,不用关系底层的处理方式,但是作为一名具有求知欲的开发者,还是有必要了解JVM是回收对象,一个对象在内存中是如何分配的,更好的操作JVM与编写代码

如何判断对象可回收

既然JVM可以自动进行垃圾回收,那么首先应该解决的是如何判断什么样的对象可以被回收

简单来讲,即当一个对象不被使用的时候,就可以被回收,那么怎么判断这个对象已经不被使用了

  • 引用计数法

    JVM给每个对象增加一个引用计数器,每当该对象被引用的时候,该计数器就加1,当引用失效时,计数器就减1,当一个对象的计数器为0的时候,就表示该对象可以被回收。

    引用计数法是一个相当简洁的算法,实现效率高,并且判定效率也高,缺点是无法解决循环引用的问题

    JVM中并没有采用引用计数法作为来作为对象是否可以回收的标准

  • 可达性分析算法

    主流的JVM中基本都是采用可达性分析算法来判定对象是否存活

    • 通过选取一系列的"GC Roots"对象作为起始对象,开始向下搜索,搜索过程中的路径称为引用链,当一个对象到GC Roots对象之间没有任何的引用链时,就表示该对象不可达,则该对象是不再被使用,可以被回收

    • GC Roots对象

      JVM中定义了某些对象可以作为GC Roots对象作为可达性分析算法的起始点,这些对象的基本特征都是生命周期比较长,不会很快被回收。

      • 虚拟机栈中引用的对象
      • 方法区中的静态变量引用的对象
      • 方法区中的常量引用的对象
      • 本地方法栈中引用的对象

Java中的引用

无论是引用计数法还是可达性分析算法,都是通过引用来判定对象是否可以回收,那么Java中的引用是什么

  • 什么是引用

    如何引用类型的数据保存的是另一块内存的地址,并不是真实的数据,那么这块内存就代表着一个引用

  • 引用的分类

    Java中的引用分为4种,强引用弱引用软引用虚引用,不同的引用类型有不同的生命周期和特征

    • 强引用

      开发过程中最普遍的引用,手动创建的对象,一般都是强引用,例如:Object obj = new Object()

      特点:只要强引用还在使用,那么强引用的对象不会被回收,就算JVM的内存不足,抛出OOM异常,也不会回收强引用对象,但是如果这个强引用对象不使用了, 那么还是会被回收的,例如在方法中定义的强引用,一旦该方法执行结束,强引用会随着方法栈的回收二被回收。、

    • 软引用

      SoftReference类实现的对象称为软引用,软引用在JVm内存足够的时候不会被回收,但是当JVM内存不足的时候,就会将软引用对象进行回收。

      特点:当JVM内存不足的时候进行回收,可以利用该特性实现相关的功能,例如本地缓存,将某段时间的数据放置缓存中,当内存不足的时候,将缓存的数据回收即可

垃圾收集算法

JVM中在不同的垃圾收集器中使用了不同的垃圾收集算法,每个垃圾收集算法采用不同的回收策略,在不同的时期使用不同的垃圾收集器选择不同的回收算法,保证垃圾收集的高效

标记-清除算法
  • 什么是标记清除算法

    标记-清除算法是最基础的垃圾收集算法,整体的过程分为2个阶段,第一阶段标记:首先将需要回收的对象进行标记,第二阶段清除:将标记的对象统一进行回收。

  • 优点:

    • 实现简单,并且可以准确进行回收对象
  • 缺点:

    • 效率较慢,标记和清除两个阶段的效率都不高
    • 产生内存碎片,在清除的过程中会产生内存碎片,那么很容易造成大对象无法分配内存,提前触发垃圾收集
复制算法
  • 什么是赋值算法

    将内存划分为相等的两块内存,每次只使用其中的一块,当这一块使用完成,那么将当前的内存中存活的对象复制到另一块没有使用的内存中,然后对当前的内存块进行一次清理

  • 优点:

    • 实现简单,并且回收效率高
    • 不会产生内存碎片
  • 缺点:

    • 将内存划分为两半,有效的内存使用率低,并且触发垃圾收集次数高
    • 如果对象的存活率较高,那么复制算法每次需要移动的对象较多,效率低

一般复制算法使用在JVM内存中的新生代(Survivor区域就是使用复制算法),因为新生代的对象的存活率较低,所以使用复制算法较为高效

标记-整理算法
  • 什么是标记-整理算法

    标记-整理算法主要是根据老年代的特点设计,标记-整理算法主要分为2个阶段,第一阶段标记:将需要回收的对象进行标记,第二阶段整理:没有标记的对象即为存活的对象,那么让存活的对象都向内存的一端移动,然后以存活对象的边界开始直接清理所有的内存

  • 优点:

    • 不会产生内存碎片,清理的效率高
  • 缺点:

    • 产生对象的移动,需要STW(stop the world)
分代回收算法
  • 什么是分代回收算法

    将内存划分为老年代和新生代,根据每个年代的对象生命周期的不同的特点采用不同的回收算法,新生代有大量的对象需要被回收,那么可以采用复制算法只需要复制少量的对象,老年代的对象生命周期长,没有额外的空间取进行划分,所以可以采用标记-清除或者标记-整理

垃圾收集器

垃圾收集器是用来做垃圾收集的工作,每个垃圾收集器采用的不同的垃圾回收算法对内存进行回收,并且垃圾收集器主要分为新生代垃圾收集器和老年代垃圾收集器,新生代的垃圾收集器和老年代的垃圾收集器需要配套使用,在JVM参数中指定使用的垃圾收集器

Serial收集器
  • 特点

    新生代的单线程收集器,采用复制算法进行垃圾回收,并且在进行垃圾回收的时候,必须暂定其他所有的工作线程,直到垃圾收集结束(STW)。所以如果回收的时间长或者触发的GC次数过多,会造成程序的长时间停顿或者处理时间慢

ParNew收集器
  • 特点

    ParNew收集器是新生代的多线程收集器,和Serial收集器类似,不过采用的是多线程回收,并且也是用复制算法,在垃圾收集期间,也需要STW,不过ParNew收集器采用的是多线程进行回收,所以垃圾收集的效率相对于Serial较高

ParNew收集器是CMS收集器在新生代使用的配套收集器,如果开启CMS收集器,那么新生代默认使用的是ParNew

通过JVM参数:-XX:+UseParNewGC选项来强制使用ParNew

Parallel Scavenge收集器
  • 特点

    新生代多线程收集器,采用复制算法,基本的策略和ParNew收集器类似。最大的区别是Parallel收集器主要目的是提高吞吐量,即提高CPU的使用率,所以如果是CPU密集型任务可以采用Parallel收集器

Serial Odl收集器
  • 特点

    Serial Old收集器是Serial收集器的老年代版本,作用于老年代采用标记-整理算法,单线程进行收集,并且在回收的时候需要将其他的所有工作线程暂停(STW)

Serial Old收集器不仅是Serial的老年代收集器,并且还可以作为CMS收集器的备用收集器

Parallel Old收集器
  • 特点

    Parallel Old是Parallel收集器的老年代版本,多线程收集器作用于老年代采用标记-整理算法策略,Parallel Old和Parallel配套使用的收集

Prallel Old和Parallel收集器都是注重于吞吐量的收集器,在CPU密集型任务中可以选择该收集器

CMS收集器
  • 特点

    CMS收集器是一种以获取最短回收停顿时间为目标的收集器,作用于老年代,多线程并发收集采用标记-清除回收算法。CMS收集器主要可以提高程序程序的响应速度,因为主要的目的是降低STW的时间

  • 回收步骤

    • 初始标记

      需要STW来进行第一次的标记,只标记GC Roots能够关联到对象,速度很快

    • 并发标记

      GC Roots tracing阶段,并发标记过程GC线程和用户是并发执行,不会有STW

    • 重新标记

      需要STW来进行准确标记,重新标记的目的是为了准确统计需要回收的对象,因为在并发标记的过程中,GC线程和用于线程并发执行,用户线程可能会再次产生垃圾

    • 并发清除

      并发清除阶段对标记的对象进行清除,在此阶段,用户线程和GC线程并发执行,不会STW,不过在此阶段可能会产生浮动垃圾,产生的浮动垃圾无法在这次GC中进行回收,只有在下次的GC阶段才会进行回收

    整体的回收过程,除了初始标记重新标记需要STW,其他的过程都是和用户线程并发执行,所以整体的STW时间是较短的

CMS收集器使用的标记-清除算法,会产生内存碎片,所以可能会多次触发Full GC,不过CMS收集器提供了-XX:+UseCMSCompactAtFullCollection,该参数可以对内存进行合并整理,防止触发Full GC,不过STW的时间就会变长

-XX:+UseConcMarkSweepGC使用CMS收集器,那么新生代默认使用的收集器是ParNew收集器,并且Serial Old收集器还会作为CMS收集器的备用收集器

因为CMS收集器和用户线程并发执行,所以当内存不足的时候,CMS收集器就无法使用,这时候就需要使用Serial Old这种单线程的收集器来进行收集

G1收集器
  • 特点

    G1收集器是JDK1.7中最新的垃圾收集器,目标是让用户控制停顿时间的多少,在有限的停顿时间中对内存进行回收的性价比达到最高。并且G1收集器的作用域是整个堆,在G1收集器中没有了物理上的新生代、老年代的概念,不划分不同年代(不过仍然有逻辑上的新生代和老年代),将整个堆分为若干个小的Region,并发的对每个堆的内存进行回收,局部采用复制算法,整体采用标记-整理算法

    • 并行与并发:G1可以充分利用多核CPU的优势,回收的时候采用并行的收集方式,降低STW时间

    • 分代收集:将不同的Region分为逻辑上的不同的年代, 根据不同年代存活对象的多少分配不同的Region,并且收集的时候也会根据不同的年代进行收集,提高收集的效率

    • 空间整合:G1局部采用复制算法,整体采用标记-整理算法,都不会产生内存碎片,防止提前触发Full GC

    • 可预测的停顿时间:G1最大的特点即可让使用者指定停顿的时间,在停顿时间内记性垃圾回收,保证程序的响应速度

      G1维护了一个不同的Region的优先级列表,每次根据允许的停顿时间对列表中的优先级最高的Region进行回收,保证G1的收集效率和STW的时间

  • 回收步骤

    • 初始标记

      对GC Roots直接关联的对象进行初始标记,需要STW

    • 并发标记

      GC线程和用户线程并发的进行标记,对GC Roots关联的对象标记

    • 重新标记

      对所有的对象记性重新标记,保证标记的准确性,需要STW

    • 筛选回收

      首先对所有的Region的优先级进行排序,根据用户设置的停顿时间来制定回收计划,GC线程和用户线程并发执行。

内存分配策略

分配规则

对象的创建就伴随着内存的分配,对象一般是分配在堆内存中(不考虑逃逸分析),因为堆在分代回收中被分为了新生代和老年代,新生代一般是存放新创建的对象,老年代一般是放置生命周期较长的对象,新生代又划分为Eden区和Survivor区,其中Eden区是新创建的对象分配之后的内存,Survivor是对象在经过一次MinorGC之后,如果还存活,那么就会进入Survivor,如果已经不可达,那么就会在Minor GC中回收该对象。

当然分配的规则并不是固定的,因为还有特殊的情况会执行特殊的分配规则,保证JVM中内存分配的合理性和更好的优化

  • 对象优先在Eden分配

    一般对象在分配的过程中,都会直接分配到Eden区,如果Eden区没有足够的内存进行分配,那么会进行一次Minor GC,如果Minor GC之后还是没有内存空间,那么会直接内存溢出

    Minor GC:新生代的GC,Minor GC触发的相对频繁,回收的速度也相对较快

    Major GC:老年代的GC,Major GC的回收效率比Minor GC更慢

  • 大对象直接进入老年代

    当创建的兑现足够大的时候,会将该对象直接分配到老年代,因为大对象如果分配在新生代,会有多次在Eden和两个Survivor之间的复制,效率慢,并且占用大量的连续内存。所以JVM规定大对象直接分配到老年代中,避免频繁的复制导致频繁的Minor GC或者复制消耗性能,而且新生代的内存比老年代的内存更小

    • 什么是大对象

      需要大量连续内存空间的对象就是大对象,例如长字符串或者大数组

    • 多大的对象会被认为是大对象

      JVM提供了参数-XX:PretenureSizeThreshold用来表示对象,超过这个配置的参数的大小的对象就被认为是大对象

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

    JVM使用了分代收集的方式管理堆内存,那么对象初始进入的是新生代,一个对象在Eden中经过一次Minor GC后如果仍然存活,那么GC年龄会加1,并且移动在Survivor中,在Survivor中继续进行Minor GC,当GC年龄达到15(默认值),那么就认为该对象是一个长声明周期对象,会被移动到老年代中存放。

    JVM提供参数-XX:MaxTenuringThreshold参数设置GC年龄的次数,移动到老年代默认的值是15

  • 动态年龄判断

    JVM对对象的分配也会有特殊的规则,并不一定需要达到15次GC之后才会进入老年代中,如果在Survivor中的相同年龄的对象的所有对象的内存大小超过了Survivor的空间的一半,那么会将年龄等于或者大于该对象的所有对象直接移动到老年代。

    这个和大对象直接进入老年代基本差不多,不过这个判断条件时针对连续创建多个对象

  • 空间分配担保

    当进行Minor GC的时候会提前进行判断老年代是否有足够大的连续空间足够容纳下新生代的所有的内存大小,如果有,那么可以进行Minor GC,如果没有,那么会查看JVM的-XX:HandlePromotionFailure参数的值是否为true,如果为false,那么会直接进行一次Full GC,如果为true,表示JVM允许担保失败,这时会判断老年代中的最大可用的连续空间大小是否大于历次晋升到老年代的对象的内存的平均大小,如果大于,那么会进行一次Minor GC,如果小于,表示老年代没有足够的内存空间,也会直接进行一次Full GC,如果Full GC之后无法回收足够得内存空间,那么会抛出OutOfMemoryError