类加载和类加载器部分讲解

类加载定义

JVM将描述类的数据从.class文件中加载到内存中,并且对数据进行校验、转换、解析和初始化,最终变成能够被JVM所直接使用的Java类型,这个过程叫做类加载。

类的声明周期

一个类型从被加载到虚拟机内存(即JVM进程的拥有的内存)中开始到卸载出内存,一共会经历7个阶段。
在这里插入图片描述
其中加载,验证,准备,初始化和卸载这五个阶段的顺序是一定的(这里所说的顺序执行不代表只有当前一步完成再能执行下一步,一般情况都是在执行该阶段就会激活下一阶段的任务执行),但是解析阶段则有可能会在初始化之后再开始执行,这是为了支持Java语言的运行时绑定特性(也称为动态绑定和晚绑定)。

无论什么情况,类加载的开始一定是加载放在首位执行。但是对于初始化阶段,Java虚拟机定义了6种情况必须立即对类进行初始化(但是这不代表初始化在加载前面,而是仍然需要执行加载,验证,准备)。

6种特殊情况

1.遇到 new, getstatic, putstatic, invokestatic 这四条字节码指令的时候,如果类或者接口没有被初始化,则需要先对其初始化。能够使用这四条字节码指令的Java代码场景有:

  • 使用new关键字实例化对象时
  • 读取或者设置一个类的静态字段的时候(被final修饰的字段除外,因为被final修饰的字段已在编译期就将结果放入常量池中)
  • 调用一个类的静态方法时候。

2.使用Java的reflect包的方法对类型进行反射时,如果类型没有被初始化,则需要先进行初始化操作。

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

4.当Java虚拟机启动时,用户需要指定一个要执行的主类(PSVM),虚拟机会先初始化该主类。

5.JDK1.7加入的动态语言支持,如果一个MethodHandle实例最后被解析为REF_getstatic, REF_putstatic, REF_invokestatic, REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法的句柄对应的类没有被初始化,则需要先初始化该类。

6.JDK1.8接口中定义了default方法,如果有这个接口的实现类发生了初始化, 那么该接口则需要在其之前被初始化。(有点类似于第3条)

对于这个六种情况,《Java虚拟机规范》定义有且只有这六种情况能够被称为主动引用,其他的情况都不会触发初始化,称为被动引用

下面附加点代码方便理解主动引用和被动引用

场景一

package test;

public class Father {
    public static int age = 50;
    static{
        System.out.println("father init");
    }
}


class Child extends Father {

    static{
        System.out.println("child init");
    }


}

class Run{
    public static void main(String[] args) {
        System.out.println("age = " + Child.age);
    }
}

--------------------------输出结果---------------------------
father init
age = 50

输出结果说明我们通过子类使用父类的静态字段并不会导致子类初始化,而是父类发生了初始化(因为父类是直接使用静态字段的类)

场景二

package test;

public class Father {
    public static int age;
    static{
        System.out.println("father init");
    }

}


class Child extends Father {

    static{
        System.out.println("child init");
    }


}

class Run{
    public static void main(String[] args) {
        Child[] children = new Child[3];
    }
}


--------------------------输出结果---------------------------

这里实用类的数组,但是并没有任何输出,说明没有触发类的初始化,但是虚拟机会生成一个数组类,直接继承于Object,创建动作由newarray字节码执行。这个类代表一个对应类的一维数组,数组中包含类的所有字段和方法,并且Java针对数组也有安全保障(当数组越界会抛出ArrayIndexOutOfBoundsException).

场景三

package test;

public class Father {
    public static final int age = 50;
    static{
        System.out.println("father init");
    }

}


class Child extends Father {

    static{
        System.out.println("child init");
    }


}

class Run{
    public static void main(String[] args) {
        System.out.println(Child.age);
    }
}

--------------------------输出结果---------------------------
50

这里并没有初始化子类或者父类,是因为static final修饰的字段会在编译阶段就将值存入常量池中,虽说确实引用了父类的常量,但是编译阶段通过常量传播优化的方式,已经将常量 age 存储在Run类的常量池中,所以在运行阶段相当于是直接对自己的常量池的使用,与子类或者父类的常量毫无关系。

接口的加载过程和类的加载有点区别,首先我们需要确认的是接口也是有初始化的,但是接口中是无法使用static代码块的,但是编译器仍然会为接口生成clinit()构造器用于初始化接口中定义的成员变量,但是接口只有在前面所说道的6中主动引用中的第3种:当子类使用父类的属性时(类似于父类接口的常量),那么则需要父类先完成初始化。(PS:如果一个接口拥有多个父类接口,那么如果子接口使用其中一个父接口的常量时,则需要那一个父接口完成初始化)。

类加载过程

加载

