Java堆内存与GC

  • 前言

开设JVM系列以后,就一直打算写写GC这块的事情。总体上来说这块也算是比较偏理论,很多时候是看的时候理解了,一段时间以后又忘记了,所以打算不仅写写理论还会写上一些调优的相关例子,这样会加深印象。

  • 堆内存区域划分

我们由GC的角度来进行堆内存的区域划分:新生代老年代。而新生代又可以划分为Eden区SurvivorFrom区SurvivorTo区。而整个区域的比例应该是新生代比老年代为1:2,而Eden比SurvivorFrom比SurvivorTo为8:1:1

堆内存区域划分.png

说完堆内存的区域划分以后,我们再给出关于设置堆内存的相关参数大小的VM命令:

VM堆的相关参数 描述
-Xms 设置JVM启动时堆的初始化内存大小
-Xmx 设置JVM的堆最大可用内存大小
-Xmn 设置新生代的空间大小,剩下的为老年代的空间大小(-Xmn 是将NewSize与MaxNewSize设为一致)同下面两个参数-XX:NewSize=XXXm与-XX:MaxNewSize=XXXm
-XX:PermGen 设置永久代内存的初始化大小(1.8以后就没有永久代了,用元数据空间代替)
-XX:MaxPermGen 设置永久代的最大值(1.8以后就没有永久代了,用元数据空间代替)
-XX:MetaspaceSize 元数据空间初始化大小
-XX:MaxMetaspaceSize 元数据空间最大
-XX:SurvivorRatio 提供Eden区和survivor区的空间比例。比如,如果年轻代的大小为10m并且VM参数是-XX:SurvivorRatio=2,那么将会保留5m内存给Eden区和每个Survivor区分配2.5m内存。默认比例是8
-XX:NewRatio 提供年老代和年轻代的比例大小。默认值是2
-Xss Stack(栈)内存大小设置(每个线程都会产生一个栈。在相同物理内存下,减小这个值能生成更多的线程。如果这个值太小会影响方法调用的深度)
-XX:MaxTenuringThreshold 设置新生代代对象进入老年代的年龄(设置垃圾最大年龄。如果设置为0的话,则新生代对象不经过Survivor区,直接进入老年代。对于老年代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则新生代对象会在Survivor区进行多次复制,这样可以增加对象再新生代的存活时间,增加在新生代即被回收的概论)

通过上面的参数命令,大家可以尝试的进行相关JVM的内存设置,然后根据不同的设置来模拟各种OOM的情况。


  • 对象在内存中的分配
1.对象被创建以后,进行内存分配的流程

首先会尝试是否能直接分配到栈上空间(这个跟JIT的逃逸分析有关),如果不能则再次尝试能否分配到TLAB上(本地线程分配缓冲区,存在与Eden区域),如果不能则对对象进行大小判断,如果是大对象(指的是占据了一个大量的连续内存空间的对象,如数组)则直接进入老年代如果不是大对象则直接进入新生代里的Eden区域

关于本地线程缓冲分配区域(TLAB)

由于堆内存是线程共享区域,在每次为对象进行内存空间分配的时候需要加锁操作,可以知道的是这个操作的开销是比较大的。所以针对这种情况,Sun Hotspot JVM为了提高对象内存分配效率,会为每个线程在堆内存区域开辟一个专属各个线程的缓存分配区域(Thread Local Allocation Buffer),在这个区域进行对象内存分配的时候是不用加锁的,所以效率都是很高的。但如果对象过大的话则仍然是直接使用堆空间分配。TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

2. 几种内存分配策略
  • 对象优先被分配到Eden区域
  • 如果是大对象,则直接分配到老年代区
  • 根据对象的年龄(每经历一次GC,则存活下来的对象的年龄+1),如果年龄超过标准(默认是15),则对象进入老年代。
  • Survivor区域里的相同年龄数的对象数量超过了Survivor区域里可容纳对象的数量的一半,则该些对象进入老年代。
  • 对象分配空间担保机制
    在执行一次minorGC的时候会检查一下老年代连续可用的内存空间是否大于新生代所有对象的大小(防止新生代全部晋升对象到老年代),如果大于则表示本次minorGC是安全的。如果不是,则会进行一次预测,根据以往minorGC结束后新生代活下来的对象的平均数大小是否超过了老年代最大连续空闲空间,如果没超过则表示虽然minorGC有风险但是还会执行,如果超过了则启动majorGC(fullGC)对老年代进行GC回收腾出空间以方便给新生代做空间担保。

分配担保是老年代为新生代作担保。由于新生代中使用“复制”算法实现垃圾回收,老年代中使用“标记-清除”或“标记-整理”算法实现垃圾回收,只有使用“复制”算法的区域才需要分配担保,因此新生代需要分配担保,而老年代不需要分配担保。

