在 Java 中,对象的创建过程离不开类的加载与初始化,因此理解类加载的原理和对象的内存布局,是掌握 JVM 性能优化的关键。
本章基于类加载机制的深入解析,将详细讲解对象的创建、内存布局、访问方式及分配策略,帮助你从理论到实践全面掌握 JVM 对象管理的底层逻辑。
类加载机制概述
类加载是 Java 对象创建的基础。
JVM 通过类加载器将 .class 文件中的二进制数据加载到内存,并将其转化为 JVM 可以识别的运行时数据结构。以下是类加载的核心步骤:
类加载的七个阶段
根据《Java 虚拟机规范》,类加载分为七个阶段:
- 加载 (Loading) :将 .class 文件的二进制数据加载到内存,生成 Class 对象。
- 验证 (Verification) :校验 .class 文件的格式和内容是否符合规范,确保安全性。
- 准备 (Preparation) :为静态变量分配内存并初始化默认值。
- 解析 (Resolution) :将符号引用替换为直接引用。
- 初始化 (Initialization) :执行静态变量的赋值及静态代码块。
- 使用 (Using) :通过程序调用类的静态变量或方法。
- 卸载 (Unloading) :释放类占用的内存资源。
根据 《Java 虚拟机规范》 中的规定,类加载可以分为七个阶段,分别为 加载 (Loading)、验证 (Verification)、准备 (Preparation)、解析 (Resolution)、初始化 (Initialization)、使用 (Using) 和 卸载 (Unloading),其中 验证、准备 和 解析 三个阶段整体又称为 链接 (Linking)。
图片
类加载就像从蓝图设计到建筑施工的过程:
- 加载阶段是获取蓝图,确保设计的正确性;
- 验证阶段是检测建筑规范;
- 准备与解析阶段是施工基础;
- 初始化阶段是建筑的竣工与验收。
加载阶段主要是使用 "类加载器" 将本地或者远程网络中的字节码文件,通过读字节流的方式加载到 Java 虚拟机内存中。在加载阶段中 Java 虚拟机主要完成以下三件事情:
- ① 通过一个类的全限定名称来获取定义此类的二进制字节流。
- ② 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- ③ 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区中这个类的各种数据的访问入口。
其中常用的类加载器有三种,分别是:
类加载器 | 描述 |
引导类加载器 BootstrapClassLoader | 引导类加载器是使用 C++ 语言实现的,用于加载 Java 中的核心类库的,一般会加载 |
扩展类加载器 ExtClassLoader | 扩展类加载器主要负责加载 Java 的扩展类库,一般会加载 |
应用类加载器 AppClassLoader | 应用类加载器是应用程序中默认的类加载器,可以加载 |
对象的内存分配与初始化
当类加载完成后,JVM 开始为新对象分配内存并完成初始化。
对象内存分配
确定分配区域
- 堆分配:大部分对象分配在堆中。
- 栈上分配:通过逃逸分析,局部且生命周期短的对象可分配在栈上。
分配方式
- 指针碰撞:堆内存连续,分配指针向空闲区域移动。
- 空闲列表:堆内存不连续,分配时通过列表找到合适的空闲块。
对象初始化流程
- JVM 将分配的内存清零(不包括对象头)。
- 调用对象的构造方法
,完成实例变量初始化。
对象的内存布局
Java 对象在内存中的布局分为三部分:对象头、实例数据 和 对齐填充。
图片
对象头
对象头包含以下内容:
- Mark Word ,存储对象的哈希码、GC 状态、锁标志等运行时信息。
- Class Pointer ,指向对象的类元信息,用于确定对象类型。
- 数组长度(仅数组对象) ,数组对象会额外存储数组长度信息。
对象头结构示意图
图片
对象访问方式
JVM 提供了两种对象访问模式:句柄池 和 直接指针。
句柄池
句柄:如果使用句柄访问对象,JAVA 堆中将会划分一块内存作为句柄池,reference 中存储的就是对象的句柄地址,句柄中包含对象实例数据与类型数据。
图片
优点:对象内存地址变化时,只需更新句柄,而无需修改引用。
直接指针
如果使用直接指针访问,则 reference 存储对象地址。优点:访问速度快,少了一次间接访问。
图片
对象内存分配策略
JVM 的内存分配策略与垃圾回收机制密切相关。以下是常见的内存分配方式:
- 栈上分配:通过逃逸分析,JVM 可将生命周期短的对象分配在栈上,避免 GC 的参与。
- 新生代与老年代分配:新生代分配,默认分配在 Eden 区;Survivor 区用于存活对象的复制和晋升。生命周期较长或大对象直接分配到老年代。
对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能直接分配在老年代中,分配的规则并不是百分之百固定的。
图片
大对象直接进入老年代
虚拟机提供了一个 -XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区和及两个 Survivor 区之间发生大量的内存复制。
长期存活的对象将进入老年代
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。
对象在 Survivor 空间中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄到达一定程度(最大为 15 岁),就将会被晋升到老年代。
对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。
对象是否能够晋升到老年代,也不全由-XX:MaxTenuringThreshold 参数控制,如果 Survivor 空间中相同年龄的所有对象大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
空间分配担保
新生代在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象之和(或者历次晋升老年代对象的平均大小)。
如果这个条件不成立,那么虚拟机将直接进行 Full GC 动作;如果这个条件成立,那么虚拟机就会进行一次 Minor GC 操作,但是这次 Minor GC 是有风险的,因为比较的值是平均值,可能出现极端的情况 —— 大量对象在 Minor GC 后还存活,这时就只好在失败后重新发起一次 Full GC。
总结
本章深入解析了类加载机制对对象创建的支持,探讨了 JVM 的内存布局、访问方式及分配策略。
通过理解这些底层原理,开发者可以有效优化代码性能,并在内存问题排查中更加游刃有余。