抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

JVM面试题-类加载

img

类加载器有哪些

类与类加载器

​ 虚拟机设计团队将类加载阶段中的 “通过一个类的全限定名来获取描述此类的二进制字节流” 这个动作放到JVM外部去实现,以便让应用程序可以自己决定如何获取所需要的类, 实现这个动作的代码模块称为类加载器

​ 对于任意一个类,都需要由加载它的类加载器和类本身一同确认其在JVM中的唯一性,每一个类加载器,都拥有独立的类名称空间。通俗来说,比较两个类是否相等,只有这两个类在同一个类加载器下才有意义,不然肯定不没有比较意义的,因为即使是同一个类,不同的类加载器加载出来之后,他们也是不相等的。

双亲委派模型

类加载器分为四种:启动类加载器、扩展类加载器、应用程序类加载器、自定义类加载器

img

​ 上图展示的类加载器之间的层次关系,称为类加载器的 双亲委派模型 ,双亲委派模型要求 除了顶层的启动类加载器之外,其余的类加载器都应该有自己的父类加载器 。这里虽然是父子关系,但是 并不是继承实现,而是通过组合实现的 。它并不是一个强约束性的模型,而是推荐开发者的实现方式。

双亲委派思想

​ 双亲委派模型的思想:如果一个类加载器收到了类加载的请求,它首先不会自己去加载,而是把所有的加载请求向上传递给父类加载器完成,层层上推,只有当父类加载器反馈自己无法完成这个加载请求(没有在搜索范围内找到所需的类),子加载器才会尝试自己去加载。

​ 双亲委派最大的好处就是:带有层级关系,且不会重复的去加载一个加载过的类,这样会变得很混乱。

各种类加载器
启动类加载器

​ 启动类加载器(BootStrap ClassLoader) ,这个类是C++语言实现的,是虚拟机的一部分,它负责加载放在 \lib 目录下的类,或者被 -Xbootclasspath参数所指定 的路径种的类,并且是虚拟机能够识别的。启动类无法被程序直接引用,用户编写自定义类加载器的时候,如果需要把加载请求委派给这个类加载器,那直接使用 null 代替即可

扩展类加载器

​ 扩展类加载器(Extension ClassLoader) ,这个类加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载 \lib\ext 目录下的类,或者被 java.ext.dirs 系统变量所指定的路径种的所有类库,开发者可以直接使用这个类加载器。

应用程序类加载器

​ 应用程序类加载器(Application ClassLoader) ,这个类加载器由sun.misc.Launcher$AppClassLoader实现,由于这个类加载器是ClassLoader种的getSystemClassLoader()方法的返回值,所以也成为系统类加载器,她负责加载用户类路径 (ClassPath) 上所有的类库,开发者可以直接使用这个类加载器,这也是默认的类加载器。

自定义类加载器

自定义类加载器(User ClassLoader) ,用户可以在上面三个系统加载器下面,添加自定义的类加载器,而且类加载器之间不是继承关系,而是组合关系。

破坏双亲委派模型

​ 虽然双亲委派模型是Java设计者推荐给开发者的一种设计实现,但是并不是强约束性的,在一些特殊原因,为了实现需求和功能,也会出现不得已破坏双亲委派模型的情况。

SPI机制

​ 例如大家常用的JDBC,JNDI等等SPI机制的实现,都需要破坏双亲委派模型,因为他们的场景是需要在上层的类中加载下层的类,而双亲委派并不允许这么做,所以出现了一种不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader),它通过Thread类的setContextClassLoader()方法进行设置,而且它默认的就是应用程序类加载器,这样,子类加载器就可以获取到父类加载器去加载它所需要的资源了。

代码热部署

​ 比如OSGI,它不再是双亲委派中的树结构,而是一种网状结构

JVM加载流程

JVM类加载过程共分为加载、验证、准备、解析、初始化、使用和卸载七个阶段

img

这些阶段通常都是互相交叉的混合式进行的,通常会在一个阶段执行的过程中调用或激活另外一个阶段。

加载

加载过程是JVM类加载的第一步

