《深入理解JVM》第七章 虚拟机类加载机制


第七章 虚拟机类加载机制

框架图

高清图片地址

高清图片地址


概述

虚拟机的类加载机制:将Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程就是虚拟机的类加载机制。

好处:Java的动态拓展语言特性就是依赖运行期间的动态加载和动态连接这个特点实现的。例如面向接口的程序,可以等到运行时再指定其实际的实现类。


类加载的时机

七个阶段

解析和使用:这两个的顺序不一定非要如上面,解析有时候也可以在初始化之后再开始,为了支持Java语言的运行时的绑定特性(就上面说的面向接口程序)。
其他的几个顺序都是固定的。

加载阶段:交给虚拟机自由把握。

初始化阶段:规定了有且仅有六种情况必须对类进行初始化。

  1. 遇到四个字节码指令new、 getstatic、 putstatic或invokestatic。场景有:1、使用new关键字实例化对象。2、设置或读取静态字段。(这回好像不一定时初始化包含字段的类?)。3、调用类型的静态方法。
  2. 对类型进行反射调用的时候。
  3. 初始化类的时候,如果父类没初始化,则要出发父类的初始化。(接口不需要)。
  4. 包含main()方法的类要初始化。
  5. REF_getStatic、 REF_putStatic、 REF_invokeStatic、 REF_newInvokeSpecial四种类型句柄的类没有进行过初始化的化,要触发其初始化。(并不知道这是个啥)。
  6. 如果接口里面有deafult方法,如果实现类进行了初始化,接口要先初始化

主动引用:除了上面的六种方法,其他的所有行为都不会触发初始化,都是被动引用。

三种被动引用情况

  1. 通过子类引用父类的静态字段,不会导致子类初始化,只会出发父类的初始化。
  2. 通过数组定义来引用类,不会触发此类的初始化。(会触发一个虚拟机自动生成的子类的初始化,由字节码指令newarray触发)。
  3. 常量在编译阶段会存入调用类的常量池,不会触发定义常量的类的初始化。

这个叫做常量传播优化,已经把常量值"hello world"存到Not类的常量池里了,以后引用就是对自己常量池的引用。(跟别人跑了)。

接口初始化:虽然接口不能使用static{},但是编译器会为它生成一个<clinit>()类构造器,用来初始化接口定义的成员变量。

接口与类的区别:上面六种的第三条,接口在初始化的时候,不会要求其父类接口全部完成了初始化,只有在真正使用到父接口的时候,才会初始化。


类加载的过程

加载、验证、准备、解析、初始化。

加载

加载阶段,Java虚拟机要做三件事

  1. 通过类的全限定名来获取定义该类的二进制字节流。(虚拟机并没有指明要从哪里获取)
  2. 将字节流代表的静态存储结构转化为方法区中运行时的数据结构(这个结构由虚拟机实现自行定义)。(方法区的作用就是存放被虚拟机加载的类型信息、常量等数据)。
  3. 在堆内存中生成以一个代表这个类的java.lang.Class对象,作为程序访问方法区中这个类型数据的外部接口。
    (感觉可以画个图)

类加载分两种类型,一种是非数组类型的加载;另一种是数组类型的加载。

  • 非数组类型:这个是可控性最强的,可以通过Java虚拟机内置的引导类加载器完成,也可以通过用户自定义的类加载器完成。(本身就是通过类加载器创建的)。
  • 数组类型:情况就不太一样,首先数组类本身就不是通过类加载器创造的,是由Java虚拟机直接在内存中动态构造出来的。虽然如此,但里面的数组元素(Element Type,除去所有维度)还是要靠类加载器加载的,针对数组元素,处理分为两大类(对应着低纬度和高纬度数组):
    • 数组的组件类型是引用类型:引用类型就是大于等于二维,去掉了第一维后还是一个数组,这样就要用递归的方法来加载这个组件类型了,将数组标记在加载该组件类型的类加载器的类命名空间上。(所以这是个啥????为啥不能像下面一样标记,通过递归来做?)
    • 数组的组件类型不是引用类型:一维数组,直接面对元素了,Java虚拟机就把数组标记为与引导类加载器关联。(这还不一样啊,单个元素就是引导类了)。