加载作为类加载的第一步,主要有三个步骤。

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

2.将该字节流所代表的静态存储结构转换成方法区对应的数据结构。

3.在内存中创建一个代表该类的java.lang.Class对象,作为方法区这个类的各种参数的入口。

验证

验证是作为连接部分的第一步,用于判断读取的二进制字节流是否完全符合《Java虚拟机规范》的全部约束要求,保证这些信息被加载以后不会对虚拟机产生危害。

主要分为四部分的验证。

文件格式验证

主要就是用来验证字节流是否符合Class文件格式,并且对应版本是否能够被当前虚拟机处理。

元数据验证(类型检验)

对字节码描述的信息进行语义分析,保证加载二进制流满足《Java语言规范》。
1.这个类是否有父类(除了Object,所有的都应当有父类)

2.这个类是否继承了不能被继承的类(被final修饰的类)

3.如果一个类(非抽象类)继承了一个抽象类或者接口,那么该类是否实现了父类的所有抽象方法。

4.类的字段或者方法是否和父类对应的字段和方法出现了冲突(类似于想要重写父类final方法)

5.等等

字节码验证(逻辑检验)

通过数据流分析和控制流分析来保证程序语义是合法,符合逻辑的。第二阶段的数据校验完毕后,这一步验证是通过判断Code是否合法。
例如:
1.将long值赋值给Int引用(这属于向下转型,Java支持向上转型)

2.保证跳转指令不会跳转到方法体外的字节码去。

3.等等

符号引用验证

本阶段用于检验行为法神虚拟机将符号引用转换成直接引用阶段(解析阶段)。符号引用验证可以看作该类是否缺少类外部所依赖的外部类,字段,方法等资源。主要检验下面内容:

1.符号引用中能否通过字符串的全限定名找到对应的一个类。

2.符号引用中的类、字段、方法的可访问性(访问修饰符:public,protected…)是否可以被当前类访问等。

符号引用验证的主要目的是确保解析行为能够正常执行,如果无法通过符号引用验证,那么JVM则会抛出异常。

准备

准备阶段是正式为类中定义的变量(即static变量)分配内存并且设置类初始值的阶段。从概念上将这部分内存应该在方法区进行分配,在JDK8之前,方法区是存放在永久代中,是一块真正的内存空间,在JDK8及以后,JVM将永久代移出,此时类变量(即static变量)会随着Class对象分配在堆中,此时的方法区则是一个逻辑概念(无实际内存空间)。
注意
1.我们此时分配空间的只有类变量,但不包含实例变量(即类的非static变量),实例变量通常是随着类的实例化而分配空间。

2.如果我们定义了一个类变量 public static int value = 123,此时我们准备阶段后value = 0 而不是 123,因为将 value 赋值为 123 是putstatic字节码指令,该指令我们放置在clinit()中,在初始化阶段在会被执行。

解析

解析阶段是将常量池中的符号引用转换成直接引用。

符号引用
符号引用是指以一组符号来描述所引用的目标,与虚拟机布局无关。简单点说就是编译阶段每个.java类文件都会被翻译成.class文件,而此时虚拟机并不知道真正的引用类地址。

直接引用
直接引用和虚拟机的布局是相关的,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定被加载到了内存中。

假如一个类的代码需要加载一个从未解析过的符号引用N,将N解析成一个类C。

1.如果C不是数组类型,则JVM会将代表N的全限定名传递给D的类加载器让其进行类加载,如果在这个解析过程中发生了任何异常,那么宣告这次解析失败。

2.如果C是数组类型,并且数组类型为对象(非8大基本类型),则JVM会根据第1点规则进行数据元素类型加载。

3.如果上面过程没有出现问题,则说明C已经在JVM中成了一个有效的接口或者类,接下来需要判断其访问修饰符,判断D是否可以访问C。

初始化

类的初始化是类加载过程的最后一个步骤(抛开使用和卸载两个阶段)。在初始化之前的阶段基本都是由JVM进行处理的,初始化阶段才是开始执行类中编写的Java代码。

在准备阶段的时候我们已经将类变量赋值为初值(int 初值为 0),而在初始化阶段,就是执行clinit()方法的过程。clinit()方法并不是用户编写的,而是javac编译生成的。

clinit()

该方法是由编译器自动收集类中的所有类的变量的赋值操作和静态代码块。

clinit()的执行顺序:按照代码的顺序执行。

测试

package test;

public class Father {
    static{
        age = 2;
    }

    static int age = 1;

    public static void main(String[] args) {
        System.out.println(Father.age);
    }
}

-------------------------输出结果---------------------------
1
因为静态代码块在前

