JVM拾遗(4): Java对象的创建及内存布局
上一节JVM拾遗-3-类装载机制讲了JVM如何将类装载到虚拟机以供后续使用 那么JVM是如何创建类的实例呢?该对象是如何分配内存的?
1. 实例化
1.1 创建对象的方式
Java对象的创建, 有多种方式,最简单就是new XXClass
, 还可以通过反射
,xx.clone()
,反序列化
以及黑科技Unsafe.allocateInstance
等方法.
new
和反射创建对象实例的时候,会初始化实例字段.
如果类没有构造器,会默认添加构造器,并且编译成<init>
方法.
默认生成的构造器里,如果父类有无参构造器, 会隐式递归调用父类的构造器.
如下类:
|
|
生成的字节码如下(用javap -v TestClass
可以得到):
|
|
那两行invokespecial
指令是用来调用类构造器或者类的私有方法的.
可以看到,TestClass
类添加了默认的构造器,生成了<init>方法
,同时调用了Object
类的<init>
方法,也就是java.lang.Object
类的构造方法.
也可以显式调用父类的构造器, 使用super(a,b,c)
这样的形式即可
1.2 虚拟机如何处理new指令
new
指令会实例化一个对象, JVM如何处理new
指令生成具体的对象并不是规范的一部分,所以这里指的JVM实现指的是Hotspot
的实现
下面是new
指令实例化对象的过程:
- JVM会判断对应的类是否被加载,链接,初始化, 判断的依据是根据符号引用找方法区是否有类的数据.如果没有需要进行类的加载过程
- 为对象分配内存, 有2种方式
- 指针提升(bump pointer, 注意没翻译错), 如果
堆内存
是规整的, 那么只需要把指针移动一个对象大小的距离就完成了分配 - 空闲列表(free list), 如果
堆内存
是不连续的,碎片很多, JVM会维护一个可用内存队列, 从中查出可用内存分配给对象. 具体使用哪种方式依据堆内存的情况而定,而堆内存的情况很大受到gc
的影响,后面会细谈
- 初始化内存, JVM会将分配到的内存初始化为0值.
- 设置对象的元数据信息到
对象头
, 例如对象的hashcode
,gc分带年龄
,偏向锁状态
等信息
一般来讲,new
指令后面都会跟着invokespecial
来执行<init>
方法,也就是执行类构造器里的逻辑.
对象创建的并发安全问题
JVM处理对象创建的并发安全一般有2种方法:
CAS
操作加上失败重试- 内存分配的动作按照线程划分在不同的内存空间区域进行,这些预先分配的内存仅属于该线程私有,也称为
本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
, 可以通过-XX:UseTLAB
来设置
2. 对象的内存布局
对象的内存布局也不是JVM规范的一部分,属于实现的细节,Hotspot
将对象分成3部分,分别是:
- 对象头(Object Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
2.1 Object Header
对象头由标记字段(Mark Word)
和类型指针(Klass Pointer)
组成
Mark Word
用于存储该对象的运行时数据, 包括:
- 对象
hashcode
,gc
分代年龄 - 锁记录指针, 重量级锁指针
- 偏向线程id, 偏向时间戳
在64位的虚拟机上标记字段
一般是8个字节,类型指针
也是8个字节,总共就是16个字节.
可以使用-XX:UseCompressedOops
来开启压缩指针, 以减少对象的内存使用量, 默认是开启的.
而类型指针
指向的是对象的元数据信息, 也就是对象所属类的信息.
HotSpot实现
Hotspot
一般用OOP-Klass
二分模型来实现, OOP(Ordinary Object Pointer)
是普通对象指针,Klass
用来描述具体的类型.
以下是openjdk的实现:
|
|
其中OOP
的实现就是instanceOopDesc
和arrayOopDesc
,分别是普通对象实现和数组对象实现, 均继承自上面的oopDesc
,
数组对象比普通对象多一个长度字段.
oopDesc
存放有_mark
和_metadata
, _mark
就是标记字段(mark word), 而_metadata
里就有一个指针指向Klass
.
限于篇幅,markOop.hpp
的代码就不贴了,大致就是上面提到的信息。
类型指针(上面_meta
里__klass
指针)指向的Klass
信息, 即类在JVM的真身, 看看就好.
|
|
类的各种数据非常多,不过都是实现细节,为了更好的存储JVM规范里的class file format
对应的数据,如类的名字符号,符号引用,常量池数据, 方法引用等等
而继承的类Metadata
如下:
// hotspot/src/share/vm/oops/metadata.hpp
class Metadata : public MetaspaceObj {
public:
NOT_PRODUCT(Metadata() { _valid = 0; })
NOT_PRODUCT(bool is_valid() const volatile { return _valid == 0; })
...一堆辅助函数...
int identity_hash() { return (int)(uintptr_t)this; }
...
没错,这个identity_hash
函数就是hashcode()
函数的真身, 可以看到返回了对象地址:).
而Metadata
继承的MetaspaceObj
则是8以后才有的, java8移除了永久代(PermGen)
,可能因为经常溢出(Spring: 你们看我干啥?), 生活在元空间的对象都要继承这个类,这个放后面聊.
2.2 Instance Data
实例数据存在instanceOopDesc
的父类oopDesc
类的实例里,就是上面那堆私有属性, JVM还会对字段重排序,相同的宽度可能被分到一起,比如long/double
.所以父类的变量可能出现在子类之前,子类中较窄的变量也可能插入到父类的间隙。
Hotspot
采用的方法是直接指针访问对象, 如图:
计算对象的大小可以单独讲一小节,也比较有意思,后面再说。
2.3 Padding
对齐填充是最常见的优化手段,CPU一次寻址一般是2的倍数,所以一般会按照2的倍数来对齐提高CPU效率.这个似乎没什么好讲的. 此外,JVM上对齐填充也方便gc, JVM能直接计算出对象的大小, 就能快速定位到对象的起始终止地址.
3. 总结
本节回顾了java对象的创建方式,以及hotspot
执行new
指令的一系列操作,包括寻找加载类,分配初始化内存,设置对象的元数据信息到header
。
同时我们通过代码分析了hotspot
中对象的内存布局,分为Object Header
, Instance Data
和Padding
, 其中对象头包括mark word
和klass pointer
, mark word
里有丰富的对象运行时信息,比如hashcode,gc分代年龄等
,而klass pointer
指向一个c++对象Klass
的实例,也就是JVM类的真身.存放的类的各种信息,如类的名称,继承的父类,实现的接口,字段信息,方法信息等.而Instance Data
存放了运行时类的实例数据值, 字段数据的地址会被重排序优化掉。
instanceKlass
对象放在了方法区,instanceOop
放在了堆,instanceOop的引用
放在了JVM栈.
希望大家有所收获:).
- 原文作者:Chris Wang
- 原文链接:https://www.sound2gd.wang/post/jvm%E6%8B%BE%E9%81%974-java%E5%AF%B9%E8%B1%A1%E7%9A%84%E5%88%9B%E5%BB%BA%E5%8F%8A%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议. 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。