​ 如果JVM配置中打开-XX:+TraceClassLoading,我们可以在控制台观察到类似

1
[Loaded chapter7.SubClass from file:/E:/EclipseData-Mine/Jvm/build/classes/]

的输出,这就是类加载过程的日志。

​ 加载过程是作为程序猿最可控的一个阶段,因为你可以随意指定类加载器,甚至可以重写loadClass方法,当然,在jdk1.2及以后的版本中,loadClass方法是包含双亲委派模型的逻辑代码的,所以不建议重写这个方法,而是鼓励重写findClass方法。

​ 类加载的二进制字节码文件可以来自jar包、网络、数据库以及各种语言的编译器编译而来的.class文件等各种来源。

类加载工作
  1. 通过类的全限定名(包名+类名)来获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转化为运行时数据结构存储在方法区
  3. 为类生成java.lang.Class对象,并作为该类的唯一入口
唯一性解释

​ 这里涉及到一个概念就是类的唯一性,书上对该概念的解释是:在类的加载过程中,一个类由类加载器和类本身唯一确定。也就是说,如果一个JVM虚拟机中有多个不同加载器,即使他们加载同一个类文件,那得到的java.lang.Class对象也是不同的。因此,只有在同一个加载器中,一个类才能被唯一标识,这叫做类加载器隔离。

验证

验证阶段的目的是为了确保Class字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的安全。

​ 我们知道Java语言具有相对的安全性(这里的安全性体现为两个方面:一是Java语言本身特性,比如Java去除指针,这点可以避免对内存的直接操作;二是Java所提供的沙箱运行机制,Java保证所运行的机制都是在沙箱之内运行的,而沙箱之外的操作都不可以运行)。但是需要注意的是Java虚拟机处理的Class文件并不一定是是从Java代码编译而来,完全可能是来自其他的语言,甚至可以直接通过十六进制编辑器书写Class文件(当然前提是编写的Class文件符合规范)。从这个角度讲,其他来源的Class文件是不可能都保证其安全性的。所以如果Java虚拟机都信任其加载进来的Class文件,那么很有可能会造成对虚拟机自身的危害。

验证阶段

虚拟机的验证阶段主要完后以下4项验证:文件格式验证、元数据验证、字节码验证、符号引用验证。(结合前文,查看Class类文件结构)

文件格式验证

​ 这里的文件格式是指Class的文件规范,这一步的验证主要保证加载的字节流(在计算机中不可能是整个Class文件,只有0和1,也就是字节流)符合Class文件的规范(根据前面对Class类文件的描述,Class文件的每一个字节表示的含义都是确定的。比如前四个字节是否是一个魔数等)以及保证这个字节流可以被虚拟机接受处理。

​ 在Hotspot的规范中,对文件格式的验证远不止这些,但是只有通过文件格式的验证才能进入方法区中进行存储。所以自然也就知道,后面阶段的验证工作都是在方法区中进行的。

元数据验证

​ 元数据可以理解为描述数据的数据,更通俗的说,元数据是描述类之间的依赖关系的数据,比如Java语言中的注解使用(使用@interface创建一个注解)。元数据验证主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范(Java语法)的元数据信息。

具体的验证信息包括以下几个方面:

  1. 这个类是否有父类(除了java.lang.Object外其余的类都应该有父类)
  2. 这个类的父类是否继承了不允许被继承的类(比如被final修饰的类)
  3. 如果这个类不是抽象类,是否实现了其父类或者接口中要求实现的方法
  4. 类中的字段、方法是否与父类产生矛盾(比如是否覆盖了父类的final字段)
字节码验证

​ 这个阶段主要对类的方法体进行校验分析。通过了字节码的验证并不代表就是没有问题的,但是如果没有通过验证就一定是有问题的。整个字节码的验证过程比这个复杂的多,由于字节码验证的高度复杂性,在jdk1.6版本之后的虚拟机增加了一项优化,Class类文件结构这篇文章中说到过有一个属性:StackMapTable属性。可以简单理解这个属性是用于检查类型是否匹配。

符号引用验证

