第3章 垃圾收集器与内存分配策略

222 阅读12分钟

第一节:浅谈对象死亡

一、概述

  • 每个栈帧的分配多少内存,在class结构确定下来的时候就已经确定大小。
  • java堆,只有在运行时才能确定其大小。

二、引用计数算法

  • 概述:实现简单,判定效率高。采用该算法的python,AS。同时,因为引用计数算法无法解决对象的循环引用问题,所以jvm并没有采用。
  • 给对象添加一个引用计数器,每当有一个地方引用该对象,计数器就+1,每当引用失效就-1,一直减到0

三、可达性分析算法

  • 概述:当前主流jvm采用
  • 可达性分析算法示意图

1、引用链

  • GC Roots对象作为起点,从节点向下搜索,搜索走过的路径称为引用链。

2、回收条件

  • 当一个对象没有任何一个引用链,到达GC Roots的时候,这个对象就会被回收。(GC Roots形成一颗引用树,当对象不再这课树上的时候,这个对象就会被回收)

3、四种GC Roots:

  • 栈帧局部变量表中reference类型指针,保存该对象地址
  • 方法区class静态属性中对obj有引用(静态属性是类属性,故生命周期会和class相同)。
  • 常量池中对obj有引用(常量池和class生成的对象无关)。
  • native中对obj有运用。

4、强软弱虚

  • 强引用,只要有引用,GC就永远不会回收
  • 软引用,有用但非必需对象。在OOM之前回收。
  • 弱引用,非必需对象。只能活到下一次GC之前。
  • 虚引用,完全无法获取该对象,他存在的唯一目的就是被GC之前,会得到一个通知。

四、死亡判定

  • 概述: 判定一个obj死亡至少经过两次标记(GC Roots引用链上没有该对象的引用)。
  • 使用可达性分析,进行死亡判定示意图

1、两次标记过程

  • 第一次发现GC Roots引用链上没有该引用的时候,进行第一次标记,并筛选是否有必要执行finalize():
    • 无必要
      • 对象覆盖finalize()方法
      • 虚拟机调用过finalize()方法
    • 有必要
      • 标记到F-Queue队列中,由低优先级线程Finalizer执行。
    • finalize()更多的是对C/C++程序员的妥协,方便他们理解。

五、方法区回收(hotspot永久代回收)

1、废弃常量(针对方法区中的常量池)

  • 常量池中类(接口)、方法、字段的符号引用,在系统中没有任何引用的时候,就会造成回收。

2、无用的类(牵扯类的卸载,后面详述)

  • 该class所有实例都已经被回收
  • 加载该class的classLoader已经被回收
  • 该类在方法区中class对象(访问方法区中class相关信息的入口)没有被应用,也没有办法被reflact
  • 虚拟机对满足上述三个条件的class进行回收。Node:
    • 不是和对象一样不使用以后必然回收。
    • 大量使用动态class加载框架以后,造成方法区大量class存在,防止溢出。(大量使用反射,动态代理,动态生成jsp,频繁自定义classloader)

第二节:JVM回收算法概述

  • 概要:主要针对垃圾算法回收基础描述,奠定了后续serial、parNew、parallel scavenge、CMS、G1、serial Old、parallel Old算法基础

    • 标记-清除算法(cms)
    • 复制算法
    • 标记-整理算法(serial、parNew)
    • 分代收集算法

一、标记-清除算法(Mark-sweep)

  • Node:后续的收集算法都是基于标记-清除算法完成的

1、实现原理

  • 标记
  • 在两次判定对象死亡的过程中, 就已经对对象进行了标记(即在F-Queue队列)
  • 清除
  • 标记完成后统一清除。

2、存在问题

  • 效率问题,标记和清除效率都不高
  • 空间问题,标记清除以后,会造成很多不连续的内存空间。尤其是在给大对象分配内存时,情况尤其复杂,可能会引起再一次触发GC。

二、复制算法(Copying)

  • Node:为了解决标记-清除算法中的效率和空间问题(单个大对象空间不足)。

1、实现原理

  • 1、将内存按等比大小分成两块,每次只用其中一块
  • 2、一块用完后,将该块中存活的对象,copy到另一块中

2、存在问题

  • 浪费空间,有一半的空间在处于闲置状态
  • 需要老年代进行空间担保
  • 对象存活率高的情况,会造成大面积复制

3、Node

  • 目前主流虚拟机新生代均采用复制算法。
  • IBM研究表明在新生代中98%的obj,都是立即死亡,所以内存不必按照1:1分配
  • hotspot中按照Eden:Survivor:Survivor=8:1:1,每次使用Eden+Survivor,当触发一次GC就把这两块中的存活obj复制到剩下的一块Survivor中。
    • 但有可能剩下的一块Survivor不足以存放存活obj的时候,就需要老年代进行分担
    • 这里有个小知识:obj的年龄是存在Header中的,一般默认是15岁进入老年代

