《深入理解Java虚拟机》读书笔记5—虚拟机类加载
虚拟机类加载
1、类加载(Class Loading
虚拟机加载class类文件到内存,直至类被卸载出内存整个生命周期如下图所示:

以上加载、验证、准备、解析、初始化就是类加载(Class Loading)。上图中的箭头指向并不是完全指明他们的顺序,其中只有加载、验证、准备、初始化、卸载是按上图的顺序开始的,而且不是按顺序完成。
2、类加载时机
虚拟机没有明确规定类在什么时候触发加载,只是在以下五种主动引用中类还没有初始化则会进行初始化操作(加载则肯定在之前了):
- l 遇到new、getstatic、putstatic、invokestatic4条指令时(对应实例化、获取或设置静态字段、调用静态方法)
- l 使用java.lang.reflect包的方法对类进行反射调用时
- l 当初始化一个类时,发现其父类还没初始化(父接口不会,只有使用的时候才会触发)(2018.12.15更正:初始化一个类的时候,会先初始化其父类和含有默认方法的实现的父接口,但初始化一个接口的时候才不会初始其父接口,含有默认方法实现的接口时java8新增的特性。即用default修饰,且有方法体。见https://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4 )
- l 当虚拟机启动时,指定的含有程序入口方法main的主类
- l 当使用jdk1.7动态语言,一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,且这个方法句柄对应的类还没进行初始化
有且只有以上五种情况会触发初始化,而以下的三种被动引用则不会触发:
- l 通过子类引用父类的静态字段,只会触发父类的初始化(子类是否初始化需要看具体虚拟机实现)
- l new一个对象数组时,不会触发数组元素类的初始化(会触发数组类的初始化,数组类是由虚拟机自动生成的,字节码指令newarray)
- l 当在类A中使用另一个类B中的常量C时,因为编译阶段的常量传播优化手段,已经把常量C的值存储当A的常量池中,不会触发B的初始化。
3、类加载器
把实现“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块叫做“类加载器”。(这句话摘自《深入理解Java虚拟机》,但个人觉得类加载器的作用不只是到获得二进制字节流,根据Jdk7虚拟机规范,类加载器的结果是该类的Class对象,这个Class对象包含了对方法区类型信息的引用。所有不只是获取二进制字节流,应该还包括后续的验证、准备阶段,对应ClassLoader的defineClass方法?)
除了实现加载功能以外,对于任意一个了,需要由类加载器和类本身一起才能确定这个类在虚拟机里的唯一性,即比较两类是否相等,只有在两个类在同一个加载器加载的前提下才有意义。
类加载器总的分为两种,一是启动类加载器(Bootstrap class loader),这是虚拟机提供的;二是用户定义类加载器,都必须继承自java.lang.ClassLoader类,用于扩展加载方法(动态加载)。 但从程序员的角度,加载器可以分为以下三种:
- l 启动类加载器(bootstrap classLoader):这个加载器负责加载存放在<JAVA_HOME>\lib目录中的,或被-Xbootclasspath参数指定目录下,且拥有能被虚拟机识别的文件名(比如rt.jar)的类库。这个加载器不能直接被程序员引用,如需委派加载请求给启动类加载器,用null代替即可。
- l 扩展类加载器(extension classLoader):加载<JAVA_HOME>\lib\ext目录中,或被java.ext.dir系统变量指定的路径中的类库。
- l 应用程序类加载器(Application ClassLoader):ClassLoader.getSystemClassLoader()返回的加载器,负责加载用户类路径上指定的类库,如果开发者没有自定义过加载器,则默认使用这个。
三种加载器直接配合进行加载,通常这些加载器之间的关系如图:

这种层次关系叫双亲委派模型:如果一个加载器接到加载请求,它会先委派给父加载器。每一个层次都这样,只有当父类加载器的搜索范围内无法找到要加载的类时,才会自己尝试加载。这样做的好处是能够让Java类随加载器一起具备优先级(自己定义的java.lang.Object永远不能替代rt.jar库中的Object类)。
4、类加载之加载
类加载器其实对应于加载阶段,该阶段需要完成:(这应该才是类加载器要做的)
- l 通过类的全限定名获取二进制流
- l 将二进制流代表的静态结构转化为方法区的运行时数据结构
- l 在内存中生成代表这个类的Class对象,作为访问方法区类型数据的入口。
第一条中获取二进制流并没有规定从文件获取,因此扩展开来有了从zip读取(后来的jar,war),运行时生成(动态代理),其他文件生成(jsp)等。特殊的数组类是由虚拟机直接生成,但是在数组类的元素类型仍由类加载器加载,所有对数组类的创建有以下规则:
- l 如果元素类型是引用类型,则采用正常的类加载递归加载元素类型,最终数组类将在该加载器的类名称空间被标识
- l 如果元素类型是不是引用类型,即为基础类型,则数组类与引导类加载器关联
- l 数组类的可见性与它的组件类型可见性一致,如果不是引用类型,则为public
5、类加载之验证
验证阶段目的是检查二进制字节流包含的信息是否符合当前虚拟机的要求且不危害虚拟机安全。大致可分为以下四个检验阶段:
- l 文件格式验证:基于二进制流进行,验证字节流能正确地解析并存储于方法区,之后的验证阶段才会基于方法区的数据结构进行验证
- l 元数据验证:对类的元数据进行语义校验,确保符合java语言规范的要求
- l 字节码验证:通过数据流和控制流分析,确定程序语义合法和符合逻辑。Code属性的属性表中的StackMapTable就是作用于这个阶段,用于校验在基本指令块的开始,操作数栈和本地变量表的状态是否正确。
- l 符号引用验证:对类自身以外(常量池中各种符号引用)的信息进行匹配性校验(解析阶段)。
6、类加载之准备
准备阶段是给类变量分配内存空间并设置初始值。首先注意的是类变量,即被static修饰的变量。其次设置初始值是各种类型相对的零值(基础类型为0,引用对象为null)。但需要注意的是若是常量(即final,static修饰的基础类型或String),常量值存储在ConstantsValue表中,在这个阶段就会赋值。
7、类加载之解析
解析是虚拟机将常量池中的符号引用替换为直接引用的过程,其中:
- l 符号引用即class文件里面的CONSTANT_Class_info,CONSTANT_Fieldref_info等类型的常量
- l 直接引用是可以是直接指向目标的指针、相对偏移量或能够间接定位目标的句柄。
虚拟机规范没有明确指明什么时候进行解析,只是规定了在遇到以下指令之前需要对它们使用的符号引用进行解析:anewarray,checkcast,getfield,getstatic ,instanceof,invokedynamic,invokeinterface,invokespecial,invokestatic,invokevirtual,ldc,ldc_w,multianewarray,new,putfield,putstatic。除了invokedynamic指令以外,其他指令在不同时间对同一个符号引用的的解析请求结果必须保持一致,而invokedynamic指令本就是为了完成动态语言支持,只有到实际运行时才会解析。以下是计中简单符号引用解析的过程:
7.1类或接口的解析
当前所处代码为D,要把一个符号引用N解析到一个类或接口C:
- 如果C不是数组,则虚拟机会把代表C的全限定名N传给C的类加载器,去加载C;
- 如果C是数组,则先按上一点去加载元素类型(此处加载元素类型并不会达到初始化阶段,见被动引用,即这个加载应该是加载,而不是类加载)。接着虚拟机生成一个代表此数组维度和元素的数组对象;
- 此时虚拟机已经有一个类或接口了,验证是否有访问权限。
7.2字段解析
- 首先段字段类型常量中的class_index所指的类或接口类型常量解析,获得结果C;
- 若C中本身就含有了字段描述符和简单名称匹配的字段,则返回这个字段的直接引用 ;
- 否则如果C实现接口,则按继承顺序从下往上搜索各个接口,若能找到则返回直接引用;
- 否则如果C不是java.lang.Object,则搜索父类,若能找到则返回直接引用;
- 若找到则检查访问权限,否则抛出java.lang.NoSuchFieldError异常。
(注意:实际中编译器会更加严格,若在父类和接口中都发现符合的字段,则会拒绝编译。)
7.3类方法解析
- 第一步同字段解析,先获得class_index索引所指的类C(若发现是接口则抛出java.lang.IncompatibleClassChangeError异常);
- 若C中本身含有了方法描述符和简单名称匹配的字段,则直接返回;
- 否则,查找按继承顺序查找C的父类,若找到则返回;
- 否则,若C实现了接口,则按继承顺序从下往上搜索接口,若找到则抛出java.lang.AbstractMethodErro异常(在C本身中没找到说明未实现这个接口,说明C是抽象类)
- 如果找到则检查访问权限,否抛出java.lang.NoSuchMethodErro异常。
7.4接口方法解析
- 第一步同字类方法解析,只是获得的C必须是接口(若是类,则抛出java.lang.IncompatibleClassChangeError)
- 若C本身含有,则直接返回
- 否则,按继承顺序查找C的父接口;
- 如果没找到则抛出java.lang.NoSuchMethodError异常,找到则直接返回(接口方法都是public,无权限限制)
8、初始化
初始化阶段是类或接口在准备阶段的基础上对类变量和资源再次进行赋值,这个阶段是程序员可控的。初始化阶段是执行<clinit>方法的过程:
- l <clinit>是由编译器自动收集类变量(static修饰)的赋值动作和静态语句块(static语句块)构成,收集顺序按照出现的顺序,其中静态语言块可以赋值定义在后面的类变量,但是不能访问
- l 由于父类的初始化过程一定早于子类(见类加载时机第三点),所以父类的<clinit>方法早于子类的<clinit>方法
- l 如果无类变量和静态语句块,则不会生成<clinit>方法
- l 接口没有静态语句块,但接口中的变量都被static final修饰,所以也会生成<clinit>方法(2018.12.15修正:当接口C中有这样的语句int i = C.getvalue(), getValue()返回一个int值,通过javap命令也能看到会生成一个static语句块用来执行这个调用赋值过程)
- l 在多线程中,在同一时间只有一个线程能进入<clinit>方法,其他线程将被阻塞,直到进入的线程结束(但<clinit>方法也只会执行一遍)(2018.12.15补充:在多线程通过如下方式控制<clinit>方法的线程安全:
- 首先每个Class对象有四种初始化的状态:
- Class对象已经验证和准备但还没有初始化
- Class对象正在被某个线程初始化
- Class已经完成初始化,等待被使用
- Class对象处于错误状态,可能是因为初始化失败
- 其次每个类或者接口C,都有一个唯一的初始化锁LC,C到LC的具体映射方式由具体虚拟机实现。初始化C的过程如下:
- 先请求C的初始化锁LC
- 查看C的状态,如果显示C正在被其他某个线程初始化,则释放LC并阻塞当前线程,直到被通知C的初始化完成
- 如果C的状态显示,C正在被当前进程初始化,则说明有递归的初始化请求,则释放LC,继续后续正常步骤(不是初始化步骤,而是说明当前线程不需要进入初始化,执行其他的动作)
- 如果C的状态显示,C已经完成初始化,则释放LC,正常工作(应该就是跳过初始化,已经完成初始化了嘛)
- 如果C的处于错误状态,那么释放LC并抛出NoClassDefFoundError
- 否则,更新C的状态为正在被当前线程处理初始化,然后释放LC,然后初始化C的常量fields
- 之后如果C是类,则递归处理C的父类和父接口的初始化过程,如果中途有异常,则获取LC,标志C的状态为错误,通知所有等待线程,然后释放LC,结束。
- 然后是否有C的断言处于enabled(断言没怎么看懂~~~)
- 然后执行类变量的初始化,static块初始化或者接口的field初始化(这里接口的field初始化,在接口里面定义的变量一般都为static final修饰的常量,当存在如int i=C.generaterValue() ;这样的定义时,就会生成一个static块来执行这个过程,也就是这里所说的field初始化过程)
- 如果初始化过程正常结束,则获取LC,标志C的状态为完成初始化,然后通知所有等待线程,然后释放LC,结束初始化
- 否则,初始化过程发生某种错误E,如果E不是Error或其子类,则以E为参数创建一个ExceptionInInitializerError并替代E。(若果这时候发生OutOfMemoryError则将OutOfMemory替代E
- 获取LC,将C标志位错误状态,通知其他等待线程,然后释放LC,然后报告错误E
- 具体的虚拟机可能会对这个过程有一定优化。具体见原文https://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.2)
- 首先每个Class对象有四种初始化的状态:

浙公网安备 33010602011771号