飞道的博客

JVM 基础 (8) -- 类加载器和类加载机制

399人阅读  评论(0)

1. 双亲委派模型

如果一个类加载器收到类加载的请求,首先它自己不会去尝试加载这个类,而是把这个请求委托给它的父类加载器去完成。每个类加载器都是如此,所以这个加载请求最终会到达启动类加载器,只有当父类加载器在自己的搜索范围内找不到指定的类时(即 ClassNotFoundException),子类加载器才会尝试自己去加载。

  1. 需要注意的是自定义类加载器是可以不遵循双亲委派模型的,只要我们重写 loadClass() 方法即可。
  2. 双亲委派模型有什么作用呢?
    1. 第一是为了避免程序员随意串改系统级别的类
    2. 第二是解决了加载基础类的统一问题,如果父类加载器已经加载过这个类了,那么我们就无须再去加载它,保证了数据安全
    3. 就比如说 JDK 中的类,像 String 类所在 jar 包是位于 JAVA_HOME/lib 目录下的,该目录下的 jar 包是由启动类加载器加载的,也就是说,String 类最终是由启动类加载器加载进内存的,如果这个时候我们自定义了一个 String 类(包名类名都和 JDK 的 String 类一样),根据双亲委派模型,这个自定义的 String 类的加载请求最后会到达启动类加载器,但由于启动类加载器已经加载过 String 类了,那么这个自定义的 String 类就不会被加载了,这样我们就无法串改 String 类了,同时也就保证了数据安全。

1. 沙箱安全机制

沙箱安全机制是基于双亲委派机制采取的一种 JVM 的自我保护机制,假设要写一个 java.lang.String的类,由于双亲委派机制的原理,此请求会先交给 Bootstrap 试图进行加载,但是 Bootstrap 在加载类时首先通过包和类名查找rt.jar中有没有该类,有则优先加载rt.jar包中的类,如果没有,一般也会报错,警告用户不能使用java.lang为包名,因此就保证了 java 的运行机制不会被破坏。

2. 打破双亲委派模型

双亲委派很好地解决了加载基础类的统一问题(越基础的类由越上层的类加载器进行加载),基础类之所以称为基础,是因为它们总是作为被用户代码调用的 API ,比如说 JDK 中的类。

两种打破双亲委派模型的案例:第一是 SPI 代码的加载问题;第二是用户对程序动态性的追求。

1. SPI 代码的加载问题

但事事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办?这并不是没可能的事情,一个典型的例子便是 JNDI 服务,JNDI 现在已经是 Java 的标准服务,它的代码由启动类加载器去加载的(在 JDK 1.3 时放进去的 rt.jar),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath 目录下的 JNDI 接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能认识这些代码 ,因为在启动类加载器的搜索范围内根本找不到用户应用程序类,那该怎么办呢?为了解决这个问题,Java 设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器(Application ClassLoader)。
有了线程上下文类加载器,就可以做一些舞弊的事情了,JNDI 服务通过使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器 ,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI 、JDBC、JCE、 JAXB 和 JBI 等。

2. 用户对程序动态性的追求

