类文件加载、链接和初始化

2022-05-24  本文已影响0人  程序员札记

有了java class文件之后,为了让class文件转换成为JVM可以真正运行的结构,需要经历加载,链接和初始化的过程。

加载过程

image.png

从上面的图中,我们可以看到JVM中有三大部分,分别是类加载系统,运行时数据区域和Execution Engine。加载就是根据特定名称查找类或者接口的二进制表示,并根据此二进制表示来创建类和接口的过程。

image.png

类的加载、连接和初始化

当Java程序中需要使用到某个类时,虚拟机会保证这个类已经被加载、连接和初始化。而连接又包含验证、准备和解析这三个子过程,这个过程必须严格的按照顺序执行。

类的加载

Class 只有在必须要使用的时候才会被装载,Java 虚拟机不会无条件地装载 Class 类型。Java 虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的 “使用”, 是指主动使用,主动使用只有下列几种情况:

加载类处于类装载的第一个阶段。在加载类时,Java 虚拟机必须完成以下工作:

启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。

扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。

应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

加载采用双亲委派模型,每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。

连接

链接指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。

初始化

类加载最后阶段,若该类具有父类,则先对父类进行初始化,执行静态变量赋值和静态代码块代码,成员变量也将被初始化.要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值。如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。

初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。初始化触发的条件有:

  1. 当虚拟机启动时,初始化用户指定的主类(main);

  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;

  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;

  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;

public class Singleton {
 
    private Singleton() {}
 
    private static class LazyHolder {
 
        static final Singleton INSTANCE = new Singleton();
 
    }

    public static Singleton getInstance() {
 
        return LazyHolder.INSTANCE;
    
        }
 
}

只有当调用Singleton.getInstance 时,程序才会访问 LazyHolder.INSTANCE,才会触发对LazyHolder 的初始化

  1. 子类的初始化会触发父类的初始化;

  2. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;

  3. 使用反射 API 对某个类进行反射调用时,初始化这个类;

  4. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

例子:

public class Singleton {
 
    private Singleton() {}
 
    private static class LazyHolder {
    
    static final Singleton INSTANCE = new Singleton();
 
    static {
 
        System.out.println("LazyHolder.<clinit>");
 
        }
 
    }
 
public static Object getInstance(boolean flag) {
 
    if (flag) return new LazyHolder[2];
 
    return LazyHolder.INSTANCE;
 
}
 
public static void main(String[] args) {
 
    getInstance(true);
 
    System.out.println("----");
 
    getInstance(false);
 
}
 
}

新建数组new LazyHolder[2]会加载元素类LazyHolder;不会初始化元素类。虚拟机必须知道(加载)有这个类,才能创建这个类的数组(容器),但是这个类并没有被使用到(没有达到初始化的条件),所以不会初始化,也不会链接元素类LazyHolder;

调用getInstance(false)的时候约等于告诉虚拟机要使用这个类了,你把这个类造好(链接),然后把static修饰的字符赋予变量(初始化)。

运行时常量池

每个class文件都包含一个表结构的常量池(constant_pool),该常量池的功能跟C/C++编译过程中用到的符号表是一样的,主要用于保存源代码文件中的各种字面常量(如字符串常量,字段名,方法名等)和符号引用(如对其他某个类的方法调用)。当类或者接口创建时,常量池表会被用来构造运行时常量池,如new对象时使用的类的符号引用来自于CONSTANT_Class_info结构,读取对象字段值时使用的字段的符号引用来自于CONSTANT_Fieldref_info,方法调用中使用的方法的符号引用来自于CONSTANT_Methordref_info结构。运行时常量池是二进制形式的常量池表在Hotspot中的对应C++实现,即oops模块下的ConstantPool类,定义在oops/constantPool.hpp中,运行时常量池所有引用最初都是符号引用,在链接阶段会将符号引用解析成对应的内存地址。

测试代码如下:

package jvmTest;
 
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
 
class D{
    private int a=123;
    public int b=456;
 
    public D() {
    }
 
