深入学习Java虚拟机——类加载机制

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 推荐书籍《深入理解Java虚拟机》,本文为个人学习笔记,删除一些不必要文字,并加入部分个人理解,日后复习较为简洁易懂

当Java源码编译为字节码文件(Class类文件,即一串2进制字节流)时,虚拟机是如何将字节码文件加载到虚拟机中成为一个Class对象的?

1. 类加载

    1. 类从被加载到虚拟机内存中开始,到卸出内存为止,它的整个生命内存周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个阶段统称为连接。

    2. 在类的加载过程的7个阶段中,加载、验证、准备、初始化、卸载这5个阶段的顺序是固定的,类加载过程中这5个阶段必须按照这个顺序开始,顺序开始指这几个阶段都是互相交叉的混合式进行,也就是说会在一个阶段执行过程中调用另一个阶段,而不是按顺序完成每个阶段,而解析阶段则不一定:解析在某种情况下可以在初始化阶段之后执行,这是为了支持Java的运行时绑定(动态绑定或晚期绑定)。

    3. 对于类加载过程中加载阶段的执行时机并没有明确指定,但初始化阶段(加载、验证、准备自然会在这之前开始)的时机有以下几项:

(1)遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行过初始化,则必须进行初始化。这四条指令的源代码场景为:使用new关键字实例化对象,读取或设置一个静态变量的值,调用静态方法。

(2)使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行过初始化,则必须进行初始化。

(3)当初始化一个类的时候,如果其父类还没有初始化,则先进行父类的初始化。

(4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的类),虚拟机会先初始这个类。

(5)使用jdk1.7的动态语言支持时,如果有一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有初始化,则会触发该类的初始化。

    注意:类的初始化必须满足以上条件中任何一个的所有要求,不能少也不能加,比如static静态变量前再加上final修饰符之后,访问或设置其值就不会引起类的初始化,因为对于常量的引用会直接将该引用转化成常量值,存储进入调用该常量的所在类的常量池中,使用时也只会使用常量池中的值,而不是原静态常量的reference,也就与静态常量所在类无关了,所以不会对静态常量所在的类初始化;

如果调用静态字段,只有直接定义该字段的类会被初始化,如果在其子类中调用不会初始化子类,只会初始化父类。

