【JVM】类的奇幻漂流——类加载机制探秘

我们写的类,在编译完成后,究竟是怎么加载进虚拟机的?虚拟机又做了什么神奇操作?本文可以带着读者初探类加载机制。上来先放类加载各个阶段的主要任务,用于给读者一个大概的印象体验,现在记不住也没有什么关系。

现在只需要记住三个名词,装载——>连接——>初始化,记住了吗,我们要开始奇幻漂流了!

在文章的最后,我们使用几个例子来加深对程序执行顺序的理解。


1. 装载

我觉得这里使用装载更好一点,第一,可以避免与类加载过程中的“加载”混淆,第二,装载体现的就是一个“装”字,仅仅是把货物从一个地方搬到另外一个地方而已,而这里的加载,却包含搬运货物、处理货物等一系列流程。

装载阶段,将.class字节码文件的二进制数据读入内存中,然后将这些数据翻译成类的元数据,元数据包括方法代码,变量名,方法名,访问权限与返回值,接着将元数据存入方法区。最后会在中创建一个Class对象,用来封装类在方法区中的数据结构,因此我们可以通过访问此Class对象,来间接访问方法区中的元数据。

在Java7与Java8之后,方法区有不同的实现,这部分详细内容可以参考我的另外一篇博客灵性一问——为什么用元空间替换永久代?

总结来讲,装载的子流程为:

.class文件读入内存——>元数据放进方法区——>Class对象放进堆中

最后我们访问此Class对象,即可获取该类在方法区中的结构。


2. 连接

连接又包括验证、准备、初始化


2.1 验证

验证被加载类的正确性与安全性,看class文件是否正确,是否对会对虚拟机造成安全问题等,主要去验证文件格式、元数据、字节码与符合引用。

2.1.1 验证文件格式

2.1.1.1 验证文件类型

每个文件都有特定的类型,类型标识字段存在于文件的开头中,采用16进制表示,类型标识字段称为魔数,class文件的魔数为0xCAFEBABY,关于此魔数的由来也很有意思,可以看这篇文章class文件魔数CAFEBABE的由来

2.1.1.2 验证主次版本号

检查看主次版本号是否在当前jvm处理的范围之内,主次版本号的存放位置紧随在魔数之后。

2.1.1.3 验证常量池

常量池是class文件中最为复杂的一部分,对常量池的验证主要是验证常量池中是否有不支持的类型。

例如,有以下简答的代码:

public class Main { public static void main(String[] args) { int a=1; int b=2; int c=a+b;
    }
}

在该类的路径下,使用javac Main.java编译,然后使用javap -v Main可以输出以下信息:

以上标红处,就是class文件中存储常量池的地方。

2.1.2 验证元数据

主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求,比如说验证这个类是不是有父类,类中的字段方法是不是和父类冲突等等。

2.1.3 验证字节码

这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

2.1.4 验证符号引用

它是验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成。

对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么就没有必要去验证,毕竟验证需要花费一定的的时间,可以使用-Xverfity:none来关闭大部分的验证。


2.2 准备

在这个阶段中,主要是为类变量(静态变量)分配内存以及初始化默认值,因为静态变量全局只有一份,是跟着类走的,因此分配内存其实是在方法区上分配。

这里有3个注意点:

(1)在准备阶段,虚拟机只为静态变量分配内存,实例变量要等到初始化阶段才开始分配内存。这个时候还没有实例化该类,连对象都没有,因此这个时候还不存在实例变量。

(2)为静态变量初始化默认值,注意,是初始化对应数据类型的默认值,不是自定义的值。

例如,代码中是这样写的,自定义int类型的变量a的值为1

private static int a=1;

但是,在准备阶段完成之后,a的值只会被初始化为0,而不是1。

(3)被final修饰的静态变量,如果值比较小,则在编译后直接内嵌到字节码中。如果值比较大,也是在编译后直接放入常量池中。因此,准备阶段结束后,final类型的静态变量已经有了用户自定义的值,而不是默认值。


2.3 解析

解析阶段,主要是将class文件中常量池中的符号引用转化为直接引用

符号引用的含义:

可以直接理解为是一个字符串,用这个字符串来表示一个目标。就像博主的名字是SunAlwaysOnline,这个SunAlwaysOnline字符串就是一个符号引用,代表博主,但是现在不能通过名字直接找到我本人。

直接引用的含义:

直接引用是一个指向目标的指针,能够通过直接引用定位到目标。比如

Student s=new Student();

我们可以通过引用变量s直接定位到新创建出的Student对象实例。

将符号引用转化为直接引用,就能将平淡无奇的字符串转化为指向对象的指针。


3. 初始化

执行初始化,就是虚拟机执行类构造器<clinit>()方法的过程,<clinit>()方法是由编译器自动去搜集类中的所有类变量与静态语句块合并产生的。可能存在多个线程同时执行某个类的<clinit>()方法,虚拟机此时会对该方法进行加锁,保证只有一个线程能执行。

到了这个阶段,类变量与类成员变量才会被赋予用户自定义的值。

当然,一个类并不是被初始化多次,只有当对类的首次主动使用的时候才会导致类的初始化。主动使用包含以下几种方式:

  • 使用new语句创建类的对象
  • 访问类静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 通过反射方式获取对象实例
  • 有public static void main(String[] args)方法的类会首先被初始化
  • 初始化一个类时,如果父类还没有被初始化,则首先会初始化父类,再初始化该类。

