Java类加载机制由浅入深

Java类加载机制由浅入深

(一)简述

Java虚拟机把描述类的数据从.class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是Java虚拟机的类加载过程。

类加载具体指将代码编译后生成的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区(具体实现为元空间)内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。所以我们可以认为,类的加载的最终产品是位于堆区中的Class对象,Class对象向Java程序员提供了访问方法区内的数据结构的接口。

下图展现了类加载机制的基本流程,也是一个类从创建到销毁的整个生命周期:

在这里插入图片描述
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。下面我们先从类加载的过程来仔细分析:

(二)类装载过程

1. 加载

查找并加载类的二进制数据是类装载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取其定义的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

相对于类装载的其他阶段而言,加载阶段(准确地说是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

加载阶段和连接阶段(Linking)的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

2. 验证

当JVM加载完Class字节码文件并在方法区创建对应的Class对象之后,JVM便会启动对该字节码流的校验,只有符合JVM字节码规范的文件才能被JVM正确执行。字节码的验证主要包括以下几项:

  • 文件格式的验证:比如常量中是否有不被支持的常量,文件中是否有不规范的或者附加的其他信息。
  • 元数据的验证:比如该类是否继承了被final修饰的类,类中的字段、方法是否与父类冲突,是否出现了不合理的重载。
  • 字节码的验证:保证程序语义的合理性,比如要保证类型转换的合理性。
  • 符号引用的验证:比如校验符号引用中通过全限定名是否能够找到对应的类,校验符号引用中的访问性(private,public等)是否可被当前类访问。

3. 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

我们必须要注意,Java中的变量有类变量和类成员变量两种类型,类变量指的是被static修饰的变量,而其他所有类型的变量都属于类成员变量。在准备阶段,JVM只会为类变量分配内存,而不会为类成员变量分配内存。类成员变量的内存分配需要等到初始化阶段才开始。

在准备阶段,JVM会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予Java 语言中该数据类型的零值,而不是用户代码里初始化的值。但如果一个变量是常量(被 static final修饰)的话,那么在准备阶段,属性便会被赋予用户希望的值。

4. 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。下面先简单介绍一下符号引用和直接引用:

  • 符号引用:即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息。
  • 直接引用:可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针。而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量。

举个例子来说,现在调用方法hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。这一部分对程序员也基本是透明的,我们了解即可。

5. 初始化

在介绍初始化时,要先介绍两个方法:<clinit> 和 <init>。这两个是在编译生成.class文件时,会自动产生两个方法,一个是类的初始化方法,另一个是实例的初始化方法。

  • <clinit>:在JVM第一次加载class文件时调用,包括静态变量初始化语句和静态块的执行。
  • <init>: 在实例创建出来的时候调用,包括调用new操作符、调用 Class或Java.lang.reflect.Constructor对象的newInstance()方法、调用任何现有对象的clone()方法、通过java.io.ObjectInputStream类的getObject() 方法反序列化。

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

类初始化阶段开始为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。换句话说,只对static修饰的变量或语句进行初始化(这句是重点)。在Java中对类变量进行初始值设定有两种方式:

  • 声明类变量时指定的初始值
  • 使用静态代码块为类变量指定的初始值

到了初始化阶段,用户定义的Java程序代码才真正开始执行。在这个阶段,JVM会根据语句执行顺序对类对象进行初始化,一般来说,当JVM遇到下面5种情况的时候会触发初始化:

  • 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个主类。
  • 当使用JDK1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

6. 使用

当JVM完成类初始化阶段之后,JVM便开始从入口方法开始执行用户的程序代码。

7. 卸载

当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象,最后负责运行的JVM也退出内存。

我们可以总结一下类加载的执行顺序:

  1. 确定类变量的初始值:在类加载的准备阶段,JVM会为类变量初始化零值,这时候类变量会有一个初始的零值。如果是被final修饰的类变量,则直接会被初始成用户想要的值。
  2. 初始化入口方法:当进入类加载的初始化阶段后,JVM会寻找整个main方法入口,从而初始化main方法所在的整个类。当需要对一个类进行初始化时,会首先初始化类构造器。
  3. 初始化类构造器:JVM会按顺序收集类变量的赋值语句、静态代码块,最终组成类构造器由JVM执行。
  4. 初始化对象构造器:JVM会按照收集成员变量的赋值语句、普通代码块,最后收集构造方法,将它们组成对象构造器,最终由JVM执行。

如果在初始化main方法所在类的时候遇到了其他类的初始化,那么就先加载对应的类,加载完成之后返回。如此反复循环,最终返回main方法所在类。

(三)类加载器与双亲委派模型

1. 类加载器

对于任意一个类,都需要加载它的类加载器和这个类本身来确定这个类在Java虚拟机中的唯一性,每一个类加载器都有一个独立的类名称空间。也就是说,如果比较两个类是否是同一个类,除了这比较这两个类本身的全限定名是否相同之外,还要比较这两个类是否是同一个类加载器加载的。即使同一个类文件两次加载到同一个虚拟机中,但如果是由两个不同的类加载器加载的,那这两个类仍然不是同一个类。这个相等性比较会影响一些方法,比如Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法等,还有instanceof关键字做对象所属关系判定等。

JVM一共有三种类加载器,分别是:

  • 启动(Bootstrap)类加载器:启动类加载器是用C++实现的类加载器,它负责将JAVA_HOME/lib下面的核心类库或-Xbootclasspath选项指定的jar包等虚拟机能够识别的类库加载到内存中。由于启动类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用。
  • 扩展(Extension)类加载器:扩展类加载器是由Sun的ExtClassLoader实现的,它负责将JAVA_HOME /lib/ext或者由系统变量-Djava.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
  • 系统(System)类加载器:系统类加载器是由Sun的AppClassLoader实现的,它负责将用户类路径(java -classpath或-Djava.class.path变量所指的目录,即当前类所在路径及其引用的第三方类库的路径)下的类库加载到内存中。开发者可以直接使用系统类加载器。

在这里插入图片描述
上图我们可以清晰的看出三种JVM自带的类加载器的父子关系,但是这里的父子关系是由组合方式实现的,而不是继承关系,下面的继承图就可以证明这个说法:

在这里插入图片描述
很明显,ExtClassLoader和AppClassLoader都继承自URLClassLoader,所以他们两个之间不存在继承关系,我们可以从源码角度来看一下这三种类加载器的具体细节,从而理解为什么这三个类加载器表面是继承关系但实际又不是由继承关系实现的。

我们可以写一段代码来验证这件事情:

class T{
    static int m = 0;
}
public class Test {
    public static void main(String[] args) {
        T t = new T();
        ClassLoader classLoader1 = t.getClass().getClassLoader();
        System.out.println(classLoader1);
        ClassLoader classLoader2 = t.getClass().getClassLoader().getParent();
        System.out.println(classLoader2);
        ClassLoader classLoader3 = t.getClass().getClassLoader().getParent().getParent();
        System.out.println(classLoader3);
    }
}

这段代码就能让我们明白,所谓的父类加载器,实际上就是classLoader对象中的一个parent属性而已,他是父类加载器的引用,通过他我们就能找到任意类加载器的父加载器,很显然,代码的输出应该如下:

jdk.internal.loader.ClassLoaders$AppClassLoader@3d4eac69
jdk.internal.loader.ClassLoaders$PlatformClassLoader@77459877
null

注:这里的PlatformClassLoader实际就是ExtClassLoader,在Java9之后,原来的ExtClassLoader就改成了PlatformClassLoader,功能不变。

可能有人好奇为什么最后一个输出是null,原因很简单,因为ExtClassLoader的父类加载器是BootStrapClassLoader,但他是由native方法(C++)实现的,所以我们无法找到他的引用。下面我们可以从源码中看到这个parent属性:

public abstract class ClassLoader {
	// parent属性在这里
	private final ClassLoader parent;
	
	private static ClassLoader scl;
	
	private ClassLoader(Void unused, ClassLoader parent) {
	    this.parent = parent;
	    ...
	}
	protected ClassLoader(ClassLoader parent) {
	    this(checkCreateClassLoader(), parent);
	}
	
	protected ClassLoader() {
	    this(checkCreateClassLoader(), getSystemClassLoader());
	}
	
	public final ClassLoader getParent() {
	    if (parent == null)
	        return null;
	    return parent;
	}
	
	public static ClassLoader getSystemClassLoader() {
	    initSystemClassLoader();
	    if (scl == null) {
	        return null;
	    }
	    return scl;
	}
	
	private static synchronized void initSystemClassLoader() {
	    if (!sclSet) {
	        if (scl != null)
	            throw new IllegalStateException("recursive invocation");
	        sun.misc.Launcher l = sun.misc.Launcher.getLauncher();
	        if (l != null) {
	            Throwable oops = null;
	            //通过Launcher获取ClassLoader
	            scl = l.getClassLoader();
	            try {
	                scl = AccessController.doPrivileged(
	                    new SystemClassLoaderAction(scl));
	            } catch (PrivilegedActionException pae) {
	                oops = pae.getCause();
	                if (oops instanceof InvocationTargetException) {
	                    oops = oops.getCause();
	                }
	            }
	            if (oops != null) {
	                if (oops instanceof Error) {
	                    throw (Error) oops;
	                } else {
	                    // wrap the exception
	                    throw new Error(oops);
	                }
	            }
	        }
	        sclSet = true;
	    }
	}
}

我们可以看到getParent()实际上返回的就是一个ClassLoader对象parent,parent的赋值是在ClassLoader对象的构造方法中,它有两个情况:

  1. 由外部类创建ClassLoader时直接指定一个ClassLoader为parent。
  2. 由getSystemClassLoader()方法生成,也就是在sun.misc.Laucher通过getClassLoader()获取,也就是AppClassLoader。

直白的说,一个ClassLoader创建时如果没有指定parent,那么它的parent默认就是AppClassLoader。

既然我们发现了parent这个属性,那么这个属性的来源是哪里呢?对于整个问题,首先我们先来看Launcher这个类,在这个类里面扩展类构造器和系统类构造器会进行创建:

public class Launcher {
    private static URLStreamHandlerFactory factory = new Factory();
    private static Launcher launcher = new Launcher();
    private static String bootClassPath = System.getProperty("sun.boot.class.path");

    public static Launcher getLauncher() {
        return launcher;
    }

    private ClassLoader loader;

    public Launcher() {
        // 创建扩展类加载器
        ClassLoader extcl;
        try {
            extcl = ExtClassLoader.getExtClassLoader();
        } catch (IOException e) {
            throw new InternalError("Could not create extension class loader", e);
        }

        // 创建系统类加载器
        try {
	    // 将ExtClassLoader对象实例传递进去
            loader = AppClassLoader.getAppClassLoader(extcl);
        } catch (IOException e) {
            throw new InternalError("Could not create application class loader", e);
	}

	public ClassLoader getClassLoader() {
        return loader;
    }
    
	static class ExtClassLoader extends URLClassLoader {
        public static ExtClassLoader getExtClassLoader() throws IOException{
            final File[] dirs = getExtDirs();
            try {
                return AccessController.doPrivileged(
                    new PrivilegedExceptionAction<ExtClassLoader>() {
                        public ExtClassLoader run() throws IOException {
                            // ExtClassLoader在这里创建
                            return new ExtClassLoader(dirs);
                        }
            	});
            } catch (java.security.PrivilegedActionException e) {
                throw (IOException) e.getException();
    		}
		}

       	public ExtClassLoader(File[] dirs) throws IOException {
           	super(getExtURLs(dirs), null, factory);
    	}
	}
}

我们可以注意下面的三行代码:

ClassLoader extcl;
        
extcl = ExtClassLoader.getExtClassLoader();

loader = AppClassLoader.getAppClassLoader(extcl);

我们会发现,ExtClassLoader的实例传参给了getAppClassLoader方法,所以AppClassLoader的parent对象肯定已经被赋值了,但是好像ExtClassLoader并没有对parent属性直接赋值。实际上,它的构造函数调用了它的父类也就是URLClassLoder的构造方法并传递了3个参数:

public ExtClassLoader(File[] dirs) throws IOException {
	super(getExtURLs(dirs), null, factory);   
}

对应的URLClassLoader的构造方法:

public  URLClassLoader(URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory) {
     super(parent);
}

从这里我们就可以理解为什么ExtClassLoader的父类加载器为null了。

除了三种原装的类加载器之外,我们还可以自定义类加载器。在讲自定义类加载器之前,我们先讲一下类加载器中最重要的一个规范:双亲委派模型。

2. 双亲委派模型

JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归(本质上就是loadClass函数的递归调用),因此所有的加载请求最终都应该传送到顶层的启动类加载器中。如果父类加载器可以完成这个类加载请求,就成功返回,只有当父类加载器无法完成此加载请求时,子加载器才会尝试自己去加载。事实上,大多数情况下,越基础的类由越上层的加载器进行加载,因为这些基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API(当然,也存在基础类回调用户用户代码的情形,即破坏双亲委派模型的情形)。

在这里插入图片描述
那为什么要用这种机制呢?因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要再加载一次。考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrap ClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。

下面的时序图非常清楚地展现了整个过程:

在这里插入图片描述
有一点要明确,双亲委派模型只是官方给出的建议,而并不都是必须遵守的规定。因为本文的篇幅已经太长,我会在下一篇文章中讲双亲委派模型可能存在的问题并且讲如何自定义类加载器。

(四)命名空间(NameSpace)

熟悉C++的朋友应该对这个东西不陌生,实际上,Java中的命名空间的功能与C++是类似的。JVM在判定两个class是否相同时候,不仅要判断两个类的包名是否相同,还要判断该类是否由同一个类加载器实例加载的。只有两者同时满足的情况下,JVM才会认为这两个class是相同的。

同一个命名空间内的类是相互可见的,命名空间由该加载器及它的所有父加载器所加载的类组成。在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类,在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类。

上面这段可能会有一点抽象,我们先来举例子。由子加载器加载的类能看见父加载器的类,例如AppClassLoader加载的类能看见BootStrapClassLoader加载的类。由父亲加载器加载的类不能看见子加载器加载的类,例如BootStrapClassLoader加载的类不能看见AppClassLoader加载的类。如果两个加载器之间没有直接或间接的父子关系,那么它们各自加载类相互不可见,例如我们自定义两个类加载器,他们的父加载器都是AppClassLoader,那么他们两个加载的类就互相不可见。

下图是命名空间之间的关系:

在这里插入图片描述

有两个加载器A和B加载同一个类,他们如果不是父子关系,A已经把类加载到内存当中,B也可以把类加载到内存当中。但如果A和B是父子关系,那么他们中只有先去加载类的的才能将类加载到内存。因为加载的条件首先判断是否已经加载了这个类,如果没有加载则进行加载,如果加载就不会再进行加载。

2020年9月14日

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值