虚拟机类加载机制

虚拟机类加载机制


仅作为笔记


前言

仅作为笔记


一、概述

java的类型的加载、连接和初始化都是在程序运行期间完成的。虽然这样做会增加一些性能的开销,但是会为java应用程序提高高度的灵活性

  • 本文约定:1)文下对类的描述包括了类和接口,而对于类和接口需要分开描述的场景会单独指出。2)下面说到的Class文件都指的是一串二进制的字节流,无论什么存在形式都可以。

二、类加载的时机

  • 类从被加载到虚拟机内存中到卸载出内存为止,生命周期包括:加载、验证、准备、解析、初始化、使用、卸载,一共七个阶段

  • 生命周期顺序加载、验证、准备、初始化、卸载这五个阶段的顺序是确定的,解析阶段在某些特殊情况下会出现在初始化阶段之后(为了支持Java语言的运行时绑定)。

  • 有且仅有的五个需要初始化情况
    1)遇到new、getstatic、putstatic、invokestatic这四条指令的时候(被final修饰、已在编译期把结果放入常量池的静态字段除外),如果类没有初始化就需要先触发初始化,生成这四个指令的情况是:使用new关键字读取或设置一个类的静态字段的时候调用类的静态方法的时候
    2)使用反射的时候,如果没有进行初始化那就要触发初始化。
    3)当初始化一个类时,他的父类还没初始化的话先初始化他的父类
    4)当虚拟机启动时,先初始化主类。
    5)使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化

  • 以上五种称为一个类的主动引用,除此之外的都叫被动引用,被动引用是不会初始化的,如下:

package org.fenixsoft.classloading/**
*被动使用类字段演示一:
*通过子类引用父类的静态字段,不会导致子类初始化。
**/
public class SuperClass{
	static{
		System.out.println("SuperClass init!");
	}
	public static int value=123}
public class SubClass extends SuperClass{
	static{
		System.out.println("SubClass init!");
	}
}
/**
*非主动使用类字段演示
**/
public class NotInitialization{
	public static void main(String[]args){
		System.out.println(SubClass.value);
	}
}

以上代码中,在Main函数里面,只是通过SubClass类调用其父类的静态成员value,所以这里只会初始化其父类而不会初始化SubClass类本身

public class SuperClass{
	static{
		System.out.println("SuperClass init!");
	}
	public static int value=123}
public class SubClass extends SuperClass{
	static{
		System.out.println("SubClass init!");
	}
}
/**
*被动使用类字段演示二:
*通过数组定义来引用类,不会触发此类的初始化,因为所有的数组类都没有对应的字节流,
*是由java虚拟机直接说生成的,所以不需要使用java的类加载器来加载,也就不会经过类加载器
*的初始化阶段。
**/
public class NotInitialization{
	public static void main(String[]args){
		SuperClass[]sca=new SuperClass[10]}
}

上面代码的情况均不属于以上提到的必须初始化的五种情况,所以不会发生初始化。

package org.fenixsoft.classloading/**
*被动使用类字段演示三:
*常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
**/
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);
	}
}

在上面代码中,表面上看NotInitialization类里面调用了ConstClass类的静态成员变量HELLOWORLD,但是注意,这个变量是被final修饰的静态变量,在编译阶段,编译阶段,编译阶段,重要的事情说三遍!通过常量传播优化,已经将此常量的值“hello world”存储到了NotInitialization类的常量池中意味着此时实际上调用的这个HELLOWORLD已经和ConstClass类没关系了,直白的说就是压根儿就没走上类加载的路上,更谈不上进入加载器的初始化阶段。

注意:接口与类有所区别的是五个必须初始化条件的第三条:接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

三、类加载的过程

3.1、加载

