[Java JVM] Hotspot GC研究- 开篇&对象内存布局

Hotspot简介

Hotspot是openjdk的JVM虚拟机, linux发行版下默认安装的是openjdk, 而oracle 的jdk也基本是由openjdk代码编译而来, 外加上一些商业代码, 形成orcale的jdk. 由此可见, hotspot的无处不在. 现在越来越多的应用构建在java之上, 大数据的很多项目, 如Hbase, Hive, flume等等, 都可运行在hotspot之上. 此外, Android的开发用的也是java, 虽然不是运行在hotspot之上, 底下也是JVM在支撑.

所以, hotspot JVM的研究价值无疑是巨大的. 而在使用hotspot众多问题之中, 又以gc问题最为广泛和突出, 遂萌生了研究hotspot GC的想法. 为了备忘和分享, 将研究过程中的点滴, 记录至博客.

按照openjdk官网的说法, hotspot是 “the best Java virtual machine on the planet

准备资料

学习计划

  1. 基础准备, 对象布局, 指针压缩, GC Root, GC安全点等
  2. Serial GC算法 - 单核小内存环境适合的算法
  3. Parallel GC算法 - 吞吐量型算法
  4. Garbage First算法(G1) - 低延时, 同时兼顾吞吐量, 适应大内存

至于Concurrent Mark Sweep算法, 见如下JEP(JDK Enhancement Proposal)

JEP 291: Deprecate the Concurrent Mark Sweep (CMS) Garbage Collector

Summary

Deprecate the Concurrent Mark Sweep (CMS) garbage collector, with the intent to stop supporting it in a future major release.

Goals

Accelerate the development of other garbage collectors in HotSpot.

Motivation

Dropping support for CMS and then removing the CMS code, or at least more thoroughly segregating it, will reduce the maintenance burden of the GC code base and accelerate new development. The G1 garbage collector is intended, in the long term, to be a replacement for most uses of CMS.

在JDK9中, G1将作为默认选项(之前是Parallel Scavenge), 并意图取代Low Pause类型的CMS收集器, 所以CMS只做大概了解.

研究环境

  • Ubuntu 15.10 64bit
  • Hotspot 64位默认参数下的行为

第一篇-对象内存布局


对象的metadata

内存的抽象就是一个线性空间内的字节数组, 通过下标来访问某一位置的数据. 熟悉C语言的同学对C式内存应该都不会陌生, 这些背景了解一点就好, 不了解也无伤大雅, 这里就不讨论C语言的细节了.

在C语言中, 动态分配一块内存通常是使用malloc函数, 形如:

//分配一块1024字节的内存
char* pBuffer = (char*)malloc(1024);
//访问内存的内容
pBuffer[0] = 'a';
pBuffer[1] = 'b';
//释放
free(pBuffer);

在C语言中使用内存直接通过指针使用base[index]的方式访问内存的某一个Item, 指针的第一个位置直接就是buffer内存段的开始. 而对于java对象来说, 虽然经过了jvm的一层屏蔽, 把指针这个概念给隐去了, 但对象终归是要存在内存当中的. 我们知道java有各种各样的class, 在内存中分配对象时, class就是对应要分配的对象模板, 对象占多大空间, 每个字段在此空间内的偏移值, 等等信息, 都由class的定义提供. 对于GC来说, 必须知道对象占多大空间, 才好在回收时把相应的内存释放, 不然就没办法准确的管理了.

JVM的heap可以理解为一次性malloc了一大块的内存, 比如1G等, 然后由自己管理内部对象的分配. 由于回收需要知道对象占多大空间, 所以在分配对象时, 除了对象本身我们看得见的字段外, 还需要对象的描述信息, 这就是对象的metadata. 直觉来看, 只要在对象buffer的头几个字节中保留一份对应的class信息即可,确实如此. 来看代码:

//hotspot/src/share/vm/oops/oop.hpp
class oopDesc {
 //....
private:
  volatile markOop _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;
 //....
}

在hotspot中对象指针称为oop(我也觉得很怪异, 貌似可以解释为ordinary object pointer), 而oopDesc则是对象头的结构. 除了预想中的Klass(之所以叫kclass是因为class是C++关键字)指针外, 还由一个_mark字段, 是因为除了对象的class信息以外, 还有一些对象信息需要保留, 比如GC年龄, 锁状态等.

