喜欢乏味的文章?这篇JVM类加载总结肯定适合你

7.1 概述

在了解class文件的存储格式后,虚拟机如何加载Class文件?Class文件中的信息进入虚拟机后会发生什么变化?

在java语言中,类的加载、连接、初始化过程都是在程序运行期完成的,这种策略会使类加载时稍微增加一些性能开销,但是会为java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

7.2 类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking),这7个阶段的发生顺序如图:

在这里插入图片描述

加载、验证、准备、初始化和卸载这五个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

虚拟机规范严格规定了有且只有5种情况必须对类进行“初始化”,当然初始化前的三个阶段(加载、验证、准备)就必须在此之前开始执行了。关于这5种必须初始化的场景如下:

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有初始化,则需要先触发其初始化;这4条指令对应的的常见场景分别是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

    注:静态内容是跟类关联的而不是类的对象。

  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
    注:反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制,这相对好理解为什么需要初始化类。

  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

    注:子类执行构造函数前需先执行父类构造函数。

  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
    注:main方法是程序的执行入口

  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化。则需要先触发其初始化。
    注:JDK1.7的一种新增的反射机制,都是对类的一种动态操作。
    这5种场景中的行为称为对一个类的主动引用,字面意思,程序员主动引用一个类,如果这个类没有初始化,则会先触发初始化。除此之外,引用类却不会发生初始化称为被动引用,以下有3个典型例子,通过子类引用父类的静态字段、通过数组定义来引用类、直接调用类的静态常量字段。

这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。下面举3个例子来说明何为被动引用。

class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
        //SubClass subClass = new SubClass();    //会进行初始化,打印SubClass init!
    }
}
SuperClass init!
123

对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

通过数组定义来引用类,不会触发此类的初始化

/**
*被动使用类字段演示二:
*通过数组定义来引用类,不会触发此类的初始化
*虚拟机会初始化一个[SuperClass的数组类,由虚拟机自动产生,通过执行newarray字节码
**/
public class TestArrayNotInitialization {
    public static void main(String[]args){
        SuperClass[] sca = new SuperClass[10];
    }
}

直接调用类的静态常量字段(static final)

public class ConstClass {
	 static{
	        System.out.println("ConstClass init!");
	    }
	    public static final String HELLOWORLD="hello world";
}
public class NotInitialization {
	public static void main(String[]args){
        System.out.println(ConstClass.HELLOWORLD);
    }
}
hello world

这里同样没有输出“ConstClass init!”,虽然引用了ConstClass类中的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”存储到了NotInitialization类的常量池中,以后NotInitialization对常量ConstClass.HELLOWORLD的引用实际上都被转化为NotInitialization类对自身常量池的引用。也就是说,实际上NotInitialization的Class文件中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。

接口的加载过程和类加载过程稍有不同:接口也有初始化过程,这点与类是一致的,编译器会为接口生成“()”类构造器,用于初始化接口中定义的成员变量。接口与类真正的区别是在前面所讲的5种初始化场景中的第3种:当一个类在初始化时,要求其父类全部都已经初始化了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到了父接口的时候(如引用接口中定义的常量)才会初始化。

7.3 类加载过程

7.3.1 加载

加载是“类加载”过程的一个阶段。在加载阶段,虚拟机需要完成3件事情:
  1)通过一个类的全限定名来获取定义此类的二进制字节流(字节码)。注意,这里第1条中的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。
  2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。本书“2.2.5方法区”章节介绍:“方法区域Java堆一样,是各线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据”。方法区中的数据存储结构是采用C++定义的instanceKlass来描述Java类的元数据,它有以下这些重要的域(field):

_java_mrror 即类的镜像,作用是把Klass暴露给堆中的Class对象使用,如下图就把Klass暴露给Person.class使用

_super 父类

_fields 成员变量

_method 方法

_constants 常量池

_class_loader 类加载器

_vtable 虚方法表

_itable 接口方法表

3)在堆中生成一个代表这个类的java.lang.Class对象,它持有instanceKlass的地址指针,作为方法区这个类的各种数据的访问入口。

在这里插入图片描述

**上图描述了Person类对象、Person类对象实例和instanceKlass之间的关系:**类加载的时候首先判断父类有没有加载,没有的话要先加载父类;然后将这个类的Class字节码加载到方法区中,通过instanceKlass存储类的元信息;然后在堆中生成一个代表这个类的java.lang.Class对象,它持有instanceKlass的指针,并且instanceKlass中的_java_mirror也存有指向这个Class对象的指针;当新建了对象实例,对象实例的对象头会存储它的Class类对象的地址,通过这个地址找到Class类对象,然后通过Class类对象存储的instanceKlass指针找到方法区中的instanceKlass,再在instanceKlass里面找到类的成员变量( _field)、方法( _method)等。

类加载阶段的第一件事“通过一个类的全限定名来获取定义此类的二进制字节流”,是类加载器完成的。类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。

非数组类和数据类的加载阶段有有所不同,从以上“被动引用例子3”我们就知道,数组类的应用是不会对该类进行初始化,而是由虚拟机直接通过字节码指令“newarray”去创建一个“Object”对象。只有数组类的元素类型最终是靠类加载器去创建。

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

7.3.2 验证

验证是连接的第一步,验证阶段目的是为了确保Class文件的字节流包含的信息符合当前虚拟机的要求,确保Java虚拟机不受恶意代码的攻击。

7.3.3 准备

准备阶段是正式为类变量分配内存设置类变量初始化值的阶段,这些变量所使用的内存都将在堆中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下。首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:

public static int value = 123