符号引用的验证其实是发生在符号引用向直接引用转化的过程中,而这一过程发生在解析阶段。

​ 这个验证是最后阶段的验证,符号引用是Class文件的逻辑符号,直接引用指向的方法区中某一个地址,在解析阶段,将符号引用转为直接引用,这里只进行转化前的匹配性校验。符号引用验证主要是对类自身以外的信息进行匹配性校验。比如符号引用是否通过字符串描述的全限定名是否能够找到对应点类。

  • 符号引用(Symbolic Reference)
    符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可(符号字面量,还没有涉及到内存)。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载在内存中。各种虚拟机实现的内存布局可以各不相同,但是他们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
  • 直接引用(Direct Reference)
    直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄(可以理解为内存地址)。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般都不相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

进行符号引用验证的目的在于确保解析动作能够正常执行,如果无法通过符号引用验证那么将会抛出java.lang.IncomingChangeError异常的子类。

准备

完成了验证阶段之后,就进入准备阶段。准备阶段是正式为变量分配内存空间并且设置类变量初始值

​ 需要注意的是,这时候进行内存分配的仅仅是类变量(也就是被static修饰的变量),实例变量是不包括的,实例变量的初始化是在对象实例化的时候进行初始化,而且分配的内存区域是Java堆。这里的初始值也就是在编程中默认值,也就是零值。

静态变量处理

​ 例如public static int value = 123 ;value在准备阶段后的初始值是0而不是123,因为此时尚未执行任何的Java方法,而把value赋值为123的putStatic指令是程序被编译后,存放在类构造器clinit()方法之中,把value赋值为123的动作将在初始化阶段才会执行。

常量处理

​ 特殊情况:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量就会被初始化为ConstantValue属性所指定的值,例如public static final int value = 123 编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将变量赋值为123。

解析

​ 解析阶段是将常量池中的符号引用替换为直接引用的过程(前面已经提到了符号引用与直接引用的区别)。在进行解析之前需要对符号引用进行解析,不同虚拟机实现可以根据需要判断到底是在类被加载器加载的时候对常量池的符号引用进行解析(也就是初始化之前),还是等到一个符号引用被使用之前进行解析(也就是在初始化之后)。

​ 到现在我们已经明白解析阶段的时机,那么还有一个问题是:如果一个符号引用进行多次解析请求,虚拟机中除了invokedynamic指令外,虚拟机可以对第一次解析的结果进行缓存(在运行时常量池中记录引用,并把常量标识为一解析状态),这样就避免了一个符号引用的多次解析。

​ 解析动作主要针对的是类或者接口、字段、类方法、方法类型、方法句柄和调用点限定符7类符号引用。这里主要说明前四种的解析过程。

类或者接口解析