对于其中的_klass是存在于union类型的_metadata中的, 我们知道union类型的分配是按成员最大的那个进行分配的, 然后对这块内存的解释取决于代码中使用的是其中哪个字段.

typedef juint  narrowKlass; -> typedef uint32_t juint;

为什么要这么写呢, 从narrowKlass中可以窥得一二, 之所以叫narrow, 小的意思. 因为64位环境下, 寄存器是64位的, 对应指针也就成64位了, 也就是8字节. 我们知道4字节可以表示4G, 实际中基本不会有需要加载这么多对象的情况, 因此8字节就显得浪费了, narrowKlass只使用4个字节, 预分配给_metadata的8字节中的另外4字节就可以用做他用了. 看似4个字节无关紧要, 但是堆中存在上千万到亿个对象时, 省下的内存就是几百兆啊.

另外一个字段:

typedef class   markOopDesc*                markOop;

指针类型, 8字节.

总结以上, 对象头默认情况占16字节, 在开启压缩对象指针时(通过-XX:+UseCompressedClassPointers), 占12字节, 默认状态是开启的.


对象的成员

介绍完了对象头, 接下来就是对象的成员了. 对于原始数据类型:

  • long / double - 8 bytes
  • int / float - 4 bytes
  • short / char - 2 bytes
  • byte/boolean - 1 bytes
  • reference type - 4 or 8 bytes

对于对象引用, 最直接的方式就是存对象的指针了, 这样可以方便的操作对象的各部分内容. 不过又回到64bit的问题, 64bit能表达的数量实在太大了, 实际中很少需要这么大的表达能力. 因此, 类似与kclass指针的做法, 可以选择性的启用指针压缩技术, 将引用压缩为4字节表示, 由于对象引用远比kclass引用来的多, 因此节省的内存相当可观.

当采用4字节表示引用时, 直观来看是表示4G bytes大小的空间, 但是, 由于对象分配时是8字节对齐的, 也就是对象指针的低3bit是0, 因此可以把这3bit压缩掉, 实际32bit的可以表示4G*8 bytes = 32G bytes的内存空间, 对于大部分服务来说足够了. heap小于32G时, 指针压缩默认开启. JVM相应的控制参数为: -XX:+/-UseCompressedOops.


对象布局

对象的定义顺序和布局顺序是不一样的, 我们在写代码的时候想怎么写就怎么写, 不用关心内存对齐问题, byte后面跟个double或者int, 都没有关系, 但是如果内存也按这么布局的话, 由于cpu读取内存时, 是按寄存器(64bit)大小单位载入的, 如果载入的数据横跨两个64bit, 要操作该数据的话至少需要两次读取, 加上组合移位, 会产生效率问题, 甚至会引发异常. 比如在一些ARM处理器上, 如果不按对齐要求访问数据, 会触发硬件异常.

基于此, JVM内部的对象布局和定义布局是不同的. 在class文件中, 字段的定义是按照代码顺序排列的, 加载后, 会生成相应的数据结构, 包含字段的名称, 字段在对象中的偏移等, 重新布局后, 只要改变相应的偏移值即可. 呵呵, 有没有联想到java字段反射?

在hotspot中, 对象布局有三种模式, 看代码注释更直观:

  // Rearrange fields for a given allocation style
  if( allocation_style == 0 ) {
    // Fields order: oops, longs/doubles, ints, shorts/chars, bytes, padded fields
    ....
  } else if( allocation_style == 1 ) {
    // Fields order: longs/doubles, ints, shorts/chars, bytes, oops, padded fields
    ....
  } else if( allocation_style == 2 ) {
    // Fields allocation: oops fields in super and sub classes are together.
    ....
  } 
  1. 类型0, 引用在原始类型前面, 然后依次是longs/doubles, ints, shorts/chars, bytes, 最后是填充字段, 以满足对其要求.
  2. 类型1, 引用在原始类型后面
  3. 类型2, JVM在布局时会尽量使父类对象和子对象挨在一起, 原因后面解释.

另外, 由于填充会形成gap空洞, 比如使用压缩kclass指针时, 头占12字节, 后面如果是long的话, long的对齐要求是8字节, 中间会有4个字节的空洞, 为了高效利用, 可以把int/short/byte等比较小的对象塞进去, 与此同时JVM提供了开关控制该特性-XX:+/-CompactFields, 默认开启.

