搞懂java类加载机制和类加载器

1 篇文章 0 订阅
本文深入探讨了Java的类加载过程,包括加载、验证、准备、解析和初始化阶段,以及类加载器的工作原理。重点阐述了双亲委派模型,解释了为何及如何打破这一模型。此外,还介绍了自定义类加载器的实现步骤,并通过实例展示了类加载的过程。
摘要由CSDN通过智能技术生成

搞懂java类加载机制和类加载器

类加载概述

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为连接。如下图所示:

未命名文件

其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定)。

类加载的阶段

加载

加载阶段是整个“类加载”过程中的一个重要阶段。在加载阶段java虚拟机需要完成三件事

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

  • 将这个字节流所代表的静态存储结构转化方法区的运行时数据结构(将类的字节码载入方法中,内部采用c++的instanceKlass描述java类),它的重要field有:

    • _java_mirror 即 java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴 露给 java 使用
    • _super 即父类
    • _fields 即成员变量
    • _methods 即方法
    • _constants 即常量池
    • _class_loader 即类加载器
    • _vtable 虚方法表
    • _itable 接口方法表
  • ​ 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

解释一下虚函数:虚函数的存在是为了多态。Java 中其实没有虚函数的概念,它的普通函数就相当于 C++ 的虚函数,动态绑定是Java的默认行为。如果 Java 中不希望某个函数具有虚函数特性,可以加上 final 关键字变成非虚函数。

注意:

  • instanceKlass这样的【元数据】是存储在方法区(1.8后的元空间内),但_java_mirror是存储在堆中的
  • 可以使用HSDB工具查看(后文会简单使用HSDB工具进行分析,工具的详细使用步骤可以看《深入理解java虚拟机》的第四章)

image-20220512232004402

连接

验证

验证Class文件的字节流中包含的信息是否符合Jvm规范,安全性检查。

准备

为static变量分配空间,设置默认值

  • static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
  • static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
解析

将常量池中的符号引用解析为直接引用

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要可以唯一定位到目标即可。符号引用于内存布局无关,所以所引用的对象不一定需要已经加载到内存中。各种虚拟机实现的内存布局可以不同,但是接受的符号引用必须是一致的,因为符号引用的字面量形式已经明确定义在Class文件格式中。

直接引用(Direct References):直接引用时直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机上翻译出来的直接引用一般不会相同。如果有了直接引用,那么它一定已经存在于内存中了。

代码示例:

为了进一步理解什么是解析可以看下面的例子

先使用classloader.loadClass进行试验,运行这段代码

package cn.itcast.jvm.t3.load;

import java.io.IOException;

/**
 * 解析的含义
 */
public class Load2 {
    public static void main(String[] args) throws ClassNotFoundException, IOException {
        ClassLoader classloader = Load2.class.getClassLoader();
        // loadClass 方法不会导致类的解析和初始化
        Class<?> c = classloader.loadClass("cn.itcast.jvm.t3.load.C");
//        new C();
        System.in.read();
    }
}

class C {
    D d = new D();
}

class D {

}

然后使用jps命令查看进程

image-20220512235938854

使用HSDB工具分析

先进入自己的jdk目录

image-20220513000209895

然后使用java -cp ./lib/sa-jdi.jar sun.jvm.hotspot.HSDB命令进入HSDB界面

image-20220513000424703

点击file->Attach to HotSpot process

image-20220513000609635

输入进程id进入使用

image-20220513000705048

点击tools->Class Browser可以看到类c的加载信息

image-20220513001020956

可以点击类C详细查看信息,可以看到类c的常量池信息有类D的相关信息但是Constant TypeJVM_CONSTANT_UnresolvedClass(未解析的类),说明类D还没有被解析

image-20220513001451763

可以将上面的代码中使用classloader.loadClass的部分注释并且使用new C(),并且以同样的方式使用HSDB工具分析,就可以发现类D已经被解析为具体的信息了,这就是将符号引用解析为直接引用(未被解析时仅仅是一个符号,解析后是具体类的地址)

image-20220513002711943

初始化

初始化是类加载的最后一步,在前面的阶段里,除了加载阶段可以通过用户自定义的类加载器加载,其余部分基本都是由虚拟机主导的。但是到了初始化阶段,才开始真正执行用户编写的java代码了。

