类的加载过程

由 笔尖 发布

概述

类的加载按照JVM规范经历了如下五大过程:

61

从类的使用过程来看,流程图为:

62

加载

将java类的字节码加载到内存中,并在方法区中构建出java类的原型——类模板对象(java类在jvm内存中的快照)jvm从字节码中解析出运行时常量池、字段、方法信息,这些存储在方法区中,另外,还有静态变量、stringtable,这些存储在堆中。这样jvm就可以在运行期间获取到java类信息,进而创建类实例。

反射机制正是基于此,通过获取类信息,访问创建类实例。

  • 通过类全限定名,获取字节码二进制流。

    • 通过磁盘文件获取class后缀的文件
    • 读入jar、zip包,并自动解压获取
    • 从数据库中获取
    • 通过http协议从网络中获取
    • 运行时动态生成class信息,动态代理、jsp等
  • 将二进制流解析到方法区中的运行时数据结构。
  • 在堆中创建instance-class实例,作为访问该类的入口对象。

.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。(instanceKlass -> mirror : Class的实例)之后,外部通过访问order实例来创建/访问order class对应的实例对象。

image-20210203101637231

类的反射举例:

 /* 过程一:加载阶段
 * 通过Class类,获得了java.lang.String类的所有方法信息,并打印方法访问标识符、描述符
 */
public class LoadingTest {
    public static void main(String[] args) {
        try {
            Class clazz = Class.forName("java.lang.String");
            //获取当前运行时类声明的所有方法
            Method[] ms = clazz.getDeclaredMethods();
            for (Method m : ms) {
                //获取方法的修饰符
                String mod = Modifier.toString(m.getModifiers());
                System.out.print(mod + " ");
                //获取方法的返回值类型
                String returnType = m.getReturnType().getSimpleName();
                System.out.print(returnType + " ");
                //获取方法名
                System.out.print(m.getName() + "(");
                //获取方法的参数列表
                Class<?>[] ps = m.getParameterTypes();
                if (ps.length == 0) System.out.print(')');
                for (int i = 0; i < ps.length; i++) {
                    char end = (i == ps.length - 1) ? ')' : ',';
                    //获取参数的类型
                    System.out.print(ps[i].getSimpleName() + end);
                }
                System.out.println();
            }
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

链接

验证

保证加载的字节码是符合规范的。

  • 格式检查:魔数检查、版本检查
  • 语义检查:涵盖点较多

    1. 待加载的类的父类是否已加载
    2. 是否一些final方法或类被重写、继承
    3. 非抽象方法是否实现了抽象方法或接口方法
    4. 是否存在不兼容的方法,如签名、参数相同,仅返回值不同。
  • 字节码检查:

    1. 字节码执行过程中,是否会跳转到不存在的指令
    2. 函数调用是否传递了正确类型的参数
    3. 变量赋值是否是正确的数据类型

在前面3次检查中,已经排除了文件格式错误、语义错误以及字节码的不正确性。但是依然不能确保类是没有问题的。

准备

为静态变量分配内存,并赋初值。基本数据类型非final修饰的变量赋零值,final修饰的直接显示赋值。引用数据类型不论是否final修饰,都赋null值。

解析

将接口、方法、字段、类等信息的符号引用转化为直接引用。由于class文件中的类信息、常量池信息都是内存无关的,因此在转化为动态数据结构时,就需要将符号引用与直接引用进行绑定。

66

初始化

前面的加载、链接没有问题就可以为类的静态变量进行初始化了,通常是显式赋值,直观体现是字节码中的<clinit>方法,该方法有JVM自动生成,由静态变量赋值语句和static代码块合并产生。初始化顺序同样遵循先父后子,静态先行。

public class InitializationTest {
    public static int id = 1;
    public static int number;
    static {
        number = 2;
        System.out.println("father static{}");
    }
}
//子类
public class SubInitialization extends InitializationTest {
    static{
        number = 4;//number属性必须提前已经加载:一定会先加载父类。
        System.out.println("son static{}");
    }
    public static void main(String[] args) {
        System.out.println(number);
        //输出结果
        //father static{}
        //son static{}
        //4
    }
}

如下情况不会生成clinit方法:

  • 类中没有声明任何静态变量,没有静态代码块
  • 声明了静态变量,但没有显式赋值
  • static final修饰的基本数据类型和string对象(字面量方式赋值)时,在准备阶段完成赋值,而不会在初始化中
/**
 * 哪些场景下,java编译器就不会生成<clinit>()方法
 */
public class InitializationTest1 {
    //场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
    public int num = 1;
    //场景2:静态的字段,没有显式的赋值,不会生成<clinit>()方法
    public static int num1;
    //场景3:比如对于声明为static final的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
    public static final int num2 = 1;
    //不加final的变量会生成clinit,加final变为常量就不会
    public static  int num3 = 1;
}
/**
 * 说明:使用static + final修饰的字段的显式赋值的操作,到底是在哪个阶段进行的赋值?
 * 情况1:在链接阶段的准备环节赋值
 * 情况2:在初始化阶段<clinit>()中赋值
 * 结论:
 * 在链接阶段的准备环节赋值的情况:
 * 1. 对于基本数据类型的字段来说,如果使用static final修饰,则显式赋值(直接赋值常量,而非调用方法)通常是在链接阶段的准备环节进行
 * 2. 对于String来说,如果使用字面量的方式赋值,使用static final修饰的话,则显式赋值通常是在链接阶段的准备环节进行
 *
 * 在初始化阶段<clinit>()中赋值的情况:
 * 排除上述的在准备环节赋值的情况之外的情况。
 *
 * 最终结论:使用static + final修饰,且显示赋值中不涉及到方法或构造器调用的基本数据类型或String类型的显式赋值,是在链接阶段的准备环节进行。
 */
public class InitializationTest2 {
    public static int a = 1;//在初始化阶段<clinit>()中赋值
    public static final int INT_CONSTANT = 10;//在链接阶段的准备环节赋值

    public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);//在初始化阶段<clinit>()中赋值
    public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000);//在初始化阶段<clinit>()中赋值

    public static final String s0 = "helloworld0";//在链接阶段的准备环节赋值
    public static final String s1 = new String("helloworld1");//在初始化阶段<clinit>()中赋值

    public static String s2 = "helloworld2";

    public static final int NUM = 2;//字面量,在链接阶段的准备环节赋值
    public static final int NUM1 = new Random().nextInt(10);//在初始化阶段<clinit>()中赋值,编译阶段确定不了具体值
}

线程加载安全性

初始化过程需要保证在JVM中只被执行一次,即<clinit>方法是隐式带锁的,如果<clinit>方法需要执行很长时间,则有可能造成类加载缓慢,程序整体性能下降。

/**
 * 死锁举例
 */
class StaticA {
    static {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        try {
            Class.forName("com.dsh.jvmp2.chapter03.java1.StaticB");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        System.out.println("StaticA init OK");
    }
}
class StaticB {
    static {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        try {
            Class.forName("com.dsh.jvmp2.chapter03.java1.StaticA");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        System.out.println("StaticB init OK");
    }
}

public class StaticDeadLockMain extends Thread {
    private char flag;

