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

-

垃圾收集器概述

  • 垃圾收集(Garbage Collection,GC)在1960年诞生于MIT的Lisp第一门真正使用内存动态分配和垃圾收集技术的语言
  • 程序计数器,虚拟机栈,本地方法栈都是随线程而生,随线程而灭,所以没有垃圾回收
  • java堆和方法区实现GC,一个接口中的多个实现类需要的内存都不一样,一个方法中的多个分支需要的内存也可能不一样,程序在运行中才会知道创建哪些对象,所以创建和回收都是动态的,GC所关注的是这部分内存

    对象存活判断

    在GC回收前要判断哪些对象是”存活”,哪些“死去”,可以回收
  • 引用计数法(Reference Counting)
    • 给对象添加一个引用计数器,没有一个地方引用它,计数器加1,当引用失效,计数器减1,任何时刻计数器值为0时对象就是不可能在被使用了
    • 此法实现简单,效率高,微软的COM(Component Object Model)技术,使用ActionScript3的FlashPlayer,Python语言和在游戏脚本领域被广泛应用的Squirrel
    • 难以解决对象之间相互循环引用的问题
  • 可达性分析算法(Reachability Analysis)
    主流程序语言(Java,C#,古老的Lisp)都使用它
    • 通过一系列“GC Roots”的对象作为起始点,从这些节点开始往下搜索,所经过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连,则证明此对象是不可用的(即被回收)
    • 可做GC Roots:虚拟机栈(帧栈中的本地变量表)中的引用对象,方法区中的类静态属性引用的对象,方法区中的常用引用的对象,本地方法中的JNI(即Native方法)引用的对象

      回收方法区

  • 很多人认为方法区是没有垃圾收集的,java虚拟机规范中说过不要求虚拟机中在方法区实现垃圾收集
  • 方法区中垃圾收集的效率非常低,在堆中,特别是新生代,常规的一次垃圾收集可回收70%~95%的空间,而方法区远低于此
  • 方法区垃圾收集主要是2部分内容:废弃常量和无用的类
    • 废弃常量和java堆中的对象非常相似,如果一个字段没有任何对象引用,则将回收
    • 无用的类的判定(以下仅仅是“可以”,不像java对中的对象实例一样,一定被回收)
      • 该类所有的实例都被回收,也就是java堆中不存在改类的任何的实例
      • 加载改类的ClassLoader已经被回收
      • 该类对于的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法
    • 在大量使用反射,动态代理,CGLib等ByteCode框架,动态生成JSP 以及OSGi这类频繁自定义ClassLoader的场景需要虚拟机具备类卸载的功能,以保证方法区不溢出

      垃圾收集算法

  • 标记——清除算法(Mark-Sweep,最基本的回收算法)
    • 标记所有需要回收的对象,然后统一回收所有被标记的对象
    • 缺点:1.效率不高,2,.容易产生大量的不连续的内存碎片,在分配大大对象的时候,无法找到足够的连续内存,会触发垃圾收集回收
  • 标记——整理算法
    • 对标记——清除算法的改进,在标记后,让所有存活的对象向一端移动,然后直接清理掉标记内容,以保证不产生不连续的内存碎片
  • 复制算法
    • 将可用内存划分为大小相等的两块,每次使用其中的一块,当一块内存用完,就将存活对象复制到另外一块上面,然后将使用过的那块一次清除掉
    • 缺点:内存缩小,花费太大
    • IBM研究表明新生代对象98%是可被回收的,所以不用采用1:1的比例分配内存,将内存分配为一块较大的Eden两块较小的Survivor,每次使用一块Eden和Survivor,回收时将Eden和Survivor存活的对象复制到另一块Survivor内存上,HotSpot虚拟机默认Eden:Survivor=8:1
  • 分代收集算法(Mark-Compact,目前最主流)
    • 根据对象存活的不同周期,将内存划分为几块,根据各个特点选择最合理的收集回收算法

      HotSpot算法实现

  • 枚举根节点(可达性分析中GC Roots节点引用链这个操作为例)
    • 可作为GC Roots的节点在全局性的引用(比如常量或类静态属性)与执行上下文(例如帧栈中的本地变量表),很多应用在方法区有100+M以上,如果每个引用都检查,会消耗大量时间
    • 可达性分析对执行时间的敏感体现在GC停顿,分析工作必须保持在一致性的快照中,即在分析期间系统冻结在某一时刻,导致GC执行时必须停到所有的java线程(“Stop The World”) ,
    • 目前主流java虚拟机使用准确式GC,当执行系统停下来后,不需要检查全部的执行上下文和全局引用位置,虚拟机有办法知道哪些地方存放了对象
    • 在HotSpot中,使用一组叫OopMap的数据结构在类加载的时候,就把对象内什么偏移量是什么类型的数据计算出来,在JIT编译过程,也会在特定的位置下记录下栈和寄存器中哪些位置是引用,在GC扫描时,就可以直接得到哪些地方存放了对象了
  • 安全点(SafePoint)
    通过OopMap可以快速且准确的完成GC Roots枚举,但可能导致引用关系变化,或者OopMap内容变化的指令非常多,若每条指令都有一个OopMap,那将需要大量的额外空间
  • HotSpot不会为每一条指令生成OopMap,它只会在安全点来记录这些信息
  • SafePoint 的选定不能太少也不能太频繁导致运行时的负荷增大,它的选定是以程序“是否具有长时间的执行的特征”的标准而选定的,长时间最明显的特征就是指令序列复用(方法调用,循环跳转,异常跳转),从而产生SafePoint
  • GC Roots如何让所有线程停下来(不包括JNI调用的线程),然后执行到安全点,有2种方法
    • 抢断式中断(Preemptive Suspension),在GC 发生时,把所有线程中断,然后把没有达到安全点的线程恢复运行直到到达安全点(此方法以不在使用)
    • 主动式中断(Voluntary Suspension),在GC 需要中断线程时,不直接对线程进程操作,仅仅设置一个简单的标志(安全点位置),各个线程执行时主动轮询此标志,发现中断标志时,自己中断挂起
  • 安全区域(Safe Region)
    • 当线程进入Sleep状态或者Blocked状态,线程无法响应JVM中断请求,JVM不会等到线程被分配Cpu时间然后运行到安全点,所以我们把这样一种状态就表示为线程进入安全区域
    • 当线程进入安全区域会把自己标示为进入Safe Region,当GC行为发生时,就不用管进入安全区域的线程,当线程要脱离Safe Region状态时,就需要检查GC是否完成,若没完成,要等待他完成后,才可以继续

      垃圾收集器(JDK 1.7 Update 14之后的HotSpot虚拟机)

      垃圾收集器是内存回收的具体实现,java虚拟机对垃圾收集器没有任何规定,因此不同厂商,不同版本的垃圾收集有很大的差别
  • Serial收集器
    • 最基本,最古老的收集器
    • 单线程收集器,并不仅仅是只使用一个Cpu或一个线程去收集,而它收集的时候回暂停所有的工作线程,知道它收集结束
    • 它仍然是虚拟机运行在Client下的默认新生代收集器,因为它简单而高效(与其他单线程相比),在单线程下,Serial没有线程交互的开销,在用户桌面级下,一般虚拟机管理的内存不会太大,所以它的效率依然 高效
  • ParNew 收集器
    • ParNew为Serial收集器的多线程版本,除此之外没有太多创新之处
    • 是运行在Server模式下的虚拟机首选新生代收集器
    • 除了Serial收集器外,唯一能与CMS收集器配合工作的收集器
    • 单个CPU,ParNew没有Serial效果好,2个CPU也无法保证100%比Serial效果好,以为存在在线程的交互
  • Parallel Scavenge收集器(“吞吐量优先”收集法)
    *与ParNew一样是多线程新生代收集器,使用复制算法,但是它更关注一个可控制的吞吐量,即CPU运行用户代码时间与CPU总消耗时间的比值(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),运行100分钟,垃圾收集1分钟,吞吐量=99%
    • 此方法会根据运行情况收集性能监控信息,然后动态调整参数以提供最合适的停顿时间或者最大吞吐量,这种调节叫做GC自适应调节策略(GC Ergonomics)
  • Serial Old收集器
    • 是Serial收集器的老年代版本,单线程收集器,“标记——整理算法”
    • 用于Client模式下的虚拟机使用
    • 在Server模式下,2大功能:1.在JDK1.5之前是与Parallel Scavenge配合使用,2.做为CMS收集的后备预案
  • Parallel Old收集器
    • 是Parallel Scavenge收集器的老版本,使用多线程和“标记——整理算法”
    • 若在注重吞吐量以及CPU资源敏感的场合,可以优先考虑Parallel Scavenge和Parallel Old组合
  • CMS (Concurrent Mark Sweep)收集器(并发低停顿收集器)

    • 是一种以获取最短回收停顿时间的收集器,用“标记——清除算法”,收集步骤
      • 初始标记(CMS initial mark),需要“Stop The World”,标记GC Roots能直接关联的UI小,速度很快
      • 并发标记(CMS concurrent mark),需要“Stop The World”,进行GC Roots Tracing的过程
      • 重新标记(CMS remark),修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象,比初始标记时间长,远比并发标记时间段
      • 并发清除(CMS concurrent sweep),并发消除标记的对象
      • 因为用时最长的并发标记和并发消除都可以与用户线程一起工作,所以停顿低
    • CMS收集器对CPU资源很敏感(面向并发设计的程序对CPU资源都非常敏感),在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分CPU资源而导致应用程序变慢,所以当CPU数量太少,就会导致用户程序执行速度直线下降,为了解决此问题,虚拟机提供“增量式并发收集器”(Increment Concurrent Mark Sweep/i-CMS),所用单CPU时的抢占式来模拟多任务机制思想,交替GC线程与用户线程,但是方法效果一般,所以已经不提倡用户使用
    • CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”,失败后会导致Full GC(老年代回收)产生,因为CMS并发清理时用户线程还在进行,所以会不断的产生新的垃圾,这一部分垃圾无法在此次清理,只有下一次才能处理他们,所以需要预留内存空间给用户线程使用,所以需要设置一个百分比来触发Full GC,在jdk1.5为老年代使用的68%,在jdk1.6,已经上升为92%,如果在不满足此条件时,出现“Concurrent Mode Failure ”会启动预备方案:临时启用Serial Old收集器对老年代垃圾进行收集
    • CMS是“标记——清除算法”,所以会有大量的空间碎片产生,这样就会导致老年代有很大空间,但是没有一块连续足够大的空间来分配当前对象,而触发Full GC,所以当CMS 有一参数(UsCMSCompactAtFullCollection)来停顿在要Full GC时开启碎片合并整理
  • G1收集器

    • 从JDK 6u14中就开始就提供给开发人员实验,试用,到JDK7u4,才正式使用
    • G1收集器是面向服务端的垃圾收集器,主要目的是在未来可以替换CMS收集器
    • 与其他GC收集相比,有以下不同:
      • 并行与并发:G1收集器能够利用多CPU,多核环境下的硬件优势,使用多个CPU来缩短Stop The World停顿的时间,部分收集器需要停顿java线程来进行GC动作,但G1可以通过并发让java线程继续执行
      • 分代收集:G1可以独立的管理整个GC堆,它可以采用不同的方式去处理新创建的对象和已经存活了一段时间的对象
      • 空间整合:G1从整体是基于“标记——整理算法”的收集器,从局部(2个Region之间)是基于“复制算法”,但是这2种算法都不会产生空间碎片,所以不会因为创建大对象而找不到空间触发GC
      • 可停顿的预测:这是G1相对于CMS的一大优势,降低停顿时间是G1和CMS共同目标,但G1还能够建立可预测的停顿时间模型,能让使用者指定一个长度为M毫秒的时间内,垃圾收集上不得超过N毫秒,这几乎是实时Java(RTSJ)的垃圾收集器特征了
  • java堆的内存布局与其他收集器有很大的差别,它将整个java堆划分为多个大小相等的独立区域(Region),保留着新生代和老年代的概念,新生代和老年代不再是物理隔离,他们都是一部分Region(不需要连续)的集合
  • G1收集器建立可预测的停顿时间模型,是因为他能够避免在java堆中进行全区域的垃圾收集,G1跟踪各个Region里面的垃圾堆积的价值大小(回收获得的空间大小以及回收需要的时间),,在后台维护一个优先列表,每次根据回收价值最大的Region(Garbage—First的由来),这可以保证在G1在有线的时间内可以获取尽可能高的收集效率
  • java堆虽然被划分为相等的独立区域(Region),但是Region中的对象可能被其他Region中的对象引用,所以可能被整个java堆任意对象发生引用关系,所以确保对象是否存活岂不是要扫描整个java堆,在虚拟机中使用Remembered Set来避免扫描,G1中每个Region都有一个Remembered Set,虚拟机在Reference类型数据进行写操作时会有一个Write Barrier暂时中断写操作,检测tReference引用的对象是不是在不同Region之间,如果是,便通过CardTable把相关引用信息记录到被引用对象的Region的Remembered Set之中,在GC根节点的枚举范围中加入Remembered Set即可以保证不对全堆扫描也不会有遗漏。
  • 忽略维护Remembered Set 的操作,G1收集器运行时以下步骤:
    • 初始标记(Initial Marking),标记GC Roots能直接关联的对象,并修改TAMS(Next Top at Mark Start)的值,让用户程序并发运行时,能够在正确可用的Region中创建新对象,需要停顿线程,但时间很短
    • 并发标记(Concurrent Marking),进行可达性分析,找出存活的对象,时间很长,但可以与用户程序并发执行
    • 最终标记(Final Marking),修正在并发标记期间因用户程序继续运行而导致标记产生变化的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,然后最终标记把Logs中的数据整合到Remembered Set中,需要停顿线程,但是可以并执行
    • 筛选回收(Live Data Counting and Evacuation),筛选回收价值和成本Region进行排序,根据用户所期望的GC停顿时间制定回收计划,这部分可以与用户程序并发执行

      内存分配策略

      对象的内存分配,往大方向讲,就是在堆上分配(也可能进过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代Eden 上,如果启动本地线程分配,则按线程优先在TLAB上分配,也有情况在老年代分配,分配规则很多。
      下面是几种最普遍的分配方式:
  • 对象优先在Eden上分配
  • 大对象直接进入老年代
  • 长期存活对象进入老年代
  • 动态对象年龄判断
  • 空间分配担保