虚拟机类加载机制(五)--- 类加载的过程

Java的技术体系包括

  • 支持Java程序运行的虚拟机(JVM)
  • 提供接口支持的Java API
  • Java 编程语言
  • 第三方Java框架(如Spring等)

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,确实编程语言的一大步。


前面几篇文章详细介绍了java代码编译后形成的Class文件的结构,编译成Class文件之后java虚拟机才能够识别相应的代码程序。那么虚拟机会如何把Class文件加载到内存呢?并会对加载的Class文件做哪些处理呢?这就是类加载的过程。

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成能被虚拟机直接使用的java类型的过程,就是类加载机制

java语言最大的特点之一就是不需要在编译期间进行类的加载和连接,而是在程序运行期间去完成。这种策略虽然会使类加载的时候增加一些性能开销,但是却为java语言提供了高度的灵活性,主要体现在

  1. 接口可以在运行时再指定实际实现的类
  2. 通过自定义类加载器,可以让程序在运行时从网络加载一个二进制流作为程序代码的一部分。而这种方式目前已经被广泛运用到java语言应用的各个领域,从最初的Applet,JSP到OSGi,再到Android的热更新等等。

类的整个生命周期

类从被加载到虚拟机内存开始,到被卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载7个阶段,可以用下图来表示

其中,类的加载,验证,准备,初始化,卸载这五个阶段的顺序是确定的,类的加载过程会按照这个顺序开始,但彼此并没有严格的界限,而是通常在一个阶段执行的过程中会激活另一个阶段。解析阶段则有时候可以出现在初始化之后,这是为了支持java语言的运行时绑定。

类加载的时机

什么时候会开始类加载的第一个阶段,加载呢?java虚拟机规范中没有严格的要求。但是对类初始化阶段做了严格的规定,有且只有下面5中情况时,需要立即对类进行初始化

  1. 遇到 new, getstatic, putstatic, invokestatic 这四个字节码指令时。对应的java代码场景是:实例化一个对象,读取或者设置一个类的静态变量(被final修饰的静态常量除外,final static 会在编译期被放入常量池),调用一个类的静态方法。
  2. 使用java.lang.reflect包的方法对类进行反射调用时。
  3. 当初始化一个类的时候,如果发现其父类没有初始化,需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的哪个类),虚拟机会先初始化这个主类。
  5. 如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄时。
    除了上面5中情况之外,其他任何场景都不会对类进行初始化。而在类进行初始化之前,必然要先进行加载,验证,准备。

注意接口与类的区别在于第3条,当一个类在进行初始化时,要求其父类全部都已经初始化过,但在一个接口初始化时,并不要求其父接口初始化,只有在真正使用到父接口的时候,比如引用接口中定义的常量,才会初始化父接口。

类加载的过程

加载

虚拟机使用类需要进行的第一个动作就是加载,虚拟机需要做3件事

  1. 通过类的全限定名获取类的二进制字节流
  2. 将二进制流所代表的静态数据结构(也就是Class文件结构),转换为内存中方法区运行时的数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,用来访问方法区该类的各种数据。

以上虚拟机规范中所规定的动作,最被广为应用的就是第一个,也就是说虚拟机规范没有规定二进制字节流只能来源于Class文件,它可以有任意多的来源,比如用的最多jar包,Android的Dex文件,已经被更广泛应用的从网络中获取二进制字节流。只要所获得的二进制字节流符合Class结构,就可以被认定为一个规范的类。

而当类被加载到方法区之后,已怎样的结构存储,虚拟机规范中并没有规定,这个依赖于与虚拟机的具体实现。之后在内存中实例化的 java.lang.Class对象,虚拟机也没有规定在内存的哪个区域,比如HotSpot虚拟机就将这种特殊的Class对象存储在了方法区。

验证

加载之后,紧接着的是验证阶段,而且在虚拟机将Class二进制字节流加载到内存的过程中,验证阶段就要开始了。验证的目的是为了确保Class文件的二进制字节流符合虚拟机规范,同时不会危害虚拟机的安全。因为Class文件的来源可以非常广泛,并不一定是由java源码编译而来,极端情况下甚至可以直接由十六进制编辑器编辑而来,因此Class文件的二进制字节流是不可信任的,必须对齐进行验证。

