JVM类加载机制
定义:在代码编译后,就会生成JVM(Java虚拟机)能够识别的二进制字节流文件(*.class)。而JVM把Class文件中的类描述数据从文件加载到内存,并对数据进行校验、转换解析、初始化,使这些数据最终成为可以被JVM直接使用的Java类型,这个说来简单但实际复杂的过程叫做JVM的类加载机制。
Class文件中的“类”从加载到JVM内存中,到卸载出内存过程有七个生命周期阶段。类加载机制包括了前五个阶段。如下图所示:
过程:
类的加载:我们平常说的加载大多不是指的类加载机制,只是类加载机制中的第一步加载。在这个阶段,JVM主要完成三件事:
1、通过一个类的全限定名(包名与类名)来获取定义此类的二进制字节流(Class文件)。而获取的方式,可以通过jar包、war包、网络中获取、JSP文件生成等方式。
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。这里只是转化了数据结构,并未合并数据。(方法区就是用来存放已被加载的类信息,常量,静态变量,编译后的代码的运行时内存区域)
3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。这个Class对象并没有规定是在Java堆内存中,它比较特殊,虽为对象,但存放在方法区中。
类的连接:类的加载过程后生成了类的java.lang.Class对象,接着会进入连接阶段,连接阶段负责将类的二进制数据合并入JRE(Java运行时环境)中。类的连接大致分三个阶段。
1、验证:验证被加载后的类是否有正确的结构,类数据是否会符合虚拟机的要求,确保不会危害虚拟机安全。
2、准备:为类的静态变量(static filed)在方法区分配内存,并赋默认初值(0值或null值)。对于非静态的变量,则不会为它们分配内存。
如static int a = 100,静态变量a就会在准备阶段被赋默认值0。
对于一般的成员变量是在类实例化时候,随对象一起分配在堆内存中。
另外,静态常量(static final filed)会在准备阶段赋程序设定的初值。
如static final int a = 666,静态常量a就会在准备阶段被直接赋值为666;对于静态变量,这个操作是在初始化阶段进行的。
3、解析:将类的二进制数据中的符号引用换为直接引用。
类的初始化:初始化阶段是根据用户程序中的初始化语句为类的静态变量赋予正确的初始值。这里初始化执行逻辑最终会体现在类构造器方法clinit方法中。该方法由编译器在编译阶段生成,它封装了两部分内容:静态变量的初始化语句和静态语句块。
类的初始化的主要工作是为静态变量赋程序设定的初值。
如static int a = 100;在准备阶段,a被赋默认值0,在初始化阶段就会被赋值为100。
初始化执行时机:jvm规范明确规定了初始化执行条件,只要满足以下四个条件之一,就会执行初始化工作
(1) 通过new关键字实例化对象、读取或设置类的静态变量、调用类的静态方法(对应new,getstatic,putstatic,invokespecial这四条字节码指令)。
(2) 通过反射方式执行以上行为时。
(3) 初始化子类的时候,会触发父类的初始化。
(4) 作为程序入口直接运行时的主类。
初始化过程:初始化过程包括两步:
(1) 如果类存在直接父类,并且父类没有被初始化则对直接父类进行初始化。
(2) 如果类当前存在clinit方法,则执行clinit方法。
需要注意的是接口(interface)的初始化并不要求先初始化它的父接口,只有当使用父接口的变量的时候才会进行初始化。(接口中不能有static块,但可以有变量初始化)
clinit方法存在的条件:并不是每个类都有clinit方法,如下情况下不会有clinit方法:
a. 类没有静态变量也没有静态语句块
b.类中虽然定义了静态变量,但是没有给出明确的初始化语句。
c.如果类中仅包含了final static的静态变量的初始化语句,而且初始化语句采用编译时常量表达时,也不会有clinit方法。
类的主动引用和被动引用的区别
类的主动引用(一定会发生类的初始化)
—— new一个类的对象
—— 调用类的静态成员(除了final常量)和静态方法
—— 使用java.lang.reflect包的方法对类进行反射调用
—— 当虚拟机启动,先启动main方法所在的类
—— 当初始化一个类,如果父类没有被初始化,则先初始化它的父类
类的被动引用(不会发生类的初始化)
—— 当访问一个静态域时,只有真正声明这个域的类才会被初始化
通过子类引用父类的静态变量,不会导致子类初始化
—— 通过数组定义类引用,不会触发此类的初始化
—— 引用final常量不会触发此类的初始化(常量在编译阶段就存入类的常量池中)
类加载器:类加载器的作用不仅仅是实现类的加载,它还与类的的“相等”判定有关,关系着Java“相等”判定方法的返回结果,只有在满足如下三个类“相等”判定条件,才能判定两个类相等:
1、两个类来自同一个Class文件
2、两个类是由同一个虚拟机加载
3、两个类是由同一个类加载器加载
JVM类加载器分类详解:
1、Bootstrap ClassLoader:启动类加载器,也叫根类加载器,它负责加载Java的核心类库,加载如(%JAVA_HOME%/lib)目录下的rt.jar(包含System、String这样的核心类)这样的核心类库。根类加载器非常特殊,它不是java.lang.ClassLoader的子类,它是JVM自身内部由C/C++实现的,并不是Java实现的。
2、Extension ClassLoader:扩展类加载器,它负责加载扩展目录(%JAVA_HOME%/jre/lib/ext)下的jar包,用户可以把自己开发的类打包成jar包放在这个目录下即可扩展核心类以外的新功能。
3、System ClassLoader\APP ClassLoader:系统类加载器或称为应用程序类加载器,是加载CLASSPATH环境变量所指定的jar包与类路径。一般来说,用户自定义的类就是由APP ClassLoader加载的。
各种类加载器间关系:参考ClassLoader源代码会发现,这些Class之间并不是采用继承的方式实现父子关系,而是采用组合方式。
类加载器的双亲委派加载机制:当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
双亲委派模型的源码实现:主要体现在ClassLoader的loadClass()方法中,思路很简单:先检查是否已经被加载过,若没有加载则调用父类加载器的loadClass()方法,若父类加载器为空则默认使用启动类加载器作为父类加载器。如果父类加载器加载失败,抛出ClassNotFoundException异常后,调用自己的findClass()方法进行加载。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected synchronized Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// First, check if the class has already been loaded
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
对象的创建、内存布局和访问定位
对象的创建
①类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
②分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式:(补充内容,需要掌握)
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的
内存分配并发问题:(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在Eden区分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配
③初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
④设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
⑤执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为3块区域:对象头、实例数据和对齐填充。
对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希码、GC分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:
句柄: 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象的地址。
两种访问方式的比较:这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。