深入理解JVM系列(五)阿里面试题-关于对象Object o =
如果觉得写的还可以请关注微信公众号:程序猿的日常分享,定期更新分享。
请解释一下对象的创建过程?
1、加载
2、链接(验证、准备、解析)
3、初始化
4、申请对象内存
5、成员变量赋默认值
6、调用构造方法<init>:1)成员变量顺序赋初始值 2)执行构造方法语句
对象在内存中的存储布局?
jvm中的对象分为两种,一种是普通对象,一种是数组对象。这两种对象在内存中的布局是不一样的。如下图所示:
image.png
普通对象new Object()有4部分组成,分别是对象头、类型指针、实例数据、填充。
数组对象int i = new int[4]有5部分组成,分别是对象头、类型指针、数组长度、实例数据、填充。
-
对象头(Mark Word)
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为4个字节和8个字节,官方称它为 Mark Word。 -
类型指针(Class Pointer)
存储对象所属类的地址,就是为了标记到底是什么类的实例。jvm默认开启了指针压缩,所以占用4个字节,如果关闭指针压缩,就占用8个字节。此外,指针压缩还会影响instance data的实例对象的指针空间占用大小。如果开启了指针压缩,Long型的成员变量和long型的成员变量占用空间大小是有区别的:Long占用4个字节;long是基础类型占用8个字节。如果关闭了指针压缩:Long占用8个字节;long是基础类型占用8个字节。
Hotspot开启内存压缩的规则(64位机):
1、4G以下,直接砍掉高32位
2、4G - 32G,默认开启内存压缩 ClassPointers Oops
3、32G,压缩无效,使用64位,所以内存并不是越大越好 -
实例数据(Instance Data)
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。父类定义的变量会出现在子类定义的变量的前面。各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段总是被分配到一起,便于之后取数据。 -
填充(Padding)
填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。为什么需要有对齐填充呢?由于JVM读数据时是按照一块一块的读取的,这样读取效率更高,64位虚拟机的话对象的大小必须是8字节的整数倍。因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。 -
数组长度(Length)
存储了数组对象的长度,占用4个字节
对象头具体包括什么?
Mark Word的结构,定义在markOop.hpp文件中,其中定义了32位是怎么实现的,64位是怎么实现的。源码如下:
以64位虚拟机来看翻译过来以后如下:
image.png
1、当我们创建一个无锁态对象的时候:25位没有用;31位装的identity Hashcode,但是只有在被调用的时候,才填充,没有调用的时候是空的;1位没有使用的;4位分代年龄(解释在下面);1位偏向锁位;2位锁标志位。
2、偏向锁的时候:54位存下当前线程的ID;2位存批量撤销Epoch;1位没有使用;4位分代年龄;1位偏向锁位;2位锁标志位。
3、 自旋锁:62位指向线程中的Lock Record的指针。Lock Record与锁重入有关,synchronize默认是可重入的。自旋锁在竞争锁的时候,会在自己的内存的线程栈中创建一个Lock Record对象,抢到锁对象的资源时,锁对象头存的就是这个线程的Lock Record对象的指针,所以在重入的时候,会再创建一个Lock Record对象,利用Lock Record来记录到底琐了多少次。解锁的时候,就将一个Lock Record移除,移除的方式是FILO,也就是先进后出的原则。
4、 重量级琐:重量级琐是在C++代码层面进行的,会生成一个ObjectMonitor对象,这个对象中记录了一系列的队列。
5、分代年龄:JVM有10种垃圾回收器,前面7种都涉及分代年龄,采用分代算法。当我们创建一个对象的时候,把它放在年轻代中,每经过一次垃圾回收后年龄就+1,也就是垃圾回收无法回收掉这个对象,它的年龄就会不断的增长,到达15,因为4个字节最大为15,就转到老龄代,年轻代的回收就不再对它进行回收。
6、hashCode部分:对象头上的hashCode并不是我们调用重写的hashCode()方法生成的,而是为重写的hashCode()方法或者调用System.identityHashcode()方法才能获取并且存入对象头中。通俗来讲,这里的hashCode是按照原始内容计算的,重写过的hashCode()方法计算的结果并不会存在此处。如果对象没有重写hashCode()方法,那么默认调用的os::random产生hashCode,也可以通过System.identityHashcode()获取。os::random产生hashCode的规则是:next_rand = (16807seed)mod(2*31-1),因此可以使用31位存储空间进行存储,并且一旦产生这个hashCode,JVM就会记录在mark word中。
关于锁有几个需要注意的地方:
1、当一个对象已经计算过identity hash code,则它就无法进入偏向锁状态。因为如果已经计算过identity hash code的值以后,在上图中偏向锁记录线程ID的内存已经被占用了。
2、当一个对象正处于偏向锁状态,并且需要计算identity hash code的话,则它的偏向锁会被撤销、膨胀为重量级锁
3、重量级锁的实现中,ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值。
对象怎么定位
JVM中对象访问定位两种方式:
1、直接指针访问:Java栈直接与对象进行访问,在Java堆中对象帆布中必须考虑存储访问类型的数据的相关信息 ,直接指针访问的优点比较明显,就是访问速度快,不需要和句柄一样指针定位的开销 。缺点也比较明显,就是对象在GC过程中,在新生代区域复制移动时,会比较麻烦。如下图:
image.png
2、通过句柄池方式访问:在Java堆中分出一块内存进行存储句柄池,在栈中存储的是句柄的地址,通过句柄池访问有独特的优点,就是当对象移动的时候(垃圾回收的时候移动很普遍),这样值需要改变句柄中的指针,但是栈中的指针不需要变化,因为栈中存储的是句柄的地址。那么对应的缺点就是需要两次指针转换进行访问,访问速度比直接指针访问稍慢一些。如下图:
image.png
对象怎么分配
对象分配流程如下图:
image.png
1、当我们new出一个对象,JVM会首先尝试往栈上分配,如果能够分配得下,就分配到栈上分配到栈上的对象有好处就是不需要GC进行管理,什么时候不需要用到此对象了,将对象出栈就可以了。但是分配到栈上的对象是有要求的:第一,对象比较小,因为栈空间本来就不够大;第二,对象比较简单。
2、如果栈上分配不下,我们就判断这个对象是不是够大,如果足够大就直接放在老年代区,在老年代区的对象经过一次全量垃圾回收FGC后,才有可能被回收掉。
3、如果如果栈上分配不下并且对象不大,就会判断对象能否被存在线程本地分配缓冲区-TLAB(Thread Local Allocation Buffer)。但是不管放不放得下,都是放在新生代区的伊甸区eden。 但是因为堆是共享的,多个线程可以同时创建对象就可能会争夺同一块内存区域,所以为了保证线程安全,Eden区又被分配成一个个线程本地分配缓冲区,这个TLAB是线程私有的,每个线程都有自己的TLAB,避免了多线程环境下使用同步技术带来的性能损耗。
4、伊甸区eden的对象在经过一次GC后,如果被回收掉了,那就结束了生命周期。
5、伊甸区eden的对象在经过一次GC后,如果没有被回收掉,会被拷贝到幸存者区survivor1,对比上面的堆内存逻辑分区图。幸存者区survivor1中的对象再经过一次GC后如果对象还存活,那么就拷贝到幸存者区survivor2并且清理掉幸存者区survivor1中的所有对象,再有GC就反复这个操作,直到对象的分代年龄达到了移到老年代的界限(一般默认是15),就会被移到老年代中。
Object o = new Object()在内存中占用多少字节?
这里考察的知识点是对象在内存中的存储布局结构和类指针以及普通对象指针的概念。存储布局不再多说,类指针就是存储布局中的class pointer,普通对象指针就是存储布局中的instance data中,成员变量如果不是基础类型而是引用类型,那么也会有普通对象指针指向所属类。默认情况下JVM是开启了类指针和普通对象指针的指针压缩,将8个字节压缩成了4个字节。我们用代码输出来观察对象的大小,实验代码如下:
image.png
(1) 默认开启所有指针压缩的情况下输出如下:
image.png
(2) 关闭类指针压缩后,如下:
image.png
如果觉得写的还可以请关注微信公众号:程序猿的日常分享,定期更新分享。