验证阶段大体可以分为4个阶段的验证工作

  1. 文件格式验证

首先要验证字节流符合Class文件格式规范,格式上符合描述一个java类型信息的要求,以便字节流能被正确的解析并存储于方法区中。通过该阶段的验证后,字节流就会进入内存的方法区并存储,后面的3个阶段均基于方法区的存储结构进行验证。

  1. 元数据验证

第二阶段是对类的元数据进行语义校验,保证其描述的信息符合java语言规范的要求。比如一个类是否有父类,是否继承了不被允许继承的类(被final修饰)等等

  1. 字节码验证

该阶段是整个验证阶段最复杂的阶段,是通过对数据流和控制流的分析,确定程序的语义是合法的并且符合逻辑的。还记得我们之前将一个java类可以分为元数据方法体中的代码逻辑两部分么?第二阶段就是对元数据的验证,而第三阶段就是对方法体中的代码逻辑进行验证,以保证不会做出危害虚拟机的事情。

JDK1.6之后,为了避免过多的时间性能消耗在字节码验证阶段,对javac编译器和虚拟机做了优化,在方法体的Code属性中增加了一项 "StackMapTable" 属性,将原来的类型推倒验证转化为了类型检查验证,从而来节约时间。JDK1.7之后编译的Class文件,只能通过 "StackMapTable" 类型检查验证的方式来完成字节码验证。

  1. 符号引用验证

该阶段发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作发生在解析阶段,目的是对类自身以外的信息进行匹配性校验以确保解析动作能够正确的执行。比如验证字符串描述的全限定名的类能否找到,以及类中是否存在引用的字段和方法等等。

对于虚拟机而言,验证阶段是一个非常重要,但并不是必需的阶段。所以如果所运行的全部代码已经被反复的使用和验证,那么可以考虑关闭类验证措施,以缩短类加载时间。

准备

准备阶段是正式为类变量(也就是属于类的变量,即类静态变量,static)分配内存并设置初始值的阶段,类变量的内存将在方法区中进行分配。这里有3点需要注意

  1. 这里分配在内存方法区的仅仅为类变量,不包括实例变量,实例变量将在对象实例化时被分配在Java堆。
  2. 这里所说的初始值指的是对应数据类型的零值,例如
public static int value = 123;

此时在准备阶段时,value的值为0。因为此时还没有执行任何的java方法,而把123赋值给value的 putstatic 指令是被存放在类构造器 <clinit> 方法之中,这个动作在初始化阶段才会执行。

  1. 如果同时被 final static 修饰的话,例如
public final static int value = 123;

在准备阶段就会把123赋值给value。因为对于被 final static 修饰的变量,编译器会为该变量生成 ConstantValue 属性,因为在准备阶段,虚拟机就会根据 ConstantValue 属性所指向的常量池中的值赋给value,而不需要通过 putstatic 的字节码命令赋值,因此准备阶段就可以完成。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

关于符号引用和直接引用,两者之间的区别如下

  1. 符号引用,使用符号来描述所引用的目标,符号的形式被明确定义在Class文件格式中(比如各种字面量和UTF-8编码字符串)。符号引用的目标不一定被加载到了内存中。
  2. 直接引用,可以是直接指向目标的指针、相对偏移量,或者是一个能间接定位到目标的句柄。如果有了直接引用,那么目标必定已经在内存中。

也就是说,解析其实就是解析Class文件常量池中的符号引用。所以什么时候开始解析呢?自然是在执行用于操作"符号引用"的字节码指令之前。而各虚拟机可以根据需要,自己去实现,是在类加载之后就对常量池中的符号引用进行解析,还是在真正使用一个符号引用的时候才进行解析。

下面详细阐述下常量池中最常用的4类符号引用的解析过程

类或者接口的解析 --- 对应常量池的 CONSTANT_Class_info 类型的解析

如果当前代码所处的类为D,要解析一个符号引用N,解析为类或者接口C的直接引用,过程如下

  1. 如果C不是一个数组类型,那么虚拟机会把N代表的全限定名传递给D的类加载器去加载这个类C。
  2. 如果C是一个数组类型,并且数组元素类型为对象,就会按照1去加载数组元素类型,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
  3. 如果前两个步骤顺利,那么C在虚拟机中已经成为一个有效的类或者接口,接下来进行符号引用验证,验证D是否具备对C的访问权限。