3. 对象内存分配的俩种方法

为对象进行内存空间分配的任务,其实就是将一块确定大小的内存空间划走一片。

  • 指针碰撞
    假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)

  • 空闲列表
    (CMS这种基于Mark-Sweep算法的收集器) 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表


  • 关于GC

什么是GC?字面意思解释就是垃圾回收。在我们Java里面,当对象创建好以后,我们是不需要关心对象的回收工作的,由JVM虚拟机会自动帮我们去回收这些对象,而JVM能这样做的原因就是因为这个GC垃圾回收机制。

1. 确定对象为垃圾的2种算法
  • 引用计数法
    Jvm会为每个对象进行引用计数,如果一个对象被引用了这计数加1,如果该引用被释放了,则计数减1,当计数为0的时候则表示该对象可以被回收。缺点:无法识别循环引用的对象。
    给出例子:
public class ReferenceCountingGC {
    public Object instance;
    public ReferenceCountingGC(String name){}
}

public static void testGC(){

    ReferenceCountingGC a = new ReferenceCountingGC("objA");
    ReferenceCountingGC b = new ReferenceCountingGC("objB");

    a.instance = b;
    b.instance = a;

    a = null;
    b = null;
}

1. 定义2个对象
2. 相互引用
3. 置空各自的声明引用

循环引用.png

我们可以看到,最后这2个对象已经不可能再被访问了,但由于他们相互引用着对方,导致它们的引用计数永远都不会为0,通过引用计数算法,也就永远无法通知GC收集器回收它们。

  • 可达性分析算法
    根据GCroots来作为探索起点来探索到对象之间是否有可到达的路径,如果没有路径这表示该对象是不可达(不可达对象不代表是可回收对象在这之间会有俩次标记过程,俩次标记以后任然是可回收对象则才是可回收对象)


    可达性算法.png

哪些是GC roots?
根据JVM规定只有虚拟机方法栈上和本地方法栈上引用的对象和方法区中类的静态属性引用的对象以及方法区中常量引用的对象

如何找到GC roots?
通过采用一个OopMap的数据结构来记录系统中存活的“GC Roots”,在类加载完成的时候,虚拟机就把对象内什么偏移量上是什么类型的数据计算出来保存在OopMap,通过OopMap就可以找到堆中的对象,这些对象就是GC Roots。而不需要一个一个的去判断某个内存位置的值是不是引用。这种方式也叫准确式GC

2. GC的分类

GC可以被划分为minorGCmajorGC。第一个是用来处理新生代区内的对象回收的GC,第二个是用来处理老年代和永久代(jdk8以后就没有永久代了)区内的对象回收的GC。至于Full GC网上众说纷坛我看了好几篇博客大概感觉fullGC应该可以理解为minorGC+majorGC。

堆内存中何时触发GC进行工作的?
minorGC 当Eden区的大小满了以后会触发minorGC来进行工作;
majorGC 当old区满了以后会触发majorGC来清理old区的对象,或者当老年代无法为新生代提供空间担保的时候则会触发majorGC来清理老年代对象为新生代腾出空间。

3. 垃圾回收算法
  • 标记清除算法(mark-sweep)
    最基础的回收算法,在标记阶段对可回收对象进行标记,在清除阶段对被标记的对象所占空间进行回收。缺点:内存碎片化严重,可能会导致大的对象找不到内存空间
  • 复制算法(coping)
    将内存空间分为大小相等的俩块,当其中一块内存空间已经满了的时候,将不可回收的对象复制到另一块内存空间中,然后将原先满了的内存空间清理干净,再次使用内存空间进行分配的时候就使用另一块内存空间。这样的好处是避免了上述算法会产生大量的内存碎片空间,缺点:每次只能使用一半的内存,而且如果存活对象比较多的情况会导致复制算法效率下降(大量的存活对象需要进行复制迁移)

  • 标记压缩算法(mark-compact)
    综合以上俩种方法的缺陷,提出第三种回收算法,标记阶段与标记清除算法是差不多的,然后标记结束不是立即回收,而是将存活的对象移动到内存的一端,然后再清除端边界外的对象。好处:解决了上述俩种方法的内存空间碎片化严重、内存空间利用不足的问题。

  • 分代收集算法
    分代收集法是目前大部分 JVM 所采用的方法,老年代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。新生代都采取复制算法,老年代采取标记压缩算法

为什么堆内存要分区?
是为了更好的对堆内对象进行回收工作,在新生代中针对对象的朝生夕死特性,选择使用复制算法来进行GC工作,因为存活的对象少所以复制算法的效率很高。在老年代中采用的是标记压缩法来进行,因为存活的对象比较多如果采用复制算法则会导致效率很低。