那变量value在准备阶段过后的初始化值为0而不是123,因为这是尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后存放在类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。以下表格列出了所有基本数据类型的零值:

在这里插入图片描述

上面提到的在“通常情况”下初始值为零值,但还是会有一些特殊情况,如果类字段的字段属性表中存在ConstantValue属性**(即被final修饰且字段类型是基本数据类型或者引用类型)**,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值。如下:

public static final int value = 123

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

7.3.4 解析

解析阶段是虚拟机将常量池的符号引用直接替换为直接引用的过程

字段解析的搜索顺序的例子:

class Super{
	public static int m = 11;
	static{
		System.out.println("执行了super类静态语句块");
	}
} 
class Father extends Super{
	public static int m = 33;
	static{
		System.out.println("执行了父类静态语句块");
	}
} 
class Child extends Father{
	static{
		System.out.println("执行了子类静态语句块");
	}
} 
public class StaticTest{
	public static void main(String[] args){
		System.out.println(Child.m);
	}
}
执行了super类静态语句块
执行了父类静态语句块
33//子类没有初始化,所以找到了父类中的m
7.3.5 初始化

前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与外,其他动作完全由虚拟机主导和控制,到初始化阶段,才真正开始执行类中定义的Java程序代码。

在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器()方法的过程,虚拟机会保证这个类的构造方法的线程安全。

在这里插入图片描述

这里简单说明下()方法的执行规则:

  1. ()方法方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
  2. ()方法与实例构造器()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕。因此,在虚拟机中第一个被执行的()方法的类肯定是java.lang.Object。
  3. 由于父类的()方法先执行,也就意味着父类中定义的静态语句块要先于子类的变量赋值操作。
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);//B=2
}
  1. ()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成()方法。
  2. 接口也会生成()方法,但接口执行()方法不需要先执行父接口的()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不会执行接口的()方法。
  3. 虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的()方法,其他线程都要阻塞等待。

7.4 类加载器

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

7.4.1 类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每个类加载器都拥有一个独立的类名称空间。这句话更通俗点解释就是:比较两个类是否“相等”,只有在这两个类由同一个类加载器加载的前提下才有意义,否则,即使两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类就必定不相等。

这里的“相等”包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

一个简单的例子说明:注意getResourceAsStream的应用:Class.getResourceAsStream(String path):path 不以"/“开头时默认是从此类所在的包下取资源,以”/"开头则是从ClassPath根下获取,也就是bin开始。

import java.io.IOException;
import java.io.InputStream;

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 className = name.substring(name.lastIndexOf(".") + 1) + ".class";//若不加1,输出true
                    //返回读取指定资源的输入流
                    InputStream is = getClass().getResourceAsStream(className);
                    if (is == null) return super.loadClass(name);
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    //将一个byte数组转换为Class类的实例
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
        Object object = myLoader.loadClass("ClassLoaderTest").newInstance();
        System.out.println(object.getClass());
        System.out.println(object instanceof ClassLoaderTest);
    }
}
class ClassLoaderTest
false

站在Java开发人员的角度来看,类加载器可以大致划分为以下三类:

  1. 启动类加载器:Bootstrap ClassLoader,跟上面相同。它负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被BootstrapClassLoader加载)。启动类加载器是无法被Java程序直接引用的。
  2. 扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
  3. 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:
   1)在执行非置信代码之前,自动验证数字签名。
   2)动态地创建符合用户特定需要的定制化构建类。
   3)从特定的场所取得java class,例如数据库中和网络中。

在这里插入图片描述

可以利用参数让启动类加载器加载类路径里的类

package load;

public class A {
    static {
        System.out.println("Class A init");
    }
}
import load.A;

public class TestLoader {
    public static void main(String[] args) throws ClassNotFoundException {
        Class <?> aClass = Class.forName("load.A");
        System.out.println(aClass.getClassLoader());
    }
}

我们在命令行利用参数去执行程序:

D:\Idea\IdeaWorkspace\Java\java基础\src> javac .\TestLoader.java //编译
D:\Idea\IdeaWorkspace\Java\java基础\src> java -Xbootclasspath/a:. TestLoader 
Class A init
null //因为Bootrap启动类加载器是用C++实现的,Java程序无法访问它,所以是null
7.4.2 双亲委派模型

下面开始介绍双亲委派机制:

在这里插入图片描述

例如上图,这种层次关系称为类加载器的双亲委派模型,双亲委派模型是一种设计模式(代理模式)。我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。

双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

使用双亲委派模型来组织类加载器之间的关系,有一个很明显的好处,就是Java类随着它的类加载器(说白了,就是它所在的目录)一起具备了一种带有优先级的层次关系,这对于保证Java程序的稳定运作很重要。例如,类java.lang.Object类存放在JDK\jre\lib下的rt.jar之中,因此无论是哪个类加载器要加载此类,最终都会委派给启动类加载器进行加载,这边保证了Object类在程序中的各种类加载器中都是同一个类。

双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法中。

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) {
                    //如果有双亲,委派双亲去加载这个类
                        c = parent.loadClass(name, false);
                    } else {
                    //如果没有,就委派启动类加载器BootstrapClassLoader
                        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);

                    // 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;
        }
    }

双亲委派机制

在这里插入图片描述

7.4.3 自定义类加载器

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

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

步骤:

  1. 继承ClassLoader父类
  2. 遵从双亲委派机制,重写findClass方法
    • 注意不是重写loadClass方法,否则不会走双亲委派机制
  3. 读取类文件的字节码
  4. 调用父类的defineClass方法来加载类
  5. 使用者调用该类加载器的loadClass方法
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值