JVM_类加载

简介

Class文件中描述了需要运行的类的各种信息,但是它还不能直接运行,需要经历加载、链接、初始化三步最终到达虚拟机方法区之后才能被运行和使用。
类加载这个过程都是在程序运行期间完成的,这样虽然会增加一些性能开销,但是为程序提供了极高的扩展性和灵活性,程序可以在运行期间通过预置的或者自定义的类加载器,去加载运行期间从网络或者其他地方得来的Class二进制流文件让它成为程序的一部分。
对于Java来说,引用类型有四种:类、接口、数组类、泛型参数,由于泛型参数会在编译过程中被擦除,数组类是由JVM直接生成的,所以类加载机制只针对类和接口。

初始化的时机

类加载过程第一步加载,虚拟机会根据需要,当它感知到某个类将要被使用时就会去加载,但是JVM规范中并没有对加载时机进行强制约束,所以每个虚拟机的具体实现各不相同。
但是初始化阶段,JVM规范给出了明确的初始化时机,有且只有以下六种情况发生时,如果类还没有进行过初始化那么必须立即对类进行初始化

  1. 遇到new、get static 、 put static 或 invoke static这四条字节码指令时(即new一个对象或者调用一个类的静态方法、静态属性时)。
  2. 使用java.lang.reflect报的方法对类型进行反射调用时。
  3. 子类初始化时。(接口除外,子接口的初始化不会要求父接口初始化完成)
  4. 拥有main()方法的那个主类。
  5. 通过java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、 REF_putStatic、 REF_invokeStatic、 REF_newInvokeSpecial四种类型的方法句柄, 并且这个方法句柄对应的类没有进行过初始化时。
  6. 一个接口由default修饰的接口方法,当这个接口的实现初始化时,这个接口需要优先初始化。

以上六种主动引用会触发初始化,以下三种被动引用并不会触发:

  1. 通过子类引用父类的静态字段,不会导致子类初始化
父类:
public class SuperClass {
    static {
        System.out.println("SuperClass init");
    }
    public static int value = 111;
}
子类:
public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init");
    }
}
通过子类调用父类的静态变量:
public class Test {
    public static void main(String[] args){
        System.out.println(SubClass.value);
    }
}
运行结果:
SuperClass init
111
  1. 通过数组定义来引用类,不会触发此类的初始化
定义一个数组:
public class Test {
    public static void main(String[] args){
        SuperClass[] superClasses = new SuperClass[50];
        System.out.println(superClasses.length);
    }
}
运行结果:
50

