Java 类加载与对象内存分配机制详解
类加载机制
类加载:概念与时机
Java虚拟机把描述类的数据从Class文件(或其它来源)加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型。这个过程被称为类加载。
类型(Class)的生命周期其中有加载、验证、准备、解析和初始化五个阶段统称为类加载。
类加载的时机
- 遇到
new
、getstatic
、putstatic
或invokestatic
这四条字节码指令时。- 使用
new
关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法。
- 使用
- 使用
java.lang.reflect
包的方法对类进行反射调用的时候。 - 当初始化一个类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当一个接口中定义了JDK 8新加入的默认方法(default方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
类加载的详细过程
类加载的全过程是指加载、验证、准备、解析和初始化五个阶段。
1. 加载 (Loading)
此阶段是“类加载”过程的一个阶段,主要完成三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 来源不限于
.class
文件,可以是ZIP包(JAR/WAR)、网络、运行时计算生成(动态代理)等。
- 来源不限于
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
2. 验证 (Verification)
确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。主要包括四个检验动作:
- 文件格式验证:验证字节流是否符合Class文件格式的规范。
- 元数据验证:对字节码描述的信息进行语义分析,保证其符合Java语言规范(是否有父类、是否继承了final类、是否实现全部方法等)。
- 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的(保证跳转指令不会跳转到方法体以外的字节码上、保证类型转换是有效的等)。
- 符号引用验证:发生在解析阶段,确保解析动作能正常执行(能否通过全限定名找到对应的类、字段和方法的访问性是否可被当前类访问等)。
3. 准备 (Preparation)
正式为类变量(被static修饰的变量)分配内存并设置类变量的初始值的阶段。
- 内存分配在 方法区(Method Area) 中进行。
- 注意:
- 这里分配内存的仅包括类变量,不包括实例变量。
- 初始值通常是数据类型的零值(如
0
,0L
,null
,false
等)。 - 如果类字段的字段属性表中存在
ConstantValue
属性(即被static final
修饰的常量),那么在准备阶段变量值就会被初始化为代码中指定的值(如public static final int value = 123;
,在准备阶段后value
的值就是123,而不是0)。
4. 解析 (Resolution)
虚拟机将常量池内的**符号引用(Symbolic References)替换为直接引用(Direct References)**的过程。
- 符号引用:以一组符号来描述所引用的目标,与虚拟机实现的内存布局无关。
- 直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,与虚拟机内存布局相关。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。
5. 初始化 (Initialization)
类加载过程的最后一步。真正开始执行类中定义的Java程序代码(或者说字节码)。此阶段是执行类构造器<clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和**静态语句块(static{}
块)**中的语句合并产生的。编译器收集的顺序是由语句在源文件中出现的顺序决定的。<clinit>()
方法与类的构造函数(实例构造器<init>()
)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()
方法执行前,父类的<clinit>()
方法已经执行完毕。- 虚拟机会保证一个类的
<clinit>()
方法在多线程环境中被正确地加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()
方法,其他线程都需要阻塞等待。
类加载器 (ClassLoader)
通过一个类的全限定名来获取描述该类的二进制字节流, 这个动作被设计为放到Java虚拟机外部去实现,实现这个动作的代码被称为“类加载器。
类与类加载器的关系
对于任意一个类,都必须由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间。
比较两个类是否“相等”,只有在它们是由同一个类加载器加载的前提下才有意义。否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
双亲委派模型
从Java虚拟机的角度来讲,只存在两种不同的类加载器:
- 启动类加载器(Bootstrap ClassLoader):使用C++实现,是虚拟机自身的一部分。
- 所有其他的类加载器:由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类
java.lang.ClassLoader
。
从Java角度看,类加载器可以划分为三层模型:
- 启动类加载器 (Bootstrap Class Loader)
- 负责加载存放在
<JAVA_HOME>\lib
目录下的,或被-Xbootclasspath
参数指定的路径中的,并且是虚拟机识别的(按文件名识别,如rt.jar, tools.jar)类库。无法被Java程序直接引用。
- 负责加载存放在
- 扩展类加载器 (Extension Class Loader)
- 由
sun.misc.Launcher$ExtClassLoader
实现,负责加载<JAVA_HOME>\lib\ext
目录下的,或被java.ext.dirs
系统变量所指定的路径中的所有类库。开发者可以直接使用。
- 由
- 应用程序类加载器 (Application Class Loader / System Class Loader)
- 由
sun.misc.Launcher$AppClassLoader
实现。负责加载用户类路径(ClassPath)上所指定的类库。是程序中默认的类加载器。
- 由
工作过程
当一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
优点:
- 保证了Java程序的稳定运行。例如
java.lang.Object
类,无论哪个类加载器要加载它,最终都是委派给处于模型顶端的启动类加载器进行加载,从而保证了在整个程序中,Object
类都是同一个(由同一个类加载器和这个类本身确定唯一性)。 - 保证了Java核心API不被篡改。
对象内存分配方式
对象内存分配主要指在Java堆上为新生对象分配内存空间。具体分配方式取决于Java堆的内存布局,而堆的内存布局又由所采用的垃圾收集器决定。主要分为两种方式:
指针碰撞 (Bump the Pointer)
- 适用场景:内存地址是连续的(主要用于新生代)。
- 支持收集器:主要为Serial和ParNew等带有压缩整理(Compact)功能的收集器。
- 工作原理:假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器。分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
- 优点
- 分配速度极快。仅仅是指针的移动和判断,效率高。
- 缺点
- 要求内存必须规整。这意味着必须依赖带有内存整理(Compact)能力的垃圾收集器,会在垃圾回收后对存活对象进行整理,消除碎片。
空闲链表 (Free List)
- 适用场景:内存地址不连续(主要用于老年代)。
- 支持收集器:CMS收集器和基于Mark-Sweep(标记-清除)算法的收集器。
- 工作原理:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录上哪些内存块是可用的。在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
- 优点
- 可以处理不规则的内存空间。灵活性高,适用于标记-清除这类不整理内存的收集器。
- 缺点
- 分配速度慢。需要遍历链表来寻找合适的内存块。
- 容易产生内存碎片。由于分配的内存块可能不连续,频繁分配后可能产生大量不连续的内存碎片。
内存分配安全
在并发场景下,对象创建是一个非常频繁的操作。虚拟机在为线程A分配内存,指针还未修改时,线程B可能同时使用了原来的指针来分配内存,这就导致了线程安全性问题。
解决方案
1. CAS乐观锁 + 失败重试
- 原理:虚拟机采用**CAS(Compare And Swap)**配合失败重试的方式保证更新操作的原子性。这是一种非阻塞式的同步机制。
- 过程:当多个线程同时申请内存时,JVM通过CAS操作来保证只有一个线程能成功移动指针(或更新空闲链表)。失败的线程会重试整个分配过程,直到成功为止。
2. TLAB (Thread Local Allocation Buffer)
- 原理:JVM为每个线程在Java堆中预先分配一小块私有内存,称为本地线程分配缓冲(TLAB)。
- 过程:哪个线程要分配内存,就在自己的TLAB上分配。由于TLAB是线程私有的,分配时无需任何锁同步,因此速度极快。
- 目的:将全局的并发竞争问题转化为线程本地的分配问题,极大地提升了分配效率。只有当TLAB用完并分配新的TLAB时,才需要同步锁。
- 注意:TLAB并不是对象最终的存储位置,它只是优先在本地分配。如果对象很大,或者TLAB剩余空间不足,它仍然可能会直接在堆的共享Eden区分配。
堆中的对象布局与访问定位
一个对象在堆内存中存储的布局可以分为三个区域:
1. 对象头 (Header)
对象头包含两类信息:
- Mark Word:用于存储对象自身的运行时数据。
- 长度:在32位系统占4 byte,64位系统占8 byte。
- 内容:包括哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。它是
synchronized
关键字实现锁的基础。
- 类型指针:即指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- 长度:在32位系统占4 byte,64位系统占8 byte(未开启压缩指针)。
- 数组长度(如果对象是数组):如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。
- 长度:占4 byte。
因此,一个普通的Java对象头在64位系统(开启压缩指针)下占12 byte(8 bytes Mark Word + 4 bytes 类型指针),如果是数组则占16 byte(额外增加4 bytes 数组长度)。
2. 实例数据 (Instance Data)
- 对象真正存储的有效信息,即我们在程序代码里所定义的各种类型的字段内容(包括从父类继承下来的)。
- 这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
3. 对齐填充 (Padding)
- 这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
- 由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。对象头部分已经被设计成正好是8字节的倍数,因此,如果实例数据部分没有对齐的话,就需要通过对齐填充来补全。
如何访问一个对象
Java程序需要通过栈上的reference数据来操作堆上的具体对象。reference只是一个指向对象的引用,而对象的访问方式取决于虚拟机的实现。主流的访问方式有两种:使用句柄和直接指针。
句柄访问 (Handle)
-
实现方式:Java堆中将会划分出一块内存来作为句柄池。reference中存储的就是对象的句柄地址。而句柄中则包含了对象实例数据与类型数据各自的具体地址信息。
-
示意图:
reference → 句柄地址 → (实例数据指针 → 实例数据) + (类型数据指针 → 方法区中的类型数据)
-
优点:
- 稳定。对象被移动(例如在垃圾回收时被标记-整理算法移动)时,只会改变句柄中的实例数据指针,而reference本身不需要被修改。
-
缺点:
- 访问速度相对较慢。需要两次指针定位才能访问到对象实例数据。
直接指针 (Direct Pointer) - HotSpot VM采用的方式
-
实现方式:reference中存储的就是对象的直接地址。该地址直接指向对象实例数据,而对象实例数据中的对象头则包含了指向方法区中对象类型数据的指针。
-
示意图:
reference → 对象实例数据 (含指向类型数据的指针) → 方法区中的类型数据
-
优点:
- 访问速度更快。只需要一次指针定位,节省了一次指针定位的时间开销。由于对象访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
-
缺点:
- 对象移动时更复杂。当对象被移动时(垃圾回收时很常见),reference本身也需要被更新。
HotSpot虚拟机主要使用直接指针方式进行对象访问,因为它最主要的优势——性能高,符合其设计理念。但在其他语言(如C#)的虚拟机中,句柄访问方式更为常见。