理解Java类加载

Java虚拟机类加载机制

.java ---> .class ---> 内存;对数据校验、转换解析,最后形成可以被虚拟机直接使用的Java类型

类型的加载、连接、初始化都是在程序运行期间完成的

类加载过程

为了支持运行时绑定,解析过程在某些情况可以在初始化后再进行,其他过程都必须严格按照图示流程

加载

1、通过全限定类名来获取定义此类的二进制字节流

2、将这个字节流所代表的静态存储结构 ---> 方法区运行时数据结构

3、在内存中生成一个代表这个类的 Class 对象,作为方法区这个类的各种数据的访问入口

验证

验证是连接阶段的第一步,这个阶段是为了确保 Class 文件的字节流包含的信息符合当前虚拟机的要求,白话讲,文件都不符合标准虚拟机加载个锤子,加载这个文件对虚拟机有害也加载个锤子

1、文件格式验证:比如是否以魔数 0xCAFEBABE(咖啡宝贝)开头、主、次版本号是否在当前虚拟机处理的范围之内,常量合理性验证等

2、元数据验证:是否存在父类,父类的继承链是否正确,抽象类是否实现了其父类或接口之中要求实现的所有方法,字段、方法是否与父类产生矛盾等

3、字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

4、符号引用验证:在解析阶段发生,保证可以将符号引用转化为直接引用

可是考虑使用-Xverify:none参数来关闭大部分的类验证措施,已缩短虚拟机加载的时间

总结一下:说白了验证阶段就是检查一下被加载类的Class文件中的数据是否符合虚拟机加载的标准,如果符合标准则加载,如果不符合标准就不加载;
准备

类变量 分配内存,并设置类变量初始值,这些变量所使用的内存都将在方法区中进行分配

解析

虚拟机将常量池内的 符号引用替换为直接引用 的过程

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行

初始化

到初始化阶段,才真正开始执行类中定义的Java程序代码,此阶段是执行<clinit>()方法的过程

<clinit>()方法是由编译器按语句在源文件中出现的顺序,依次自动收集类的所有 类变量 的赋值动作和 静态代码块 中的语句合并产生的。(不包括构造器中的语句。构造器是初始化对象的,类加载完成后,创建对象时候将调用的<init>()方法来初始化对象)

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问

public class Test {
    // 这里静态变量 int i = 1; 定义在静态块之前,只能赋值,不能访问
    // 指令重排现象:JVM会根据最合理的顺序来执行Class文件
    static {
        // 给变量赋值可以正常编译通过
        i = 0;
        // 这句编译器会提示"非法向前引用"
        System.out.println(i);
    }
    static int i = 1;
}

<clinit>()不需要显示调用父类(接口除外,接口不需要调用父类的初始化方法,只有使用到父接口中的静态变量时才需要调用)的初始化方法<clinit>(),虚拟机会保证在子类<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

public class Man extend Person {
    public Man(){
        // 实际上,子类的无参构造第一行隐式定义了一句调用父类的无参构造
        super();
    }
}

<clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。

虚拟机会保证一个类的 <clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。

类加载的时机

对于初始化阶段,虚拟机规范规定了有且只有 5 种情况必须 立即 对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

1、 遇到new、getstatic 和 putstatic 或 invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化,写代码时通常为 new 实例化对象,读取一个类的静态属性等
public class Test {

    public static void main(String[] args) {
        Person p = new Person();
        // 注意这里的Sex并不是常量类
        String sex = Sex.MAN;
        System.out.println(sex);  
        /*
        * 此时只会输出 man, 引用常量并不会触发类的初始化
        * */
    }
}

class Sex {
    static final String MAN = "man";
    static {
        System.out.println("Sex类被装载了");
    }
}

// 普通类定义类的静态属性一般为
public static int i = 1;

// 常量类定义常量一般为
public static final int i = 1;

// 有一个 final 的区别
// 调用常量类的常量不会触发类的初始化,而调用普通类的静态成员属性会触发类的初始化