三、标记-整理算法(Mark-compact)

1、实现原理

  • 标记:仍然使用两次标记过程,对象标记放入F-Queue队列中。
  • 整理:存活obj向一段移动,然后清除边界之外的,不在GC Roots引用链上的对象。
  • 对于新生代而言,效率并不是那么高
  • serial,parNew,serial Old、parallel Old

四、分代-收集算法

1、实现原理

  • 根据对象存活周期,讲内存分成几块
  • 新生代:采用复制-收集算法(每次有大批对象死去,只要付出少量存活对象的复制成本)
  • 老年代:采用标记-复制算法(对象存活率高,没有担保空间)

第三节:HotSpot算法实现概述

1、枚举根节点

  • Problem:
    • 遍历时间:遍历GC Roots整条引用链(尤其是方法区很大时),时间复杂度很高
    • 一致性:在执行遍历GC Roots整条引用链的过程中,停止所有java线程,保证引用关系不会混乱。
      • 包括号称永不停止的cms收集器在枚举根节点的时候也要暂停。
      • sun把这个过程叫做Stop-The-World
  • 解决方案:
    • OopMap数据结构存放对象引用。
      • jvm 创建obj的时候, 计算了数据类型。
      • JIT编译,也会在安全点记录栈和寄存器那些位置是引用。

2、安全点(SafePoint)

  • HotSpot通过OopMap精确快速完成GC Roots枚举。
  • Problem:为每条字节码指令都在OopMap中记录对应信息,很浪费内存
  • 解决方案:
    • 安全点:安全点记录obj引用,运行到SafePoint的时候,触发GC
    • GC不会随时随地Stop-The-World,HotSpot在安全点位置停止
  • GC发生时,HotSpot不会主动操作停止所有线程。
    • 设置一个标志位,当线程轮训该标志位遇到true,就主动挂起
    • 标志位和SafePoint重合

3、安全区域(SafeRegin)

  • Problem:线程在sleep或者block情况下,没法响应jvm的中断flag
  • 解决方案:
    • 安全区域:一段代码片段中,引用关系不会发生变化。任何地方都可以开始GC,其实就是拓展了safePoint
    • 当线程离开SafeRegin时,检查是否完成GC Roots枚举。完成线程继续执行,未完成等待GC完成。

4、总结

  • HotSpot通过OopMap数据结构,当中的安全点以及安全区域,触发由所有线程,自动触发GC

第四节:GC收集器

概述

  • 新生代(young generation):
    • Serial
    • ParNew
    • Parallel Scavenge
  • 老年代(Tenured generation)
    • Serial Old
    • Parallel Old
    • CMS
  • 全部适用
    • G1
  • 没有最好的收集器,要根据实际情况进行相应的选择

1、serial收集器

  • 特点:
    • 复制算法
    • 单线程收集器,暂停所有工作线程(Stop-The-World)
    • 简单高效(相对其他收集器的单线程而言),没有交互开销
    • Client模式下虚拟机没有过多内存分配,Cpu核数较少的情况下,表现不错

2、serial Old收集器

  • 特点:
    • 标记-整理算法
    • 老年代收集器
    • 单线程
    • serial老年代版本
    • 在jdk1.5以前与Parallel Scavengepehe,以及CMS备选方案
  • serialserial Old收集器和示意图

3、ParNew收集器

  • 特点:
    • 复制算法
    • serial收集器的多线程版本
    • 在Server模式下新生代收集器
    • 多线程中唯一能配合CMS收集器使用(serial单线程也可以)
    • 单核不如serial,双核不保证,多核情况下表现可以
    • 默认线程数和CPU核数相同
  • ParNew收集器示意图

4、Parallel Scavenge

  • 特点:
    • 复制算法
    • 多线程收集器
    • 达到可控制的吞吐量
      • 吞吐量=运行用户代码时间/(运行用户代码时间+)
    • 高吞吐量可以高效利用CPU,适合后台应用
    • 缩短GC的停顿时间,会降低吞吐量和减小新生代空间。
    • 如果不设置参数,虚拟机可以自动收集性能监控,自动调节停顿时间和吞吐量大小

5、Parallel Old

  • 特点:
    • 标记-整理算法
    • 多线程
    • Parallel Scavenge的老年代版本(1.6出现)
    • 这个组合完成了"吞吐量优先"