注意:加载阶段与连接阶段的部分动作是交叉进行的,开始的时候是保持固定的先后顺序的。


验证

目的:确保Class文件里的字节流信息符合规范的全部约束要求,被运行后不会危害虚拟机自身的安全。

说明:Java本身是安全的,但是Class文件不一定是java编译来的,所以可能造成危害,验证阶段在类加载阶段占用了不少资源。

四个阶段:文件格式验证、 元数据验证、字节码验证、符号引用验证。


文件格式验证

验证点

目的:保证输入的字节流能正确解析并存储到方法区里,格式上符合描述一个Java类型信息的要求。所以这阶段是基于字节流进行的,过了才能放到方法区里,后面的步骤都是基于这步放到方法区里的数据进行操作的,就不再直接读取、操作字节流了。

元数据验证

验证点

目的:对字节码描述的信息进行语义分析,保证描述的信息符合规范要求,也对元数据进行语义校验,保证符合规范要求。

字节码验证

目的:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。这阶段会对类的方法体(方法体就是里面具体代码吧)进行校验分析,保证不做出危害行为。

注意:没通过肯定是有问题的,通过了也不一定没问题,原因是:通过程序去校验程序逻辑是无法做到绝对准确的,不可能用程序来判定一段程序是否存在Bug。

符号引用验证

发生时期:在虚拟机将符号引用转化为直接引用的时候,在连接的解析阶段中发生。

功能:对类自身以外的各类信息进行匹配性校验,通俗说就是检查改类有没有被禁止访问外部某些东西,或者外部某些东西是否存在。

目的:确保解析行为能正常执行。这阶段很重要,但不是必须执行,因为如果已经知道环境没问题了,就不用再做了。


准备

作用:为类中定义的静态变量分配内存并设置初始值 。注意这里的分配,理论上是分到方法区,但实际上不是了,在JDK7之前,HotSpot使用永久代实现方法区的时候,是这样的;但是在JDK8之后,类变量随着Class对象一起存放在Java堆中了。所以,现在的实际存放位置就是Java堆。

类变量/实例变量:这阶段分配的只有类变量,不包括实例变量(区别就是加没加static,类变量有statci,实例变量实例才有),实例变量是在实例化阶段分配在Java堆中的。

初始化
加了final的实例变量才可以初始化为指定的值,否则在这个阶段都是默认零值,赋值是在编译之后。
public static int value = 123;:准备阶段过后值为0。
public static final int value = 123;:准备阶段过后值为123。

基础数据零值表


解析(没看完)

操作不了。

类型引用方式与虚拟机内存相关性引用目标是否以加载不同虚拟机中是否一致
符号引用用一组符号来描述与内存布局无关不一定已经加载到了虚拟机内存一致,按照《规范》来的
直接引用直接指向指针、相对偏移量,或是一个能直接定位到目标的句柄与内存布局直接相关必定已经在虚拟机内存存在不用虚拟机中翻译出来的引用不一定相同

解析阶段发生时间:虚拟机根据需要自行判断,可在类被加载时就解析,也可等到一个符号引用要被使用时再解析。


初始化

介绍:之前的除了加载阶段用户可以参与,其他动作全都由Java虚拟机主导。初始化阶段就是执行类构造器<clinit>()方法的过程。