// 而且,静态属性跟常量根本就不是一个东西,常量在创建后就在常量池中,不会触发类的初始化也很好理解
2、 对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
public class Test {

    public static void main(String[] args) {
        Class<?> aClass = Test.class.getClass();
        Test instance = aClass.getInstance();
    }
}

3、当初始化类的 父类 还没有进行过初始化,则需要先触发其父类的初始化。(而一个接口在初始化时,并不要求其父接口全部都完成了初始化)

4、 虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。要先加载启动类

5、 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

以上五种情况称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发其初始化,例如

1、通过子类引用父类的静态字段,不会导致子类初始化(只是一个传递的感觉)

public class Test1 {
    public static void main(String[] args) {
        int i = Son.i;
        System.out.println(i);
        // 父类被初始化了
        // 1
        
        /*
        * 从结果可以看到,只有父类触发了初始化操作,而子类作为被动引用,并没有触发初始化操作
        */
    }
}

class Father {
    public static int i = 1;
    static {
        System.out.println("父类被初始化了");
    }
}

class Son extends Father {
    static {
        System.out.println("子类被初始化了");
    }
}

2、 通过数组定义来引用类,不会触发此类的初始化。MyClass[] cs = new MyClass[10];

3、常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量类的初始化

public class Test1 {
    public static void main(String[] args) {
        String open = Status.OPEN;
        System.out.println(open);
        // open
        // 可以看到,仅仅只是调用了常量类的某一个常量,该常量在常量池中
        // Status类的static代码块并没有调用
    }
}

class Status {
    public static final String OPEN = "open";
    public static final String CLOSE = "close";
    static {
        System.out.println("Status常量类被初始化了");
    }
}

类加载器

把实现 类加载阶段中 的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块 称为“类加载器”(可以理解为类加载器是一个 工具,能把符合描述的类变成二进制字节流)

将 class 文件二进制数据放入方法区内,然后在堆内(heap)创建一个 java.lang.Class 对象,Class对象封装了类在方法区内的数据结构,并且向开发者提供了访问方法区内的数据结构的接口

类的唯一性和类加载器

对于任意一个类,都需要由 加载它的类加载器 和 这个类本身 一同确定其在 Java虚拟机 中的 唯一性

即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的 类加载器 不同,这两个类也不相同

这里所说的“相等”,包括代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况

我们来打个比方:

如果每个人能重生一次,第一次你会活成现在,第二次重生后可能就会和现在不同,即使两个你都是你,但你的生活经历也会把你塑造成不同的人;

你的性格,取向,言语谈吐就对应 Class 对象的各种方法;

你本身就是一个类;活着是虚拟机;社会是类加载器

双亲委派机制

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成;每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己加载

类加载器之间的父子关系一般不会以 继承 关系实现,而是使用 组合 关系来复用父加载器的代码

Bootstrap类加载器是C++实现的,是 jvm 的一部分,尝试获取对象返回值为 null;扩展类加载器和应用类加载器都是独立于 jvm 外部,为 Java 语言实现,继承自抽象类 java.lang.ClassLoader,开发者可以直接使用这两个类加载器

Application 类加载器对象可由 ClassLoader.getSystemClassLoader()方法返回,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中 默认的类加载器

双亲委派机制模型对于保证 Java 程序的稳定运行很重要,例如类java.lang.Object它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是一个类

    // 代码摘自《深入理解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);
                }
            } catch (ClassNotFoundException e) {
            // 如果父类加载器抛出ClassNotFoundException
            // 说明父类加载器无法完成加载请求
            }
            if (c == null) {
                // 在父类加载器无法加载的时候
                // 再调用本身的findClass方法来进行类加载
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

破坏双亲委派机制

1、双亲委派机制在引入之前就已经存在破坏它的代码存在了

JDK1.2以后双亲委派机制才被引入,在此之前类加载器和抽象类java.lang.ClassLoader就已经存在了,在JDK1.2之前,用户继承 ClassLoader 类的唯一目的就是为了重写 loadClass()方法,而双亲委派机制就在此方法中,所以JDK1.2以后,ClassLoader 类中添加了一个 protected 方法findClass(),从此建议用户重写findClass()而不是loadClass(),这样就保证了重写的类加载器也满足双亲委派机制

2、基础类无法调用类加载器加载用户提供的代码。

双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),但如果基础类又要调用用户的代码,启动类加载器只能加载基础类,无法加载用户类。