注意:此加载只是类加载过程的一个阶段。

  • 此阶段任务
    1)通过全限定名获取定义此类的二进制字节流。(注意:前文提到了数组类,没有对应的字节流,由虚拟机产生,所以没有进行数组类加载,也就谈不上初始化,你也可以理解为这一切不是由类加载器完成的)
    2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。通俗的说就是面向的是运行时内存区的方法区。
    3)在内存中生成此类的java.lang.Class对象,作为在方法区访问这个类的各种数据的入口。

  • 作为类加载机制的第一个阶段——加载,根据以上三点总有种不严谨的感觉,特别是第一句,通过全限定名获取二进制字节流,可是从哪里获取?恰巧,这不严谨的一点也是java类加载灵活的一点,意味着我们可以从很多渠道获取到这个二进制字节流,只要他是类的二进制字节流就可以了!比如:ZIP(这就是后面发展成JAR的基础)网络中获取运行时生成(动态代理技术)其他文件生成(JSP)从数据库中获取中间件服务器,完成程序代码在集群间的分发)。

  • 加载阶段时类加载机制中相对来说开发人员控制性最强的(非数组类的加载,因为数组类的加载由Java虚拟机直接创建,不是由类加载器创建的,这里又说了一遍了!)。类加载器可以使用系统的,也可以重写类加载方法(loadClass())。

  • 数组类加载规则:数组类本身不靠类加载器完成,但是其组件却依然是靠类加载器完成的,使用递归完成。有以下规则:
    1)如果数组的组件是引用数据类型递归采用标准的加载流程去加载这个组件类型,数组将在加载该组件类型的类加载器的类名称空间上被标识类必须与类加载器一起确定唯一性
    2)数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public
    3)如果数组组件不是引用类型,java虚拟机将数组标记为和引导类加载器关联

注意:java.lang.Class类的对象没有规定一定要在Java堆中,HotSpot虚拟机将其放在方法区

3.2、验证

先知道一件事,验证、准备、解析都属于连接阶段而验证阶段可能会存在和加载阶段时间上交叉进行

  • 目的和作用:验证实际上是保护java虚拟机本身的一种手段,上面已经提到过的,对class二进制流,可以各种方法得到,只要他是二进制流进行了,但是这个class文件的代码是否会对虚拟机造成影响并不得而知,所以需要对其进行检查。
  • 验证阶段的四大检验动作
    1)文件格式验证保证输入字节流能正确地解析并存储于方法区之内格式上符合描述一个Java类型信息的要求。这个阶段之后的几个验证阶段都是存储于方法区,这个阶段也是最后一个字节流阶段
    2)元数据验证:对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息
    3)字节码验证: 对类的方法体进行验证,是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,一个类的方法体通不过字节码验证代表这个一定有问题,但是通过了不代表一定没问题
    4)符号引用验证: 目的是确保解析动作能正常执行,发生在虚拟机将符号引用转化为直接引用的时候

3.3、准备

  • 阶段任务正式为类变量分配内存并设置初始值的阶段
  • 类变量:被static修饰的变量
  • 设置初始值:这里的设置初始值并不是像初始化那里的一样,而是仅仅是数据类型的零值,如下:
public static int value=123;

这样的情况下,value在此阶段为0,而不是123,需要为123还需要putstatic指令出现,而这个指令出现必须是程序被编译后,存放于类构造器的 < clinit > () 方法中,到初始化阶段再执行。但是如果是被final修饰的类变量那么就会在准备阶段之前被初始化为ConstantValue属性所指定的值(更确切的说,final修饰的变量在编译期就被存放进了方法区的运行时常量池中),简而言之,被final修饰的类变量会在准备阶段就有实际意义的值,而普通的类变量(static修饰的)会被赋类型值(零值)。如下

public static final int value =123;

3.4、解析

  • 阶段任务:虚拟机将常量池内的符号引用替换为直接引用的过程。
  • 符号引用
    1)以一组符号描述所引用的目标。
    2)无固定的格式,
    3)符号引用与虚拟机实现的内存布局无关。
    4)所引用的目标不一定加载到了内存中。
    5)内存实现不同的虚拟机其能接受的符号引用必须一致。
  • 直接引用:
    1)可以是指向目标的指针、相对偏移量、句柄。
    2)直接引用和虚拟机实现的内存布局相关。
    3)如果有了直接引用,则引用目标一定存在内存中。

3.5、初始化