    public void show(){
        System.out.println("test");
    }
 
}
 
public class MainTest3 {
 
    public static void main(String[] args) {
        test();
 
        while (true) {
            try {
                System.out.println(getProcessID());
                Thread.sleep(600 * 1000);
            } catch (Exception e) {
 
            }
        }
    }
 
    public static final int getProcessID() {
        RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
        System.out.println(runtimeMXBean.getName());
        return Integer.valueOf(runtimeMXBean.getName().split("@")[0])
                .intValue();
    }
 
    public static void test(){
        D d=new D();
        d.show();
        System.out.println(d.b);
    }
 
 
}

使用HSDB查看运行时常量池的具体内容,在Class Browser中搜索jvmTest可找到示例中的两个类,找到后点击最下方的Constant Pool链接,如下图


image.png

先看类D的常量池,如下图:

image.png

再看MainTest3的test方法,如下图:


image.png

类名和方法名的符号引用可直接解析成对应的内存地址,因为这些是运行时内存地址不可变的;字段的符号引用无法解析成保存该字段值的内存地址,因为对应的内存地址是运行时随着对象实例的地址而不断改变的。

可通过javap命令查看最初的字符形式的符号引用,如下图:


image.png

加载

启动类

日常的Java工程开发中会引入很多第三方jar包,这些jar包中的类都会被加载么?类的加载是如何触发的?何时触发的?将上述示例中main方法中调用test()方法的代码注掉再执行,在Class Browser下搜索jvmTest,结果如下图:

image.png

类D搜不出来了,说明D未加载,再看MainTest3的常量池,如下图:


image.png

UnresolvedClass表明D还未符号解析,跟D一样,java/lang/Exception和java/lang/Object两个类也未符号解析。该示例说明只有在需要该类的时候如创建类实例,调用类方法,获取类的字段值等才会加载该类,这样做是为了减少无用的类的内存占用,注意JDK自带的标准类除外,标准类在JVM启动时全部加载到内存中。常量池的符号引用的解析可以预解析,也可以按需解析,JVM规范未做要求,Hotspot选择后者。

Java程序启动时必须指定一个启动类,在JVM初始化完成后会执行此启动类的main方法,此main方法跟C/C++中的main方法一样都是作为启动入口的。在JVM执行main方法前必须先加载该启动类,该启动类加载的过程中和main方法执行的过程中都会按需加载其他的类,如启动类使用了类A,类A使用了类B和类C,类B使用了类D,类C使用了类E,如此递归循环,最终启动类,A,B,C,D,E都会依次加载到JVM内存中。正是因为并不是在程序启动时一次性加载所有未来需要用到的类,所以在使用第三方jar包时,程序运行期间可能会抛出NoClassDefFoundError错误。

类加载器

类加载器主要负责加载类的,按照给定的全限定类名如java/lang/String,从文件或者网络中读取二进制形式的类,将其转化成JVM能够识别并直接使用的Klass模型。JDK提供了三种标准类加载器:

每个类的Class都包含有加载该Class的类加载器的引用,可调Class#getClassLoader()获取,JDK核心类库中的类除外,因为这部分类是由启动类加载器加载的。因为数组类的Class不是由类加载器生成的,而是JVM根据数组元素类型自动生成的,所以调用Class#getClassLoader()返回的是数组元素类的类加载器的引用。类加载器采用委托模型加载类或者其他资源,发出加载请求的类加载器和最终完成加载并定义类的类加载器不需要是同一个类加载器。每个类加载器实例都有一个关联的父类加载器,该父类加载器就是被委托对象,可调用ClassLoader#getParent()方法获取,注意启动类加载器没有父类加载器,而是作为其他类加载器的父类加载器存在,示例如下:

 public static void main(String[] args) {
        ClassLoader classLoader=MyTest.class.getClassLoader();
        while (classLoader!=null) {
            System.out.println(classLoader.getClass().getName());
            classLoader=classLoader.getParent();
        }
        classLoader=String.class.getClassLoader();
        System.out.println(classLoader==null);
 
        ClassLoader classLoader2=int[].class.getClassLoader();
        System.out.println(classLoader2==null);
    }