6、CMS(Current Mark Sweep)

  • 特点:

    • 唯一标记-清除算法收集器(划时代)
    • 多线程
    • 获取最短停顿时间为目标的收集器,并发收集,低停顿
  • 运行过程:

    • 初始标记

      • stop-the-world,标记GC Roots能到达的对象,速度快
    • 并发标记

      • 就是进行GC Roots Tracing的过程
    • 重新标记

      • stop-the-world,修正并发标记阶段,程序继续运行,导致obj标记变动,比初始标记时间长,比并发标记时间短很多
    • 并发清除

    • 两个并发阶段,都可以和用户线程一起工作,整体看,CMS内存回收过程是和用户线程并发执行。

  • 缺点:

    • 对CPU资源敏感
      • 并发占用CPU造成吞吐量降低。程序反应慢。
      • CMS默认启动(cpu+3)/4条线程,所以在cpu低核尤其是低于4核的情况下,CMS占用资源极其明显,用户程序执行缓慢
    • 无法处理浮动垃圾
      • CMS并发清理,清理过程中,用户线程也在不停的产生垃圾。而这部分垃圾,CMS当次无法处理,只能留给下次。
    • 空间碎片
      • 当没有连续内存空间分配给大对象的时候,不得不提前触发一次Full GC
  • CMS收集器示意图

7、G1收集器

  • 特点:
    • 面向服务端垃圾收集器
    • 并行与并发。充分发挥多核优势缩短Stop-The-World时间。
    • 分代收集。不需要其他收集器配合,能独立管理整个堆。能根据不同情况采用不同算法
    • 空间整合
      • 整体看是标记-整理算法,局部看是复制算法
      • 在这两种算法都能提供连续空间,为大对象分配,不用在触发GC
    • 可预测停顿时间
      • 和CMS比的另外一个优势,G1建立了可预测的时间停顿模型。
      • 允许设置M毫秒的时间片段,GC停顿不能超过M毫秒
  • G1在整体内存结构上并没有区分新生代和老年代。
    • 将整个堆,划分成了多个大小相等的区域(小区域划分新生代和老年代),而不是和以前的收集器一样进行物理隔离。
    • 每个region中都有对应的rememberSet
  • G1建立可预测的停顿时间模型
    • 避免整个java堆垃圾收集
    • 根据各个Region中,垃圾回收价值(获得空间大小和回收所需要时间长短)
  • G1避免全堆扫描
    • 每个region中都有对应的rememberSet
    • jvm发现程序对reference类型进行写操作时,会暂时中断写,检查 reference是不是出于不同的Region之间(分代算法中就是检查老年代是否引用了新生代obj),如果是就把信息记录到被引用的Region区域的RememberSet中,当GC Roots根节点枚举时,就把RememberSet加入到根节点就保证不会对整个堆扫描也不会有遗漏
  • 运行过程:
    • 初始标记
      • stop-the-world,仅标注GC Roots能关联到的对象
    • 并发标记
      • 可达性分析,能与用户线程并发
    • 最终标记
      • 需要停顿线程,可并行执行
    • 筛选回收
      • 根据用户设置停顿时间,最优回收
  • G1收集器示意图

第五节:内存分配和回收策略

  • java内存自动管理:obj内存分配、obj内存回收

一、内存分配

1、优先在Eden空间分配

  • 当Eden空间不足时,触发一次Minor GC
  • 这时新生代中,两个survivor一个是上一次是存活对象,一个是下一次要预留的复制空间
  • 新生代GC(Minor GC):新生代obj生命很短Minor GC很频繁,速度很快
  • 老年代GC(Major GC):老年代obj生命很长,比新生代的Minor GC慢10倍以上

2、大对象直接进入老年代

  • 大对象:连续存储空间的java对象,最典型的就是很长String和byte[]
    • 生命周期很短的大对象,会给老年代回收造成很大压力

3、对象年龄

  • 分代思想
    • 第一次进入Eden空间,被移动到Survivor空间(假如空间一直足够,没有直接进入老年代),每熬过一次Minor GC,年龄就+1,一直加到15岁,就会进入老年代
  • 动态对象年龄
    • 如果survivor空间中,相同年龄所有对象大小的总和大于survivor空间的一半。年龄大于或等于该年龄的对象直接进入老年代

4、空间分配担保

  • 发生新生代GC之前(Minor GC之前),检查老年代空间是否大于所有对象总空间(个人感觉只要大于Eden就可以)
    • 如果大于,就是安全的,那么进行Minor GC
    • 如果不大于,就是危险的:
      • 允许担保失败,检查老年代最大连续可用空间是否大于历次晋升到老年代的对象大小平均值,如果大于就冒险进行Minor GC,如果小于就Major GC
      • 不允许担保失败,那就major GC