属性

  • <clinit>()方法是编译器收集类中所有类的赋值动作、静态语句块中的语句合并产生的,收集顺序就是语句中原本的顺序。注意,static里面是可以给后面定义的变量赋值的,但是不能在这访问后面定义的。
  • 不需要显示调用父类构造器(Java需要Super.init()?),这里会提前自动执行。
  • 父类中的静态代码块赋值优先于子类的变量赋值。(这里按照顺序也应该是=2啊,对了,好像只有static会被加载为0?但是和初始化不一样吧)
  • 如果父类中没有静态语句块,也没有对变量的赋值操作,编译器就可以不给这个类生成<clinit>()方法。(没有利用价值)
  • 接口也会生成<clinit>()。接口中只有用了父类接口的变量,才会初始化父类。接口的实现类在初始化时不会执行接口的<clinit>()(为什么?执行自己的?因为接口中没有静态语句块也没有静态变量?)。(但是接口没有父类啊,父类只有Object类,这类面有变量吗?)
  • JVM要保证一个类的<clinit>()在多线程中要被正确加锁,只有一个线程能执行,其他的都需要阻塞等待,直到完成。如果执行<clinit>()方法的线程退出了<clinit>()方法,其他的线程被唤醒后也不会再进入了,同一个类加载器下,一个类只能被初始化一次。

类加载器

类与类加载器

功能:对于任意一个类,必须由他的类加载器和这个类本身一起,才能在JVM中确定唯一性。每一个类加载器都拥有一个独立的类名称空间(即使是同一个Class文件,不同方法加载出来的类名称空间也不是一个)。也就是说,必将两个类是否相同,必须在同一类加载器下才有意义。

举例
下面这个例子看似一样,因为都是用的同一个Class文件,但实际不一样,因为JVM中同时存在了两个ClassLoaderTest类,一个是JVM的应用程序类加载的,另一个是自定义的类加载器加载的,虽然Class文件相同,在JVM中却是两个互相独立的类。

package JVM;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;

public class ClassLoaderTest {

    public static void main(String[] args) throws Exception{
        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    // 获取全名路径的最后一部分,就是类的名字
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    // 通过资源文件方式加载?
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null){
                        // 父类是什么,Object?
                        return super.loadClass(name);
                    }
                    // available返回估计的可以从输入流数取的字节数量
                    byte[] b = new byte[is.available()];
                    // 读取一些字节并保存到缓冲数组b,数量应该是视b的长度而定
                    is.read(b);
                    // 将一个字节数组转换成类的实例
                    // 参数是类的名字,字节数组,从字节数组中开始位置的偏移量,一共读取多少
                    return defineClass(name,b,0,b.length);
                } catch (IOException e){
                    throw new ClassNotFoundException(name);
                }
            }
        };

        // 使用自己重写的方法加载
        Object obj = myLoader.loadClass("JVM.ClassLoaderTest").newInstance();

        // getClass返回运行期的类
        System.out.println(obj.getClass());
        System.out.println(obj instanceof JVM.ClassLoaderTest);
    }
}

输出:

class JVM.ClassLoaderTest
false

双亲委派模型

知乎文章,清晰

类加载器分类:在JVM的角度,只有两种,一是启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分;另一种是其他所有类加载器,独立存在于虚拟机外部,全部继承自抽象类java.lang.ClassLoader。

类加载器加载路径加载内容能否被java程序直接引用
启动类加载器<JAVA_HOME>\bin或者-Xbootclasspath指定路径按照文件名识别类库不能,需要的时候用null代替
扩展类加载器<JAVA_HOME>\lib\extjava.ext.dirs系统变量指定指定目录下所有类库,一般存放通用性的类库
应用程序类加载器(系统类加载器)用户类路径ClassPath用户类路径上的所有类库ClassLoader类中的getSystem-ClassLoader()方法的返回值,能直接用

然后这几个类加载器一起组成双亲委派模型:

从父类开始,是不是有利于让用子类给一个父类赋值?

虚拟机外部继承关系

要求:除了顶层的启动类加载器外,其他的类加载器都要有自己的父类加载器(看上图)。不过这里的父子关系一般不是继承(Inheritance),而是用组合(Composition)来实现的。

