6.理解垃圾收集

6.1 标记和清除

标记和清除算法(mark and sweep)

image

  1. 循环遍历已分配链表,清空标记位
  2. 从GC根开始,寻找活跃对象
  3. 在到达的每一个对象上设置一个标记位
  4. 循环遍历已分配链表,对于每个标记位尚位设置的对象: a. 回收堆中内存,将其放回空闲链表 b. 从已分配链表中移除该对象

活跃对象按照深度优先来定位,生成的图叫活跃对象图(live object graph) ,也称为可达对象的传递闭包(transitive closure of reachable object)

垃圾收集术语

  • 全部停顿 – 在进行垃圾收集时用户线程会停止
  • 并发 – 垃圾可以在应用程序线程运行的时候运行
  • 并行 – 多个线程执行垃圾收集
  • 精确 – 有足够的类型信息
  • 保守 – 缺乏精确模式的类型信息
  • 移动 – 对象在内存中的位置可能变化
  • 压缩 – 已分配的内在被组织成一个单一的连续的内存区
  • 疏散 – 已经收集区域完全为空

6.2 Hotspot运行时

java的两种类型值

  1. 基本类型
  2. 对象引用

java是纯粹的按指引用(call by value)

Java是用偏移操作符来访问字段和调用对象引用的方法。

6.2.1 对象的运行时表示

Hotspot用oop的结构表示运行时java对象,即 普通对象指针(ordinary object pointer)

oop的继承结构:

oop(抽象基类)
|-instanceOop(实例对象)
|-methodOop(方法的表示)
|-arrayOop(数组的抽象基类)
|-symobolOop(内部符号/字符串类)
|-klassOop(klass头部,只在Java7以更早的版本里)
|-markOop

oop的每一个对象上都有两个机器字的头。

  1. 第一个是mark word,是一个指针,指向该实例的元数据;
  2. 第二个是kclass word,指向同级别的元数据;

Java7 及之前的版本里,kclass word 指向名为PermGen的内存区,是堆的一部分,需要一个相应的对象头 在这种情况下,把元数据据为klassOop

Java8 及之后的版本中,kclass 被保存在堆的主要内存之外,不需要有对象头。

表示运行时对象时一个指针指向类级别的元数据,一个指针指向实例级别的原数据是一种常见的设计形式

6.2.2 GC根和Arena

GC根—-内存的“锚点”,从内存区外指向一个内存区。

与之相对的是内部指针,从内存区内指向同一个内存区内的另一个区域。

垃圾收集器是以内存区域的方式工作的,这里的内存区统称为Arena

6.3 分配与生命周期

垃圾收集行为由两个驱动因素:

  1. 分配率—-新创建的对象在一个时间段内所使用的内存量,通常会用Mb/s来计量;
  2. 对象生命周期

弱分代假说

  1. JVM 和类似的软件的中,对象生命周期表现为双峰分布—-大部分对象生命周期很短,次一级的对象寿命长于预期
  2. 有极少数从老年代指向新生代的引用

HotSpot利用弱分代假说 1 的机制:

  1. 跟踪每个对象的年龄(即代数,熬过的垃圾收集次数)
  2. 除大对象外,在Eden(也叫Nursery)空间中创建新对象
  3. 维护一个单独的内存区域(Old Gerneration / Tenured Generation) 保存已经存活足够长而且很有可能继续存活下去的对象

应对弱分代假说2,hotspot 维护了一个叫卡表card table的结构,用以记录哪些老年代对象可能指向新生代对象

6.4 Hotspot 中的垃圾收集

  1. 新生代(Eden)内存分配以线程本地分配进行;
  2. 收集为结合Survivor空间进行半空间收集

    6.4.1 纯程本地分配

线程本地缓冲区(thread-local allocation buffer,TLAB)

  • 将eden分为若干个缓冲区
  • 将缓冲区交给应用程序线程使用
  • 每个线程不用考虑其他线程

6.4.2 半空间收集