执行结果如下:


image.png

即AppClassLoader的父类加载器是ExtClassLoader,ExtClassLoader的父类加载器为空,注意这里的为空仅是父类加载器引用为空,在逻辑上启动加载器是ExtClassLoader的父加载器,各类加载器的详细介绍参考《Hotspot 类加载器Java源码解析》。

类加载器与类

如果类加载器L直接创建了类C,就说L定义了C或者L是C的定义类加载器。如果L委托其他的类加载器完成加载并定义类C,则说L导致了C的加载或者L是C的初始加载器。在Java虚拟机运行时,类或者接口不仅仅是由他的名称来确定,而是由全限定名(二进制名称)和定义类加载器共同确定的,同理类或者接口所属的运行时包结构由所属的包名和定义类加载器决定。

一个功能良好的类加载器必须保证下面2个属性:

这两个属性其实是JVM底层的类加载机制保证的,上层的Java类加载器实现只要满足委托机制即可,因为Java类加载器中查找已经加载的类,将二进制流转换成Class对象以及解析验证Class的逻辑都是由native方法实现的,Java语言层面只是重写了如何根据类名找到并读取对应类文件的逻辑。

测试代码如下:

package jvmTest;
 
 
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
import java.lang.reflect.Constructor;
 
class TestA{
 
    public TestA() {
    }
 
    public void say(){
        System.out.println("TestA");
    }
}
 
class MyClassLoader extends ClassLoader{
    public MyClassLoader(ClassLoader parent) {
        super(parent);
    }
 
    public MyClassLoader() {
 
    }
 
    @Override
    public Class<?> findClass(String name) throws ClassNotFoundException {
        String fileName="D:\\git\\"+name.replace(".", File.separator)+".class";
        System.out.println(fileName);
        try {
            File file=new File(fileName);
            if(file.exists()){
                InputStream ins=new FileInputStream(file);
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                byte[] buffer = new byte[4096];
                int bytesNumRead = 0;
                while ((bytesNumRead = ins.read(buffer)) != -1) {
                    baos.write(buffer, 0, bytesNumRead);
                }
                byte[] classData=baos.toByteArray();
                System.out.println("file  size->"+classData.length);
                return defineClass(name, classData, 0,classData.length);
            }
        } catch (Exception e) {
 
        }
        return null;
    }
}
 
public class ClassLoaderTest {
    public static void main(String[] args) throws Exception {
        TestA a=new TestA();
        a.say();
        //MyClassLoader的默认父类加载器就是AppClassLoader
        MyClassLoader classLoader=new MyClassLoader();
        //调用loadClass方法会返回AppClassLoader已经加载过的Class
        Class c=classLoader.loadClass("jvmTest.TestA");
        System.out.println(c==a.getClass());
        //调用findClass则绕开了委托机制,强制加载一个同名的已经存在的类
        Class c2=classLoader.findClass("jvmTest.TestA");
        System.out.println(c2==a.getClass());
//        Constructor cons=c2.getConstructor(null);
//        cons.setAccessible(true);
//        TestA a2=(TestA) cons.newInstance();
//        a2.say();
 
 
        while (true){
            try {
                System.out.println(getProcessID());
                Thread.sleep(600*1000);
            } catch (Exception e) {
 
            }
        }
    }
 
    public static final int getProcessID() {
        RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
        System.out.println(runtimeMXBean.getName());
        return Integer.valueOf(runtimeMXBean.getName().split("@")[0])
                .intValue();
    }
}

上述文件编译后,将TestA.class然后运行,结果如下:

image.png

c和a.getClass()返回的是同一个Class对象,c2则是另一个Class对象,可用通过HSDB Class Browser工具进一步查看,如下图:


image.png

后面被注掉的几行代码是尝试用c2来创建一个新的TestA实例,结果报错:


image.png

