8.1彻底搞透彻内存模型
目录介绍
- 01.分析内存模型背景
- 1.1 为何分析内存模型
- 1.2 如何着手学习
- 1.3 学习内存那些东西
- 02.JVM整体内存模型
- 2.1 按照私有划分
- 2.2 线程私有内存区
- 2.3 线程共享内存区
- 03.线程私有区域
- 3.1 程序计数器
- 3.2 虚拟机栈
- 3.3 本地方法栈
- 04.线程共享区域
- 4.1 Java堆
- 4.2 方法区
- 4.3 运行时常量池
- 4.4 直接内存
- 05.看内存分配案例
- 5.1 首先看代码案例
- 5.2 JVM加载类过程
- 5.3 对构造方法赋值
- 5.4 通过对象调用方法
- 5.5 对对象进行回收
- 5.6 内存泄漏是啥回事
- 5.7 堆和栈区别举例
- 5.8 成员和局部变量
- 5.9 了解下对象内存结构
- 06.搞懂对象内存布局
- 6.1 对象在哪里分配
- 6.2 JOL分析对象内存布局
- 6.3 对象内存布局组成
- 6.4 对象内存布局详解
- 6.5 什么是指针压缩
01.分析内存模型背景
1.1 为何分析内存模型
- 为何要内存优化
- App消耗内存过大,导致手机内存低于内存警戒线的时候,Low Memory Killer机制就会触发,App占用内存越多,被处理掉的机会就越大。
- 受虚拟机堆内存限制,出现OOM,内存溢出,程序出现crash。
- 内存如何优化
- 随着app开发,代码功能越来越多,内存优化就非常重要了。那么内存优化的首要条件,就是了解内存是如何分配,如何回收等等基础知识。因此掌握内存模型很重要!
1.2 如何着手学习
- 基础知识掌握
- 掌握内存模型的组成,栈和堆分别是怎样设计的。为何要划分私有的和共有的?
- 综合运用知识
- 创建一个对象,对象中的成员属性分别放在JVM中哪里,如何通过引用指向对象?对象分配在哪里?对象如何判断死亡,如何销毁?
1.3 学习内存那些东西
- 如下所示
Java内存分配机制.png
02.JVM整体内存模型
2.1 按照私有划分
- Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。这些组成部分一些事线程私有的,其他的则是线程共享的。
image
- 在JVM(Java虚拟机)中,有一些内存区域是线程私有的,而另一些是线程共享的。
- 这些区域的访问权限和生命周期与线程的创建和销毁相关。
- 线程私有的内存区域保证了每个线程在执行时有独立的工作空间,而线程共享的内存区域则用于存储全局的数据和共享的资源。
- 这种区分使得多线程程序能够同时运行并共享数据,同时保证了线程之间的独立性和安全性。
2.2 线程私有内存区
- 线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
2.3 线程共享内存区
- 线程共享的:
- Java堆
- 方法区
- 运行时常量池
- 直接内存
03.线程私有区域
3.1 程序计数器
- 程序计数器
- 是一个数据结构,用于保存当前正常执行的程序的内存地址。
- Java虚拟机的多线程就是通过线程轮流切换并分配处理器时间来实现的,为了线程切换后能恢复到正确的位置,每条线程都需要一个独立的程序计数器,互不影响,该区域为“线程私有”。
- 生命周期:随着线程的创建而创建,随着线程的结束而销毁。
- 程序计数器是一块较小的内存空间
- 可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。
- 程序计数器主要有两个作用:
- 1.字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。如:顺序执行、选择、循环、异常处理。
- 2.在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿。
- 程序计数器会OOM吗
- 注意:程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
3.2 虚拟机栈
3.2.1 Java虚拟机栈
- Java 虚拟机栈的定义
- Java 虚拟机栈会为每一个即将运行的 Java 方法创建一块叫做“栈帧”的区域,用于存放该方法运行过程中的一些信息。
- Java虚拟机栈存放什么
- Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。
- 局部变量表:放着基本数据类型(8种基本类型) ,还有对象的引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
image
- Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
- StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
- OutOfMemoryError: 若Java虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。
- Java 虚拟机栈也是线程私有的
- 每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
3.2.2 压栈出栈过程
- 当方法运行过程中需要创建局部变量时,就将局部变量的值存入栈帧中的局部变量表中。
- Java 虚拟机栈的栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。
- 只有这个活动的栈帧的本地变量可以被操作数栈使用,当在这个栈帧中调用另一个方法,与之对应的栈帧又会被创建,新创建的栈帧压入栈顶,变为当前的活动栈帧。
- 方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中操作数栈的操作数没有变化。
- 虚拟机栈是否涉及同步问题
- 由于 Java 虚拟机栈是与线程对应的,数据不是线程共享的(也就是线程私有的),因此不用关心数据一致性问题,也不会存在同步锁的问题。
- 来看下这段代码的执行过程
int main() { int a = 1; int ret = 0; int res = 0; ret = add(3, 5); res = a + ret; printf("%d", res); reuturn 0; } int add(int x, int y) { int sum = 0; sum = x + y; return sum; }
- 从代码中我们可以看出,main() 函数调用了 add() 函数,获取计算结果,并且与临时变量 a 相加,最后打印 res 的值。
- 为了让你清晰地看到这个过程对应的函数栈里出栈、入栈的操作,我画了一张图。图中显示的是,在执行到 add() 函数时,函数调用栈的情况。
image
3.2.3 局部变量表
- 定义为一个数字数组,主要用于存储方法参数、定义在方法体内部的局部变量
- 数据类型包括各类基本数据类型,对象引用,以及 return address 类型。
- 局部变量表容量大小是在编译期确定下来的。最基本的存储单元是 slot,32 位占用一个 slot,64 位类型(long 和 double)占用两个 slot。
- 对于 slot 的理解:
- JVM 虚拟机会为局部变量表中的每个 slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
- 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this,会存放在 index 为 0 的 slot 处,其余的参数表顺序继续排列。
- 栈帧中的局部变量表中的槽位是可以重复的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
- 在栈帧中,与性能调优关系最密切的部分,就是局部变量表
- 方法执行时,虚拟机使用局部变量表完成方法的传递局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
3.2.4 操作数栈
- 栈顶缓存技术:
- 由于操作数是存储在内存中,频繁的进行内存读写操作影响执行速度,将栈顶元素全部缓存到物理 CPU 的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。
- 每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编译期就定义好。
- 32bit 类型占用一个栈单位深度,64bit 类型占用两个栈单位深度操作数栈。
- 并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问。
3.2.5 方法的调用
- 静态链接:
- 当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下降调用方的符号引用转为直接引用的过程称为静态链接。
- 动态链接:
- 如果被调用的方法无法在编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为动态链接。
- 方法绑定
- 早期绑定:被调用的目标方法如果在编译期可知,且运行期保持不变。
- 晚期绑定:被调用的方法在编译期无法被确定,只能够在程序运行期根据实际的类型绑定相关的方法。
- 非虚方法:
- 如果方法在编译期就确定了具体的调用版本,则这个版本在运行时是不可变的,这样的方法称为非虚方法静态方法。私有方法,final 方法,实例构造器,父类方法都是非虚方法,除了这些以外都是虚方法。
3.3 本地方法栈
- 本地方法栈的定义
- 本地方法栈是为 JVM 运行 Native 方法准备的空间,由于很多 Native 方法都是用 C 语言实现的,所以它通常又叫 C 栈。
- 它与 Java 虚拟机栈实现的功能类似,只不过本地方法栈是描述本地方法运行过程的内存模型。
- 本地方法栈:
- 跟虚拟机栈很像, 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
- 栈帧变化过程
- 本地方法被执行时,在本地方法栈也会创建一块栈帧,用于存放该方法的局部变量表、操作数栈、动态链接、方法出口信息等。
- 方法执行结束后,相应的栈帧也会出栈,并释放内存空间。也会抛出 StackOverFlowError 和 OutOfMemoryError 异常。
04.线程共享区域
4.1 Java堆
- Java堆:所有线程共享的一块内存区域。
- 此内存区域的唯一目的就是存放对象实例,对象实例几乎都在这分配内存。在虚拟机启动时创建。
- Java 堆是垃圾收集器管理的主要区域
- 我们讨论的内存泄漏优化的关键存储区。GC会根据内存的使用情况,对堆内存里的垃圾内存进行回收。
- 因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法。
- 所以Java堆还可以细分为:新生代和老年代:在细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
image
- 堆内存是一块不连续的内存区域
- 如果频繁地new/remove会造成大量的内存碎片,GC频繁的回收,导致内存抖动,这也会消耗我们应用的性能。
- 在 JDK 1.8中移除整个永久代
- 取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。
- 注意同步问题
- Java 堆所使用的内存不需要保证是连续的。而由于堆是被所有线程共享的,所以对它的访问需要注意同步问题,方法和对应的属性都需要保证一致性。
4.2 方法区
- 方法区:各个线程共享的区域,储存虚拟机加载的类信息,常量,静态变量,编译后的代码。
- 虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
- 方法区的特点
- 线程共享。 方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的。整个虚拟机中只有一个方法区。
- 永久代。 方法区中的信息一般需要长期存在,而且它又是堆的逻辑分区,因此用堆的划分方法,把方法区称为“永久代”。
- 内存回收效率低。 方法区中的信息一般需要长期存在,回收一遍之后可能只有少量信息无效。主要回收目标是:对常量池的回收;对类型的卸载。
- Java 虚拟机规范对方法区的要求比较宽松。 和堆一样,允许固定大小,也允许动态扩展,还允许不实现垃圾回收。
- **相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。**如何理解这句话?
4.3 运行时常量池
- 运行时常量池:代表运行时每个class文件中的常量表。包括几种常量:编译时的数字常量、方法或者域的引用。
- .Class 文件中包括类的版本、字段、方法、接口等描述信息
- 既然运行时常量池时方法区的一部分
- 自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
- JDK1.7及之后版本的JVM已经将运行时常量池从方法区中移了出来,在Java堆(Heap)中开辟了一块区域存放运行时常量池。
4.4 直接内存
- 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。
- JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式。
- 它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。
- 这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
- 本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
- 直接内存与堆内存比较
- 直接内存申请空间耗费更高的性能
- 直接内存读取 IO 的性能要优于普通的堆内存
- 直接内存作用链: 本地 IO -> 直接内存 -> 本地 IO
- 堆内存作用链:本地 IO -> 直接内存 -> 非直接内存 -> 直接内存 -> 本地 IO
- 直接内存如何使用?
- 直接内存是通过java.nio包中的ByteBuffer来分配的,它使用了操作系统的本地内存。关于ByteBuffer举一个用例场景……
//ByteBuffer案例
05.看内存分配案例
5.1 首先看代码案例
- 以下面代码为例,来分析,Java 的实例对象在内存中的空间分配。
//JVM 启动时将 Person.class 放入方法区 public class Person { //静态变量,直接放到常量池中 public static final String number = "13667225184"; //new Person 创建实例后,name 引用放入堆区,name 对象放入常量池 private String name; //new Person 创建实例后,age = 0 放入堆区 private int age; //Person 方法放入方法区,方法内代码作为 Code 属性放入方法区 public Person(String name, int age) { this.name = name; this.age = age; } //toString 方法放入方法区,方法内代码作为 Code 属性放入方法区 @Override public String toString() { return "Person{" + "name='" + name + '\'' + ", age=" + age + '}'; } } //JVM 启动时将 Test.class 放入方法区 public class Test { //main 方法放入方法区,方法内代码作为 Code 属性放入方法区 public static void main(String[] args) { //局部变量,定义的一些基本类型的变量和对象的引用变量都是在函数的栈(本地方法栈)内存中分配 String name1 = "张三"; int age1 = 18; //person1 是引用放入虚拟机栈区,new 关键字开辟堆内存 Person 自定义对象放入堆区 //堆内存用于存放所有由new创建的对象(内容包括该对象其中的所有成员变量)和数组。 Person person1 = new Person(name1, age1); Person person2 = new Person("李四", 20); //通过 person 引用创建 toString() 方法栈帧 person1.toString(); person2.toString(); } private void clear(){ //对象设置为null,回收 person1 = null; person2 = null; } }
- 简单说一下加载的流程
- 首先将 Test.class 放入方法区,然后执行 main 方法放到方法区
- 通过 new 创建对象,那么对象放到堆中,引用(有的叫指针)放到虚拟机栈中
- 最后通过引用创建方法栈帧
5.2 JVM加载类过程
- 首先 JVM 会将 Test.class, Person.class 加载到方法区,找到有 main() 方法的类开始执行。
image
- 分析步骤
- 如上图所示,JVM 找到 main() 方法入口,创建 main() 方法的栈帧放入虚拟机栈,开始执行 main() 方法。
- Person person1 = new Person("张三", 18);
- 执行到这句代码时,JVM 会先创建 Person。实例放入堆区,person2 也同理。
5.3 对构造方法赋值
- 创建完 Person 两个实例,main() 方法中的 person1,person2 会指向堆区中的 0x001,0x002(这里的内存地址仅作为示范)。
- 紧接着会调用 Person 的构造函数进行赋值,如下图:
image
- 如上图所示,新创建的的 Person 实例中的 name, age 开始都是默认值。
- 调用构造函数之后进行赋值,name 是 String 引用类型,会在常量池中创建并将地址赋值给 name,age 是基本数据类型将直接保存数值。
- 注:Java 中基本类型的包装类的大部分都实现了常量池技术,这些类是 Byte, Short, Integer, Long, Character, Boolean,另外两种浮点数类型的包装类则没有实现。 | 基本数据类型 | 包装类(是否实现了常量池技术) | | :--------- | :----------------------- | | byte | Byte 是 | | boolean | Boolean 是 | | short | Short 是 | | char | Character 是 | | int | Integer 是 | | long | Long 是 | | float | Float 否 | | double | Double 否 |
5.4 通过对象调用方法
- Person 实例初始化完后,执行到 toString() 方法,同 main() 方法一样 JVM 会创建一个 toString() 的栈帧放入虚拟机栈中,执行完之后返回一个值。
image
5.5 对对象进行回收
- 如何对对象进行回收,这里说的是主动手动回收
private void clear(){ person1 = null; person2 = null; }
- 如何判断对象被回收?
- 方法1: 引用计数。该算法通过在对象中维护一个引用计数器,每当有一个引用指向该对象时,计数器加1,当引用失效时,计数器减1。当计数器为0时,表示该对象不再被引用,可以被回收。
- 方法2: 可达性分析。该算法基于可达性分析,从一组称为"GC Roots"的根对象开始,通过遍历对象图,标记所有与根对象直接或间接相连的对象为可达对象,未被标记的对象即为不可达对象,可以被回收。
5.6 内存泄漏是啥回事
- 典型内存泄漏案例
Vector v = new Vector(10); for (int i = 1; i < 100; i++) { Object o = new Object(); v.add(o); o = null; }
- 分析
- 在这个例子中,我们循环申请Object对象,并将所申请的对象放入一个 Vector 中,如果我们仅仅释放引用本身,那么 Vector 仍然引用该对象,所以这个对象对 GC 来说是不可回收的。
- 因此,如果对象加入到Vector 后,还必须从 Vector 中删除,最简单的方法就是将 Vector 对象设置为 null。
5.7 堆和栈区别举例
- 代码如下所示
public class Sample() { int s1 = 0; Sample mSample1 = new Sample(); public void method() { int s2 = 1; Sample mSample2 = new Sample(); } } Sample mSample3 = new Sample();
- Sample 类的局部变量 s2 和引用变量 mSample2 都是存在于栈中,但 mSample2 指向的对象是存在于堆上的。
- mSample3 指向的对象实体存放在堆上,包括这个对象的所有成员变量 s1 和 mSample1,而它自己存在于栈中。
5.8 成员和局部变量
- 接着上面案例可以知道
- 成员变量全部存储在堆中(包括基本数据类型,引用及引用的对象实体),因为他们属于类,类对象最终还是要被new出来的
- 局部变量的基本数据类型和引用存在栈中,应用的对象实体存储在堆中。因为它们属于方法当中的变量,生命周期会随着方法一起结束
5.9 了解下对象内存结构
- 对象的内存结构
- 对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
image
- 对象头的结构
- HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为"Mark Word"。
- 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,这点将在2.3.3节讨论。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
06.搞懂对象内存布局
6.1 对象在哪里分配
- 在 Java 虚拟机中,Java 堆和方法区是分配对象的主要区域
- 但是也存在一些特殊情况,例如 TLAB、栈上分配、标量替换等。 这些特殊情况的存在是虚拟机为了进一步优化对象分配和回收的效率而采用的特殊策略,可以作为知识储备。
- 1、Java 堆(Heap):
- Java 堆是绝大多数对象的分配区域,现代虚拟机会采用分代收集策略,因此 Java 堆又分为新生代、老生代和永生代。
- 如果新生代使用复制算法,又可以分为 Eden 区、From Survivor 区和 To Survivor 区。
- 除了这些每个线程都可以分配对象的区域,如果虚拟机开启了 TLAB 策略,那么虚拟机会在堆中为每个线程预先分配一小块内存,称为线程本地分配缓冲(Thread Local Allocation Buffer,TLAB)。
- 在 TLAB 上分配对象不需要同步锁定,可以加快对象分配速度(TLAB 中的对象依然是线程共享读取的,只是不允许其他线程在该区域分配对象);
- 2、方法区(Method Area):
- 方法区也是线程共享的区域,堆中存放的是生命周期较短的对象,而方法区中存放的是生命周期较长的对象,通常是一些支撑虚拟机执行的必要对象,将两种对象分开存储体现的是动静分离的思想,有利于内存管理。
- 存储在方法区中的数据包括已加载的 Class 对象、静态字段(本质上是 Class 对象中的实例字段,下文会解释)、常量池(例如 String.intern())和即时编译代码等;
- 3、栈上分配(Stack Allocation):
- 如果 Java 虚拟机通过逃逸分析后判断一个对象的生命周期不会逃逸到方法外,那么可以选择直接在栈上分配对象,而不是在堆上分配。
- 栈上分配的对象会随着栈帧出栈而销毁,不需要经过垃圾收集,能够缓解垃圾收集器的压力。
- 4、标量替换(Scalar Replacement):
- 在栈上分配策略的基础上,虚拟机还可以选择将对象分解为多个局部变量再进行栈上分配,连对象都不创建。
6.2 JOL分析对象内存布局
- 演示使用 JOL(Java Object Layout) 来分析 Java 对象的内存布局。
- JOL 是 OpenJDK 提供的对象内存布局分析工具,不过它只支持 HotSpot / OpenJDK 虚拟机,在其他虚拟机上使用会报错:
java.lang.IllegalStateException: Only HotSpot/OpenJDK VMs are supported
- 使用 JOL 分析 new Object() 在 HotSpot 虚拟机上的内存布局,模板程序如下:
// 步骤一:添加依赖 implementation 'org.openjdk.jol:jol-core:0.11' // 步骤二:创建对象 Object obj = new Object(); // 步骤三:打印对象内存布局 // 1. 输出虚拟机与对象内存布局相关的信息 System.out.println(VM.current().details()); // 2. 输出对象内存布局信息 System.out.println(ClassLayout.parseInstance(obj).toPrintable());
- 输出日志如下所示
# Running 64-bit HotSpot VM. # Using compressed oop with 3-bit shift. # Using compressed klass with 3-bit shift. # Objects are 8 bytes aligned. # Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] # Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
- 其中关于虚拟机的信息:
- Running 64-bit HotSpot VM. 表示运行在 64 位的 HotSpot 虚拟机;
- Using compressed oop with 3-bit shift. 指针压缩(后文解释);
- Using compressed klass with 3-bit shift. 指针压缩(后文解释);
- Objects are 8 bytes aligned. 表示对象按 8 字节对齐(后文解释);
- Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] :依次表示引用、boolean、byte、char、short、int、float、long、double 类型占用的长度;
- Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] :依次表示数组元素长度。
6.3 对象内存布局组成
- 在 Java 虚拟机中,对象的内存布局主要由 3 部分组成:
- 1、对象头(Header): 包括对象的运行时状态信息 Mark Work 和类型指针(直接指针访问方式),数据对象还会记录数组元素个数;
- 2、实例数据(Instance Data): 普通对象的实例数据包括当前类声明的实例字段以及父类声明的实例字段,而 Class 对象的实例数据包括当前类声明的静态字段和方法表等;
- 3、对齐填充(Padding): HotSpot 虚拟机对象的大小必须按 8 字节对齐,如果对象实际占用空间不足 8 字节的倍数,则会在对象末尾增加对齐填充。
image
6.4 对象内存布局详解
6.4.1 对象头(Header)
- Mark Work: Mark Work 是对象的运行时状态信息,包括哈希码、分代年龄、锁状态、偏向锁信息等。
- 由于 Mark Work 是与对象实例数据无关的额外存储成本,因此虚拟机选择将其设计为带状态的数据结构,会根据对象当前的不同状态而定义不同的含义;
image
- 类型指针(Class Pointer): 指向对象类型数据的指针,只有虚拟机采用直接指针的对象访问定位方式才需要在对象上记录类型指针,而采用句柄的对象访问定位方式不需要此指针;
- 数组长度: 数组类型的元素长度是不能提前确定的,但在创建对象后又是固定的,所以数组对象的对象头中会记录数组对象中实际元素的个数。
- 以下演示查看数组对象的对象头中的数组长度字段:
char [] str = new char[2]; System.out.println(ClassLayout.parseInstance(str).toPrintable());
- 输出日志,可以看到,对象头中有一块 4 字节的区域,显示该数组长度为 2。
[C object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 41 00 00 f8 (01000001 00000000 00000000 11111000) (-134217663) 12 4 (object header) 【数组长度:2】02 00 00 00 (00000010 00000000 00000000 00000000) (2) 16 4 char [C.<elements> N/A 20 4 (loss due to the next object alignment) Instance size: 24 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
6.4.2 实例数据(Instance Data)
- 普通对象和 Class 对象的实例数据区域是不同的,需要分开讨论:
- 1、普通对象: 包括当前类声明的实例字段以及父类声明的实例字段,不包括类的静态字段;
- 2、Class 对象: 包括当前类声明的静态字段和方法表等
- 其中,父类声明的实例字段会放在子类实例字段之前,而字段间的并不是按照源码中的声明顺序排列的,而是相同宽度的字段会分配在一起:
- 引用类型 > long/double > int/float > short/char > byte/boolean。
- 如果虚拟机开启 CompactFields 策略,那么子类较窄的字段有可能插入到父类变量的空隙中。
image
6.4.3 对齐填充(Padding)
- HotSpot 虚拟机对象的大小必须按 8 字节对齐,如果对象实际占用空间不足 8 字节的倍数,则会在对象末尾增加对齐填充。
- 对齐填充不仅能够保证对象的起始位置是规整的,同时也是实现指针压缩的一个前提。