深入探究JVM | klass-oop对象模型研究

来源:互联网 发布:js currenttime 编辑:程序博客网 时间:2024/05/29 08:47
├─agent                            Serviceability Agent的客户端实现├─make                             用来build出HotSpot的各种配置文件├─src                              HotSpot VM的源代码│  ├─cpu                            CPU相关代码(汇编器、模板解释器、ad文件、部分runtime函数在这里实现)│  ├─os                             操作系相关代码│  ├─os_cpu                         操作系统+CPU的组合相关的代码│  └─share                          平台无关的共通代码│      ├─tools                        工具│      │  ├─hsdis                      反汇编插件│      │  ├─IdealGraphVisualizer       将server编译器的中间代码可视化的工具│      │  ├─launcher                   启动程序“java”│      │  ├─LogCompilation             将-XX:+LogCompilation输出的日志(hotspot.log)整理成更容易阅读的格式的工具│      │  └─ProjectCreator             生成Visual Studio的project文件的工具│      └─vm                           HotSpot VM的核心代码│          ├─adlc                       平台描述文件(上面的cpu或os_cpu里的*.ad文件)的编译器│          ├─asm                        汇编器接口│          ├─c1                         client编译器(又称“C1”)│          ├─ci                         动态编译器的公共服务/从动态编译器到VM的接口│          ├─classfile                  类文件的处理(包括类加载和系统符号表等)│          ├─code                       动态生成的代码的管理│          ├─compiler                   从VM调用动态编译器的接口│          ├─gc_implementation          GC的实现│          │  ├─concurrentMarkSweep      Concurrent Mark Sweep GC的实现│          │  ├─g1                       Garbage-First GC的实现(不使用老的分代式GC框架)│          │  ├─parallelScavenge         ParallelScavenge GC的实现(server VM默认,不使用老的分代式GC框架)│          │  ├─parNew                   ParNew GC的实现│          │  └─shared                   GC的共通实现│          ├─gc_interface               GC的接口│          ├─interpreter                解释器,包括“模板解释器”(官方版在用)和“C++解释器”(官方版不在用)│          ├─libadt                     一些抽象数据结构│          ├─memory                     内存管理相关(老的分代式GC框架也在这里)│          ├─oops                       HotSpot VM的对象系统的实现│          ├─opto                       server编译器(又称“C2”或“Opto”)│          ├─prims                      HotSpot VM的对外接口,包括部分标准库的native部分和JVMTI实现│          ├─runtime                    运行时支持库(包括线程管理、编译器调度、锁、反射等)│          ├─services                   主要是用来支持JMX之类的管理功能的接口│          ├─shark                      基于LLVM的JIT编译器(官方版里没有使用)│          └─utilities                  一些基本的工具类└─test                             单元测试


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”

准备资料

  • Open JDK官网
  • Hotspot源码
  • Hotspot术语
  • Hotspot存储管理

学习计划

  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);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

在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.hppclass oopDesc { //....private:  volatile markOop _mark;  union _metadata {    Klass*      _klass;    narrowKlass _compressed_klass;  } _metadata; //....}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

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

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

typedef juint  narrowKlass; -> typedef uint32_t juint;
  • 1
  • 2
  • 1
  • 2

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

另外一个字段:

typedef class   markOopDesc*                markOop;
  • 1
  • 1

指针类型, 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
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  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();    }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

不用觉得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;};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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


布局的代码位于:

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




当我们在写Java代码的时候,我们会面对着无数个接口,类,对象和方法。但我们有木有想过,Java中的这些对象、类和方法,在HotSpot JVM中的结构又是怎么样呢?HotSpot JVM底层都是C++实现的,那么Java的对象模型与C++对象模型之间又有什么关系呢?今天就来分析一下HotSpot JVM中的对象模型:oop-klass model,它们的源码位于openjdk-8/openjdk/hotspot/src/share/vm/oops文件夹内。

注:本文对应的OpenJDK版本为openjdk-8u76-b02。对于不同的版本(openjdk-7, openjdk-8, openjdk-9),其对应的HotSpot JVM的对象模型有些许差别(7和8的差别比较大)


oop-klass model概述

HotSpot JVM并没有根据Java实例对象直接通过虚拟机映射到新建的C++对象,而是设计了一个oop-klass model。