准备阶段,类变量已经赋值过一次系统初始值了(零值),在这个阶段,就会真正意义上初始化类变量和其他资源。

  • 定义执行类构造器的 < clinit > ()方法的过程。

  • < clinit >()方法的构造 : 由编译器自动收集类中的所有类变量的赋值动作静态代码块中的语句合并而成。意味着如果一个类没有这些,那么他可以没有< clinit >()方法。

  • 编译器收集的顺序(初始化顺序)
    1)父类的静态变量(类变量)和静态代码块赋值(按排列顺序)
    2)自身的静态变量(类变量)和静态代码块赋值(按排列顺序)
    3)父类的成员变量和块赋值(按排列顺序)
    4)父类的构造器赋值
    5)自身的成员变量和块赋值(按排列顺序)
    6)自身的构造器赋值
    注意:上面的3,4,5,6步不是说一定会在本阶段执行,只有调用了实例化对象才会一定执行3,4,5,6。

  • 类变量是声明在class内,method之外,且使用static修饰的变量。
    实例变量是声明在class内,method之外,且未使用static修饰的变量。
    类变量与实例变量的区别是:
    1)存储位置不同。静态变量存储于方法区,而实例变量存储于堆区。
    2)生命周期不同。静态变量在加载类过程中优先加载,其生命周期取决于类的生命周期;实例变量在创建实例时才创建,它的生命周期取决于实例的生命周期。
    3)引用对象不同。静态变量属于类,被类的所有实例共享,可以直接使用类名来引用也可以通过类的实例引用;而实例变量则属于某个对象,它必须在创建对象后才可以通过这个对象来使用。
    4)使用方法不同。一个类只能有一个同名静态变量,无论是通过类或者任何一个实例对静态变量重新赋值,结果都是一样;而一个类创建多少个实例就会有多少个同名实例变量,各实例变量存储空间不同,对其中一个实例变量重新赋值不影响其它实例的同名变量。

  • 静态代码块访问注意事项:静态代码块中,只能访问到定义在这之前的变量,定义在静态代码块后面的变量只可以赋值不可以访问

  • 接口和类对于< clinit >()方法的不同接口虽然不能有静态代码块,但是有其他赋值操作。接口不需要先执行父类的初始化方法,只有需要使用父类中定义的变量时才初始化,当然,其实现类也不需要先初始化接口

  • < clinit >()方法的线程安全问题:如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法

注意:线程如果退出了< clinit >()方法,那么将不会再次执行。同一个类加载器下,一个类型只会初始化一次,这个特性可以应用于单例模式的懒汉模式下保证线程安全,也就是内部类方式。

注意:虚拟机会保证一个类的< clinit >()方法在多线程情况下是安全的。可以被正确的加锁,同步,如果多线程同时去初始化一个类,那么只会有一个线程去执行这个类的< clinit >()方法,其他线程都需要等待,直到活动线程执行完毕。如果一个类的< clinit >()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

四、类加载器

  • 类加载器的作用:根据一个类的全限定名称生成描述此类的二进制字节流到java虚拟机。

4.1、类与类加载器

  • 类加载器除了加载类还负责表示类的唯一性
  • 比较两个类是否相等时,这个比较必须是这两个类属于同一个类加载器才有意义,不然一定不相等。

4.2、双亲委派模型

  • 启动类加载器(引导类加载器)由C++实现,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代其他类加载器均由java实现),负责加载存放在<JAVA_HOME>\lib 目录中的类,或者被-Xbootclasspath参数所知指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。无法使用ClassLoader来调用,如果需要用这个加载器,直接使用null代替即可

  • 扩展类加载器由 Java 核心类库提供扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib\ext(<JAVA_HOME>\lib\ext) 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类),开发中可直接使用

  • 应用程序类加载器由 Java 核心类库提供应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。是ClassLoader中的getSystemClassLoader()方法的返回值,所以也叫做系统类加载器。负责加载用户类路径上所指定的类库。可直接使用

  • 自定义类加载器由开发者重写的自己定义的类加载器,可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。

  • 双亲委派模型结构图:如下
    在这里插入图片描述

  • 双亲委派模型的特点
    1)除了启动类加载器,其余所有类加载器都有自己的父类。
    2)父子类加载器之间不是用继承连接的,而是用的组合(Composition)关系来复用父类的代码。

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

  • 双亲委派模型的优势(作用或者意义)
    Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类类的唯一性有其来加载器构成)。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为同java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证(比如比较类,使用equal这种方法时,因为类的唯一性由类名和类加载器共同确定,换言之很多java语言的操作都会失效,破坏了java语言本身),应用程序也将会变得一片混乱总之,作用算是维护java类型体系的基础,实际上双亲委派机制也是Java语言运行的沙箱安全机制的组成

  • 双亲委派模型代码实现