这是因为代码层面无法区分不同ClassLoader加载的同名Class对象,所以编译时默认是只有一个TestA的Class对象,就存在上述的ClassCastException。

格式检查

JVM准备加载某个class文件,必须首先检查其是否符合class文件的基本格式,这个过程就称为格式检查,验证的事项如下:

格式检查并不确保某字段或者方法真的在某个类中,也不保证某描述符会指向真实存在的类,只保证class文件中各项的格式是正确的,更为复杂详细的检查在验证字节码和解析class文件时执行。

从class文件得到类

使用类加载器L从class文件得到标记为N的非数组类或者接口C的Class对象,主要包含以下几个步骤:

链接

链接类或者接口包括验证和准备类或接口,它的直接父类,直接父接口,元素类型(如果是数组类型),而解析这个类的符号引用如对某个类的方法调用则是链接过程中可选的部分。Java虚拟机规范允许灵活的选择链接的时机,但必须保证以下几点成立:

Java虚拟机实现可以选择在只有用到类或者接口的符号引用时才去逐一解析,称为延迟解析,也可以在验证类的时候就解析每个符号引用,称为预先解析,Hotspot为了节省内存占用选择延迟解析,也只有在解析需要某个类时才会加载对应的class文件。链接包含三个步骤,验证、准备和解析,下面逐一分析。

验证

验证用于保证类或者接口的二进制表示是否符合静态约束和结构化约束,验证过程会导致某些额外的类或者接口被加载进来,但是不一定导致他们也需要验证或者准备。

静态约束主要是指一系列用来定义文件是否良好编排的约束,加载过程中执行的格式检查就是静态约束的一部分,验证环节验证的静态约束主要包含对虚拟机指令的验证,包括虚拟机指令在Code数组中是否正确排列,部分特殊的指令是否带上了必要的操作数,如:

结构化约束主要是为了限定虚拟机指令之间的关系,如:

除检查虚拟机指令是否满足上述两种约束外,还需验证:

链接期验证有助于增强解释器的运行期执行性能,因为解释器无需再对每条执行指令做检查。根据版本号的不同,JVM可以采用两种不同的策略来完成验证,版本号低于50.0即编译时JDK1.6之前的的class文件采用类型推导策略,版本号大于或者等于50.0的使用类型检查策略,这里的策略相当于Java规范给出的如何实现这些验证的官方指导,详情参考虚拟机规范。

准备

准备阶段的任务是创建类或者接口的静态字段,并用默认值初始化这些字段,这个阶段不会执行任何的虚拟机指令,在初始化阶段会有显示的初始化器来初始化这些字段,所以准备阶段不做初始化。除此之外,JVM会在准备阶段强制实施加载约束,具体的约束规则参考虚拟机规范。

执行完类验证后,任何时间都可以执行准备,但一定要保证在初始化阶段前完成。

解析

解析是根据运行时常量池里的符号引用来动态决定具体值的过程,如果某个符号引用解析过程出现异常,则应该在直接或者间接使用该符号引用的地方抛出异常。对类D引用的类或者接口C的符号引用解析时,大体解析流程如下:

初始化

初始化对类和接口来说就是执行它的初始化方法,只有在发生下列行为时,类或者接口才会被初始化:

因为JVM支持多线程,所以存在并发初始化某个类或者接口的问题,JVM实现需要处理好线程同步和递归初始化,通常采用与某个类或者接口唯一关联的全局初始化锁来控制,参考ClassLoader类中的getClassLoadingLock的实现,如下:

protected Object getClassLoadingLock(String className) {
        Object lock = this;
        //如果当前ClassLoader是支持并发初始化则parallelLockMap在ClassLoader初始化时初始化
        if (parallelLockMap != null) {
            Object newLock = new Object();
            //如果该类的锁已存在则返回该锁,否则会保存该锁
            lock = parallelLockMap.putIfAbsent(className, newLock);
            if (lock == null) {
                lock = newLock;
            }
        }
        return lock;
}

上一篇 下一篇

猜你喜欢

热点阅读