半空间疏散式收集器(hemispheric evacuation collector)

  • 使用两个空间
  • 将空间作为实际寿命比较短的临时对象存储区

好处

  1. 防止短寿命对象把老年代弄乱
  2. 降低整堆收集的频率

空间的基本属性

  1. 当收集器在收集当前活跃的半空间时,对象会被以压缩方式移到另一半空间,完成收集的一半空间会被清空以等待利用
  2. 任何时间,总有一半空间是空的

Hotspot用这种方法,结合Eden空间为新生代提供收集器 Hotsopot新生代堆的半空间部分被称为Survivor空间

6.5 并行收集器

在JAVA8之前,默认的收集器是并行收集器,并且新生代和老年代的收集都是全部停顿(STW)

主要有如下:

  • Parallel GC —- 用于新生代的简单垃圾收集
  • ParNew —-与CMS配合使用的并行垃圾收集变种
  • ParalleOld —- 用于老年代的并行收集器

6.5.1 新生代并行收集

场景

当一个线试图向Eden空间中分配对象,但是TLAB中已经没有足够空间

步骤

  1. 停止应用线程
  2. 查看新生代(Eden+非空Suvivor)的非垃圾对象;
  3. 用卡表查找老年代对新生代的引用;
  4. 将所有的幸存对象疏散到非空的Survivor空间中,同时存活次数加一
  5. Eden和疏散过的Survivor空间标记为可用空间
  6. 启动应用线程

缺点

public static void main(String[] args){
    int[] anInt = new int[1];
    anInt[0]=42;
    Runnable r = ()->{
        anInt[0]++;
        System.out.println("Chanded: "+anInt[0]);
    }
    new Thread(r).start();
}
  • TLAB 是单个线程私有的,只有在分配时才成立;
  • 这个环境分配后就会被销毁;

6.5.2 老年代并行收集

ParallOld是一个连续内存压缩的收集器

缺点

  • 老年代空间在堆中占大部分(大小默认为新生代的7倍)
  • 随着堆大小不断增加,暂停时间变得糟糕

6.6 分配的作用

堆参数

区域 大小
总体 2G
老年代 1.5G
新生代 500Mb
Eden 400Mb
Survivor1 50Mb
Survivor2 50Mb

垃圾收集指标

  • 内存分配率 100 Mb/s
  • 新生代垃圾收集时间 2ms
  • 整堆收集时间 100ms
  • 对象生命周期 200ms

分析

Eden空间填满的时间:

400Mb / 100Mb/s = 4s

此时产生第一次清理,因为对象生命周期是200ms,故要保留的内存大小:

400Mb / ( 4s / 200ms) = 20Mb

因此

GC0 @ 4s 20Mb Eden -> Survivor1 

即: GC0 发生在第4秒,将 20Mb 内存从 Eden 疏散到 Survivor1

过了4秒后,进行第二次收集;

GC0 @ 8s+2ms 20Mb Eden -> Survivor2 

即: GC1 发生在第8秒,将 20Mb 内存从 Eden 疏散到 Survivor2 2ms为第一次收集的耗时

第三次:

GC2 @ 12s+4ms 20Mb Eden -> Survivor1

问题

  1. 因为对象存活时间是200ms,在这个场景里没有对象进入老年代

另一种场景

如果Eden中已经分配了200Mb,同时分配锋值到达,另外200Mb在200ms分配,虽然对象的存活时间都是200ms,而且最近分配的对象存活时间刚到100ms,但此时JVM只能让对象进行进入老年代。 但

GC0 @ 2.2s 100Mb Eden -> Tenured(100)

按此类推:

GC1 @ 2.602s 200Mb Eden -> Tenured(300)
GC2 @ 3.004s 200Mb Eden -> Tenured(500)
GC3 @ 7.006s 20Mb Eden -> Survivor1(20)

如果分配率过高,会将对象最终晋升到老年代,称为过早晋升(premature promotion),这是垃圾收集器的间接效应之一,也是调优的起点