前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >类加载机制

类加载机制

作者头像
胖虎
发布2020-11-24 10:17:18
3940
发布2020-11-24 10:17:18
举报
文章被收录于专栏:晏霖

点击上方“晏霖”,选择“置顶或者星标”

曾经有人关注了我

后来他有了女朋友

1.6类加载机制

1.6.1概述

学习本章前我们要对类文件结构有一个简单的认识,而学习类文件结构没有任何难度,更多的是参考《Java虚拟机规范》、《Java语言规范》中定义的规则。我们要对class文件了解,知道class文件格式、包括常量池类型、访问表示类型、属性表结构及名称等等。例如我们可以在我们的IDEA下载jclasslib插件,然后打开我们的Java文件进行学习,如图1-29所示。左侧是我们的Java文件,右侧是我们插件展示的部分,我们可以看到字节码指令,其文件结构都展示在jclasslib的左侧标签中。

图1-29 jclasslib插件展示

1.6.2什么是类加载

我们要想知道什么是类加载的前提是知道要把什么样类作为我们的加载对象。我们在IDEA编写的Java文件通过javac编译成class文件,用文本文件打开,如图1-30所示,截取部分内容。class再由javap编译输出字节码指令(2.6.2章有说明)。我们常说的这种图1-30所示的内容为字节码文件也叫class文件,是实实在在的文件,而javap输出的是虚拟机识别的指令,即反汇编后人可认识的。javap并没有生成新文件仅仅是输出而已(当然输出内容可以指向一个文件,但仅仅是把控制台内容转移到一个文本文件而已),虽然这个问题很简单,但是也有很多人记混,通常我们认为的class文件就是字节码文件,只是他的表现形式有很多种而已。

图1-30 class文件样例

讲到这里我们应该知道虚拟机把什么文件加载进来了,其实在虚拟机中,类加载就是把class文件加载到虚拟机内存中的过程,称为类加载也叫类型加载。

1.6.3类加载的过程

类的整生命周期经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading) 7个阶段,其中验证、准备、解析三个阶段成为连接(Linking),如图1-31所示为类的生命周期顺序图。

图1-31 类的生命周期

在整个类的生命周期中加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

在类在加载前我们要清楚什么情况下类才会开始加载呢?这个开始的条件在《Java虚拟机规范》中并未强制约束,根据自己虚拟机的实现决定。但《Java虚拟机规范》中规定了仅有六种情况必须对类进行初始化,而加载、验证、准备、解析这四个阶段需提前就完成。

1. 没有经历初始化的类遇到new、getstatic、putstatic、invokestatic等字节码指令时,如对象遇到new关键字、读取或设置静态字段(除被final修饰的)、调用静态方法。

2. 没有经历初始化的类使用了java.lang.reflect进行反射调用。

3. 当初始化类的时候,发现其父类还没有初始化,则先对其父类进行初始化。

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

5. JDK1.7后,如果java.lang.invoke.MethodHandle的实例最后对应的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄,并且这个方法所在类没有初始化,则先初始化。

6. JDK1.8后,如果接口定义了默认方法,接口的实现类发生了初始化,那么接口要在其前被初始化。

n 加载

在加载阶段要完成三件事:

1. 通过类的全限定名获取类的二进制文件流。

2. 将字节流所代表的静态存储结构转变为方法区的运行时数据结构。

3. 在堆中生成此类的 java.lang.Class对象,作为方法区这些数据的访问入口。

在加载阶段中《Java虚拟机规范》对其约束并没有那么强烈,例如类的全限定名可以是很多形式,我们常见的有JAR、WAR文件等。而获取这些文件中类的二进制文件流也有几种方式,可以用虚拟机自带的类加载器,也可以我们开发人员自定义类加载器,总之这个阶段我们要把我们的类变成二进制文件流然后交给虚拟机放到指定的位置就可以了。

n 验证