protected synchronized Class?>loadClass(String name,boolean resolve)throws ClassNotFoundException
{
	//首先,检查请求的类是否已经被加载过了
	Class c=findLoadedClass(name);
	if(c==null{
		try{
			if(parent!=null{
				c=parent.loadClass(name,false);
			}else{
				c=findBootstrapClassOrNull(name);
			}
		}catchClassNotFoundException e){
		//如果父类加载器抛出ClassNotFoundException
		//说明父类加载器无法完成加载请求
		}
		if(c==null{
			//在父类加载器无法加载的时候
			//再调用本身的findClass方法来进行类加载
			c=findClass(name);
		}
	}
	if(resolve){
		resolveClass(c);
	}
	return c;
}

4.3、破坏双亲委派模型

  • 第一次被破坏:在 jdk 1.2 之前,那时候还没有双亲委派模型,不过已经有了 ClassLoader 这个抽象类,所以已经有人继承这个抽象类,重写loadClass 方法来实现用户自定义类加载器。而在 1.2 的时候要引入双亲委派模型,为了向前兼容, loadClass 这个方法还得保留着使之得以重写而双亲委派的具体逻辑就实现在这个方法之中,JDK 1.2之后已不提倡用户再去覆盖loadClass()方法,而应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里如果父类加载失败,则会调用自己的findClass()方法来完成加载,但是又允许重写 loadClass(只是呼吁大家不要重写,但是可以重写),重写了之后就可以破坏委派逻辑了
  • 第二次被破坏线程上下文类加载器,核心是启动类加载器去委托子类加载器(应用程序类加载器)去加载类,这个核心思想本身就是违背双亲委托原则的(简单来说就是接口定义在了启动类加载器中,而实现类定义在了其他类加载器中,当启动类加载器需要加载其他子类加载器路径中的类时,使用了线程上下文类加载器(默认是应用程序类加载器)来实现父类调用子类的加载器进行类的加载)。应用场景比如JNDI,JDBC,JCE等所有需要涉及SPI的加载动作的。具体原因:启动类加载器只能加载<JAVA_HOME>\lib或Xbootclasspath指定的路径中,而JDBC 等的实现类在我们用户定义的 classpath 中,只能由应用程序类加载器去加载,所以不得不由启动类加载器委托应用程序类加载器去完成。
  • 第三次被破坏:这次破坏是为了满足热部署的需求(也就是代码热替换,模块热部署等),不停机更新这对企业来说至关重要,毕竟停机是大事。OSGI 就是利用自定义的类加载器机制来完成模块化热部署,而它实现的类加载机制就没有完全遵循自下而上的委托,有很多平级之间的类加载器查找,具体就不展开了,有兴趣可以自行研究一下。
  • 补充知识点:
    全盘委托机制:如果A类调用了B类,则B类由A类的加载器进行加载。
    JDBC、JCE等为什么会违背了双亲委派机制呢?简言之,JDBC等实际上是因为他的SPI(全称:Service Provider Interface,是一种服务发现机制)违背了双亲违背机制,整个JDBC要使用起来,首先是使用方调用这个接口,再由继承了这个接口的类去寻找其具体实现类,但是JDBC为了满足可拔插设计原则(这其实也是开发中必须遵循的),其接口和实现类肯定是不在一个地方,那么就需要有一种服务可以帮助寻找到具体的实现类,而这个服务就是SPI,有两种方式,一类是在项目里引入jar包,另一类是在微服务的环境中的服务治理中去发现。JDBC是前者,这里只以前者为例。而JDBC的标准接口是由启动类加载器加载的(为了标准!学第三方接口时说过,java指定接口标准,实现由第三方公司实现,避免接口混乱),但是他的实现类却是由应程序加载类加载的(因为由第三方提供),并且由于是在运行中加载,那么只能是由启动类加载器委托其子类来加载,这个逻辑本身就是不符合双亲委派机制的,也同时破坏了全盘委托机制。详情参考“山鬼谣me”大神的文章:SPI、API与双亲委派

五、总结

一张来自于牛客网的类加载机制内容的结构图,原作者不记得了,如侵,删
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值