    public StaticDeadLockMain(char flag) {
        this.flag = flag;
        this.setName("Thread" + flag);
    }

    @Override
    public void run() {
        try {
            Class.forName("com.dsh.jvmp2.chapter03.java1.Static" + flag);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        System.out.println(getName() + " over");
    }

    public static void main(String[] args) throws InterruptedException {
        StaticDeadLockMain loadA = new StaticDeadLockMain('A');
        loadA.start();
        StaticDeadLockMain loadB = new StaticDeadLockMain('B');
        loadB.start();
    }
}

类的主动使用VS被动使用

都会完成加载和链接阶段,区别在于是否执行初始化过程。

主动使用场景:

  1. 使用new创建一个类的实例,或者通过反射、反序列化。
  2. 调用类的静态方法
  3. 当使用类、接口的静态字段时(同样final修饰除外)
  4. 当使用class.forname执行反射获取类时。
  5. 初始化子类须先初始化父类。
  6. JVM启动时,执行主类main先被初始化。

注意:访问静态字段对于是否执行初始化和变量是在准备阶段赋值还是在初始化阶段赋值类似,即如果访问的变量在准备阶段就赋值了,则访问该变量不会导致类的初始化,如果类变量是在初始化阶段赋值,则访问该变量会导致类的初始化。

/**
 *
 * 3. 当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用getstatic或者putstatic指令。(对应访问变量、赋值变量操作)
 *
 */
public class ActiveUse2 {
    @Test
    public void test1(){
//        System.out.println(User.num);//类初始化了   num是变量
//        System.out.println(User.num1);//类没有初始化 num1是静态常量
        System.out.println(User.num2); //类初始化了 num2不是字面量方式声明的,需要方法调用,编译期间无法确定
    }

    @Test
    public void test2(){
//        System.out.println(CompareA.NUM1);//接口不需要初始化
        System.out.println(CompareA.NUM2);//接口需要初始化
    }
}

class User{
    static{
        System.out.println("User类的初始化过程");
    }

    public static int num = 1;
    public static final int num1 = 1;
    public static final int num2 = new Random().nextInt(10);

}

interface CompareA{
    public static final Thread t = new Thread(){
        {
            System.out.println("CompareA的初始化");
        }
    };

    public static final int NUM1 = 1;
    public static final int NUM2 = new Random().nextInt(10);

}

注意:对于接口的主动使用,和类比较相似,但是父接口的初始化只有在子类首次使用到该接口中特定的字段时才会导致初始化。此外,接口字段修饰符默认添加final。

public class SonTest implements father {
    public static void main(String[] args) {
        System.out.println(SonTest.a);
        //访问a会导致初始化,而b不会
    }
}

interface father {
    static final Object OBJECT = new Object() {{
        System.out.println("initial");
    }};
    static int a = new Random().nextInt(10);
    static int b = 10;
}

被动使用场景:

  1. 子类引用父类静态变量时,不会导致子类初始化
  2. 数组创建时,不会导致数组引用类初始化
  3. 获取准备阶段赋值的静态变量
  4. classloader的loadclass方法不会导致初始化

类卸载

方法区的落地实现由JDK7的永久代到JDK8的元空间,本地内存空间大小放开,但还是需要注意类的及时卸载,否则会导致方法区的OOM。想要卸载方法区中的class,并非易事,因为有很多引用关联,如下图所示:

70

只有当代表Sample类的Class对象不再被引用时,方法区中的Sample类才可被卸载。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收。也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的。同时我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下,来实现系统中的特定功能。


暂无评论

发表评论