3、 用户对程序动态性的追求。

代码热替换(HotSwap)、模块热部署(Hot Deployment)等,OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。

自定义类加载器

Java 默认 ClassLoader,只加载指定目录下的 class,如果需要动态加载类到内存,例如要从远程网络上下载来类的二进制,然后调用这个类中的方法实现我的业务逻辑,如此,就需要自定义类加载器 ClassLoader

1、继承 Java.lang.ClassLoader

2、重写父类的 findClass() 方法

首先我们写一个需要被加载的类,就在D盘根目录

// 存放于D盘根目录
public class Test {

    public static void main(String[] args) {
        System.out.println("Test类已成功加载运行!");
        ClassLoader classLoader = Test.class.getClassLoader();
        System.out.println("加载我的classLoader:" + classLoader);
        System.out.println("classLoader.parent:" + classLoader.getParent());
    }
}

使用 javac -encoding utf8 Test.java 编译成 Test.class 文件

类加载器如下:

import java.io.*;

public class MyClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 加载D盘根目录下指定类名的class
        String clzDir = "D:\\" + File.separatorChar
                + name.replace('.', File.separatorChar) + ".class";
        // 获取类的数据字节数组
        byte[] classData = getClassData(clzDir);

        // 如果字节数组为空,则报出类不存在异常
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            // 把class文件读入的byteArray转化成Class
            // 将硬盘中的类数据转化为内存中的类对象
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] getClassData(String path) {
        // new两个io对象
        try (InputStream ins = new FileInputStream(path);
             ByteArrayOutputStream baos = new ByteArrayOutputStream()
        ) {

            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead = 0;
            // 把path路径的文件读进来,再用写出流把文件二进制流数据写出
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            // 最终返回写出流的字节数组
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

使用类加载器加载调用Test类

public class MyClassLoaderTest {
    public static void main(String[] args throw Exception {
        // 指定类加载器加载调用
        MyClassLoader classLoader = new MyClassLoader();
        classLoader.loadClass("Test").getMethod("test").invoke(null);
    }
}

输出信息:

Test.test()已成功加载运行!
加载我的classLoader:class MyClassLoader
classLoader.parent:class sun.misc.Launcher$AppClassLoader

new一个对象过程中发生了什么

1、确认类元信息是否存在

当 jvm 接收到 new 指令时,首先再 metaspace 内检查需要创建的类元信息是否存在,若不存在,那么再双亲委派机制模式下,使用当前类加载器以 ClassLoader + 包名 + 类名 以Key进行查找对应的 class 文件,如果没有找到文件,则抛出 ClassNotFoundException 异常 , 如果找到,则进行类加载(加载 - 验证 - 准备 - 解析 - 初始化),并生成对应的 Class 类对象。

2、分配对象内存

首先计算对象占用空间大小,如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小;接着在堆中划分一块对象给新对象。分配内存空间时,需要进行同步操作,比如采用 CAS 失败重试、区域加锁等方式保证分配操作的原子性

3、设置默认值

成员变量都需要设定为默认值,即各种信息不同形式的零值

4、设置对象头

设置新对象的哈希码、GC信息、锁信息、对象所属的类元信息等。这个过程的具体设置方式取决于 JVM 实现

5、执行 init<>()方法

初始化成员变量,执行实例化代码块,调用类的构造方法,并把对内对象的首地址赋值给引用变量

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值