要把一个类或者接口的符号引用解析为直接引用,需要以下三个步骤:

  1. 如果该符号引用不是一个数组类型,那么虚拟机将会把该符号代表的全限定名称传递给调用这个符号引用的类。这个过程由于涉及验证过程所以可能会触发其他相关类的加载
  2. 如果该符号引用是一个数组类型,并且该数组的元素类型是对象。我们知道符号引用是存在方法区的常量池中的,该符号引用的描述符会类似”[java/lang/Integer”的形式,将会按照上面的规则进行加载,虚拟机将会生成一个代表此数组对象的直接引用
  3. 如果上面的步骤都没有出现异常,那么该符号引用已经在虚拟机中产生了一个直接引用,但是在解析完成之前需要对符号引用进行验证,主要是确认当前调用这个符号引用的类是否具有访问权限,如果没有访问权限将抛出java.lang.IllegalAccess异常
字段解析

对字段的解析需要首先对其所属的类进行解析,因为字段是属于类的,只有在正确解析得到其类的正确的直接引用才能继续对字段的解析。对字段的解析主要包括以下几个步骤:

  1. 如果该字段符号引用(后面简称符号)就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,解析结束
  2. 否则,如果在该符号的类实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果在接口中包含了简单名称和字段描述符都与目标相匹配的字段,那么久直接返回这个字段的直接引用,解析结束
  3. 否则,如果该符号所在的类不是Object类的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都相匹配的字段,那么直接返回这个字段的直接引用,解析结束
  4. 否则,解析失败,抛出java.lang.NoSuchFieldError异常
    如果最终返回了这个字段的直接引用,就进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常
类方法解析

进行类方法的解析仍然需要先解析此类方法的类,在正确解析之后需要进行如下的步骤:

  1. 类方法和接口方法的符号引用是分开的,所以如果在类方法表中发现class_index(类中方法的符号引用)的索引是一个接口,那么会抛出java.lang.IncompatibleClassChangeError的异常
  2. 如果class_index的索引确实是一个类,那么在该类中查找是否有简单名称和描述符都与目标字段相匹配的方法,如果有的话就返回这个方法的直接引用,查找结束
  3. 否则,在该类的父类中递归查找是否具有简单名称和描述符都与目标字段相匹配的字段,如果有,则直接返回这个字段的直接引用,查找结束
  4. 否则,在这个类的接口以及它的父接口中递归查找,如果找到的话就说明这个方法是一个抽象类,查找结束,返回java.lang.AbstractMethodError异常(因为抽象类是没有实现的)
  5. 否则,查找失败,抛出java.lang.NoSuchMethodError异常
    如果最终返回了直接引用,还需要对该符号引用进行权限验证,如果没有访问权限,就抛出java.lang.IllegalAccessError异常
接口方法解析

同类方法解析一样,也需要先解析出该方法的类或者接口的符号引用,如果解析成功,就进行下面的解析工作:

  1. 如果在接口方法表中发现class_index的索引是一个类而不是一个接口,那么也会抛出java.lang.IncompatibleClassChangeError的异常
  2. 否则,在该接口方法的所属的接口中查找是否具有简单名称和描述符都与目标字段相匹配的方法,如果有的话就直接返回这个方法的直接引用。查找结束
  3. 否则,在该接口以及其父接口中查找,直到Object类,如果找到则直接返回这个方法的直接引用
    否则,查找失败

接口的所有方法都是public,所以不存在访问权限问题

初始化

到了初始化阶段,虚拟机才开始真正执行Java程序代码,前文讲到对类变量的初始化,但那是仅仅赋初值,用户自定义的值还没有赋给该变量。只有到了初始化阶段,才开始真正执行这个自定义的过程,所以也可以说初始化阶段是执行类构造器方法clinit() 的过程。那么这个clinit() 方法是这么生成的呢?

clinit是什么

​ clinit() 是编译器自动收集类中所有类变量的赋值动作和静态语句块合并生成的。编译器收集的顺序是由语句在源文件中出现的顺序决定的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

示例代码:

1
2
3
4
5
6
7
 public class Test {
static{
i =0; //给变量赋值可以正常编译通过
System.out.println(i); //这句编译器会提示“非法向前引用”
}
static int i = 1;
}
clinit和构造方法区别

​ clinit() 方法与类的构造器方法不同,因为前者不需要显式调用父类构造器,因为虚拟机会保证在子类的clinit() 方法执行之前,父类的clinit() 方法已经执行完毕

​ 由于父类的clinit() 方法会先执行,所以就表示父类的static方法会先于子类的clinit() 方法执行。如下面的例子所示,输出结果为2而不是1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Parent { 
public static int A = 1;
static{
A = 2;
}
}

public class Sub extends Parent{
public static int B = A;
}

public class Test {
public static void main(String[] args) {
System.out.println(Sub.B);
}
}
client方法特点
  • clinit()方法对于类或者接口来说并不是必需的,如果一个类中没有静态语句块也没有对变量的赋值操作,那么编译器可以不为这个类生成clinit()方法。
  • 接口中不能使用静态语句块,但仍然有变量赋值的初始化操作,因此接口也会生成clinit()方法。但是接口与类不同,执行接口的clinit()方法不需要先执行父接口的clini>()方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也不会执行接口的clinit()方法。
  • 虚拟机会保证一个类的clinit()方法在多线程环境中被正确地加锁和同步。如果有多个线程去同时初始化一个类,那么只会有一个线程去执行这个类的clinit()方法,其它线程都需要阻塞等待,直到活动线程执行clinit()方法完毕。如果在一个类的clinit()方法中有耗时很长的操作,那么就可能造成多个进程阻塞。

对象内存布局

img

对象内存构成

img

对象组成部分

在 JVM 中,Java对象保存在堆中时,由以下三部分组成:

  • 对象头(object header):包括了关于堆对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息。Java对象和vm内部对象都有一个共同的对象头格式。
  • 实例数据(Instance Data):主要是存放类的数据信息,父类的信息,对象字段属性信息。
  • 对齐填充(Padding):为了字节对齐,填充的数据,不是必须的。

img

对象头

我们可以在Hotspot官方文档中找到它的描述(下图)。从中可以发现,它是Java对象和虚拟机内部对象都有的共同格式,由两个(计算机术语)组成。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。

img

​ 它里面提到了对象头由两个组成,这两个是什么呢?我们还是在上面的那个Hotspot官方文档中往上看,可以发现还有另外两个名词的定义解释,分别是 mark wordklass pointer

img

从中可以发现对象头中那两个字:第一个字就是 mark word,第二个就是 klass pointer

Mark Word

用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。

​ Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。我们打开openjdk的源码包,对应路径/openjdk/hotspot/src/share/vm/oops,Mark Word对应到C++的代码markOop.hpp,可以从注释中看到它们的组成,本文所有代码是基于Jdk1.8。

img

Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的

img

在64位JVM中是这么存的

img

虽然它们在不同位数的JVM中长度不一样,但是基本组成内容是一致的。

  • 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  • 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
  • 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
  • epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。
Klass Pointer

​ 即类型指针

定这个对象是哪个类的实例。

实例数据

​ 如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节,例如boolean类型占1个字节,int类型占4个字节等等;

对齐数据

​ 对象可以有对齐数据也可以没有。默认情况下,Java虚拟机堆中对象的起始地址需要对齐至8的倍数。如果一个对象用不到8N个字节则需要对其填充,以此来补齐对象头和实例数据占用内存之后剩余的空间大小。如果对象头和实例数据已经占满了JVM所分配的内存空间,那么就不用再进行对齐填充了。

​ 所有的对象分配的字节总SIZE需要是8的倍数,如果前面的对象头和实例数据占用的总SIZE不满足要求,则通过对齐数据来填满。

为什么要对齐数据

​ 字段内存对齐的其中一个原因,是让字段只出现在同一CPU的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。其实对其填充的最终目的是为了计算机高效寻址。

估算对象大小

​ 32 位系统下,当使用 new Object() 时,JVM 将会分配 8(Mark Word+类型指针) 字节的空间,128 个 Object 对象将占用 1KB 的空间。

基本数据类型

​ 如果是 new Integer(),那么对象里还有一个 int 值,其占用 4 字节,这个对象也就是 8+4=12 字节,对齐后,该对象就是 16 字节。

对象类型

以上只是一些简单的对象,那么对象的内部属性是怎么排布的?

1
2
3
4
5
Class A {
int i;
byte b;
String str;
}

​ 其中对象头部占用 ‘Mark Word’4 + ‘类型指针’4 = 8 字节;byte 8 位长,占用 1 字节;int 32 位长,占用 4 字节;String 只有引用,占用 4 字节;
​ 那么对象 A 一共占用了 8+1+4+4=17 字节,按照 8 字节对齐原则,对象大小也就是 24 字节。

​ 这个计算看起来是没有问题的,对象的大小也确实是 24 字节,但是对齐(padding)的位置并不对:

​ 在 HotSpot VM 中,对象排布时,间隙是在 4 字节基础上的(在 32 位和 64 位压缩模式下),上述例子中,int 后面的 byte,空隙只剩下 3 字节,接下来的 String 对象引用需要 4 字节来存放,因此 byte 和对象引用之间就会有 3 字节对齐,对象引用排布后,最后会有 4 字节对齐,因此结果上依然是 7 字节对齐。此时对象的结构示意图,如下图所示:

img

评论