来看个代码例子:

public class JavaTest {
    public static class TestLayout {
        Object filed1;
        char field2;
        short field3;
        Object filed4;

        long field5;
        byte field6;
        double filed7;
    }

    public static class SubTestLayout extends TestLayout{
        Object subFiled1;
        char subField2;
        short subField3;
        Object subFiled4;

        long subField5;
        byte subField6;
        double subFiled7;
    }

    /**
     * @param args
     */
    public static void main(String[] args) throws Exception{
        SubTestLayout.class.toString();
    }
}

不用觉得main函数奇怪, 我们只需要载入类, 然后利用-XX:PrintFieldLayout来查看布局情况. 该选项只在调试版本中有效. 至于布局模式, 可以使用-XX:FieldsAllocationStyle=mode来指定, 默认是1.


FieldsAllocationStyle=0, oop在前面

@后面是偏移值

com.lqp.test.JavaTest$TestLayout: field layout
@ 12 — instance fields start —
@ 12 “filed1” Ljava.lang.Object;
@ 20 “field2” C
@ 22 “field3” S
@ 16 “filed4” Ljava.lang.Object;
@ 24 “field5” J
@ 40 “field6” B
@ 32 “filed7” D
@ 44 — instance fields end —
@ 48 — instance ends —
@112 — static fields start —
@112 — static fields end —


FieldsAllocationStyle=1, oop在末尾

com.lqp.test.JavaTest$TestLayout: field layout
@ 12 — instance fields start —
@ 36 “filed1” Ljava.lang.Object;
@ 12 “field2” C
@ 14 “field3” S
@ 40 “filed4” Ljava.lang.Object;
@ 16 “field5” J
@ 32 “field6” B
@ 24 “filed7” D
@ 44 — instance fields end —
@ 48 — instance ends —
@112 — static fields start —
@112 — static fields end —


FieldsAllocationStyle=2, 父子oop相连

com.lqp.test.JavaTest$TestLayout: field layout
@ 12 — instance fields start —
@ 36 “filed1” Ljava.lang.Object;
@ 12 “field2” C
@ 14 “field3” S
@ 40 “filed4” Ljava.lang.Object;
@ 16 “field5” J
@ 32 “field6” B
@ 24 “filed7” D
@ 44 — instance fields end —
@ 48 — instance ends —
@112 — static fields start —
@112 — static fields end —

com.lqp.test.JavaTest$SubTestLayout: field layout
@ 44 — instance fields start —
@ 44 “subFiled1” Ljava.lang.Object;
@ 52 “subField2” C
@ 54 “subField3” S
@ 48 “subFiled4” Ljava.lang.Object;
@ 56 “subField5” J
@ 72 “subField6” B
@ 64 “subFiled7” D
@ 76 — instance fields end —
@ 80 — instance ends —
@112 — static fields start —
@112 — static fields end —


为什么由父子oop布局连续的形式呢, 从代码来看, 我能看到的好处: 一个好处是减少OopMapBlock的数量. 由于GC收集时要扫描存活的对象, 所以必须知道对象中引用的内存位置, 对于原始类型, 是不需要扫描的, OopMapBlock结构用于描述某个对象中引用区域的起始偏移和引用个数(见下面代码引用). 另外一个好处是连续的对象区域使得cache line的使用效率更高. 试想如果父对象和子对象的对象引用区域不连续, 而中间插入了原始类型字段的话, 那么在做GC对象扫描时, 很可能需要跨cache line读取才能完成扫描.

OopMapBlock结构如下:

class OopMapBlock {
....
 private:
  int  _offset;
  uint _count;
};

由起始偏移和数量描述, 描述的是连续的空间, 当在对象中, 父对象和子对象oop连续时, 只需要一个OopMapBlock结构, 不然就需要2个了.


布局的代码位于:

layout_fields() - hotspot/src/share/vm/classfile/classFileParser.cpp


总结

从上面的描述中可以看到hotspot中对于布局的处理, 以及在众多细节上的优化. 我们可以不必关心对象布局, 不用关心内存管理, 这些都是JVM的功劳.

展开阅读全文

没有更多推荐了,返回首页