【摘要】 类加载子系统
1、class文件结构
我们都知道,Java程序的运行过程是首先编写java源程序,然后通过编译器将源程序编译成class文件,也就是Java字节码文件,然后通过jvm虚拟机将字节码文件加载到内存中,再由jvm虚拟机中的执行引擎进行执行字节码中的指令,那jvm虚拟机是如何根据字节码文件将对应的数据或指令加载到内存中呢?又是如果去解析且执行对应的指令…
类加载子系统
1、class文件结构
我们都知道,Java程序的运行过程是首先编写java源程序,然后通过编译器将源程序编译成class文件,也就是Java字节码文件,然后通过jvm虚拟机将字节码文件加载到内存中,再由jvm虚拟机中的执行引擎进行执行字节码中的指令,那jvm虚拟机是如何根据字节码文件将对应的数据或指令加载到内存中呢?又是如果去解析且执行对应的指令呢?我们就得先去了解一下字节码文件的结构是怎样的,这样就能知道jvm虚拟机是如果去加载内存、又如果去解析指令。
我们先来分析一下一个class文件到底是由那些部分组成,每一个组成部分又是具有什么样的作用。首先我们先编写一个Java源程序代码Main.java,然后再通过Java编译指令javac Main.java将java文件编译成class字节码文件,然后我们就能查看对应的字节码文件是长啥样了。
Main.java文件
public class Main { public static void main(String[] args){ int a = 0; int b = 1; int c = 2; int result = a + b + c; System.out.println("result: " + result); }
}
然后通过javac Main.java编译成的class字节码文件。
上图的字节码文件左侧表示的就是虚拟内存的地址,而右侧的二进制数字就是将要存储到内存中的数据或指令。右侧的二进制数字我们是看不出什么信息的,它需要jvm通过它的class字节码文件结构规范去解析这些二进制数字,才能转换成对应的数据和指令,再把对应的数据和指令存储到内存中,然后由jvm中的执行引擎一步步的去执行指令,达到程序运行的结果。所需jvm的class文件结构是怎样的呢?
class文件结构
ClassFile { u4 magic; u2 minor_version; u2 major_version; u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; u2 access_flags; u2 this_class; u2 super_class; u2 interfaces_count; u2 interfaces[interfaces_count]; u2 fields_count; field_info fields[fields_count]; u2 methods_count; method_info methods[methods_count]; u2 attributes_count; attribute_info attributes[attributes_count];
}
上面就是class字节码文件结构的规范,我们根据这个规范就能去解析class字节文件中的组成,比如:第一项,u4 magic表示的就是字节码文件的前四个字节的二进制数字的魔术,它的作用就是判断这个文件是否是jvm虚拟机所认识的字节码文件,是为一个固定值,第二项和第三项即 u2 minor_version和u2 major_version则表示接下来的两个自己表示的是class文件的副版本号,再接下来的两个字节是主版本号,第四项 u2 constant_pool_count表示接下来的两个字节表示的是常量池的字节数量,假如是n个字节,那接下来的n个字节就表示的就是常量池中的数据。 所以我们参照class文件的结构规范就能够去解析出字节码文件中的二进制数字是代表什么东西,我们可以参照对应的文档去解析此字节码文件,就能得到对应的数据和指令信息。
但是如果我们自己通过对照着class文件结构的规范去解析字节码文件是比较繁琐的,我们可以通过一个指令javap -v Main.class将字节码文件进行反编译,这样能够得到便于我们去理解的class字节码文件结构的一种表达形式,类似于汇编语言的形式。
Main.class文件:
通过反编译后得到的文件,Main.txt:
Classfile /F:/software/Typora/jvm/pos/Main.class
Last modified 2021-4-15; size 643 bytes
MD5 checksum 05327fa10f217694f30759596fd6a121
Compiled from "Main.java"
public class Main
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool: #1 = Methodref #11.#20 // java/lang/Object."<init>":()V #2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream; #3 = Class #23 // java/lang/StringBuilder #4 = Methodref #3.#20 // java/lang/StringBuilder."<init>":()V #5 = String #24 // result: #6 = Methodref #3.#25 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; #7 = Methodref #3.#26 // java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder; #8 = Methodref #3.#27 // java/lang/StringBuilder.toString:()Ljava/lang/String; #9 = Methodref #28.#29 // java/io/PrintStream.println:(Ljava/lang/String;)V
#10 = Class #30 // Main
#11 = Class #31 // java/lang/Object
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 main
#17 = Utf8 ([Ljava/lang/String;)V
#18 = Utf8 SourceFile
#19 = Utf8 Main.java
#20 = NameAndType #12:#13 // "<init>":()V
#21 = Class #32 // java/lang/System
#22 = NameAndType #33:#34 // out:Ljava/io/PrintStream;
#23 = Utf8 java/lang/StringBuilder
#24 = Utf8 result:
#25 = NameAndType #35:#36 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#26 = NameAndType #35:#37 // append:(I)Ljava/lang/StringBuilder;
#27 = NameAndType #38:#39 // toString:()Ljava/lang/String;
#28 = Class #40 // java/io/PrintStream
#29 = NameAndType #41:#42 // println:(Ljava/lang/String;)V
#30 = Utf8 Main
#31 = Utf8 java/lang/Object
#32 = Utf8 java/lang/System
#33 = Utf8 out
#34 = Utf8 Ljava/io/PrintStream;
#35 = Utf8 append
#36 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#37 = Utf8 (I)Ljava/lang/StringBuilder;
#38 = Utf8 toString
#39 = Utf8 ()Ljava/lang/String;
#40 = Utf8 java/io/PrintStream
#41 = Utf8 println
#42 = Utf8 (Ljava/lang/String;)V
{
public Main(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=5, args_size=1 0: iconst_0 1: istore_1 2: iconst_1 3: istore_2 4: iconst_2 5: istore_3 6: iload_1 7: iload_2 8: iadd 9: iload_3 10: iadd 11: istore 4 13: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 16: new #3 // class java/lang/StringBuilder 19: dup 20: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V 23: ldc #5 // String result: 25: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 28: iload 4 30: invokevirtual #7 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder; 33: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 36: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 39: return LineNumberTable: line 5: 0 line 6: 2 line 7: 4 line 8: 6 line 9: 13 line 10: 39
}
SourceFile: "Main.java"
上面的Main.txt文件和字节码文件Main.class表示的同一样东西,Main.class文件是存储在内存中二进制数据,而Main.txt是根据class文件结构规范将Main.class中所表示的数据、指令等解析出来的信息,也就是说这两个文件是等价的。根据class字节码文件的规范和通过反编译出来的文件,我们就能够比较清晰的知道class字节码文件是长啥样和具体做了什么事情。
class字节码的文件结构包括了常量池、方法集合、字段集合、接口结合、父类信息以及此类的基本信息等,具体的组成就如Main.txt中所描述的一样,其中比较重要的就是常量池部分,常量池部分有着该类所有信息的符号引用,就是常量池中有着对于该类的类名、方法名、接口名、属性名的符号表示。待将class字节码文件加载进入内存中时会将类的方法、属性、接口等信息加载入方法区中,最终会将常量池中的这些符号表示的信息都转换为对应的类、方法、属性存储在内存中的地址。
2、类加载子系统
类加载子系统功能就是将class文件加载进内存中,它通过加载、链接、初始化将一个class文件加载入内存,其中加载一般是通过以上的三个加载器来完成的,而链接包括验证、准备、解析三个小步骤,最后通过初始化达到了最终class文件的加载。
2.1 加载阶段
加载阶段说白了就是将class字节码文件加载进入方法区当中,那具体是如何去加载呢?以何种形式器加载呢?
其实对于jvm虚拟机来说,对于java中的一个类的内部是通过c++中的instanceKlass对象来描述的,用此来表示一个java类的组成情况,这个instanceKlass的重要的字段如下。
- _java_mirror:java的类镜像,例如:对于String来说,表示的就是String.class
- _super:即父类
- _fields: :表示类的属性
- _methods: 表示类的方法
- _constants:常量池
- _class_loader: 表示加载此类的类加载器
- _vtable:虚函数表 - _itable:接口表
上图就是表示jdk1.8以上的jvm虚拟机中一个类加载时的内存情况,加载的过程如下:jvm通过类的全限定名找到class字节码文件,然后就将class字节码文件根据class文件结构规范找到对应字节码所代表的字段、方法、常量等各部分组成,然后将这些组成封装成一个instanceKlass对象,最后在jvm虚拟机中的堆中产生一个_java_mirror的镜像,即一个类对象,如上图中的Person.class对象,该对象又会有一个指向instanceKlass地址的属性,因为instanceKlass是由c++编写的,所以java是不能直接调用instanceKlass来得到一个类的信息,所以通过_java_mirror到类对象的双向引用,这样就可以通过java类去得到对应类的属性、方法、常量等信息,也就可以通过类对象(如Person.class)去产生对应类的一个个实例。
总结一下关于类加载阶段所做的事情,其实就是将class字节码文件中的内容加载进入方法区当中,方法区一般是由类型信息、域信息、方法信息、运行时常量区所组成,所以根据class字节码文件的结构将对应的数据加载到方法区中,然后对于这些信息的引用是通过instanceKlass这个对象进行引用,而这个对象不能通过java代码字节引用,所以通过它在堆中所产生的镜像类对象进行引用,这就是类加载阶段所完成的事情。
2.2 链接阶段
2.2.1 验证
验证就是去验证class字节码文件是否符合class文件结构的规范,确保该字节码文件符合当前虚拟机的要求,并且不会危害虚拟机的安全。
验证阶段大致会完成 4 个阶段的检验动作:
- 文件格式验证 – 验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。
- 元数据验证 -对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。
- 字节码验证 -通过数据流和控制流分析,确保程序语义是合法、符合逻辑的。
- 符号引用验证 -发生在虚拟机将符号引用转换为直接引用的时候,对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。
2.2.2 准备
准备阶段就是给类的静态变量分配内存空间,并赋予默认值,自从jdk1.8以后,类的静态变量就是存放在堆中,如下图所表示,类的静态变量就是存储在instanceKlass的镜像类对象中。准备阶段就是将类中的静态变量在这个镜像类对象中分配内存和进行初始化。
对于类的静态变量内存的分配和初始化有一下几种情况:
- 只有static修饰的静态变量,如果是基本数据类型,则直接分配对应大小的内存空间,被赋默认值为零值(0,0L,false)。对于引用类型来说,直接为null。
- 用final static修饰的静态变量,对于基本数据类型,也是同上一样进行内存的分配,但是默认值是在编译阶段就已经确定,所以对该种变量的默认值就为编译阶段所赋予的值。对于引用类型,则直接将对应对象在堆中的地址赋值给该变量。
2.2.3 解析
解析就是将class字节码文件中常量池内的符号引用转换为直接引用。常量池中的符号引用表示了类中的类名、接口名、变量名、方法名、字段名等信息,它只是表示了类中的基本信息,而在class字节码文件加载进内存中后,类的字段、方法等也被加载入对应的方法区,解析所做的就是将对应类名、接口名、方法名、字段名等符号引用转化为真正加载到方法区中的内存的地址值,说白了就是将对应符号值转换为地址值。
- 符号引用:就是用符号来表示我们所引用的目标,就是我们所定义的类名、接口名、变量名、方法名等。
- 直接引用:就是我们所引用目标的内存地址。
2.3 初始化
jvm对加载的类进行初始化,其实就是对类中的静态代码块、静态变量进行初始化,且静态方法的初始化是最后执行,当然,静态变量不包括静态常量,因为静态常量在编译阶段就已经确定它的值了,而且在准备阶段就进行内存的分配和初始化了。java编译器在编译时就把静态代码块、静态变量置于一个名叫clinit方法中,所以在jvm进行初始化时就是执行这个方法的代码进行初始化。如果静态变量在静态代码块的后面,则静态代码块可以对这个静态变量赋值,但不能够访问。对类进行初始化的情况如下:
- 访问静态变量(静态常量除外)。
- 访问静态方法。
- 创建实例对象。
- 使用Class.forName(“”)来加载类时也需要对类进行初始化。
- 启动类,虚拟机在启动时就要将启动类进行初始化。
- 初始化子类时,父类也要进行初始化。
3、类加载器
类加载子系统中的第一步是加载阶段,就是根据类文件的全限定名将class字节码文件根据它的结构加载到方法区的不同地方,而实现这个功能是通过类加载器完成的,下面就来介绍一下java中的类加载器的种类以及加载机制。
3.1 类加载器的种类
在java中的类加载器总的可以分为四种,分别是启动类加载器(BoostrapClassLoad)、扩展类加载器(ExtClassLoad)、应用程序加载器(AppClassLoad)。
- 启动类加载器:加载JAVA_HOME/lib下或通过-Xbootclasspath参数进行指定的路径下jar包中的class字节码文件,一般加载的就是java中的基础类库,比如String、Float等基础类。启动类加载器是由c++所实现的,因此java程序不能直接引用该加载器,如果在我们需要将加载委托给该加载器时,直接将加载器设置为null就代表委托启动类加载器进行加载。
- 扩展类加载器:加载JAVA_HOME/lib/ext下或java.ext.dir所指定路径下的jar包中的class字节码文件。
- 应用程序加载器:加载用户路径(即classpath)下的类库,java中getSystemClassLoader()的返回值就是该加载器。
3.2 类加载的方式
类加载的方式可以分为三种:
- 应用启动时由jvm进行加载。
- 通过调用Class.forName()进行加载。
- 通过调用ClassLoader.loadClass()进行加载。
Class.forName()与ClassLoader.loadClass()加载的区别:
- Class.forName(className)内部实际调用的是Class.forName(className,true,classloader)来实现类的加载,其中的true表示对类进行初始化,所以其实我们可以自己调用Class.forName(className,false,classloader)来加载一个类,且让它不进行初始化。
- ClassLoader.loadClass(classname)内部则是调用ClassLoader.loadClass(classname,false)来实现类的加载,其中的false则表示加载类时不进行链接,所以也就不会对加载的类进行初始化。
3.3 双亲委派机制
前面我们说过java中有三种类加载器,通过这三种类加载器的合作来完成类的加载阶段,那到底这三种加载器是如何配合去实现类的加载呢,这就不得不说双亲委派机制了,他们就是根据双亲委派机制来确定使用那个加载器来对类文件进行加载,那到底什么是双亲委派机制呢?便由我慢慢道来。
我们可以通过一副图来形象的描述双亲委派机制的流程。
上图所描述的就是双亲委派机制,简单的描述一下,当需要去加载一个java类时,会先经过应用程序类加载器,它先判断此类是否已经被加载过了,如果被加载了,则结束,否则将加载委派为扩展类加载器,扩展类加载器也是去判断此类是否被加载了,如果被加载了就结束,否则就去委托给启动类加载器,启动类加载器也是去判断此类是否被加载了,如果是则结束,否则,启动类加载器就去查询自己能够加载此类,如果自己能加载,就将此类加载进入内存中,否则就退回让扩展类加载器去加载,扩展类加载器去查询自己能够加载,能够就自己去加载且结束,不能就让应用程序类加载器去加载,应用程序类加载器做最后的加载判断,如果该加载器能够加载,就自己去加载,加载就结束,不能加载就会抛出一个ClassNotFoundException异常。这就是双亲委派机制,其实就是让启动类加载器先去加载,如不能加载就让扩展类加载器去加载,还是不能就让应用程序类加载器去加载,如果这三个类加载器都不能加载就抛出ClassNotFoundException异常。虽然名字交双亲委派机制,但是这三个类加载器之间并不是继承关系。
使用双亲委派机制的目的:
- jvm去判断两个类相同是根据类的全限定名和类加载器是否相同来进行判断的,如果两个类的全限定名相同,但是类加载器不同则就属于两种类,所以如果不使用双亲委派机制,则用不同的类加载器去加载同一个类就会在内存中产生多个不同的类,造成对类的使用过程发生错误。使用双亲委派机制就能保证同一个类只会被一个类加载器加载,且只加载一次。
- 保证系统基础库的安全性。通过事情双亲委派机制,就使得系统的基础库一定是有启动类加载器去加载的,如果用户在classpath下写了与基础库的全限定名一样的类,这个类是不能被加载的,因为该类已经在启动类加载器中被加载了,就不会在其他的类加载器中加载了,这样就保证了基础类库中的类的安全性。例如,我们在classpath下写一个java.lang.String的类,这个类就不会被加载到内存中;在JAVA_HOME/lib下已经存在一个java.lang.String的类,基于双亲委派机制,通过启动类加载器去加载了JAVA_HOME/lib下的String类,那么应用程序类加载器就没有机会去加载classpath下的String类。
一般情况下我们的类加载器都会遵行双亲委派机制,但是在一些特定的程序软件中需要打破双亲委派机制,例如tomcat、MySQL中都需要打破这种机制。
3.4 自定义类加载器
我们在上面介绍了java中有三种类加载器,其实上面介绍的三种类加载器都是属于系统的类加载器,他们只能够加载对应路径下的类库,如启动类加载器加载的是JAVA_HOME/lib下的类库、扩展类加载器加载JAVA_HOME/lib/ext下的类库和应用程序类加载器加载classpath下的类库,他们只能加载以上三种路径下的class文件,那问题来了,如果我们想加载一个F盘路径下的class文件呢?或者我们加载网络中传输过来的class文件呢?该如何去加载呢?遇到这些情况,我们就能够通过自定义的类加载器去加载特定路径中的class文件。
那我们应该如何去定义属于自己的类加载器呢?首先我们先来分析ClassLoader是如何去通过双亲委托机制去实现类的加载的。我们来看看ClassLoader中加载类的核心方法loadClass(),就是通过这个方法来实现双亲委托机制。
public abstract class ClassLoader { protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name);//检查对应的类是否已经加载过了 if (c == null) {//此类还未加载过 long t0 = System.nanoTime(); try { if (parent != null) {//1、如果是AppClassLoader调用此方法,则parent=ExtClassLoader, //而ExtClassLoader的parent为BoostrapClassLoader //2.如果是自定义的classloader调用此方法,则parent=AppClassLoader 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. long t1 = System.nanoTime(); c = findClass(name);//自己去加载类,findClass方法中实现自己加载对应的class文件的逻辑。 // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
}
我们通过阅读以上的ClassLoader的源码可以知道,一个ClassLoader去加载class字节码文件时是通过它的loadClass()方法来实现,而它的实现逻辑就是双亲委托机制,是先让它的上级类加载器parent去加载,如果上级不能加载,则由自己加载,自己通过调用findClass()方法来实现类的加载,因此如果想要去自定义一个类加载器,就去继承ClassLoader类,且实现它的findClass()方法就能自定义一个类加载器了,findClass()方法实现了加载字节码文件的过程。
自定义一个类加载器:MyClassLoader.java
package reflection.main;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.WritableByteChannel;
public class MyClassLoader extends ClassLoader
{ @Override protected Class<?> findClass(String name) throws ClassNotFoundException { // TODO Auto-generated method stub String Dir = "F:\\software\\Typora\\jvm\\pos";// 用户自定义路径进行拼接 //File.separatorChar 返回本地文件系统的名称分隔符。 String classPath = Dir + File.separatorChar + name.replace('.', File.separatorChar) + ".class"; //classPath:要加载的class字节码文件的路径
// System.out.println("classPath: "+classPath); //读取文件 File file = new File(classPath); byte[] buff = null; InputStream in; try { in = new FileInputStream(file); buff = new byte[in.available()]; in.read(buff); in.close(); //将其读入到buff中 //然后调用defineClass生成Class对象 Class<?> c = defineClass(name, buff, 0, buff.length); return c; } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } //如果失败抛出ClassNotFoundException异常 return super.findClass(classPath); }
}
类加载器的使用:
public class Main { public static void main(String[] args) throws Exception { MyClassLoader myClassLoader = new MyClassLoader(); Class<?> loadClass = myClassLoader.loadClass("Person"); System.out.println(loadClass.getClassLoader()); }
}
我们通过继承ClassLoader并且去实现findClass()来自定义类加载器,这种方式依然遵循双亲委派机制,他们之间上下级关系为:自定义类加载器——->应用程序类加载器———->扩展类加载器———>启动类加载器。而实现双亲委派机制是通过ClassLoader中的loadClass()来 完成的,所以,如果我们想要去打破这种双亲委派机制,就可以通过去重写ClassLoader中的loadClass()来实现,在loadClass()方法中实现类加载器的加载机制。
文章来源: blog.csdn.net,作者:小先生duang,版权归原作者所有,如需转载,请联系作者。
原文链接:blog.csdn.net/nianqingren_/article/details/115892843