验证是连接的第一步,这个阶段主要是校验class文件的字节流包是否符合《Java虚拟机规范》所规定的,是虚拟机自身保护的机制。

验证虽然是校验加载时生成的文件,但校验的过程和细节还是蛮多的,他主要是完成四个阶段的验证:

文件格式的验证:验证class文件字节流是否符合class文件格式规范,并且能够被当前版本的虚拟机处理。这里面主要对魔数、主版本号、常量池等等的校验,这部分内容都在《Java虚拟机规范》定义的规则中。

元数据验证:主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,比如说验证这个类是不是有父类,父类是否可以继承、类中的字段方法是不是和父类冲突等等。

字节码验证:这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出危害虚拟机安全的事。经过该阶段的文件不一定是安全的,但没通过字节码验证的文件是一定不安全的,程序逻辑验证不能保证程序运行过程中出现的问题,就好像我们写过的代码反复检查都没有问题,但运行出来的结果不能保证一定按照我们预先定义好的,可能出现想象不到的Bug一样。

符号引用验证:这是验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成,例如通常校验定义了一些符号引用,这些引用是否可以真实的通过他的描述找到对应的方法字段和类等。

n 准备

该阶段主要为类变量分配内存并设置初始值,JDK8前都是在方法区分配都,但JDK8后变量会随着类对象一起存放在Java堆。还有一个关键内容是给类变量设置初始值,他只针对static修饰的变量,而初始值是这个变量描述类型的零值,如下表1-2展示类Java基本数据类型的零值。

表1-2 基本数据类型的零值

数据类型

零值

int

0

long

0L

short

(short)0

char

‘\u0000’

byte

(byte)0

boolean

flase

float

0.0f

double

0.0d

reference

null

注:final修饰的变量初始值就是所指的值,并且以后也不会改变。

n 解析

解析阶段是符号引用转为直接引用的过程。

符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要是能无歧义的定位到目标就好,就好比在班级中,老师可以用张三来代表你,也可以用你的学号来代表你,但无论任何方式这些都只是一个代号(符号),这个代号指向你(符号引用)。

直接引用:直接引用是可以指向目标的指针、相对偏移量或者是一个能直接或间接定位到目标的句柄。和虚拟机实现的内存有关,不同的虚拟机直接引用一般不同。

解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用。

n 初始化

初始化是类加载的最后一步,是执行Java代码的开始。初始化阶段就是执行类构造器< clinit >()方法的过程。为类的静态变量赋予真实的初始值,虚拟机负责对类进行初始化,主要对类变量进行初始化,接下来就是把权利交给程序代码。

< clinit >()方法是类构造方法,这里所说的类也称类型,是通过javac编译后才能生成的方法,我们可以用上面提到过的jclasslib插件查看某一个类,在jclasslib界面就可以看到< clinit >()方法,如图1-32所示。

图1-32 jclasslib中部分方法展示

< clinit >()的产生是由编译器按语句在源文件中出现的顺序,依次自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。 如果类中没有静态语句和静态代码块,那可以不生成该方法。并且 <clinit>() 不需要显式调用父类(接口除外,接口不需要调用父接口的初始化方法,只有使用到父接口中的静态变量时才需要调用)的初始化方法 <clinit>(),虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。上图中我们也看到了<init>()方法,<init>()方法对象构造时用以初始化对象的,构造器以及非静态初始化块中的代码。接下来我们用一段静态变量和静态代码块的Demo说明问题,这部分内容在面试Java基础的时候频繁遇到。

代码语言:javascript
复制
代码清单1-1 StaticMethodDemo.java
public class StaticMethodDemo {
    private static StaticMethodDemo instance;

    static {
        System.out.println("static开始");
        // 下面这句编译器报错,提示非法向前引用,因为静态代码块只能访问定义在他前面到变量
        // System.out.println("x=" + x);
        instance = new StaticMethodDemo();
        System.out.println("static结束");
    }