在准备阶段,变量都被赋予了初始值,但是到了初始化阶段,所有变量还要按照用户编写的代码重新初始化。换一个角度,初始化阶段是执行类构造器<clinit>()方法的过程。

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

public class Test {
  static {
    i=0;  //可以赋值
    System.out.print(i); //编译器会提示“非法向前引用”
  }
  static int i=1;
}

<clinit>()方法与类的构造函数<init>()方法不同,它不需要显示地调用父类构造器,虚拟机会宝成在子类的<clinit>()方法执行之前,父类的<clinit>()已经执行完毕,因此在虚拟机中第一个被执行的<clinit>()一定是java.lang.Object的。

也是由于<clinit>()执行的顺序,所以父类中的静态语句块优于子类的变量赋值操作,所以下面的代码段,B的值会是2。

static class Parent {
  public static int A=1;
  static {
    A=2;
  }
}

static class Sub extends Parent{
  public static int B=A;
}

public static void main(String[] args) {
  System.out.println(Sub.B);
}
发生的时机

概括得说,类初始化是【懒惰的】

  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName
  • new 会导致初始化

不会导致初始化的情况

  • 访问类的static静态变量(基本类型和字符串)不会触发初始化
  • 类对象.class 不会触发初始化
  • 创建该类的数组不会触发初始化
  • 类加载器的 loadClass 方法
  • Class.forName 的参数 2 为 false 时

实验代码

class A {
	static int a = 0;
    //静态代码块只会执行一次
	static {
		System.out.println("a init");
	}
}
class B extends A {
	final static double b = 5.0;
	static boolean c = false;
	static {
		System.out.println("b init");
	}
}

验证(实验时请先全部注释,每次只执行其中一个)

public class Load3 {
	static {
		System.out.println("main init");
	}
	public static void main(String[] args) throws ClassNotFoundException {
		// 1. 静态常量(基本类型和字符串)不会触发初始化
		System.out.println(B.b);
		// 2. 类对象.class 不会触发初始化
		System.out.println(B.class);
		// 3. 创建该类的数组不会触发初始化
		System.out.println(new B[0]);
		// 4. 不会初始化类 B,但会加载 B、A
		ClassLoader cl = Thread.currentThread().getContextClassLoader();	
		cl.loadClass("cn.itcast.jvm.t3.B");
		// 5. 不会初始化类 B,但会加载 B、A
		ClassLoader c2 = Thread.currentThread().getContextClassLoader();
		Class.forName("cn.itcast.jvm.t3.B", false, c2);
		
        // 1. 首次访问这个类的静态变量或静态方法时
		System.out.println(A.a);
		// 2. 子类初始化,如果父类还没初始化,会引发
		System.out.println(B.c);
		// 3. 子类访问父类静态变量,只触发父类初始化
		System.out.println(B.a);
		// 4. 会初始化类 B,并先初始化类 A
		Class.forName("cn.itcast.jvm.t3.B");
	}
}

类加载器

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Classs Loader)。

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类命名空间。(比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等)

启动类加载器

启动类加载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数指定的路径中存放的而且是Java虚拟机能够识别的类库加载到虚拟机内存中。 用 Bootstrap 类加载器加载类的例子:

package cn.itcast.jvm.t3.load;
public class F {
	static {
		System.out.println("bootstrap F init");
	}
}

执行

package cn.itcast.jvm.t3.load;
public class Load5_1 {
	public static void main(String[] args) throws ClassNotFoundException {
		Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.F");
        	//启动类加载器由c++编写,打印null即为启动类加载器
			System.out.println(aClass.getClassLoader());
		}
}

输出

E:\git\jvm\out\production\jvm>java -Xbootclasspath/a:.
cn.itcast.jvm.t3.load.Load5
bootstrap F init
null                 
  • -Xbootclasspath 表示设置 bootclasspath
  • 其中 /a:. 表示将当前目录追加至 bootclasspath 之后
  • 可以用这个办法替换核心类
    • java -Xbootclasspath:
    • java -Xbootclasspath/a:<追加路径>
    • java -Xbootclasspath/p:<追加路径>

扩展类加载器

扩展类加载器负责加载<JAVA_HOME>\lib\ext目录下的文件。

