Java内存管理-掌握虚拟机类加载机制(四)
勿在流沙筑高台,出来混迟早要还的。
做一个积极的人
编码、改bug、提升自己
我有一个乐园,面向编程,春暖花开!
上一篇介绍了整个JVM运行时的区域,以及简单对比了JDK7和JDK8中JVM运行时区域的一些变化,也顺便总结了哪些区域会发生异常(内存溢出)问题。前一篇的话还是非常重要,请大家务必要多多阅读学习和掌握,因为这些基础的知识点会关联后续的一系列问题内容,如果前面没有先有一定的基础知识储备,到后面的一些篇章介绍你可能会蒙B的,可能会有一种what the fuck的感觉,这TMD到底在说什么。所以墙裂建议先好好阅读前面的博文。
本章介绍JVM中类加载的机制,通过类加载的机制的学习我们可以知道类加载的整个流程是什么,让我们知其然也能知其所以然。
知识地图:
一、思考:简单示例
下面代码是两个简单的示例,请先思考30秒,最初回答,输出的结果到底是什么?
/**
* 示例1
*/classStaticLoad{privatestaticStaticLoad staticLoad =newStaticLoad();publicstaticintcount1;publicstaticintcount2 =0;privateStaticLoad(){ count1++; count2++; }publicstaticStaticLoadgetStaticLoadInstance(){returnstaticLoad; }}publicclassTestStaticLoadDemo{publicstaticvoidmain(String[] args){ StaticLoad staticLoad = StaticLoad.getStaticLoadInstance(); System.out.println("count1 = "+ staticLoad.count1); System.out.println("count2 = "+ staticLoad.count2); }}
示例1打印结果:
A :1 和 0
B :1 和 1
/**
* 示例2
*/classStaticLoad{publicstaticintcount1;publicstaticintcount2 =0;privatestaticStaticLoad staticLoad =newStaticLoad();privateStaticLoad(){ count1++; count2++; }publicstaticStaticLoadgetStaticLoadInstance(){returnstaticLoad; }}publicclassTestStaticLoadDemo{publicstaticvoidmain(String[] args){ StaticLoad staticLoad = StaticLoad.getStaticLoadInstance(); System.out.println("count1 = "+ staticLoad.count1); System.out.println("count2 = "+ staticLoad.count2); }}
示例2打印结果:
A :1 和 0
B :1 和 1
两个例子唯一的区别下面这行代码的顺序!
privatestaticStaticLoad staticLoad =newStaticLoad();
如果你能够选择出正确结果,并完全知道答案。那今天这一篇文章就不用看了,如果你在两个答案之间犹豫,那么请继续往下看,好好阅读完本篇,我相信你会有答案的。
二、类加载的过程
在来简单回顾一下JVM运行流程, java源文件程序 使用 javac 进行编译 ,编译字节码 class文件!
JVM 在指定位置读取class文件然后加载到内存中(字节码解析成二进制的代码、指令)。
JVM基本结构:
类加载器、执行引擎、运行时数据区、本地接口。
Class FIle ---> ClassLoader ---> 运行时数据区---->执行引擎,需要调用本地库接口--->本地方法库。
本文主要是在ClassLoader 这一个点做做介绍,慢慢的我们会把这一整套都串联起来。
思考:类加载机制是什么?
JVM把编译好的class文件加载的内存,并对数据进行校验、转换解析和初始化,最终形成JVM可以直接使用的Java类型的过程就是加载机制。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期包括了七个阶段:
加载(Loading)
验证(Verification)
准备(Preparation)
解析(Resolution)
初始化(Initialization)
使用(Using)
卸载(Unloading)
其中验证、准备、解析三个部分统称链接!本篇也只会介绍到初始化,后面的周期在后面文章在做介绍。
加载、验证、准备、初始化和卸载这五个阶段顺序是确定的,类的加载过程必须按照这种顺序来进行,而解析阶段不一定;它在某些情况下可以在初始化之后再开始,这是为了运行时动态绑定特性。值得注意的是:这些阶段通常都是互相交叉的混合式进行的,通常会在一个阶段执行的过程中调用或激活另外一个阶段。
1、加载阶段
什么情况下需要开始类加载的第一个阶段:加载。 JAVA虚拟机规范并没有进行强制约束,交给虚拟机的具体实现自由把握。
加载阶段是“类加载”过程中的一个阶段,这个阶段通常也被称作“装载”,在加载阶段,虚拟机主要完成以下3件事情:
1.通过“类全名”来获取定义此类的二进制字节流
2.将字节流所代表的静态存储结构转换为方法区的运行时数据结构
3.在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口(所以我们能够通过低调用类.getClass() )
注:如果不理解,建议先背下来,记住!
虚拟机规范的这3点要求其实并不规范,比如:通过“类全名”来获取定义此类的二进制字节流”并没有指明二进制流必须要从一个本地class文件中获取,准确地说是根本没有指明要从哪里获取及怎样获取(记住这个对后面我们实现自定义类加载有帮助)。许多java技术也玩出其他花样:
从Zip包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
从网络获取(URLClassLoader),下载.class文件
运行时计算生成,这种场景使用的最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用ProxyGenerator.generateProxyClass来为特定接口生成$Prxoy的代理类的二进制字节流。
由Java源文件动态编译为.class,最常用方式!
从数据库中读取.class文件,这种场景相对少见。
……
相对于类加载过程的其他阶段,加载阶段(准备地说,是加载阶段中获取类的二进制字节流的动作)是开发期可控性最强的阶段,因为加载阶段可以使用系统提供的类加载器(ClassLoader)来完成,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式有虚拟机实现自行定义,虚拟机并未规定此区域的具体数据结构。然后在java堆中实例化一个java.lang.Class类的对象,这个对象作为程序访问方法区中的这些类型数据的外部接口。加载阶段与链接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,链接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于链接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
如果上面那么多记不住: 请一定记住这句: 加载阶段也就是查找获取类的二进制数据(磁盘或者网络)动作,将类的数据(Class的信息:类的定义或者结构)放入方法区 (内存)。
一图说明:
2、连接阶段(验证、准备、解析)
只有二进制文件载入成功了,才能进行下面的阶段!
2.1 验证
验证就是字面意思,之前也提供JVM其实是有一套自己的规范,所以加载到JVM中数据是需要进行验证的。
验证是链接阶段的第一步,这一步主要的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。
验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。
1.文件格式验证
验证class文件格式规范,例如: class文件是否已魔术0xCAFEBABE开头 , 主、次版本号是否在当前虚拟机处理范围之内等。
2.元数据验证
这个阶段是对字节码描述的信息进行语义分析,以保证起描述的信息符合java语言规范要求。验证点可能包括:这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)、这个类是否继承了不允许被继承的类(被final修饰的)、如果这个类的父类是抽象类,是否实现了起父类或接口中要求实现的所有方法。
3.字节码验证
进行数据流和控制流分析,这个阶段对类的方法体进行校验分析,这个阶段的任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如:保证访法体中的类型转换有效,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但不能把一个父类对象赋值给子类数据类型、保证跳转命令不会跳转到方法体以外的字节码命令上。
4.符号引用验证
符号引用中通过字符串描述的全限定名是否能找到对应的类、符号引用类中的类,字段和方法的访问性(private、protected、public、default)是否可被当前类访问。
2.2 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的知识点:
第一:这时候进行内存分配的仅包括类变量(static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。
第二:这里所说的初始值“通常情况”下是数据类型的零值(默认值),假设一个类变量定义为:
publicstaticintvalue =123;
首先为int类型的静态变量value分配4个字节的内存空间,并赋予变量value的初始值为0而不是123。因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会被执行。
基本数据类型的零值:
| 数据类型 | 零值 | 数据类型 | 零值 | | -------- | -------- | --------- | ----- | | int | 0 | boolean | false | | long | 0L | float | 0.0f | | short | (short)0 | double | 0.0d | | char | '\u0000' | reference | null | | byte | (byte)0 | | |
上面所说的“通常情况”下初始值是零值,那相对于一些特殊的情况,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假设上面类变量value定义为:
publicstaticfinalintvalue =123;// 注意 final
编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value设置为123。
上面说了这么一长串意思是: 如果一个被static 修饰的变量加了final,则在准备阶段就会赋值为设置的值了,否则只是设置为零值(也可以认为默认值)。
2.3 解析
解析阶段是虚拟机常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用是一组符号来描述所引用的目标对象,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。Java虚拟机明确在Class文件格式中定义的符号引用的字面量形式。
直接引用:直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机内存布局实现相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。
这里重点理解加粗的两个名称,我的理解是由虚指变为实指,举个不是很恰当的例子,方便理解:
玩斗地主: 每一局输的人手里的一张牌代表 一块钱,此时一张牌虚指(符号引用)一块钱。
等一局游戏结束,将牌兑换为钱(直接引用)的时候,那就是实指了。
在解析的阶段,解析动作主要针对7类符号引用进行,它们的名称以及对于常量池中的常量类型和解析报错信息如下:
| 解析动作 | 符号引用 | 解析可能的报错 | | ---------- | ------------------------------- | ----------------------------------------------------------- | | 类或接口 | CONSTANTClassInfo | java.land.IllegalAccessError | | 字段 | CONSTANTFieldrefInfo | java.land.IllegalAccessError 或 java.land.NoSuchFieldError | | 类方法 | CONSTANTMethodefInfo | java.land.IllegalAccessError 或 java.land.NoSuchMethodError | | 接口方法 | CONSTANTInterfaceMethoderInfo | java.land.IllegalAccessError 或 java.land.NoSuchMethodError | | 方法类型 | CONSTANTMethodTypeInfo | | | 方法句柄 | CONSTANTMethodhandlerInfo | | | 调用限定符 | CONSTANTInvokeDynamicInfo | |
解析的整个阶段在虚拟机中还是比较复杂的,远比上面介绍的复杂的多,但是很多特别细节的东西我们可以暂时先忽略,先有个大概的认识和了解之后有时间在慢慢深入了。
小总结:
验证:确保被加载的类的正确性
准备:为 类的 静态变量 分配内存,并将其初始化为默认值
解析:把类中的符号引用转换为直接引用
3、初始化阶段
类初始阶段是类加载过程的最后一步,在上面提到的类加载过程中,除了加载阶段用户应用程序可以通过自定义类加载器参与之外,其余的动作全部由虚拟机主导和控制。初始化阶段,是真正开始执行类中定义的Java程序代码(或者说是字节码)。
在准备阶段,变量已经赋值过一次系统要求的初始值(零值),而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。(或者从另一个角度表达:初始化阶段是执行类构造器<clinit>()方法的过程。)
tips:
类构造器 和 构造方法有什么关系?
类构造器:构造class对象,类对象;构造方法:实例化对象!先要执行类构造器才能执行构造方法!也就是说先要有这个类,才能对类进行实例化。
在类构造器中构造器中先执行static变量,在执行static{}块,有多个static变量的话按照代码顺序执行。,如下图例子,顺序不对,编译都不能通过!
在初始化阶段,虚拟机规范则是严格规定有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备、解析要在此之前执行),5种情况分别是:
第一:遇到new、getstatic、putstatic或invokestatic这4条字节码指令时。如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用类的静态方法的时候。
备注:静态属性和静态方法,对应的指令为getstatic、putstatic、invokestatic。可能你对这些字节码指令有点蒙B,没有关系,可暂时忽略,记住一个new就行。
第二:使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
第三:当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
第四:当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个类。
第五:当使用JDK1.7的动态语言支持时,如果一个java.invoke.MethodHandle 实例最后解析结果REFgetStatic、REFputStatic、REF_invokeStatic 的方法句柄。并且这个方法句柄所对应的类没有初始化,则需要先触发其初始化。
<clinit>()方法相关的内容比较多,只需要记住一点:虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。(一个类在虚拟机中只会被加载一次,是什么机制保证只能被加载一次,后面文章进行讲解!)
三、分析示例和简单总结
上面的内容全部看完之后,我想你应该就知道最开始的简单示例的答案了。
示例1答案就是: A
示例2答案就是: B
示例1具体分析:首先指定一个要执行的主类(包含main()方法)也就是TestStaticLoadDemo,执行main()方法运行StaticLoad.getStaticLoadInstance(),调用StaticLoad类的静态方法的时候,开始加载StaticLoad。
第一步:给所有静态变量分配内存,并赋予零值。如下
publicclassTestStaticLoadDemo{publicstaticvoidmain(String[] args){ StaticLoad staticLoad = StaticLoad.getStaticLoadInstance();// ①System.out.println("count1 = "+ staticLoad.count1); System.out.println("count2 = "+ staticLoad.count2); }}// ②publicstaticStaticLoadgetStaticLoadInstance(){returnstaticLoad;}// ③privatestaticStaticLoad staticLoad =null;publicstaticintcount1 =0;publicstaticintcount2 =0;// count2 = 0 并不是代码中的count2 = 0 的含义,是赋予的默认零值!
第二步:赋值完进行初始化,把右边的值赋左边,static执行顺序从上到下,如下
privatestaticStaticLoad staticLoad =newStaticLoad();// ①publicstaticintcount1;// ⑤publicstaticintcount2 =0;//⑥privateStaticLoad(){// ②count1++;//③count2++;//④}
第三步:赋值完整,打印结果
publicclassTestStaticLoadDemo{publicstaticvoidmain(String[] args){ StaticLoad staticLoad = StaticLoad.getStaticLoadInstance(); System.out.println("count1 = "+ staticLoad.count1);// ① System.out.println("count2 = "+ staticLoad.count2);// ②}}
实例2可以按照上面的分析过程自行进行分析,这里就不在分析了。
最后在总结一下本文主要讲解的类的生命周期中的三个阶段:加载,连接(验证、准备、解析)、初始化。
参考资料
《深入理解Java虚拟机》
推荐阅读
Java内存管理-JVM内存模型以及JDK7和JDK内存模型对比总结(三)
谢谢你的阅读,如果您觉得这篇博文对你有帮助,请点赞或者喜欢,让更多的人看到!祝你每天开心愉快!
不管做什么,只要坚持下去就会看到不一样!在路上,不卑不亢!