双亲委派模型的另一次被破坏是由于用户对程序动态性的追求而导致的,这里所说的动态性指的是当前一些非常热门的名词:代码热替换(HotSwap)、模块热部署(HotDeployment)等等 ,说白了就是希望应用程序能像我们的计算机外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换个鼠标,不用停机也不用重启。
对于个人计算机来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是企业级软件开发者具有很大的吸引力。Sun 公司所提出的 JSR-294JSR-277 规范在与 JCP 组织的模块化规范之争中落败给 JSR-291(即 OSGI R4.2),虽然 Sun 不甘失去 Java 模块化的主导权,独立在发展 Jigsaw 项目,但目前 OSGI 已经成为了业界事实上的 Java 模块化标准,而 OSGI 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块( OSGI 中称为 Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。

3. 类加载器

类加载器的作用就是用来加载一些我们需要使用到的类的。系统默认的就有一些类加载器,站在 JVM 的角度,类加载器可以分为两大类:

  1. 启动类加载器 (Bootstrap ClassLoader):由 C++ 语言实现 (针对 HotSpot),负责将存放在<JAVA_HOME>/lib 目录或 -Xbootclasspath 参数指定的路径中的类库加载到内存中,启动内加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,可以把父类加载器设置为 null
  2. 其他类加载:由 Java 语言实现,都继承了抽象类 ClassLoader。
    1. 扩展类加载器 (Extension ClassLoader):负责加载<JAVA_HOME>/lib/ext 目录或 java.ext.dirs 系统变量指定的路径中的所有类库
    2. 应用程序类加载器 (Application ClassLoader):负责加载用户类路径 (也就是 classpath)上的类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器的话,默认就是用这个类加载器
    3. 自定义类加载器,用户根据需求自己定义的。也需要继承自 ClassLoader 抽象类

系统的引导类加载器指的就是启动类加载器

4. 自定义类加载器

ClassLoader 类里面有两个主要的方法:

  1. findClass():这个方法需要子类去重写,它的作用就是找到 .class 文件,然后将文件转换成字节数组,然后调用 defineClass(),将字节数组转换成 Class 对象并返回
  2. loadClass():用来定义类加载的规则,这个方法其实已经是定义好的了,默认的加载规则就是双亲委派模型,当然我们也可以去重写这个方法,然后就可以按照我们的意愿来定义加载类的规则了。

类加载器加载时的流程:

defineClass():这个方法可以将一个字节数组转为 Class 对象,这个字节数组是读取 .class 文件后最终得到的字节数组,源码如下:

protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError {
    return defineClass(name, b, off, len, null);
}

loadClass() 源码如下:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查是否已经加载过。
        Class c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    //2 .如果没有加载过,先调用父类加载器去加载
                    c = parent.loadClass(name, false);
                } else {
                    // 2.1 如果没有加载过,且没有父类加载器,就用BootstrapClassLoader去加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                //3. 如果父类加载器没有加载到,调用findClass去加载,即子类自己尝试去加载
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

自定义类加载器案例:

public class MyClassLoader extends ClassLoader{
    public MyClassLoader() {
    }
    public MyClassLoader(ClassLoader parent) {
        //一定要设置父ClassLoader不是ApplicationClassLoader,否则不会执行findclass
        super(parent);
    }
   
    //1. 覆盖findClass,来找到.class文件,并且返回Class对象
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {    
        try {
            String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
            InputStream is = getClass().getResourceAsStream(fileName);
            if (is == null) {
                //2. 如果没找到,return null
                return null;
            }
            byte[] b = new byte[is.available()];
            is.read(b);
            //3. 将字节数组转换成 Class 对象
            return defineClass(name, b, 0, b.length);
        }
        catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

演示两种情况:

//1.父类加载器设置为应用程序类加载器
MyClassLoader mcl = new MyClassLoader();
Class<?> c1 = Class.forName("Student", true, mcl);
Object obj = c1.newInstance();
System.out.println(obj.getClass().getClassLoader());
System.out.println(obj instanceof Student);

//执行结果
sun.misc.Launcher$AppClassLoader@6951a712
true
//2.父类加载器设置不为应用程序类加载器
MyClassLoader mcl = new MyClassLoader(ClassLoader.getSystemClassLoader().getParent());
Class<?> c1 = Class.forName("Student", true, mcl);
Object obj = c1.newInstance();
System.out.println(obj.getClass().getClassLoader());
System.out.println(obj instanceof Student);

//执行结果
MyClassLoader@3918d722
false

分析:第一个代码和第二个代码唯一 一点不同的就是在 new MyClassLoader() 时,第二个代码传入的是 ClassLoader.getSystemClassLoader().getParent(); (这个其实就是扩展类加载器)

  1. 当不传入这个值时,默认的父类加载器为 Application ClassLoader,那么在这个类加载器中已经加载了 Student 类(ClassPath 路径下的 Student 类)的情况下,当我们再调用 Class.forName() 时传入了自定义类加载器的时候,会调用自定义类加载器的 loadClass(),判断自己之前没有加载过,然后去调用父类的(ApplicationClassLoader) loadClass(),判断结果为已经加载,所以直接返回 Class 对象,并没有执行自定义类加载器的 findClass()。所以打印的 ClassLoader 为 AppClassLoader
  2. 如果我们传入的父类加载器为扩展类加载器,当调用父类加载器 (扩展类加载器)的 loadeClass() 时,由于扩展类加载器只加载 java_home/lib/ext 目录下的类,而 classpath 路径下的它不能加载,所以返回 null,根据 loadClass() 的逻辑,接着会调用自定义类加载器 findClass() 来加载。所以打印 ClassLoader 为 MyClassLoader
  3. 第一种情况instanceof 返回 true 是因为使用了同一个类加载器来加载同一个类;对于第二种情况,虽然这里我们加载的都是同一个 Student 类,同一个 .class 文件,但是是由两个类加载器加载的,所以就返回了 false
  4. 在 JVM 中判断一个类唯一的标准是类加载器+.class 文件都一样。像 instanceof 和强制类型转换都是这样的标准。
  5. 注意,这里所说的父类类加载器,不是以继承的方式来实现的,而是以成员变量的方式实现的。当调用构造函数传入时,就把自己的成员变量 parent 设置成了传入的加载器.

5. 类加载机制

1. 简介

虚拟机把描述类的数据从 .class 文件加载到内存,并对数据进行校验 、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就叫虚拟机的类加载机制。

2. 类的生命周期

类的生命周期可以分为 7 个阶段

类从被加载到内存中开始,到卸载出内存,经历了加载、连接、初始化、使用四个阶段,其中连接又包含了验证、准备、解析三个阶段。这些步骤总体上是按照图中顺序进行的,但是 Java 语言本身支持运行时绑定,所以解析阶段也可以是在初始化之后进行的。以上顺序都只是说开始的顺序,实际过程中是交叉进行的,加载过程中可能就已经开始验证了。

3. 类加载的时机

首先要知道什么时候类需要被加载,Java 虚拟机规范并没有约束这一点,但是却规定了类必须进行初始化的 5 种情况,很显然加载 、验证 、准备得在初始化之前。

描述:

  1. 遇到new,getstatic,putstatic,invokestatic这四条字节码指令时,如果类内没有进行过初始化,则需要先触发其初始化,这 4 条字节码指令在 Java 里最常见的场景是:
    1. new 一个对象时
    2. set 或者 get 一个类的静态字段(除去那种被 final 修饰放入常量池的静态字段)
    3. 调用一个类的静态方法
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类 (包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

案例:

  1. 例1:
    //父类
    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 test {
        public static void main(String[] args){
        //通过子类调用父类的静态字段,不会进行初始化
            System.out.println(SubClass.value);
        }
    }
    
    //结果
    SuperClass init
    123
    
    分析:对于静态字段,只有直接定义这个字段的类才会初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  2. 例2:
    //父类
    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 test {
        public static void main(String[] args){
            SuperClass[] a = new SuperClass[10];
        }
    }
    
    结果:没有任何输出
    分析:这段代码会使一个继承 Object 的类进行初始化。进行初始化的类是一个一维数组,由 JVM 自动生成,直接继承于 Object,创建动作由 newarray 触发。
  3. 例3:
    public class Const {
    
        static {
            System.out.println("ConstClass init!");
        }
        static {
            System.out.println("ConstClass init 222!");
        }
    
        public Const(){
            System.out.println("调用Const的构造函数");
        }
    
        public static final String HW = "HelloWorld";
    }
    
    public class NoInitialization {
        public static void main(String[] args){
            System.out.println(Const.HW);
        }
    }
    
    结果:只输出了 HelloWorld,这意味着 Const 没有被初始化
    分析:虽然在 java 源码中引用了 Const 类中的常量,但其实在编译阶段,通过常量传播优化,已经将此常量存到了 NoInitialization 类的常量池,以后 NoInitialization 对常量Const.HW的引用都转化为对自身常量的引用。也就是说,实际上 NoInitialization 的.class文件并没有 Const 类的符号引用入口。

4. 类加载的过程

类的加载过程分为五个阶段:加载、验证、准备、解析和初始化。

1. 加载

这个阶段其实就是通过类加载器将某个类从对应的 .class 文件中加载进内存的过程。
一般来说加载阶段主要就三步:

  1. 通过一个类的全限定名获取此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. JVM 在内存中创建一个该类对应的 java.lang.Class 对象,作为方法区中这个类的各种数据的访问入口

注意:当我们创建名字为 C 的类时,如果 C 不是数组类型,那么它就可以通过类加载器加载 C 的二进制表示(即 class 文件)。如果是数组,则是通过 Java 虚拟机创建,虚拟机递归地采用上面提到的加载过程不断加载数组的元素。
分析:

  1. 当我们加载一个非数组类时,我们可以直接通过系统提供的类加载器或用户自定义的类加载器去加载
  2. 当我们加载的是一个数组类时,情况就不同了,数组类本身不通过类加载器创建,它是由 java 虚拟机直接创建的。但数组类与类加载器的关系任然很密切,因为数组类中包含的所有元素最终都是要靠类加载器来加载创建的,一个数组类 (下面简称 C)创建过程遵循以下规则:
    1. 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组 C 将在加载该类型组件的类加载器的类名称空间上被标识(这点很重要,一个类必须与类加载器一起确定唯一性)
    2. 如果数组的组件类型不是引用类型(例如int[]数组),则 Java 虚拟机将会把数组 C 标记为与引导类加载器关联
    3. 数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public

获取二进制字节流的方式:

  1. 从本地系统中直接加载.class文件
  2. 加载通过网络下载的.class文件
  3. zip,jar 等归档文件中加载 .class 文件
  4. 加载从专有数据库中提取的.class文件
  5. 加载通过 Java 源文件动态编译出来的.class文件
  6. 运行时计算生成,这种场景使用的最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()来为特定接口生成形式为*$Proxy的代理类的二进制字节流

加载阶段完成之后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象 (并没有明确规定是在堆中),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
加载阶段与连接阶段的部分内容 (如一部分字节码文件格式验证动作) 是交叉进行的,加载阶段尚未完成,连接阶段可能就开始了,但是这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然是保持着固定的先后顺序的。

注意:
Hotspot 虚拟机中(其他虚拟机估计也一样),Class 对象是存放在堆区的,不是方法区,这点很多人容易犯错,类的元数据才是存在于方法区中的。(元数据并不是类的 Class 对象。Class 对象是加载的最终产品,类的方法代码、变量名、方法名、访问权限、返回值等等都是在方法区的)

2. 验证

验证作为连接的第一步,用于确保类或接口的二进制表示结构上是正确的,从而确保字节流包含的信息对虚拟机来说是安全的。

java 语言相对相对于 C/C++ 更安全:
使用纯粹的 java 代码无法做到一些行为,诸如访问数组边界以外的数据、将一个类型转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。但是需要注意的是,.class文件并不一定要求用 java 源码编译而来,它可以通过任何途径产生,甚至包括用十六进制编译器直接编写.class文件。因此,上述 java 代码无法做到的事情,你可以通过别的途径生成.class文件来做到。如果说虚拟机不检查输入的字节流,就很可能因为载入了有害的字节流而导致系统崩溃。因此,验证是虚拟机对自身保护的一项重要工作。
验证阶段是否严谨,直接决定了 java 虚拟机能够承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统中占了相当大的一部分。

Java 虚拟机规范中关于验证阶段的规则一直在不断增加,但大体上会完成下面 4 个验证动作:

1. 文件格式验证

主要验证字节流是否符合 .class 文件格式规范,并且能被当前版本的虚拟机处理,主要验证点:

  • 是否以魔数0xCAFEBABE开头
  • 主次版本号是否在当前虚拟机处理范围之内
  • 常量池的常量是否有不被支持的类型 (检查常量 tag 标志)
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
  • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据
  • .class 文件中各个部分及文件本身是否有被删除的或者附加的其他信息

实际上验证的不仅仅是这些,这阶段的验证是基于二进制字节流的,只有通过文件格式验证后,字节流才会进入内存的方法区中进行存储,之后的三个验证阶段就不是基于字节流的了

2. 元数据验证

主要对字节码描述的信息进行语义分析,以保证其提供的信息符合 Java 语言规范的要求,主要验证点:

  • 该类是否有父类(只有 Object 对象没有父类,其余都有)
  • 该类是否继承了不允许被继承的类(被 final 修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
  • 类中的字段 、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,出现不符合规则的方法重载,例如方法参数都一致,但是返回值类型却不同)
3. 字节码验证

主要是通过数据流和控制流分析,确定程序语义是合法的 、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,字节码验证将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,主要有:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似的情况:操作数栈里的是一个 int 型数据,但是使用时却被当做 long 类型加载到本地变量中

  • 保证跳转不会跳到方法体以外的字节码指令上

  • 保证方法体内的类型转换是合法的。例如子类赋值给父类是合法的,但是父类赋值给子类或者其它毫无继承关系的类型,则是不合法的。

4. 符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段解析阶段发生。符号引用是对类自身以外(常量池中的各种符号引用)的信息进行匹配校验,通常有:

  • 符号引用中通过字符串描述的全限定名是否找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用中的类 、方法 、字段的访问性 (private,public,protected,default)是否可被当前类访问

符号引用验证的目的是确保解析动作能够正常执行,如果无法通过符号引用验证,那么将会抛出一个 java.lang.IncompatibleClassChangeError 异常的子类,如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError

5. 总结

验证阶段非常重要,但不一定必要,如果所有代码已经被反复使用和验证过了,那么可以通过虚拟机参数-Xverify: none来关闭验证,缩短类加载时间。

3. 准备

准备阶段的任务是为类或者接口的静态字段分配空间,并且默认初始化这些字段。这个阶段不会执行任何的虚拟机字节码指令,在初始化阶段才会显式地初始化这些字段,所以准备阶段不会做这些事情。假设有:

public static int value = 123;

value 在准备阶段的初始值为 0 而不是 123,只有到了初始化阶段,value 才会为 123。

Java 中所有基础类型的零值如下图:

有一种特殊情况是,如果字段属性表中包含 ConstantValue 属性,那么准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值,比如上面的 value 如果这样定义:

public static final int value = 123;

编译时,value 一开始就指向 ConstantValue,也就是说,常量 value 的值在 .class文件中已经是确定好的了,所以准备期间 value 的值就已经是 123 了。

4. 解析

解析阶段是把常量池内的符号引用替换成直接引用的过程,符号引用就是 .class 文件中的 CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型的常量。下面我们看一下符号引用和直接引用的定义:

  1. 符号引用 (Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要可以唯一定位到目标即可。符号引用与内存布局无关,所以所引用的对象不一定需要已经加载到内存中。各种虚拟机实现的内存布局可以不同,但是接收的符号引用必须是一致的,因为符号引用的字面量形式已经明确定义好在 .class 文件中了。

  2. 直接引用 (Direct References):直接引用是直接指向目标的指针 、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机上翻译出来的直接引用一般不会相同。如果有了直接引用,那么它一定已经存在于内存中了。

以下 Java 虚拟机指令会将符号引用指向运行时常量池,执行任意一条指令都需要对它的符号引用进行解析:

上述指令对应的操作分别为:

  1. anewarray、multianewarray:创建数组
  2. checkcast、instanceof:检查类实例类型
  3. getfield、getstatic、putfield、putstatic:访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)
  4. invokeinterface:调用接口方法,他会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用
  5. invokespecial:调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法
  6. invokestatic:调用类方法(static方法)
  7. invokevirtual:调用对象实例方法,根据对象的实际类型进行分派(虚方法分派),这也是java语言中最常见的分派方式
  8. invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法
  9. ldc:将一个常量加载到操作数栈的指令
  10. ldc_w:将一个常量加载到操作数栈的指令
  11. new:创建类实例

注意(个人理解):
执行这 16 个操作的时候,要先对他们所使用的符号进行解析,他们所使用的符号意思就是类或者接口。比如 new Person 时,Person 就是所使用的符号,这时候应该对 Person 进行解析

对同一个符号进行多次解析请求是很常见的,除了 invokedynamic 指令以外,虚拟机基本都会对第一次解析的结果进行缓存,后面再遇到时,直接引用,从而避免重复的解析动作。

对于 invokedynamic 指令,上面规则不成立:
当遇到前面已经由 invokedynamic 指令触发过解析动作的符号引用时,并不意味着这个解析结果和其他执行了 invokedynamic 指令所得到的解析结果一致。这是由 invokedynamic 指令的语义决定的,它本来就是用于支持动态语言的,也就是必须等到程序实际运行这条指令的时候,解析动作才会执行。其它的命令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就解析。

解析分为 4 种基本解析

1. 类与接口的解析

假设当前代码所在的类为 D,如果要把一个从未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,那虚拟机完成整个解析的过程需要以下三个步骤:

  1. 如果 C 不是一个数组类型,那虚拟机将会把代表 N 的全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程宣告失败。
  2. 如果 C 是一个数组类型,并且数组的元素类型为对象,也就是 N 的描述符会是类似Ljava/lang/Inter;的形式,那将会按照第一点的规则加载数组元素类型。如果 N 的描述符如前面所假设的形式,需要加载的元素类型就是java.lang.inter,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
  3. 如果上面的步骤没有出现任何异常,那么 C 在虚拟机中实际上已成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认 D 是否具备对 C 的访问权限。如果发现不具备访问权限,将抛出lava.lang.IlldgalAccessError异常。

案例:

public class Person {
	public String clientName;
	public String clientId;

	public String getClientName() {
		return clientName;
	}

	public void setClientName(String clientName) {
		this.clientName = clientName;
	}

	public String getClientId() {
		return clientId;
	}

	public void setClientId(String clientId) {
		this.clientId = clientId;
	}
}
public class Main {
	public static void main(String args[]){
		Person person11 = new Person();
	}
}

分析:
当我们在 main 方法中定义 person11的时候,Main 的类加载器会去加载 Person 类,可能还会加载 Person 的父类(如果有的话)。如果没有报错或异常,那么 Person 实际上在虚拟机里面就有一个有效的类了,但是在解析完成前还要进行符号引用验证,确认 Main 类是否具备对 Person 类的访问权限。
还有就是,如果多次 new Person 的话,解析动作不会重复进行,因为第一次解析之后,JVM 会在运行时常量池中将这个符号的直接引用记录下来,并把常量标识为已解析状态。

2. 字段解析

要解析一个未被解析过的字段符号引用,首先会对字段表中的 class_index 项中索引的CONSTANT_Class_info符号引用进行解析,也就是对字段所属的类或接口的符号引用进行解析。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段解析失败。如果解析完成,那将这个字段所属的类或者接口用 C 表示,虚拟机规范要求按照如下步骤对 C 进行后续字段的搜索:

  1. 如果 C 本身包含了简单名称和字段描述符都与目标相匹配的字段,则直接返回这个字段的直接引用,查找结束;
  2. 否则,如果在 C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  3. 再不然,如果 C 不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  4. 如果都没有,查找失败退出,抛出java.lang.NoSuchFieldError异常。如果返回了引用,还需要检查访问权限,如果没有访问权限,则会抛出java.lang.IllegalAccessError异常。

在实际的实现中,要求可能更严格,如果同一字段名在 C 的父类和实现接口中,或者同时在自己或父类的多个接口中同时出现,编译器可能拒绝编译。

案例:

public class FieldResolution {
	
	interface Interface0{
		int A = 0;
	}
	
	interface Interface1 extends Interface0{
		int A = 1;
	}
	
	interface Interface2{
		int A = 2;
	}
	
	static class Parent implements Interface1{
		public static int A = 3;
	}
	
	static class Sub extends Parent implements Interface2{
		public static int A = 4;
	}
	
	public static void main(String[] arg){
		System.out.println(Sub.A);
	}
}

结果:
如果注释了 Sub 类中的public static int A=4;,接口与父类同时存在字段 A,那编译器将提示The field Sub.A is ambiguous,即方法歧义,使编译器无法确定代码中使用哪一个方法,并拒绝编译。
分析:
字段解析首先要解析这个字段所在的类。比如上述代码调用Sub.A时需要先解析 Sub 类,保证 Sub 类在虚拟机有直接引用(实际上就是对 Sub 类进行类的解析,第一点说的)。

  1. 如果没问题的话,可以认为这个类已经在内存中了 (假定为 C),然后虚拟机会在类 C 里面找,看看有没有这个字段 (按照简单名称和字段描述符找),如果有的话直接返回。
  2. 如果 C 中实现了接口,就需要在所有父接口里面找。如果 C 有父类,还要在父类里面找。
  3. 都没找到的话就抛出异常。如果成功找到了,也要进行权限验证。

如果将上述代码的 Sub 类中的public static int A=4; 注释掉,你可以把 main 中的执行代码写成 System.out.println(Parent.A);或者System.out.println(Interface1.A);或者System.out.println(Interface2.A); 这证明了在字段解析的过程中,其父类和实现的接口也被解析了。

3. 类方法解析

类方法解析也是先对类方法表中的 class_index 项中索引的方法所属的类或接口的符号引用进行解析。我们依然用 C 来代表解析出来的类,接下来虚拟机将按照下面步骤对 C 进行后续的类方法搜索:

  1. 首先检查方法引用的 C 是否为类或接口,如果是接口,那么方法引用就会抛出 IncompatibleClassChangeError 异常
  2. 方法引用过程中会检查 C 和它的父类中是否包含此方法,如果 C 中确实有一个方法与方法引用的指定名称相同,并且声明是签名多态方法 (Signature Polymorphic Method),那么方法的查找过程就被认为是成功的,所有方法描述符所提到的类也需要解析。对于 C 来说,没有必要使用方法引用指定的描述符来声明方法
  3. 否则,如果 C 声明的方法与方法引用拥有同样的名称与描述符,那么方法查找也是成功
  4. 如果 C 有父类的话,那么按照第 2 步的方法递归查找 C 的直接父类
  5. 否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在相匹配的方法,说明类 C 是一个抽象类,查找结束,并且抛出 java.lang.AbstractMethodError异常
  6. 否则,宣告方法失败,并且抛出 java.lang.NoSuchMethodError
  7. 最后的最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,那么会抛出 java.lang.IllegalAccessError异常。

类方法解析和字段解析一样,先要解析这个方法所在的类 (用 C 表示这个类)。C 只能是具体的类,不能是接口或抽象类,否则将会报错。其他的跟字段解析一样。

4. 接口方法解析

接口方法也需要解析出接口方法*中的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用 C 表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

  1. 与类方法解析不同,如果在接口方法表中发现 class_index 对应的索引 C 是类而不是接口,直接抛出java.lang.IncompatibleClassChangeError异常
  2. 否则,在接口 C 中查找是否有简单名称和描述符都与目标匹配的方法,如果有则直接返回这个方法的直接引用,查找结束
  3. 否则,在接口 C 的父接口中递归查找,直到 java.lang.Object 类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束
  4. 否则,宣告方法失败,抛出 java.lang.NoSuchMethodError 异常。

由于接口的方法默认都是 public 的,所以不存在访问权限问题,也就基本不会抛出 java.lang.IllegalAccessError 异常

C 只能是接口,不能是类,否则会抛出异常,类方法和接口方法的调用不同。

5. 初始化

初始化是类加载的最后一步,在前面的阶段里,除了加载阶段可以通过用户自定义的类加载器加载外,其余部分基本都是由虚拟机主导的。但是,只有到了初始化阶段,才开始真正执行用户编写的 Java 代码;
在准备阶段,变量都被赋予了初始值,但是到了初始化阶段,所有变量还要按照用户编写的代码重新初始化。换一个角度,初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static 语句块)中的语句合并生成的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。如:

public class Test {
    static {
        i=0;  //可以赋值
        System.out.print(i); //编译器会提示“非法向前引用”,不能访问
    }
    static int i=1;
}

注意几项:

  1. <clinit>()方法与类的构造函数 <init>()方法不同,它不需要显示地调用父类构造器,虚拟机会在执行子类的 <clinit>()方法之前,保证父类的<clinit>()方法已经执行完毕,因此在虚拟机中第一个被执行的<clinit>()一定是 java.lang.Object
  2. 由于父类的<clinit>()先执行,所以父类中的静态语句块优先执行于子类的变量赋值操作,所以下面的代码段,B 的值会是 2:
    static class Parent {
        public static int A=1;
        static {
            A=2;
        }
    }
    
    static class Sub extends Parent{
        public static int B=A;
    }
    
    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
    
  3. <clinit>()方法对于类来说不是必须的,如果一个类中既没有静态语句块也没有静态变量赋值动作,那么编译器都不会为类生成 <clinit>()方法
  4. 接口中不能使用静态语句块,但是允许有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>()方法,但是接口中的 <clinit>()不需要先执行父接口的,只有当父接口中定义的变量使用时 (如引用接口中定义的常量时),父接口才会初始化。除此之外,接口的实现类在初始化时也不会执行接口的<clinit>()方法
  5. <clinit>()方法在一次类加载中只执行一次,而<init>()方法每次初始化的时候都会执行一次
  6. 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时初始化一个类,那么只会有一个线程可以去执行<clinit>()方法,其他的阻塞等待,直至<clinit>()方法执行完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。代码演示这种场景,如下:
    public class Main {
        static class DeadLoopClass{
            static {
                if (true) {
                    System.out.println(Thread.currentThread() + "init DeadLoopClass");
                    while (true) {
    
                    }
                }
            }
        }
        
        public static void main(String args[]){
            Runnable script = new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread() + "start");
                    DeadLoopClass dlc = new DeadLoopClass();
                    System.out.println(Thread.currentThread() + "run over");
                }
            };
            
            Thread thread1 = new Thread(script);
            Thread thread2 = new Thread(script);
            thread1.start();
            thread2.start();
        }
    }
    
    //输出:
    Thread[Thread-1,5,main]start
    Thread[Thread-0,5,main]start
    Thread[Thread-1,5,main]init DeadLoopClass
    
    //表明一条线程在死循环中长时间操作,另一条在阻塞等待
    

6. 卸载

执行了exit()方法
程序正常执行结束
程序在执行过程中遇到了异常或错误而异常终止
由于操作系统出现错误而导致 Java 虚拟机进程终止


转载:https://blog.csdn.net/qq_38350925/article/details/105467256
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场