如果是new某一个类的数组类型的变量,同样不会初始化该类,在虚拟机指令中进行newarray指令执行的是  "[*.*.ClassName" 这样一个类的初始化,也就是由虚拟机自动生成的用  “ [ ” +类的全限定名 生成的直接继承于Object类的子类,所以对于一维数组对象的引用我们可以使用length属性和clone方法。

2. 类加载的过程

2.1 加载

    1. 加载阶段,虚拟机需要完成:

(1)通过一个类的全限定名来获取定义此类的二进制字节流。

(2)将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。

(3)在内存(准确说是方法区)中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

对于非数组类的加载过程,其字节码文件可以有多种不同的方式来获取,而非数组类的字节码文件可以使用虚拟机系统提供的引导类加载器完成,也可以使用自己定义的类加载器完成。

    2. 对于数组类的加载,与非数组类使用字节码文件创建有所不同,它是由虚拟机直接创建的,但数组类的元素类型(去掉所有维度的类型)仍然需要类加载器去创建,一个数组类(比如说数组类C)的创建过程有以下原则:

(1)如果数组的组件类型是引用类型,那就递归采用上面所说的加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间上被标识。

(2)如果数组的组件类型不是引用类型,如int[]数组,Java虚拟机将会把数组C标记为与类引导加载器关联。

(3)数组类的可见性与它的组件类型的可见性一致,如果数组组件类型不是引用类型,那数组的可见性默认为public。

    3. 加载阶段与连接(验证、准备、解析)阶段是交叉进行的,但加载阶段与连接阶段的先后开始顺序是固定的。

2.2 验证

    1. 这一阶段的目的是:为了确保Class文件的字节流中的信息符合当前虚拟机的要求,并且不会危害虚拟机的安全。

    2. 需要完成4个检验工作:

(1)文件格式检验:检验字节流是否符合Class文件格式的规范,比如以魔数(0xCAFEBABE)开头等验证点。

(2)元数据验证:对字节码描述的信息进行语义分析,保证其描述的信息符合Java语言规范,比如这个类是否有父类,是否继承了不允许集成的类等验证点。

(3)字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这一过程是最复杂的。

(4)符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,这个动作将在类加载过程的第四阶段解析中发生,该验证是为了对类自身以外的信息进行匹配性校验。

2.3 准备

    1. 准备阶段是正式给类变量分配内存并设置变量初始值的阶段,这些变量所使用的内存都在方法区中分配。这里的变量是指类变量,也就是有static修饰的变量,而不包括实例变量,实例变量将通过new生成对象时一同在堆中分配空间。此外,对于仅有static修饰的变量,在通常情况下,准备阶段中的设置变量初始值将统一设置为 0 、false、或者 null 值,如果在源码中有赋值语句如下

public static int i=111;

那么会在类的初始化阶段再进行赋值操作;而对于有 static 和 final共同修饰的常量,如下

public static final int i=111;

那么就会在准备阶段就将 i 直接赋值为111,因为此时 i 在编译时会成为ConstantValue属性,根据这个属性虚拟机会直接将将 i 赋值为111。

2.4 解析

    1. 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info。

    2. 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能准确的定位到目标即可。有符号引用,但内存不一定有符号引用所指向的目标。

    3. 直接引用:可以是指向目标的指针、相对偏移量或间接定位到目标的句柄。如果直接引用存在,那么内存中必定由直接引用所指向的目标。

    4. 类或接口的解析过程

    1.如果当前代码所处的类为D,要把一个从未解析的符号引用N作为一个类或接口C的直接引用,步骤如下

(1)如果C不是一个数组类型,那么虚拟机会把代表N的全限定名传递给D的类加载器去加载这个类C,在加载C类过程中,由于C中可能还会涉及其他类的加载,所以会触发其他相关类的加载,例如父类或接口,一旦有任何一个加载过程出现异常,解析过程就失败了。

(2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是“[Ljava/lang/Ingeter”(L 代表的是引用类型),然后按照上面第(1)点的规则加载数组元素类型。

(3)如果以上步骤没有异常,则类C以及其相关的类或接口已经在虚拟机内存中存在,是一个有效的类或接口了,但还需要最后一步,验证类D是否对C具有访问权限,如果不具备,则抛出java.lang.IllegalAccessError异常。

    5. 字段解析

    1. 要解析一个从未解析的字段符合引用,首先会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用,如果在这个过程中发生异常,那么整个字段解析的过程都会结束;如果该解析成功完成,如果将这个字段被声明的类或接口用C表示,那么对C进行后续解析的步骤如下

(1)如果C本身就包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束。

(2)否则,如果C实现了接口或类(但C不能是Object类),就会按照继承关系从下往上搜索其父类或接口,如果找到了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束。

(3)以上都查找不到,则查找失败,抛出java.lang.NoSuchFieldError异常。

注意,实际上如果在父类或接口中出现了与子类中相同的名称,那么将无法通过编译。

如果成功找到并返回了直接引用,还需要对字段进行权限验证,如果不具备对此字段的访问权限,则抛出java.lang.IllegalAccessError异常。

    6. 类方法解析

    1. 首先要依据类方法表中的class_index项中索引代表的类或接口的符号引用,而这个类或接口的符号引用就是当前方法所属的类或接口,如果解析成功,用C来表示这个类或接口,后续步骤如下:

(1)如果发现类方法表中class_index中索引指向的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。

(2)如果通过第一步,则在类C中查找是否有简单名称与描述符都与目标匹配的方法,如果有则返回这个方法的直接引用,查找结束。

(3)否则,则在类C中的父类中查找是否有简单名称与描述符都与目标匹配的方法,如果有则返回这个方法的直接引用,查找结束。

(4)否则,则在类C的父接口列表,以及父接口所继承的父接口中查找是否有简单名称与描述符都与目标匹配的方法,如果有则说明C是抽象类,查找结束,抛出java.lang.AbstractMethodError

(5)否则,方法查找失败,抛出java.lang.NoSuchMethodError

如果成功找到并返回了直接引用,还需要对方法进行权限验证,如果不具备对此方法的访问权限,则抛出java.lang.IllegalAccessError异常。

    7. 接口方法解析

    1. 首先要依据接口方法表中的class_index项中索引代表的类或接口的符号引用,而这个类或接口的符号引用就是当前方法所属的类或接口,如果解析成功,用C来表示这个类或接口,后续步骤如下:

(1)如果发现类方法表中class_index中索引指向的C是个类,那就直接抛出java.lang.IncompatibleClassChangeError异常。

(2)如果通过第一步,则在接口C中查找是否有简单名称与描述符都与目标匹配的方法,如果有则返回这个方法的直接引用,查找结束。

(3)否则,则在类C的父接口列表,以及父接口所继承的父接口中递归查找是否有简单名称与描述符都与目标匹配的方法,直到Object类为止,如果有则返回这个方法的直接引用,查找结束。

(4)否则,方法查找失败,抛出java.lang.NoSuchMethodError

接口中所有方法默认都是public,不存在权限问题。

2.5 初始化

    1. 初始化是类加载过程的最后一步,在准备阶段虚拟机已经对类变量进行了一次初始化过程,在之前的阶段过程中,除了加载阶段用户可以自定义类加载器之外,其余阶段的工作完全由虚拟机主导和控制,而在初始化阶段,才是真正执行字节码的阶段。初始化阶段由程序员自己去决定初始化的类变量和其他数据。或者说,初始化就是执行类构造器方法<client>()的过程。

    2. 类构造器方法<client>()运行中的细节:

(1)<client>()方法由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{ }块)中的语句合并而成,编译器收集顺序有语句在源文件中出现的先后顺序决定,静态语句块中可以访问并且赋值在静态语句块之前出现的类变量,而对于定义在静态语句块之后类变量,静态语句块中只能进行赋值而不能进行访问。

(2)<client>()方法与类的对象构造器<init>( )方法不同,它不需要显示调用父类构造器,虚拟机会在子类<client>()执行之前执行完毕父类的<client>()。因此虚拟机中第一个被执行的肯定是Object类。

(3)由于父类的<client>()先执行,那么父类的静态语句块和类变量的赋值要优先于子类中的。

(4)<client>()对于类或接口不是必须的,如果类或接口中不含对类变量或静态语句块,那么编译器可以不生成该类的<client>()方法

(5)接口中不能使用静态语句块,但仍然有类变量的初始化赋值操作,因此接口也会生成<client>()方法。但接口与类不同的是,执行接口的<client>()不需要执行父接口的<client>(),只有当父接口中的类变量使用时,才会执行父接口的<client>(),另外,接口的实现类在初始化时也不会执行接口的<client>()

(6)虚拟机会保证一个类的<client>()方法再多线程环境中被正确的加锁、同步,如果多个线程去初始化一个类,那么只有一个线程去执行该类的<client>()方法,其他线程都会阻塞等待,直到活动线程执行<client>()完毕。如果<client>()方法执行时间很久,就有可能造成多个进程阻塞。

3.  类加载器

    通过一个类的全限定名来获取描述此类的二进制字节流,这一动作会在虚拟机的外部实现,而实现这个动作的代码块就是类加载器。

3.1 类与类加载器

    1. 类加载器虽然是用于实现类的加载动作,但其作用不限于类的加载阶段。对于任何一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,也就是说:比较两个类(Class对象)是否相等,必须是在这两个类都在同一个类加载器加载的前提下才有意义,否则即使这两个类来自于同一个Class文件,被同一个虚拟机加载,是要类加载器不同,那么这两个类就不相等。判断两个类是否相等可以通过类的Class对象的equals( )方法、isAssignableFrom()方法、isInstance()方法返回的结果,或者使用类的实例对象通过instanceof关键字来运算产生的结果。

public class ClassLoaderTest {
	public static void main(String[] args) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
		ClassLoader myLoader=new ClassLoader() {
			@Override
			public Class<?> loadClass(String name) throws ClassNotFoundException {
				try{
					String filename=name.substring(name.lastIndexOf(".")+1)+".class";
					System.out.println(filename);//执行结果中的前4行输出都来自这里
					InputStream is=getClass().getResourceAsStream(filename);
					if(is==null){
						return super.loadClass(name);
					}
					byte[] b=new byte[is.available()];
					is.read(b);
					return defineClass(name, b, 0, b.length);
				}catch (IOException e) {
					throw new ClassNotFoundException(name);
				}
				
			}
		};
		Class<?> c=myLoader.loadClass("About_Jvm.ClassLoaderTest");//此行代码的执行结果为1、2行
		Object obj=c.newInstance();              //为何这里会引起执行结果中的3、4行?
        /*
		 * 个人猜测:当前代码的类ClassLoaderTest定义为A,在A类中使用myLoader这个自定义类加载器加载后会生成另一个
		 * ClassLoaderTest类,称为B,虽然他们都来自于同一个字节码文件,但他们在虚拟机中并不是同一个Class对象。
		 * 所以我们将A和B应该看做两个类,更方便分析。
		 * 对于类A,它由jdk中的应用程序类加载器来加载,所以加载A类不会有任何输出,而对于B类,
		 * 是在A类中采用自定义的类加载器匿名内部类Ca类的对象myLoader来加载的,Ca类属于A类。
		 * 
		 * B类的加载阶段完成后,B类中与A类有相同的代码,也有一个类加载器匿名内部类Cb,Cb属于B类中的,
		 * 当对B类进行实例化时,会进行对B类的后续步骤,验证,解析,准备,然后初始化,再调用无参
		 * 构造方法生成对象,但进行这几个阶段时会发现Cb类并未加载,或者说依据Cb类在常量池中的符号
		 * “ClassLoaderTest$1”,无法在方法区中找到对应B类中的ClassLoaderTest$1类的Class对象
		 * 所以,此时使用B类的类加载器,也就是A类中的myLoader来进行尝试加载,首先加载
		 * ClassLoaderTest$1的父类ClassLoader,然后在加载ClassLoaderTest$1类。
		 * 所以会出现执行结果中的3、4行
		 */
		System.out.println(obj.getClass());
		System.out.println(obj instanceof About_Jvm.ClassLoaderTest);
	}
}

//执行结果
ClassLoaderTest.class   
Object.class           
ClassLoader.class       
ClassLoaderTest$1.class
class About_Jvm.ClassLoaderTest
false

上述代码中的ClassLoaderTest 类由两个类加载器完成加载,一个是应用程序类加载器,另一个是自定义加载器,所以当进行比较时,结果为false。而对于其中类加载器,会涉及到双亲委派模型。

3.2 双亲委派模型

类加载器的双亲委派模型

    1. 类加载器在虚拟机角度来说,有两种:一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分,另一种就是其他所有的类加载器,由Java实现,是虚拟机外的模块,并且全部继承自抽象类java.lang.ClassLoader。

    2. 在开发者角度来看,虚拟机可以划分为更细致的4中类加载器:

(1)启动类加载器(Bootstrap ClassLoader):负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclassbath参数所指定的路径,并且是虚拟机识别的类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用。

(2)扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,可以使用扩展类加载器。

(3)应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现。这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称为系统加载器,负责加载用户类路径上所指定的类库,开发可以直接使用这个类加载器,一般默认该类加载器。

(4)自定义类加载器(User ClassLoader):开发时自定义的加载器,可以加载指定路径中的类。

    3. 双亲委派模型:双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。类加载器之间的父子关系一般不会以继承关系,而是以组合关系来复用父加载器的代码。

    4. 双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,他不会立即自己去尝试加载这个类,而是会先将这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此最终所有的加载请求都会传给顶层的启动类加载器中,当父加载器无法完成这个请求时,子加载器就会自己去加载。

    5. 优点:双亲委派模型使Java类随着它的类加载器一起具备了一种优先级的层次关系。例如java.lang.Object类,它存放在rt.jar中,无论哪一个类加载器加载这个类,最终都会委派给启动类加载器加载,所以Object类在程序中永远都是同一个类。相反,如果没有这种双亲委派机制,如果用户自己也编写了一个Object类并放在程序的classpath中,那么系统将会出现多个Object类,应用程序就会变得混乱。所以,如果编写一个与rt.jar类库中已有的Java类,可以被编译,但永远无法加载运行。

    6. 实现自定义类加载器:双亲委派的代码都在java.lang.ClassLoader的loadClass()方法中,因此,如果要编写自己的ClassLoader类,就必须继承java.lang.ClassLoader抽象类,重写loadClass()方法来实现自己的类加载器,比如在 3.1 中的代码实例就是一个标准的实现自定义类加载器的例子。

相关文章
|
27天前
|
XML Java 编译器
Java学习十六—掌握注解:让编程更简单
Java 注解(Annotation)是一种特殊的语法结构,可以在代码中嵌入元数据。它们不直接影响代码的运行,但可以通过工具和框架提供额外的信息,帮助在编译、部署或运行时进行处理。
86 43
Java学习十六—掌握注解:让编程更简单
|
12天前
|
Java 大数据 API
14天Java基础学习——第1天:Java入门和环境搭建
本文介绍了Java的基础知识,包括Java的简介、历史和应用领域。详细讲解了如何安装JDK并配置环境变量,以及如何使用IntelliJ IDEA创建和运行Java项目。通过示例代码“HelloWorld.java”,展示了从编写到运行的全过程。适合初学者快速入门Java编程。
|
1月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
63 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
1月前
|
存储 SQL 小程序
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
这篇文章详细介绍了Java虚拟机(JVM)的运行时数据区域和JVM指令集,包括程序计数器、虚拟机栈、本地方法栈、直接内存、方法区和堆,以及栈帧的组成部分和执行流程。
31 2
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
|
19天前
|
JavaScript Java 项目管理
Java毕设学习 基于SpringBoot + Vue 的医院管理系统 持续给大家寻找Java毕设学习项目(附源码)
基于SpringBoot + Vue的医院管理系统,涵盖医院、患者、挂号、药物、检查、病床、排班管理和数据分析等功能。开发工具为IDEA和HBuilder X,环境需配置jdk8、Node.js14、MySQL8。文末提供源码下载链接。
|
1月前
|
Java 应用服务中间件 程序员
JVM知识体系学习八:OOM的案例(承接上篇博文,可以作为面试中的案例)
这篇文章通过多个案例深入探讨了Java虚拟机(JVM)中的内存溢出问题,涵盖了堆内存、方法区、直接内存和栈内存溢出的原因、诊断方法和解决方案,并讨论了不同JDK版本垃圾回收器的变化。
30 4
|
1月前
|
Arthas 监控 Java
JVM知识体系学习七:了解JVM常用命令行参数、GC日志详解、调优三大方面(JVM规划和预调优、优化JVM环境、JVM运行出现的各种问题)、Arthas
这篇文章全面介绍了JVM的命令行参数、GC日志分析以及性能调优的各个方面,包括监控工具使用和实际案例分析。
43 3
|
1月前
|
存储 Java
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
这篇文章详细地介绍了Java对象的创建过程、内存布局、对象头的MarkWord、对象的定位方式以及对象的分配策略,并深入探讨了happens-before原则以确保多线程环境下的正确同步。
53 0
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
|
1月前
|
存储 算法 Java
带你学习java的数组军队列
带你学习java的数组军队列
35 0
|
5月前
|
监控 Java 调度
探秘Java虚拟机(JVM)性能调优:技术要点与实战策略
【6月更文挑战第30天】**探索JVM性能调优:**关注堆内存配置(Xms, Xmx, XX:NewRatio, XX:SurvivorRatio),选择适合的垃圾收集器(如Parallel, CMS, G1),利用jstat, jmap等工具诊断,解决Full GC问题,实战中结合MAT分析内存泄露。调优是平衡内存占用、延迟和吞吐量的艺术,借助VisualVM等工具提升系统在高负载下的稳定性与效率。
102 1