可以看到没有初始化SuperClass,因为JVM需要创建数组时,字节码指令newarray会生成一个SuperClass的包装类 [LSuperClass,这个包装类直接继承自java.lang.Object并且包含了数组应有的属性和方法,比如length和clone(),这个包装类的存在使得就算数组越界也不会像C/C++那样改变其他存储单元里面的内容,更加安全。
3. 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量类的初始化

定义一个包含常量的类:
public class ConstClass {
    static {
        System.out.println("ConstClass init");
    }
    public static final String TAG_LOG = "ConstClass";
}
使用这个类的常量:
public class Test {
    public static void main(String[] args){
        System.out.println(ConstClass.TAG_LOG);
    }
}
运行结果:
ConstClass

因为编译阶段有常量传播优化,这种情况下ConstClass.TAG_LOG常量已经直接存储到Test 的常量池中了,Test 实际在使用该常量时已经和ConstClass没有任何关系。

类加载过程

一个类型从被加载到JVM内存中开始,到卸载出内存为止,它的整个生命周期会经历Loading、Verification、Preparation、Resolution、Initialization、Using、Unloading七个阶段,类加载过程指的前面五个阶段的总称。
在这里插入图片描述

加载

加载阶段是开发者掌控最强的一个阶段,JVM中规定加载阶段需要完成三件事:
1.通过类的全限定名来获取该类的二进制字节流
2.把二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构
3.在堆上创建一个java.lang.class对象,用来封装类在方法区内的数据结构,并向外提供了访问方法区数据结构的接口

其中第一条事中没有指明这个二进制流的来源,这就为开发者们提供了开放广阔的舞台,目前主要加载二进制流的来源有三种:

  1. 本地文件系统中加载,从jar等归档文件中加载
  2. 将java源文件动态编译成class,比如使用ASM
  3. 网络下载,从专有数据库中加载

并且第一件事JVM设计时有意把其放到JVM外部去实现,以便让应用程序自己决定如何去获取所需的类,实现这个动作的代码叫类加载器。这是JVM的一种创新,在后来的类层次划分、QSGi、热部署、代码加密等领域中大放异彩。

类加载器

类加载器主要分四层,上一层是下一层的父类,java的模块化要求每层加载器加载各自负责的类库。类加载器不一定等到某个类被首次使用时才加载这个类,JVM规范中允许类加载器在预料到某个类将要被使用时就预先加载它。如果有class文件缺失(网络下载等方式会出现,本地的一般不会),只有该首次使用时才会报LinkageError
在这里插入图片描述

  • 启动类加载器:BootstrapClassLoader
    用于加载启动的基础模块类,比如:java.base,java.management,java.xml等。应用程序中不能直接引用启动类加载器,如果想要获取,获取的是null,如果设置classloader为null,默认使用启动类加载器。
    JDK8:主要加载<JAVA_HOME>/lib,或者Xbootclasspath参数指定路径且是虚拟机识别的类库(名称在jvm加载的白名单中,比如rt.jar)加载到内存中

  • 平台类加载器:PlatformClassLoader
    用于加载一些平台相关的模块,比如:java.scripting,java.compiler*,java.corba*等
    JDK8:之前是扩展类加载器:ExtensionClassLoader。主要加载<JRE_HOME>/lib/ext,或者java.ext.dirs系统变量指定路径中所有类库加载到内存中,这样不安全,jdk9有模块化以后,模块化本身就是可扩展的,PlatformClassLoader功能也被替代了,所以就被废弃

  • 应用程序类加载器:AppClassLoader
    用于加载应用级别的模块,比如:jdk.compiler,jdk.jartool,jdk.jshell等,还加载classpath路径中所有类库
    JDK8:负责加载classpath路径中所有类库,即只加载应用本身的类,现在按照模块化要求,也要负责将一些系统级别的类优先加载进来

	package classloader
	import java.sql.DriverPropertyInfo

	class Person

	fun main() {
		val str = "str class loader"
		println("String class loader is ${str::class.java.classLoader}")

		val driverPropertyInfo = DriverPropertyInfo("a", "1.0")
		println("driverPropertyInfo class loader is ${driverPropertyInfo::class.java.classLoader}")
		println("driverPropertyInfo class loader parent is ${driverPropertyInfo::class.java.classLoader.parent}")

		val person = Person()
		println("person class loader is ${person::class.java.classLoader}")
		println("person class loader parent is ${person::class.java.classLoader.parent}")
	}
	
	运行结果:
	String class loader is null
	driverPropertyInfo class loader is jdk.internal.loader.ClassLoaders$PlatformClassLoader@b4c966a
	driverPropertyInfo class loader parent is null
	person class loader is jdk.internal.loader.ClassLoaders$AppClassLoader@2f0e140b
	person class loader parent is jdk.internal.loader.ClassLoaders$PlatformClassLoader@b4c966a
  • 用户自定义的加载器:加载顺序在所有系统的加载器后面
自定义类加载器:
public class MyClassLoader extends ClassLoader {
    private String path;
    public MyClassLoader(String classPath) {
        path = classPath;
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = getData();
        if (classData != null){
            return defineClass(name, classData, 0, classData.length);
        }
        return null;
    }
    /**
     * 通过path将class文件转化为字节码数组
     * @return
     */
    private byte[] getData() {
        File file = new File(path);
        if (file.exists()) {
            ByteArrayOutputStream out = null;
            try (FileInputStream in = new FileInputStream(file)){
                out = new ByteArrayOutputStream();
                byte[] buffer = new byte[1024];
                int size = 0;
                while ((size = in.read(buffer)) != -1) {
                    out.write(buffer, 0, size);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
            return out.toByteArray();
        } else {
            return null;
        }
    }
}
将要被加载的类:
public class TestClassLoader {
    public static void main(String[] args){
        System.out.println("this is TestClassLoader");
        if (args != null && args.length != 0){
            System.out.println("args 0 is " + args[0]);
        }
    }
}
用自定义类加载器去加载:
public class Test {
    public static void main(String[] args) throws Exception {
    	//要加载的类需要放在其他地方,当前工程找不到的地方
        MyClassLoader myClassLoader = new MyClassLoader("C:/Users/Dean/Desktop/TestClassLoader.class");
        Class<?> clazz = myClassLoader.loadClass("TestClassLoader");
        System.out.println("自定义类加载器:" + clazz.getClassLoader());
        System.out.println("自定义类加载器的父加载器是:" + clazz.getClassLoader().getParent());
        //执行main方法
        Method method = clazz.getDeclaredMethod("main", String[].class);
        Object object = clazz.newInstance();
        String[] arg = {"abc"};
        method.invoke(object, (Object) arg);
    }
}
运行结果:
自定义类加载器:MyClassLoader@1d81eb93
自定义类加载器的父加载器是:jdk.internal.loader.ClassLoaders$AppClassLoader@3fee733d
this is TestClassLoader
args 0 is abc
双亲委派模型

双亲委派模型要求除了启动类加载器外,其余类加载器都有自己的父级加载器,这里的父子关系是组合而不是继承。
如果有一个类加载器能加载某个类,该加载器就是这个类的定义类加载器,所有能成功返回该类的Class的类加载器(父类找到也是成功返回),都被称为该类的初始类加载器。
每个类加载器都有自己的命名空间,命名空间由该加载器及其所有父加载器所加载的类构成,不同的命名空间,可以出现类的全路径名相同的情况。
运行时包由同一个类加载器的类构成,决定两个类是否属于同一个运行时包,不仅要看全路径名是否一样,还要看定义类加载器是否相同。只有属于同一个运行时包的类才能实现相同包内可见。
“双亲委托”加载类具体步骤(这边双亲委托加了引号,因为JDK9后已经不能算是真正的双亲委托了):

  1. 一个类加载器接收到类加载请求后,首先搜索它的内建加载器定义的所有“具名模块”
  2. 如果找到了合适的模块定义,将会使用该加载器来加载
  3. 如果class没有在这些加载器定义的具名模块中找到,那么将会委托给父级加载器,直到启动类加载器。
  4. 如果父级加载器反馈它不能完成加载请求,比如在它的搜索路径下找不到这个类,那子类加载器才自己来加载,在自己的classpath下找是否存在这个类,如果没有再询问自己的子类去找,找到就让这个子类来加载,直到首次接到这个类加载请求的加载器。
  5. 在类路径下找到的类将成为这些加载器的无名模块。

真正的双亲委托 JDK8:因为JDK8还没有模块,少了在模块中搜索的过程,所以JDK8及以下的类加载在收到类加载的请求后是直接委托给父加载器,父类加载器去找,找到就加载,找不到再反馈给子加载器,子加载器再来尝试加载。

实现双亲委派的代码在java.lang.ClassLoader的loadClass()方法中,如果自定义类加载器,推荐覆盖实现findClass()方法,这样既不影响按照开发者的意愿去加载类, 又可以保证新写出来的类加载器是符合双亲委派规则的。

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 {
                        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
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

双亲委托模型的作用:

  1. 使Java程序的稳定运作,保证那些公用的类只会被加载一次,比如String,所有类得到的String都是由启动类加载器加载的,避免了不同类使用的String类不同而导致错误,即使自定义重名的基础类库,程序会编译它,但是自定义的这个基础类永远无法被加载运行。
  2. 安全,已经加载的类,不能再被加载,保证了同名的类不会被恶意修改

双亲委托模型只是给提供了一个统一模型,适用于大部分的程序类加载需求,但是如果程序的类加载需求使用双亲委托模型无法满足时,需要换种方式去加载类,目前破坏双亲委派模型主要有两种情况:

  1. 双亲委派模型中父加载器无法向下识别子加载器加载的资源。子加载器可以通过Thread.currentThread().setContextClassLoader();将自己存储到当前线程里面,父加载器可以通过Thread.currentThread().getContextClassLoader();来获取,从而可以使用。
  2. 热替换也会破坏双亲委派模型,比如OSGI的模块化热部署,它的类加载器就是平级的,OSGI的每个模块都有自己的类加载器,替换模块时会将这个加载器一起替换掉,OSGI的类加载不上双亲委托那样的树状,而是更加复杂的网状。

连接(验证、准备、解析)

连接阶段细分为验证、准备、解析三个步骤,它在加载阶段开始之后才开始,但它不一定需要等待加载阶段完成,连接阶段的部分动作也许会和加载阶段交叉进行。

  • 验证:确保被加载类的正确性
    之前加载阶段表明二进制流的来源广泛,验证阶段就要保证这字节流的合法性,保证这些信息被当做代码运行后不会危害JVM自身的安全,但是这个阶段也不是必须的,如果能够确保加载的二进制流没问题,可以通过-Xverify: none参数来关闭大部分的类验证措施,以缩短JVM类加载的时间,具体有四个地方需要检验:
  1. 类文件结构检查:按照JVM规范规定的类文件结构来检查字节流是否符合规范,并且能否被当前版本的虚拟机处理。只有这个阶段是基于二进制流进行的,检查通过后字节流才被允许进入JVM内存的方法区中存储,后面三个验证阶段都是基于方法区的存储结构进行的。
  2. 元数据验证:对字节码描述的信息进行语义分析,保证其符合Java语言规范要求(比如被加载的类有父类的话,该父类是否是final修饰的,是否有abstract方法该类没有实现等)。
  3. 字节码验证:通过对数据流(类型转换有效)和控制流(if else, for循环等)进行分析,确保程序语义是合法和符合逻辑的。主要对方法体进行校验。这个过程是无法准确判定一段程序是否存在bug,比如递归调用时退出的条件不完善,在运行后导致方法栈中栈帧数量超出。
  4. 符号引用验证:发生在JVM将符号引用转化为直接引用时(即解析阶段时发生),对类自身以外的信息,进行匹配校验(比如常量池中的各种符号引用,该类是否被限制访问它需要访问的类)
  • 准备:为类的静态变量分配内存,并初始化它们。
    首先看分配到内存哪里,概念上都叫方法区,JDK7之前HotSpot用永久代来实现方法区,这时和概念符合,但JDK8以后,类变量会随着Class对象一起存放在java堆中,这时方法区只是一块逻辑上的区域。
    再看看初始化什么,静态变量不会直接初始化为想要的值而是初始化为零值,想要的值需要等到类的初始化阶段才被执行,静态常量则会被初始化为指定的值。
    在这里插入图片描述

  • 解析:把常量池中的符号引用转换成直接引用
    符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,此时目标不一定是已经被加载到JVM内存中的。
    直接引用:直接指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄,此时目标肯定已经被加载到JVM内存中。

初始化

初始化阶段其实就说执行类构造器<clinit>()方法的阶段,根据程序编码指定的主观计划去初始化类变量和其他资源。
<clinit>()方法:不是开发者编写的方法,由javac编译器自动生成,其中包含了由编译器自动收集来的类中所有类变量的赋值动作静态代码块。关于这个方法有几点说明:

  1. 编译器收集顺序和语句在源文件出现的顺序一样,因此静态代码块只能访问之前的变量,对于定义在它之后的变量,可以赋值但不能访问
静态代码块中赋值:
public class Test {
    static {
        value = 2;
    }
    static int value;
    //运行结果是2:因为value在连接的准备阶段被赋值为0,在初始化阶段被赋值为2。
    //这种<clinit>只会收集到静态代码块,因为代码块后面的value没有明确的赋值操作。
    public static void main(String[] args){
        System.out.println(value);
    }
}

静态代码块后赋值:
public class Test {
    static {
        value = 2;
    }
    static int value = 1;
    //运行结果是1:在初始化阶段收集来的<clinit>,代码块在前,所以value被先赋值为2。
    //代码块后的value有赋值操作,所以也被收集进<clinit>,在代码块之后,value被赋值为1.
    public static void main(String[] args){
        System.out.println(value);
    }
}

静态代码块中访问后面的变量:
public class Test {
    static {
        value = 2;
        //编译报错,代码块后的变量只能赋值不能访问
        System.out.println(value);
    }
    static int value;
}
  1. JVM保证父类的<clinit>()执行先于子类的<clinit>(),因此JVM中第一个被执行<clinit>()方法的类型肯定是java.lang.Object。这种机制意味着父类的静态语句先于子类的变量赋值
父类:
public class SuperClass {
    static int value = 1;
    static {
        value = 2;
    }
}
子类:
public class SubClass extends SuperClass {
    public static int subValue = value;
}
测试:
public class Test {
    public static void main(String[] args){
        System.out.println(SubClass.subValue);
    }
}

运行结果:
2
  1. 接口没有静态代码块,但是会有变量,所以也会生成<clinit>()方法,并且子接口的<clinit>()执行不要求父接口的<clinit>()一定先执行
  2. 如果类或者接口压根没有静态代码块也没有变量的赋值,那么编译器不会为这个类生成<clinit>()
  3. JVM需要保证一个类的<clinit>()方法只被执行一次,所以在多线程环境中会自动地正确地加锁同步,多线程时只会有一个线程去执行这个类的<clinit>(),其他线程在此过程中阻塞。这机制被开发者们拿来用作单例模式
public class DBHelper {
    private DBHelper(){
    }
    public static DBHelper getInstance(){
        return InnerDBHelper.instance;
    }
	//静态内部类才能有静态变量
    private static class InnerDBHelper{
    	//instance 的赋值操作在初始化阶段加锁进行
        private static DBHelper instance = new DBHelper();
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值