Java虚拟机的类加载机制

一、引言

关于类的加载机制,先从面试题开始:

public class ClassLoaderProcess {   

   public static void main(String[] args) {
       System.out.println(Singleton.count_1);
       System.out.println(Singleton.count_2);
   }
}

class Singleton {

   private static Singleton singleton = new Singleton();
   public static int count_1;
   public static int count_2 = 0;

   static {
       count_1++;
       count_2++;
   }

   private Singleton() {
       count_1++;
       count_2++;
   }

   public static Singleton getInstance() {
       return singleton;
   }
}

在上面的代码中,Singleton 是个单例模式的类,该类中有两个静态变量,在静态代码块中对两个静态变量做自增运算,在私有构造方法中,再对两个静态变量做自增运算,最后打印出来的结果是多少呢?可以先思考一下。

如果你的答案是count_1=2,count_2=2,那很遗憾,你回答错了。我们带着这个问题,去分析虚拟机是如何加载一个类的。

二、类加载机制

1、首先来看一下类的生命周期:

在这里插入图片描述

上图表示了一个类的生命周期。类从被加载到虚拟机的内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、连接(验证、准备、解析)、初始化、使用和卸载 7个阶段。
其中,加载、验证、准备、初始化和卸载5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。下面来逐个分析各个过程。

(1)、 加载

我们知道,JVM 是使用双亲委派模型来进行类的加载的,所以在描述类加载过程前,我们先看一下双亲委托模型的工作过程:

如果一个类加载器(ClassLoader)收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要加载的类)时,子加载器才会尝试自己去加载。 使用双亲委托机制的好处是:能够有效确保一个类的全局唯一性,当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。

而在类加载阶段,Java虚拟机需要完成以下3件事情。

通过一个类的全限定名来获取定义此类的二进制字节流
将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构(也就是将类的结构信息放到方法区)
在内存中(堆中)生成一个代表这个类的Class对象,用来封装类在方法区里的数据结构,作为方法区中这个类的各种数据访问入口。

从这三个步骤中可以很明显的看出,我们可以通过这个Class来获取类的各种数据,它就像一面镜子,可以反射出类的信息来,所以也就明白了在用反射的时候为什么要使用Class了。由类加载器根据一个类的全限定名来读取此类的二进制字节流到 JVM 内部,并存储在运行时内存区的方法区,然后将其转换为一个与目标类型对应的 java.lang.Class 对象实例

(2)、 验证

验证是连接阶段的第一步,这个阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

一般我们都是通过Java文件编译生成的class文件,这是没什么问题的,但是class文件并不是一定要求使用Java源码编译而来,也可以使用很多其他途径,比如用十六进制编译器直接编写来产生class文件。虚拟机如果不检查输入的字节流,对其完全信任的话,可能就会因为载入了有害的字节流而导致系统崩溃,所以验证,是虚拟机的一项重要的工作。

格式验证:验证是否符合class文件规范

语义验证:检查一个被标记为final的类型是否包含子类;检查一个类中的final方法是否被子类进行重写;确保父类和子类之间没有不兼容的一些方法声明(比如方法签名相同,但方法的返回值不同)

操作验证:在操作数栈中的数据必须进行正确的操作,对常量池中的各种符号引用执行验证(通常在解析阶段执行,检查是否可以通过符号引用中描述的全限定名定位到指定类型上,以及类成员信息的访问修饰符是否允许访问等)

(3)、 准备

接下来就是连接的第二步:准备。准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这里有两个概念要搞清楚:

类变量:即被static修饰的静态变量。
初始值:指的是该数据类型对应的“零”值。

所以也就是说,准备阶段是为静态变量分配内存,并且对其初始化为零值。但不包括静态代码块和实例变量静态代码块在后面的初始化阶段执行,实例变量将会在对象实例化的时候随着对象一起分到Java堆中的。比如我举个例子:

public static int value = 123;

在准备阶段,value的值为0,并非123!!!!当然,如果是boolean类型,则为false。零值是针对具体的类型来说的。

(4)、 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,这个符号引用和直接引用有啥关联呢?

符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。
	符号引用与虚拟机实现的内存布局是无关的,引用的目标不一定加载到内存中。
直接引用:指的是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
	直接引用是和虚拟机实现的内存布局相关的,引用的目标必定已经在内存中存在。
(5)、 初始化