工作流程:收到请求后,不会自己先尝试加载,而是直接把请求委派给父类加载器完成(先按照外部继承关系的表往上传,到头(ClassLoader)了再按照双亲委派的表往上传递),所以所有的加载最后都会传送到最顶层的启动类加载器中,只有父类加载器无法完成的时候(找不到),子类才会尝试自己完成。(有事父类顶上,不行了再子类来)。

好处

  • Java的类随着类加载器一起具备了带有优先级的层次关系,因为必须往上走,所以一定会到Object类,而在各种类加载器的环境中,Object都是用一个类。(如果用户自己编写和java.lang包下的类同名的类,会出错)。
  • 所以双亲委派模型对于保证Java程序的稳定运行极为重要。

实现代码

流程

  • 先检查缓存类有没有被加载过:findLoadedClass(name)
  • 没被加载过的话,就调用父类的加载器:parent.loadClass(name,false)
  • 如果父加载器为空,就默认使用启动类加载器作为父类加载器:fineBootstrapClassOrNull(name)
  • 如果父类出现异常了,此时就要用自己本身的方法来加载了:findClass(name)

破坏双亲委派模型

第一次被破坏
远古时期,先有的ClassLoader,已经能够自定义类加载器了,所以这点和双亲的冲突是什么呢?
答:父类加载器的继承关系。
如果想提出上面的父加载器,那么就要用继承关系,如果父类子类方法名一样的话,loadClass()方法就会被子类覆盖,所以就不能用loadClass()来加载自定义的类了,所以添加了findClass()方法,这个方法是用户自己重写的,里面包含自定义类的加载。所以此时loadClass()变成了让父类加载类的方法,父类没有,再用自己的findClass()方法。

第二次被破坏
导致原因:自身缺陷,本身解决了各个类加载协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),但没有解决既有基础类型又要调用回用户的代码问题。
JNDI是操作数据库的东西。
问题在于,有基础类型又要调用回用户的代码。(矛盾在哪里 ?)
所以是本来在启动类加载阶段就结束了(此时加载了基础类型),然而又要用到下面的引用层,但没有类加载器了(机制决定的,有一个类加载器能找到就结束了),所以自己搞了个线程上下文加载器,让这个加载器继承启动类加载器。(之前是有继承关系,但每次只有一个调用方法了,这次是有继承关系,且有多个调用方法了)。这个线程上下文加载器可以自己设置,不设置就在父线程中继承一个,应用程序范围内没有的话,就默认设置成应用程序类加载器。(所以这个加载器属于哪一个?所以自己就是个应用程序的类加载吗)
所以使用这个线程上下文类加载器区加载所需的SPI服务代码(位于ClassPath,是应用程序类管的位置),也就是一种父类加载器(启动)去请求子类加载器(线程上下文)完成类加载的行为。
设计SPI的方法基本都用这种方法,当SPI的服务提供者多于一个的时候,在JDK6的时候,提供了java.util.ServiceLoader,以META-INF/services中的配置信息, 辅以责任链模式, 这才算是给SPI的加载提供了一种相对合理的解决方案(解决的是整体还是SPI多服务问题?看这个名字是针对整个服务类,也是个加载器,所以解决了?)

第三次被破坏(理解还有问题):

JDK9中才有。
原因:用户对程序动态性的追求导致的。
OSGi:亮点是运行期动态热部署。

OSGi热部署的关键是自定义的类加载机制的实现
类加载机制:每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换Bundle的时候,就把Bundle连通类加载器一起换掉来实现代码的热替换。

类加载器结构:OSGi中的类加载不再是双亲委派模型推荐的树状结构,而是更复杂的网状结构,收到类加载请求后,OSGi按下面的顺序进行类搜索:

只有开头两点还符合双亲委派模型的原则。后面的类查找都是在平级的类加载器中进行的。


Java模块化系统(还没看)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值