    public StaticMethodDemo() {
        System.out.println("构造器开始");
        System.out.println("x=" + x + ";y=" + y);
        // 构造器可以访问声明于他们后面的静态变量
        // 因为静态变量在类加载的准备阶段就已经分配内存并初始化0值了
        // 此时 x=0,y=0
        x++;
        y++;
        System.out.println("x=" + x + ";y=" + y);
        System.out.println("构造器结束");
    }

    public static int x = 2;
    public static int y;

    public static StaticMethodDemo getInstance() {
        return instance;
    }

    public static void main(String[] args) {
        StaticMethodDemo obj = StaticMethodDemo.getInstance();
        System.out.println("x=" + obj.x);
        System.out.println("y=" + obj.y);
    }
}

代码输出结果:
static开始
构造器开始
x=0;y=0
x=1;y=1
构造器结束
static结束
x=2
y=1

我们把上述代码在jclasslib插件中打开,如图1-33所示。我们看到编译后的的字节码文件第一个指令是invokestatic,也就是说虚拟机首先执行的是类加载初始化过程中然后调用 <clinit>() 方法,就是静态变量赋值以及静态代码块中的代码,如果 <clinit>() 方法中触发了对象的初始化,也就是<init>()方法,那么会进入执行<init>()方法,执行<init>()方法完成之后,再回来继续执行<clinit>()方法。

上面代码中,先执行static代码块,此时调用了构造器,构造器中对类变量x和y进行加 1 ,之后继续到static代码块,接着执行下面的public static int x = 2;来重新给类变量x赋值为2,因此,最后输出的是x=2,y=1。

如果希望输出的是x=3,y=1,将语句public static int x = 2; 移至static代码块之前就可以了。

图1-33 静态代码块与静态方法字节码文件示例

1.6.4类加载器

在讲类加载过程的加载阶段时提到过类加载器,类加载器的作用就是实现加载阶段的任务,通过一个类的全限定名获取Class文件的二进制字节流,实现这个动作的代码成为“类加载器”(Class Loader)。Java类加载器是Java运行时环境的一部分,负责动态加载Java类到Java虚拟机的内存空间中。类通常是按需加载,即第一次使用该类时才加载。由于有了类加载器,Java运行时系统不需要知道文件与文件系统。

类加载器在虚拟机外部的,这样程序就可以决定如何获取所需的类,我们也可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性。

Java中任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类命名空间。也就是说,你现在要比较两个类是否相等,只有在这两个类是同一个类加载器加载的前提下才有意义。

1.6.5双亲委派模型

在Java中加载器不只一个。从虚拟机的角度看,类加载器分为两大类,一类是启动类加载器(Bootstrap ClassLoader),他是C++实现的,另一类是其他类加载器,这类加载器都是由Java语言实现的,虚拟机认为所有非自身实现的加载器都归属这类。从我们程序员角度看,Java语言实现这部分要分的更细一些,大致分为三类,加上启动类加载器,Java中的所有类加载器共分为四类。

n 启动类加载器(BootstrapClassLoader)

C++编写,加载java核心库 加载java.lang.*,构造ExtClassLoader和AppClassLoader。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。

n 标准扩展类加载器(ExtensionClassLoader)

Java编写,加载扩展库,加载jre/lib/ext目录下,如classpath中的jre,javax.*或者java.ext.dirs 指定位置中的类,开发者可以直接使用标准扩展类加载器。

n 应用类程序类加载器(AppliactionClassLoader)

Java编写,加载程序所在的目录,如user.dir所在的位置的class,也称系统类加载器,如果没有指定类加载器,一般情况下就是这个程序默认的类加载器。

n 用户自定义类加载器(UserClassLoader)

Java编写,用户自定义的类加载器,可加载指定路径的class文件

在JDK9前Java应用都是由上面四类加载器配合完成的,用户自定义类加载器用户认为有必要就写,大多数来说都是没必要的,除非你有一些class来源不在其他三种加载器内的。

下面我来展示一下JDK9前双亲委派模型,如图1-34所示。