初始化是类加载过程的最后一步,在前面的过程中,除了第一步加载阶段用户可以通过指定自定义类加载器参与外,其余过程完全是由虚拟机自己主导控制的。到了初始化阶段,才真正开始执行类中定义的Java程序代码了(或者说是字节码)

由上面分析可知,在准备阶段,静态变量已经赋过一次值了,只不过是系统要求的初始值而已,而在初始化阶段,为类的静态变量赋予程序中指定的初始值,还有执行静态代码块中的程序。

关于类的初始化这个阶段,可以再分析的深入一点,刚刚说初始化的阶段是为类的静态变量赋实际值的阶段,我们也可以从另外的一个角度去表达:初始化阶段是执行类构造器方法的过程(注意:不是我们平时说的类的构造方法)。构造器方法是<cinit>()方法,它是由编译器自动收集类中所有的静态变量的赋值动作和静态代码块中的语句合并产生的,所以也就清楚了,为啥初始化阶段也可以叫做类构造器方法执行的过程。

这里需要注意的是,编译器收集的顺序是由语句在程序中出现的顺序所决定的,静态代码块中只能访问到定义在静态代码块之前的变量,定义在它之后的变量,在前面的静态代码块中可以赋值,但是不能访问。可以举个例子:

	public class Test {
	   static {
	       i = 0; //给变量赋值可以正常通过编译
	       System.out.print(i); //但是不能访问,这句编译会提示非法向前引用
	   }
	   static int i = 1;

<cinit>()方法与类的构造函数不同,它不需要显示的调用父类的构造器,虚拟机会保证在子类的<cinit>()方法执行前,父类的<cinit>()方法已经执行完毕,所以在虚拟机中第一个被执行的<cinit>()方法的类肯定是java.lang.Object。

由于父类的<cinit>()方法先执行,也就意味着父类中定义的静态代码块要优先于子类的静态变量赋值操作,看一个例子:

	public class CinitMethod {
	
	   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);
	   }
	}

这段程序中,在准备阶段,先将A赋为0,B赋为0,在初始化阶段,先执行父类的<cinit>()方法,所以会执行A=1;然后A=2,然后执行子类的<cinit>()方法,执行B=A,所以打印出来是2。

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

到这里,一个类的加载过程就算完毕了,类加载的最终产品是位于堆中的Class对象,封装了类在方法区内的数据结构,并向java程序员提供了访问方法区内数据结构的接口。所以程序员就可以使用可以使用这个类去获取与该类相关的信息了。

要注意的是,这是类加载完毕了,跟类的对象是没有关系的,到目前只能使用类的静态变量和静态方法,类的对象需要我们去产生的,有了对象才能操作其中的普通成员变量和方法。

注意:static代码块只有JVM能够调用

现在再去看文章开头的那段java代码应该很简单了:

1. 在准备阶段,Java虚拟机将Singleton赋为空,count_1和count_2赋为0(count_2赋为0不是程序中的赋的0,而是int的默认值)。
2. 在初始化阶段,Java虚拟机按照顺序执行static代码:首先实例化Singleton,执行构造方法中的代码,count_1和count_2变成1;
然后按顺序执行static代码,count_1没有赋值,还是1,count_2被赋值为0;最后执行静态代码块中的代码,
count_1和count_2各自增1,所以count_1=2,count_2=1.然后按顺序执行static代码,count_1没有赋值,还是1,
count_2被赋值为0;最后执行静态代码块中的代码,count_1和count_2各自增1,所以count_1=2,count_2=1.

三、创建对象

1、在堆区分配对象需要的内存
分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量
2、对所有实例变量赋默认值
将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值
3、执行实例初始化代码
初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块然后是构造方法

如果有类似于Child c = new Child()形式的c引用的话,在栈区定义Child类型引用变量c,然后将堆区对象的地址赋值给它
需要注意的是,每个子类对象持有父类对象的引用,可在内部通过super关键字来调用父类对象,但在外部不可访问

补充:

通过实例引用调用实例方法的时候,先从方法区中对象的实际类型信息找,找不到的话再去父类类型信息中找。

如果继承的层次比较深,要调用的方法位于比较上层的父类,则调用的效率是比较低的,因为每次调用都要经过很多次查找。这时候大多系统会采用一种称为虚方法表的方法来优化调用的效率。

所谓虚方法表,就是在类加载的时候,为每个类创建一个表,这个表包括该类的对象所有动态绑定的方法及其地址,包括父类的方法,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的。当通过对象动态绑定方法的时候,只需要查找这个表就可以了,而不需要挨个查找每个父类。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

止步前行

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值