被动使用会发生呢?

  • 当访问一个静态变量时时,只有真正声明这个静态变量的类才会被初始化。例如:通过子类引用父类的静态变量,不会导致子类初始化。
  • 引用常量不会触发此类的初始化(常量在编译阶段就内嵌进字节码或存入调用类的常量池中)
  • 声明并创建数组时,不会触发类的初始化。例如Student array=new Student[2];

4. 类的初始化顺序

现在有以下的代码:

class Father { public static int fatherA = 1; public static final int fatherB = 2; static {
        System.out.println("父类的静态代码块");
    }

    {
        System.out.println("父类的非静态代码块");
    }

    Father() {
        System.out.println("父类的构造方法");
    }
} class Son extends Father { public static int sonA = 3; public static final int sonB = 4; static {
        System.out.println("子类的静态代码块");
    }

    {
        System.out.println("子类的非静态代码块");
    }

    Son() {
        System.out.println("子类的构造方法");
    }
}

(1)Main方法中实例化子类:

public class Main { public static void main(String[] args) {
        Son son = new Son();
    }
}

首先可以确定的是,这属于主动使用,父类先于子类初始化,因此会得到以下的输出:

这里可以进行总结,程序执行的顺序为:

父类的静态域->子类的静态域->父类的非静态域->子类的非静态域->父类的构造方法->子类的构造方法

这里的静态域包括静态变量与静态代码块,静态变量和静态代码块的执行顺序由编码顺序决定。

规律就是,静态先于非静态,父类先于子类,构造方法在最后。嗯给我背三遍

(2)Mian方法中输出子类的sonA属性

public class Main { public static void main(String[] args) {
        System.out.println(Son.sonA);
    }
}

这里只要输出子类的静态属性sonA,因此需要初始化子类,但父类还没有被初始化,因此先初始化父类。一般而言,静态代码块会对静态变量进行赋值,因此调用静态属性,在此之前虚拟机会调用静态代码块。所以,输出如下:

(3)Main方法输出子类继承而来的fatherA属性

public class Main { public static void main(String[] args) {
        System.out.println(Son.fatherA);
    }
}

子类从父类继承而来的属性,因此这里属于被动使用。只会执行静态属性真正存在的那个类的初始化,即只会初始化父类。因此,输出:

(4)Main方法中声明并创建一个子类类型的数组

public class Main { public static void main(String[] args) {
       Son[] sons=new Son[10];
    }
}

显然,这属于被动使用,不会初始化Son类。因此,没有任何输出。

(5)Main方法输出子类被static final修饰的变量

public class Main { public static void main(String[] args) {
        System.out.println(Son.sonB);
    }
}

显然,被static final修改的变量,也就是一个常量,在编译器就放入类的常量池中了,不需要初始化类。因此,这里只输出sonB的值,即为4。

(6)在声明前使用一个静态变量

public class Main { static {
        c = 1;
    } public static int c;
}

这样的代码,是可以运行的,小朋友,你是不是有大大的疑问?但容我自仔细分析来。

首先,在准备阶段,为静态变量c分配内存,然后赋予初始值0。等到初始化阶段,执行类的静态域,也就是执行此处的静态代码块中c=1,c此时已经存在,也有了一个默认值0,此时可以修改c的值。

但是,如果我仅仅在c=1后使用c的话,如:

public class Main { static {
        c = 1;
        System.out.println(c);
    } public static int c;
}

此时编译没法通过,编辑器提示Illegal forward reference,即非法前向引用,似乎只能写入c,不能读取c。我们之前已经分析过了,此时在内存中是有这个c的,那为什么不能读取c?

本来在正常的情况下,要想使用一个变量,变量首先需要声明出来。当然,java做出了一种特许,允许在使用前不先声明,但必须要满足几个条件,其中有一个条件是该变量只能出现在赋值表达式的左边,即c=1可以,c=2可以,c+=1不可以(c+=1也就是c=c+1,违反了左值协定)。当然如果这里使用全限定名,也就是输出Main.c时,则可以正常运行。

有的小伙伴可能还是有大大的疑问,不要紧,没看懂的可以参考以下讲解非法前向引用的文章

java报错非法的前向引用问题

Java编译时提示非法向前引用

Illegal forward Reference java issue


关于加载使用到的类加载器,双亲委派机制,如何自定义类加载器,可能需要另开篇幅。


全部评论
更多文章请移步https://blog.csdn.net/qq_33591903
点赞 回复 分享
发布于 2020-05-13 14:08

相关推荐

讯飞老萌新:站住!有人25还没有找到工作的吗
点赞 评论 收藏
分享
昨天 12:43
已编辑
小码王_运维
蚂蚁岗位内推官:1 你觉得你有哪些缺点和优点? 2 你怎么评价你面试的这家公司? 3 你在校期间,有没有哪段时间或者某件事情让你受挫? 4 在校期间遇到最有挑战的事情是什么? 5 目前手上有 offer 吗? 6 自我介绍 7 职业规划 8 报学校专业是怎么考虑的? 9 工作城市 10 你是独生子女吗? 11 那你男朋友吗? 12 那你们出来面试都了解过哪些企业? 13 到后期你们每个人手上有好几个offer,哪些因素决定你们选择这家公司? 14 你更倾向哪种公司?有什么特别的点? 15 你大学有没有特别难忘的经历或者项目分享一下的? 16 团队合作中遇到什么问题? 17 对互联网加班有什么看法? 18 那你现在的技术薄弱点在哪里,怎么去突破? 19 你的兴趣爱好有哪些? 20 现在进度最快的公司是哪家? 21 拿到哪几家offer,是否谈过薪资等
点赞 评论 收藏
分享
评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客企业服务