使用扩展类加载器的例子:

package cn.itcast.jvm.t3.load;
	public class G {
		static {
			System.out.println("classpath G init");
		}
}

执行

public class Load5_2 {
	public static void main(String[] args) throws ClassNotFoundException {
		Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.G");
		System.out.println(aClass.getClassLoader());
	}
}

输出

classpath G init
sun.misc.Launcher$AppClassLoader@18b4aac2

写一个同名的类

package cn.itcast.jvm.t3.load;
	public class G {
	static {
		System.out.println("ext G init");
		}
}

使用命令打个jar包jar -cvf my.jar cn/itcast/jvm/t3/load/G.class

将 jar 包拷贝到 JAVA_HOME/jre/lib/ext

重新执行 Load5_2

输出

ext G init
sun.misc.Launcher$ExtClassLoader@29453f44

这个例子说明当扩展类加载器和应用程序类加载器都能加载一个类时,jvm使用的是扩展类加载器

应用程序类加载器

这个类加载器负责加载用户类路径上所有的类库。如果应用程序中没有自定义过自己的类加载器,一般情况系啊这个就是程序中默认的类加载器。

双亲委派模型

image-20220513123805027

上图中各种类加载器之间的层次关系被称为类加载器的“双亲委派模型”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成的这个加载请求时,子加载器才会尝试自己去完成加载。

使用双亲委派的好处:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为Java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

loadclass的源码

protected Class<?> loadClass(String name, boolean resolve)
	throws ClassNotFoundException {
	synchronized (getClassLoadingLock(name)) {
		// 1. 检查该类是否已经加载
		Class<?> c = findLoadedClass(name);
		if (c == null) {
			long t0 = System.nanoTime();
		try {
		if (parent != null) {
		// 2. 有上级的话,委派上级 loadClass
		c = parent.loadClass(name, false);
		} else {
			// 3. 如果没有上级了(ExtClassLoader),则委派
			BootstrapClassLoader
			c = findBootstrapClassOrNull(name);
	}
} catch (ClassNotFoundException e) {
	}
		if (c == null) {
		long t1 = System.nanoTime();
		// 4. 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载
		c = findClass(name);
		// 5. 记录耗时
		sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
		sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
		sun.misc.PerfCounter.getFindClasses().increment();
	}
}
if (resolve) {
	resolveClass(c);
	}
	return c;
	}
}

线程上下文类加载器

注:内容较多不展开叙述

线程上下文类加载器(Context Classloader)是从JDK1.2开始引入的,类Thread中的getContextClassLoader()和setContextClassLoader(ClassLoader cl)分别用来获取和设置上线文类加载器。
如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器。Java应用运行时的初始线程的上下文类加载器是系统类加载器。在线程中运行的代码可以通过该类加载器来加载类与资源。

它可以打破双亲委托机制,父ClassLoader可以使用当前线程的Thread.currentThread().getContextClassLoader()所指定的classLoader来加载类,这就可以改变父ClassLoader不能使用子ClassLoader或是其他没有直接父子关系的ClassLoader加载的类的情况,即改变了双亲委托模型。

自定义类加载器

什么时候需要自定义类加载器

  • 想加载非 classpath 随意路径中的类文件
  • 都是通过接口来使用实现,希望解耦时,常用在框架设计
  • 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器

步骤

  • 继承 ClassLoader 父类

  • 要遵从双亲委派机制,重写 findClass 方法

    • 注意不是重写 loadClass 方法,否则不会走双亲委派机制
  • 读取类文件的字节码

  • 调用父类的 defineClass 方法来加载类

  • 使用者调用该类加载器的 loadClass 方法

示例代码:

class MyClassLoader extends ClassLoader {

    @Override // name 就是类名称
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = "e:\\myclasspath\\" + name + ".class";

        try {
            ByteArrayOutputStream os = new ByteArrayOutputStream();
            Files.copy(Paths.get(path), os);

            // 得到字节数组
            byte[] bytes = os.toByteArray();

            // byte[] -> *.class
            return defineClass(name, bytes, 0, bytes.length);

        } catch (IOException e) {
            e.printStackTrace();
            throw new ClassNotFoundException("类文件未找到", e);
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

懒惰的coder

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值