一、对象布局内存结构:对象头、实例数据、填充补齐(非必须)
无继承关系:
有继承关系:
1、对象头:以32位操作系统为例
对象头形式:
1)普通对象:8个字节(64位系统,不开启压缩指针是16个字节,开启压缩指针是12
2)数组对象:12个字节(64位系统,不开启压缩指针是24个字节,开启压缩指针是16)
对象头组成:
1)Mark Word:
Java对象的状态主要靠Mark Word来标记,主要有5种情况,大部分与线程有关。
这部分主要用来存储对象自身的运行时数据,如hashcode、gc分代年龄等。mark word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark word为32位,64位JVM为64位。
不同标记位下的Mark Word示意如下:5种Mark Word
64位操作系统如下:
具体含义如下:
Mark Word读取规则(64位):整体从后往前读,每个字节(8位)从前往后读,详见 markword解析以及hashcode由来.md
文档
-
25位unused(未使用):最后三个字节(24位)+倒数第4个字节的第一位(0,这里是从前往后读取的)
-
31位hashcode:倒数第4个字节中的7位(从第二位到第八位)+倒数第5个字节+倒数第6个字节+倒数第7个字节
-
1位unused(未使用):第一个字节的第1位
-
4位age(分代年龄):第一个字节的(2、3、4、5)位
-
1位biased_lock(偏向锁标识):第一个字节的第6位
-
2位的lock(锁标识):第一个字节的(7、8)位
lock:2位,锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。
biased_lock | lock | 状态 |
---|---|---|
0 | 01 | 无锁 |
1 | 01 | 偏向锁 |
0 | 00 | 轻量级锁 |
0 | 10 | 重量级锁 |
0 | 11 | GC标记(存活对象) |
biased_lock:1位,对象是否启用偏向锁标记。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
所以biased_lock、lock能够表示全部锁信息
age:4位,Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
identity_hashcode:25位,对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。没有调用对象时,hashcode不会写入对象头中。(hashcode的作用就是栈帧中局部变量表中的变量值
和堆中对象
进行关联)
thread:23位,持有偏向锁的线程ID。
epoch:2位,偏向时间戳。
ptr_to_lock_record:轻量级锁状态下,指向线程栈中 Lock Record 的指针。
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor(c语言对象)的指针。
64位下的标记字与32位的相似:
2)Klass Point
用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:
每个Class的属性指针(即静态变量)
每个对象的属性指针(即引用类型:对象变量)
普通对象数组的每个元素指针
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen(方法区)的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
如下图:
类加载后,会在方法区中生成对应的Class模板,同时jvm会在堆中创建对应的Class对象,执行方法区的Class数据结构;
当通过new创建对象实例时,通过KlassPoint指向堆中的Class对象,关联到方法区。
3)Array Length
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。
2、实例数据(非必须):
其实就是用户定义的各个属性。用户也可以不定义属性变量。
3、填充补齐(非必须):
如下就是填充字节:填充4个字节
对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头正好是8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
二、对象创建(new Object的过程)
1.整体流程:(如下图)
具体流程如下:
主要分为六步:
(1)判断对象对应的类是否加载、链接、初始化(clinit方法)
虚拟机遇到一条new指令时,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化(即判断类元信息是否存在)。如果没有,那么在双亲委派的模式下,使用当前类加载以ClassLoader+包名+类名为 Key 进行查找对应的 .class 文件。如果没有找到文件,则抛出 ClassNotFoundException 异常,如果找到,则进行类加载,并生成对应的Class类对象。
(2)为对象分配内存
编译期就能确定。首先计算对象占用的空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量时引用变量,仅分配应用变量空间即可,即4个字节大小。基本类型占用大小如下:
-
内存规整
-
指针碰撞:如果内存规整,那么虚拟机将采用的是指针碰撞(Bump The Pointer)来为对象分配内存。
意思是:所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的(标记-复制算法),虚拟机采用这种分配方式,一般使用带有compact(整理)过程的收集器,使用指针碰撞。(标记-整理算法)
-
-
内存不规整
-
空闲列表:如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的空闲列表法来为对象分配内存。
意思是:虚拟机维护一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容,这种分配方式称为"空闲列表(Free List)"(标记-清除算法)
-
说明:采用哪种分配方式是由java堆是否规整来决定的,而java堆是否规整又由所采用的垃圾收集器是否由压缩、整理算法来决定的
(3)处理并发安全问题
因为堆是所有线程共享的,所以在堆中分配内存时,是存在并发安全问题的。解决方案主要有两种:
-
采用CAS失败重试、区域加锁保证更新原子性
-
使用TLAB(本地线程缓冲池),在堆的 Eden 中给每个线程开辟一块独立的空间使用。
(4)默认初始化(零值初始化)
所有属性设置默认值(零值),保证对象实例在不赋值时,可以直接使用。<font color=red>(如果变量是 final 修饰,那么此时会直接赋值)</font>
(5)设置对象的对象头
将对象的所属类(即类的元数据信息,通过KlassPoint来连接)、对象的Hash Code和对象的GC 此乃西、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
★★★★★(6)执行init方法进行初始化:构造方法对应的字节码方法就是init方法
在Java程序的视角来看,初始化才正式开始。按照以下顺序依次执行:
-
执行父类init方法:优先执行
-
初始化成员变量(属性的显示赋值)
-
执行实例代码块
-
执行构造方法中其他逻辑,并把堆内对象的首地址赋值给栈帧中的引用变量。
因此一般来说(由字节码中是否跟随由 invokespecial 指令所决定),new 指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完整的创建出来。
如下图:
init方法流程如下:
2.内存详细流程:
1)栈上分配:默认1M大小
java虚拟机的优化技术,将线程私有的对象直接分配在栈上
-
优点:
-
随着方法运行结束,栈帧销毁,直接销毁对象,不需要垃圾回收器的介入
-
栈上分配快,提高系统运行性能
-
-
缺点:
-
栈空间比较小,对于大对象无法分配
-
-
技术基础:逃逸分析,主要目的是分析对象动态作用域,判断对象作用域是否超出函数体
逃逸分析:当一个对象在方法中被定义后,它可能被外部方法所引用(例如作为调用参数传递到其他方法中),称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸
运用:
-
栈上分配(目前还不成熟)
-
同步消除(锁去除)
-
标量替换(分离对象,就是将局部对象变量拆分成的所有属性基本类型的标量)
-
-
参数设置:
-
-server -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:+EliminateAllocations
-
-XX:+DoEscapeAnalysis:使用逃逸分析(-DoEscapeAnalysis:不使用)
-
-XX:+EliminateAllocations:使用标量替换(-EliminateAllocations:不使用),允许将对象聚合量打散分配在栈上。
-
栈上分配依赖逃逸分析
-
2)TLAB分配:(Thread Local Allocation Buffer:线程缓冲区)默认设定为占用Eden区的1%
-
线程共享的Java堆(Eden区)中划分中多个线程私有的分配缓冲区
-
优点:加速对象分配,提高分配效率
-
对象分配在堆中,而堆是线程共享的,因此可能会存在多个线程在堆上同时申请空间,而每次对象分配的都必须线程同步(加锁),分配效率下降,所以使用TLAB,避免多线程冲突,提高对象分配效率
-
局限性:TLAB空间一般不会太大,所以大对象无法分配,直接分配到堆上
-
-XX:TLABSize =64K 参数控制
-
分配策略:
-
:在虚拟机内部维护一个叫refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,反之,则会废弃当前TLAB,新建TLAB来分配新对象
-
-XX:TLABRefillWasteFraction
3)堆
-
Young区/年轻代:(Eden区)、s1、s0
-
Old区/老年代
三、访问定位:
JVM是如何通过栈帧中的对象引用访问其内部的对象实例的呢?
-
创建对象的目的是为了使用它
-
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢——定位,通过栈上refernece访问
-
不同虚拟机实现对象访问方式会有所不同,主流访问方式:使用句柄和直接指针
1.句柄访问:
java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象句柄地址,而句柄中包含了对象实例数据和类型数据(Class信息)各自的具体地址信息。,如下图:
2.直接指针访问:
reference中直接存储对象地址。如下图:
两种方式的比较
-
使用句柄池来访问最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时整体空间位置)时只会改变句柄中的实例数据指针,而reference不需要任何改变。(栈空间很稳定)
-
使用直接指针访问最大的好处就是快,节省了一次指针定位的时间开销,由于对象访问在java中非常频繁,积少成多,节省这样的开销效益非常可观。(节省空间、速度快)
-
主要虚拟机HotSpot采用直接指针访问,但是许多其他语言和框架使用句柄这种思想也非常常见。
上述代码 JVM 变量之间的关联如下:
四、对象定义完就已经确定大小
成员变量初不初始化,其实大小都是已经固定了,所以一个对象大小,在加载的时候已经是固定的了,如下图:
初始化前:
初始化后:
基本数据类型的大小都是固定的,引用类型也是默认是4个字节。所以在初始化后对象的大小都是完全可以确定的。