4. 几种垃圾收集器
  • Serial收集器(新生代的垃圾收集器)
    单线程、采用复制算法的垃圾收集器。它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程(STOP THE WORLD),直到垃圾收集结束。
    Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,是jvm运行在client模式下的默认采用的新生代垃圾收集器。
    通过-XX:+UseSerialGC参数启用
  • ParNew收集器(新生代的垃圾收集器)
    与serial垃圾收集器唯一的不同在于它是多线程下的采用复制算法的垃圾收集器。但对于单核的设备来说,需要进行线程之间的切换,效率反而没有单线程的高。默认开启的线程数等于cpu的数量。Jvm运行在server模式下默认采用的新生代垃圾收集器。
    通过-XX:ParallelGCThreads参数限制收集的线程数,-XX:+UseParNewGC参数启用

  • Parallel Scavenge收集器(新生代的垃圾收集器)
    与PN相同的是,同样使用复制算法,也是一个多线程的垃圾收集器。与PN不同点在于它关注程序是否到达了一个可控制的吞吐量(CPU计算用户代码消耗时间/cpu总消耗时间)主要追求高效的CPU计算,追求快速完成计算任务。(适用于在后台运算不需要太多交互的任务)
    通过参数-XX:MaxGCPauseMillis设置最大GC的停顿时间和-XX:GCTimeRatio 设置吞吐量的大小。-XX:+UseParallelGC参数启用

另外,可以通过-XX:+UseAdaptiveSizePolicy参数开启自适应调节策略,这样可以免去我们自己设置堆内存的一些细节参数,比如新生代内存大小,Eden与Survivor之间的比例等等。这个参数适合对内存手工优化存在困难的时候使用,他能监控系统当前的状态,动态的调整以达到最大的吞吐量

  • Serial Old垃圾收集器(老年代的垃圾收集器)
    单线程下采用标记整理算法来实现。也是在client模式下使用的默认老年代垃圾收集器。在server模式下作为CMS并发收集模式失败的情况下备选方案。

  • Parallel Old垃圾收集器(老年代的垃圾收集器)
    是SO的多线程版本。同时也保证了老年代的吞吐量与PS在新生代里保证吞吐量的功能是一致的。

  • CMS垃圾收集器(老年代的垃圾收集器)
    多线程下采用标记清除算法来实现的。主要是为了追求最短的回收停顿时间来快速响应服务。
    步骤:
    初始标记:需要进行STW的,但仅仅只是标记GC Roots能够直接关联的对象(并不是死掉的对象哦~),由于有OopMap的存在,因此该步骤速度非常快。如图,其中蓝色底纹的便是能够直接关联的对象。

    初始标记直接关联对象.png

    并发标记:这步是不需要STW的,不需要!他和我们的主程序线程共同执行,从上一步被标记的对象开始,进行可达性分析组成“关系网”。由于不需要进行SWT,所以该步骤不会影响用户体验。既然不暂停线程,大家是不是又怕回收了不该回收的对象?为了避免这个问题,因此就有了第三步。
    重新标记:针对并发标记过程中因为程序继续运行,导致标记发生变化的那部分对象进行标记修正,需要暂停所有的工作线程
    并发清除
    清除GC roots里不可达对象,和用户线程一起工作不需要暂停工作进程。由于四个阶段里耗时最长的并发标记和并发清除都不需要暂停工作进程,所以可以认为CMS的垃圾回收是和用户线程一起并发进行的。
    优点:并发收集垃圾,低停顿性。
    缺点:由于采用标记-清除算法所以会产生内存碎片,对于浮动垃圾没办法收集。(当在并发清除垃圾的时候,也就是第四步的时候,他是与当前主线程并发执行的,因此他在回收的时候,我们的主线程又会产生新的垃圾,而这些垃圾在这次回收过程已经回收不了了,只能等待下一次回收了。这些垃圾又叫做“浮动垃圾”)

  • G1垃圾收集器(作用范围为整个堆的垃圾收集器)
    相对于CMS垃圾收集器来说,G1收集器采用标记-压缩算法不会产生内存碎片。而且能更加精准的控制停顿时间,实现低停顿垃圾回收。(主要是采用了分区,针对每个区的垃圾收集进度情况来在后台维护一个优先级列表,每次根据所允许的收集时间,优先选择垃圾回收最多的区域)通过-XX:MaxGCPauseMills设置有限的收集时间。

垃圾收集器是不能随意组合的,现在给出垃圾收集器互相的组合图:


垃圾收集器组合使用.png

至此,就把堆内存与GC相关的东西说完了,有点浅显,很多都是泛泛而谈。后面我会专开一篇文章来实战分析。

推荐阅读更多精彩内容