字段解析 --- 对应常量池的 CONSTANT_Fieldref_info 类型的解析

解析一个字段的符号引用,会首先解析字段表内的 CONSTANT_Class_info 符号引用,也就是字段所属的类的符号引用。如果解析成功,将该字段所属的类用C来表示,会对字段进行如下解析过程

  1. 如果C中本身就有相应的字段,则返回这个字段的直接引用,查找结束。
  2. 否则,如果C中实现了接口,会按照继承关系从下到上递归搜索各接口和它的父接口,如果有相应的字段,则返回直接引用,查找结束。
  3. 否则,按照继承关系从下往上递归搜索其父类,如果有相应字段,则返回直接引用,查找结束。
  4. 否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。
    当返回直接引用之后,会对字段进行符号引用验证,验证是否具备对字段的访问权限,如果不具备,将抛出 java.lang.IllegalAccessError 异常
类方法解析 --- 对应常量池 CONSTANT_Methodref_info 类型的解析

类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的 CONSTANT_Class_info 符号引用,如果解析成功,我们依然用C来表示这个类,接下来过程如下

  1. 如果类方法表中发现 类的符号引用 是一个接口,则抛出异常 java.lang.IncompatibleClassChangeError
  2. 如果通过1,在类C中查找是否有相应方法,有则返回直接引用,查找结束
  3. 否则,在类C的父类中递归查找是否有相应方法,有则返回直接引用,查找结束
  4. 否则,在类C实现的接口列表以及他们的父接口中查找是否有相应的方法,如果有,说明C是一个抽象类,查找结束,抛出异常 java.lang.AbstractMethodError 异常
  5. 否则,查找失败,抛出 java.lang.NoSuchMethodError
    如果返回了直接引用,会对该方法进行符号引用验证,验证是否具备对方法的访问权限
接口方法解析 --- 对应常量池 CONSTANT_InterfaceMethodref_info 类型解析

接口方法解析也需要先解析出接口方法表的 CONSTANT_Class_info符号引用,接下来过程如下

  1. 如果接口方法表中发现 接口的符号引用 是一个类,则抛出异常 java.lang.IncompatibleClassChangeError
  2. 否则,在接口C中查找是否有相应的方法,有则返回直接引用,查找结束
  3. 否则,在接口C的父接口中递归查找是否有相应的防范,有则返回直接引用,查找结束
  4. 否则,查找失败,抛出 java.lang.NoSuchMethodError
    接口中的方法默认都是public,不存在访问权限的问题。

初始化

初始化阶段是类加载的最后一个阶段。该阶段主要是执行类构造器 <clinit> 的过程。在前面的整个过程中,只有在加载阶段,用户可以通过自定义类加载器参与,其他所有过程都是虚拟机自动完成的。到了初始化阶段,才真正开始执行类中定义的java程序代码。

关于类构造器 <clinit> 有以下几点说明

  1. <clinit> 方法是由编译器根据类静态变量和静态语句块合并产生,并按照在java源文件中的顺序进行合并。
  2. 虚拟机会在执行 <clinit> 方法之前,先执行其父类的 <clinit> 方法。因此,虚拟机执行的第一个 <clinit> 方法,一定是 java.lang.Object 的。
  3. 父类的静态语句块要优先与子类的静态变量赋值操作
  4. <clinit> 并不是必须的,编译器可以不给一个类生成该方法
  5. 接口的 <clinit> 方法。不需要先执行接口父类的 <clinit> 方法,只有需要使用父接口定义的静态变量,才会调用父接口的 <clinit> 。同时,接口的实现类也一样不会执行接口的 <clinit> 方法。
  6. 在多线程环境下,虚拟机会自动保证 <clinit> 方法的加锁和同步。即,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的 <clinit> 方法,其他线程都需要阻塞等待,知道活动线程执行完 <clinit> 方法。因此如果在 <clinit> 中执行耗时很长的操作,就会造成多进程阻塞。这种阻塞往往又是非常隐蔽的。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容