当时第一次看到oop,我的第一反应就是Object-oriented programming,其实这里的oop指的是 Ordinary Object Pointer(普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。而klass则包含元数据和方法信息,用来描述Java类。

那么为何要设计这样一个一分为二的对象模型呢?这是因为HotSopt JVM的设计者不想让每个对象中都含有一个vtable(虚函数表),所以就把对象模型拆成klass和oop,其中oop中不含有任何虚函数,而klass就含有虚函数表,可以进行method dispatch。这个模型其实是参照的Strongtalk VM 底层的对象模型。

体系总览

oopsHierarchy.hpp里定义了oop和klass各自的体系。
这是oop的体系:

1
2
3
4
5
typedef class oopDesc* oop;
typedef class instanceOopDesc* instanceOop;
typedef class arrayOopDesc* arrayOop;
typedef class objArrayOopDesc* objArrayOop;
typedef class typeArrayOopDesc* typeArrayOop;

注意由于Java 8引入了Metaspace,OpenJDK 1.8里对象模型的实现与1.7有很大的不同。原先存于PermGen的数据都移至Metaspace,因此它们的C++类型都继承于MetaspaceObj类(定义见vm/memory/allocation.hpp),表示元空间的数据。

这是元数据的体系:

1
2
3
4
5
6
7
8
9
10
11
// The metadata hierarchy is separate from the oop hierarchy
// class MetaspaceObj
class ConstMethod;
class ConstantPoolCache;
class MethodData;
// class Metadata
class Method;
class ConstantPool;
// class CHeapObj
class CompiledICHolder;

这是klass的体系:

1
2
3
4
5
6
7
8
9
10
// The klass hierarchy is separate from the oop hierarchy.
class Klass;
class InstanceKlass;
class InstanceMirrorKlass;
class InstanceClassLoaderKlass;
class InstanceRefKlass;
class ArrayKlass;
class ObjArrayKlass;
class TypeArrayKlass;

注意klass代表元数据,继承自Metadata类,因此像MethodConstantPool都会以成员变量(或指针)的形式存在于klass体系中。

以下是JDK 1.7中的类在JDK 1.8中的存在形式:

  • klassOop->Klass*
  • klassKlass不再需要
  • methodOop->Method*
  • methodDataOop-> MethodData*
  • constMethodOop -> ConstMethod*
  • constantPoolOop -> ConstantPool*
  • constantPoolCacheOop -> ConstantPoolCache*

klass

一个Klass对象代表一个类的元数据(相当于java.lang.Class对象)。它提供:

  • language level class object (method dictionary etc.)
  • provide vm dispatch behavior for the object

所有的函数都被整合到一个C++类中。

Klass对象的继承关系:xxxKlass <:< Klass <:< Metadata <:< MetaspaceObj

klass对象的布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Klass layout:
// [C++ vtbl ptr ] (contained in Metadata)
// [layout_helper ]
// [super_check_offset ] for fast subtype checks
// [name ]
// [secondary_super_cache] for fast subtype checks
// [secondary_supers ] array of 2ndary supertypes
// [primary_supers 0]
// [primary_supers 1]
// [primary_supers 2]
// ...
// [primary_supers 7]
// [java_mirror ]
// [super ]
// [subklass ] first subclass
// [next_sibling ] link to chain additional subklasses
// [next_link ]
// [class_loader_data]
// [modifier_flags]
// [access_flags ]
// [last_biased_lock_bulk_revocation_time] (64 bits)
// [prototype_header]
// [biased_lock_revocation_count]
// [_modified_oops]
// [_accumulated_modified_oops]
// [trace_id]

oop

oop类型其实是oopDesc*。在Java程序运行的过程中,每创建一个新的对象,在JVM内部就会相应地创建一个对应类型的oop对象。各种oop类的共同基类为oopDesc类。

JVM内部,一个Java对象在内存中的布局可以连续分成两部分:instanceOopDesc和实例数据。instanceOopDescarrayOopDesc又称为对象头。

instanceOopDesc对象头包含两部分信息:Mark Word元数据指针(Klass*):

1
2
3
4
5
volatile markOop _mark;
union _metadata {
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;

分别来看一下:

  • Mark Word:instanceOopDesc中的_mark成员,允许压缩。它用于存储对象的运行时记录信息,如哈希值、GC分代年龄(Age)、锁状态标志(偏向锁、轻量级锁、重量级锁)、线程持有的锁、偏向线程ID、偏向时间戳等
  • 元数据指针:instanceOopDesc中的_metadata成员,它是联合体,可以表示未压缩的Klass指针(_klass)和压缩的Klass指针。对应的klass指针指向一个存储类的元数据的Klass对象

下面我们来分析一下,执行new A()的时候,JVM native层里发生了什么。首先,如果这个类没有被加载过,JVM就会进行类的加载,并在JVM内部创建一个instanceKlass对象表示这个类的运行时元数据(相当于Java层的Class对象)。到初始化的时候(执行invokespecial A::<init>),JVM就会创建一个instanceOopDesc对象表示这个对象的实例,然后进行Mark Word的填充,将元数据指针指向Klass对象,并填充实例变量。

根据对JVM的理解,我们可以想到,元数据—— instanceKlass 对象会存在元空间(方法区),而对象实例—— instanceOopDesc 会存在Java堆。Java虚拟机栈中会存有这个对象实例的引用。


参考文档

  • HotSpot Docs - Storage Management
  • Strongtalk - VMTypesForSmalltalkObjects.wiki(讲述了”oop”的历史)