clinit() 和 init() 不同,init()是由用户编写的(针对非静态变量),clinit()是由javac编译成的(针对静态变量),并且当我们调用子类的clinit()方法时,必然会调用父类的clinit()方法,因此JVM第一个执行的clinit()一定是Object类的(所有类的父类)

测试

package test;

public class Father {
    public static int age = 50;
    static{
        age = 18;
    }

}


class Child extends Father {
    public static int myAge = age;

    public static void main(String[] args) {
        System.out.println(Child.myAge);
    }

}

---------------------测试结果-------------------------
18

当有多个线程调用类的clinit()方法时,只有一个线程会执行,其他类会被阻塞,知道活动执行完毕。

类加载器

通过一个类的全限定名的二进制流加载一个类,实现这个过程的代码被称为类加载器。

对于任意一个类,都必须由加载这个类的类加载器和这个类本身来确认这个类在这个JVM中的唯一性,每一个类加载器,都有一个独立的类名称空间,换句话说就是就算两个类来自同一个类的class文件,被同一个虚拟机加载,
只要加载他们的类加载器不同,这两个类就不同。

测试:使用两个不同类加载器加载同一个类文件

package classloader;

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


public class Test {
    public static void main(String[] args) throws Exception {
        MyClassLoader myClassLoader = new MyClassLoader();
        Object test1 = myClassLoader.loadClass("classloader.Test").newInstance();
        Test test2 = new Test();
        System.out.println(test1 instanceof classloader.Test);
        System.out.println(test2 instanceof classloader.Test);
    }
}

class MyClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {

        try {
            String filename = name.substring(name.lastIndexOf(".") + 1) + ".class";
            InputStream inputStream = getClass().getResourceAsStream(filename);
            if(inputStream == null) {
                return super.loadClass(name);
            }
            byte[] buffer = new byte[inputStream.available()];
            inputStream.read(buffer);
            return defineClass(name,buffer,0,buffer.length);
        }catch (IOException e) {
            throw new ClassNotFoundException();
        }
    }
}

---------------------输出结果-------------------------------
false
true

从上面这段代码我们可以看出来,一个test1是我用自己的类加载器加载出来的对象,test2是我们Java默认的类加载器加载出来的对象,尽管我们是从同一个.class文件读取的二进制流,但是仍然为false,说明只要不是同一个类加载加载的对象,就算是同一个Class文件但仍然不同。

双亲委派模型

从JVM的视角来看,只存在两种类加载器,一种是启动类加载器(由C++编写的),另一种就是其他所有的类加载器,全部是由Java编写完成的,全部继承于抽象类java.lang.classloader。

从Java开发人员来看,Java一直保持三层类加载器和双亲委派模型。

启动类加载器(bootstrap classloader)

这个类加载器主要加载在JAVA_HOME/lib目录下的类

扩展类加载器(extension classloader)

这个类加载器主要加载JAVA_HOME/lib/ext目录下的类,从名字就可以发现这是一种Java系统类库的扩展机制,我们可以将使用频繁的类库放置在ext目录下。

引用程序类加载器(application classloader)

这个类加载器用于加载用户类路径下(classpath)的类库,开发者同样可以直接 在代码中使用这个类加载器,如果程序中没有自定义过自己的类加载器,则默认使用该类加载器。

JDK9之前的Java应用都是有这三种类加载器实现的,如果认为有必要的话,用户可以自定义类加载器从而完成对类的加载。

双亲委派模型的工作流程

在这里插入图片描述
双亲委派模型要求除了顶级类加载器(bootstrap classloader)以外,其余所有的类加载器都应该有父类类加载器(这里所说的父类并不是真正的那种继承关系,而是他们之间存在上下级别的关联)。

双亲委派模型的工作机制:当一个类加载器收到类加载请求时,他首先不会加载这个类,而是将这个请求委派给自己的父类类加载器,委托它去完成类加载,直到当父类类加载器无法完成类加载的时候,这时候才会由子类类加载器完成类加载。

优点:使用双亲委派模型可以使得一个类不管在什么程序的各种类加载器环境中执行,都能够保证是同一个类。反之,如果没有类加载器,那么很容可能创建的类都是不同的。例如:我自定义一个类加载器加载Object,如果没有双亲委派,那么系统自带的类加载和我自定义的类加载器创建出来的类一定不同。

双亲委派模型的破坏

例如JDNI的存在,JDNI代码由启动类加载器实现加载,但是它需要调用由其他厂商实现并部署的classpath下的JDNI服务提供者接口的代码,那么这时候就会有问题,启动类加载器绝对不可能认识并且加载对应接口的类(这里就相当于父类加载器想要子类加载器加载类,已经打破双亲委派),那么如何加载呢?

这里使用了一种叫上下文类加载器,我们可以通过调用Thread的setContextClassloader方法设置,如果创建创建线程时候没有设置,他会从父类继承。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值