图1-34 JDK9前双亲委派模型

双亲委派模型除了启动类加载器之外都有自己的父类加载器,这些类加载器通常是协作关系,说是通常就意味着这是一个默认且最佳的实现方式,并不是一个强约束模型。我们从图中可以看出,双亲委派模型的工作原理:一个类收到了加载请求时,自己不做加载操作,而是把请求转交给父类加载器执行加载任务,所有请求最终都会到启动类加载器中,只有父类加载器加载失败时会向下交给子类加载器自己完成加载任务。

下面从源码角度分析双亲委派,读者可以自定翻阅class文件。

代码语言:javascript
复制
代码清单1-2 类加载机制源码
//ValueUtility.java
static {
    SharedSecrets.setJavaCorbaAccess(new JavaCorbaAccess() {
        public ValueHandlerImpl newValueHandlerImpl() {
            return ValueHandlerImpl.getInstance();
        }
//如果类加载器不是空就用当前线程上下文加载器,如果是空则使用系统类加载器
        public Class<?> loadClass(String var1) throws ClassNotFoundException {
            return Thread.currentThread().getContextClassLoader() != null ? Thread.currentThread().getContextClassLoader().loadClass(var1) : ClassLoader.getSystemClassLoader().loadClass(var1);
        }
    });
}

//ClassLoader.java
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 首先,检查类是否已经加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
//递归,双亲委派的实现,先获取父类加载器,不为空则交给父类加载器
                if (parent != null) {
                    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) {
                // 如果还是没有获得该类,调用findClass找到类
                long t1 = System.nanoTime();
                c = findClass(name);

                // JVM统计
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
//连接类
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

JDK9以后的双亲委派模型。从图1-35所示,中可以看出,JDK9中,取消了扩展类加载器,取代他的是平台类加载器。加载过程:在平台类加载器及应用程序类加载器收到加载请求时,在委派给父类加载器之前,会先判断该类能否归属到一个系统模块中,如果能,就需要优先委派给负责那个模块的加载器完成加载。

图1-35 JDK9后双亲委派模型

双亲委派模型的好处:

1、防止重复加载同一个class。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。

2、保证核心class不能被篡改。通过委托方式,不会去篡改核心class,即使篡改也不会去加载,即使加载也不会是同一个class对象了。不同的加载器加载同一个class也不是同一个Class对象。这样保证了Class执行安全。

1.6.5破坏双亲委派模型

上面讲解的双亲委派模型看起来可以加载大部分的类,我们也提到了双亲委派模型并不是强一致模型,就说明有些类的加载并不是按照这个模型实现的,在Java实际开发中我们会用到数据库,虽然所有数据库都遵循JDBC规范,但是Java并不知道你使用的是什么数据库,因此Java提供了一个Driver接口来帮助我们加载不同数据库服务商的驱动和连接。比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派,只是举了破坏双亲委派的其中一个情况,类在这种破坏双亲委派的的服务接口还有JNDI(Java Naming and Directory Interface ,Java 命名与目录接口)、JCE( Java Cryptography Extension,Java 密码学扩展)、JAXB(Java Architecture for XML Binding , Java开发者在Java应用程序中能方便地结合XML数据和处理函数)、JBI(Java Business Integration,Java业务集成,Java业务整合)等。

胖虎

热 爱 生 活 的 人

终 将 被 生 活 热 爱

我在这里等你哟!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-11-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 晏霖 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
云数据库 MySQL
腾讯云数据库 MySQL(TencentDB for MySQL)为用户提供安全可靠,性能卓越、易于维护的企业级云数据库服务。其具备6大企业级特性,包括企业级定制内核、企业级高可用、企业级高可靠、企业级安全、企业级扩展以及企业级智能运维。通过使用腾讯云数据库 MySQL,可实现分钟级别的数据库部署、弹性扩展以及全自动化的运维管理,不仅经济实惠,而且稳定可靠,易于运维。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档