JVM 与类加载
类文件结构
类文件结构比较繁琐,我暂时没有整理的兴趣,看一下《深入理解 Java 虚拟机上》上的介绍就可以了。
类加载器
Java 类结构是在运行时而不是在编译时确定的,而是由类加载器在运行期间加载的,因此称类的加载过程是动态加载,当调用某个类型对象的方法时,具体调用哪个方法是在运行期间决定的,因此称之为动态连接。
类加载器(ClassLoader)不是 Java 虚拟机的组成部分,它由外部调用,执行加载过程。
类加载器用于加载类,任何类都需要由加载它的类加载器和这个类一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间,因此由不同类加载器加载的类、就算它们真的是一个 class 出来的,也不算是同一个类型的,也不能直接进行交互(可以通过反射进行交互)。实现上,实际上 jvm 将类加载器的引用作为类型信息的一部分保存在方法区,作为判断两个类相同的依据。
写一个自定义的类加载器
1 | import java.io.ByteArrayOutputStream; |
这个类加载器:
findClass
的时候,先判断之前是否已经加载过这个类,如果加载过就直接返回了(双亲委派);- 从
/tmp
目录下面读类文件,对读进的二进制流数据使用defineClass
转换为类对象。
测试类:
1 | import java.util.StringJoiner; |
使用javac
命令编译后,将Hello.class
文件移动到/tmp
目录下。
类从哪里来
除了从文件加载以外,Java 的类加载机制还支持从网络读取,其实只要根据自己的需要实现类加载器,就能将任何二进制数据作为字节码数据进行加载。
-XX:+TraceClassLoading
跟踪类加载过程,结果形如:[Loaded java.lang.invoke.MethodHandleImpl$Lazy from D:\programme\jdk\jdk8U74\jre\lib\rt.jar]
mvn dependency:tree > ~/dependency.txt
打出所有依赖mvn dependency:tree -Dverbose -Dincludes=groupId:artifactId
只打出指定 groupId 和 artifactId 的依赖关系-XX:+TraceClassLoading
vm 启动脚本加入。在 tomcat 启动脚本中可见加载类的详细信息-verbose
vm 启动脚本加入。在 tomcat 启动脚本中可见加载类的详细信息greys:sc
greys 的 sc 命令也能清晰的看到当前类是从哪里加载过来的tomcat-classloader-locate
通过以下 url 可以获知当前类是从哪里加载的
curl http://localhost:8006/classloader/locate?class=org.apache.xerces.xs.XSObject
类加载器的种类
从虚拟机角度看,只存在两种类加载器:1. 启动类加载器。2. 其他类加载器。
从开发人员角度看,包括如下类加载器:1. 启动类加载器。2. 扩展类加载器。3. 应用程序类加载器。4. 自定义类加载器。
- 启动类加载器,用于加载 Java API,加载
<JAVA_HOME>/lib
目录下的类库。 - 扩展类加载类,由
sun.misc.Launcher$ExtClassLoader
实现,用于加载<JAVA_HOME>/lib/ext
目录下或者被java.ext.dirs
系统变量指定路径下的类库。 - 应用程序类加载器,也成为系统类加载器,由
sun.misc.Launcher$AppClassLoader
实现,用于加载用户类路径(ClassPath)上所指定的类库。 - 自定义类加载器,继承系统类加载器,实现用户自定义加载逻辑。
双亲委派模型
Java 设计者推荐所有自定义加载器都组合一个父加载器,加载类时先委派父加载器去尝试加载,若不成,再由自身去加载。所以一些基类总是由基加载器去加载,可以避免一个程序中有多个 java.lang.Object 类的情况
注意各个类加载器之间是组合关系,并非继承关系。
当一个类加载器收到类加载的请求,它将这个加载请求委派给父类加载器进行加载,每一层加载器都是如此,最终,所有的请求都会传送到启动类加载器中。只有当父类加载器自己无法完成加载请求时,子类加载器才会尝试自己加载。
双亲委派模型可以确保安全性,可以保证所有的 Java 类库都是由启动类加载器加载。如用户编写的 java.lang.Object,加载请求传递到启动类加载器,启动类加载的是系统中的 Object 对象,而用户编写的 java.lang.Object 不会被加载。如用户编写的 java.lang.virus 类,加载请求传递到启动类加载器,启动类加载器发现 virus 类并不是核心 Java 类,无法进行加载,将会由具体的子类加载器进行加载,而经过不同加载器进行加载的类是无法访问彼此的。由不同加载器加载的类处于不同的运行时包。所有的访问权限都是基于同一个运行时包而言的。
类的加载时机
Java 虚拟机规范没有强制规定类的加载时机,但是严格规定了以下 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)
- 遇到字节码指令 new(实例化对象时)、getstatic、putstatic(读取或设置一个类的静态字段)、invokestatic(调用一个类的静态方法);
- 遇到反射调用 java.lang.reflect 对类进行反射调用;
- 初始化一个类时,其父类还未初始化,则先初始化其父类(对接口不适用,即初始化子接口并不会导致父接口初始化);
- 主类(main);
- JDK7 的动态语言支持,java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先进行初始化。
上边的情况统称为主动引用,其他情况都是被动引用,被动引用都不会触发类的初始化。
- 被动引用的示例如下,主要是使用 类初始化块 进行验证的(即 static 块),只输出了”Super!”,原因是不满足上边的条件,子类是否被加载完全由虚拟机实现说了算
1
2
3
4
5
6
7
8
9
10
11
12
13
14class SuperClass {
public static int value = 123;
static {
System.out.println("Super!");
}
}
class SubClass {
static {
System.out.println("Sub!");
}
public static void main(String[] args) {
System.out.println(SubClass.value);
}
} - 书上举了另外一个例子说明 数组类初始化 的问题,下面代码并不会触发 SuperClass 类的初始化,而是初始化了一个对应的数组类,可以通过加-XX:+TraceClassLoading 的运行时参数来跟踪类的加载过程
1
2
3
4
5public class NotInitialization {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
} - 还有一个例子来说明 常量传播优化 会导致的混淆情况,即使用常量字段(static final)时不会触发初始化,编译时会将常量值保存到调用者的类常量池中,所以在调用时就和被调用者没关系了,下面的代码并不会触发 ConstClass 类的加载
1
2
3
4
5
6
7
8
9
10
11class ConstClass {
static {
System.out.println("ConstClass!");
}
public static final String HELLOWORLD = "hello world";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}
类的加载过程
加载
- 通过类的完全限定名获取表示类的二进制流;
- 转换为方法区中的类结构;
- 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区这些数据的访问入口
对于数组类而言,数组类由 java 虚拟机直接创建,不通过类加载器创建。数组类的创建过程如下:
- 如果数组元素类型是引用类型,就采用双亲委派模型进行加载(之后会介绍),数组类将在加载该元素类型的类名称空间上被标识。
- 如果数组元素类型为基本类型,数组类被标记为与引导类加载器关联。
- 数组类的可见性与其元素类型可见性一致,如果元素类型不是引用类型,那数组类的可见性默认为 public。
验证
此阶段的主要目的是确保 class 文件的字节流包含的信息符合虚拟机的要求,进行一部分的语义分析,主要是防止字节码中存在一些危险操作(数组越界、错误转型、跳转过头等),后来的 Java 虚拟机规范还规定了文件格式、符号引用等的验证。
不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面四个阶段的验证:
- 文件格式验证,是要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。如验证魔数是否为 0xCAFEBABE;主、次版本号是否正在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型。
该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。 - 元数据验证,对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法……
- 字节码验证,主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。
- 符号引用验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。验证符号引用中通过字符串描述的权限定名是否能找到对应的类;在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。
验证阶段对于虚拟机的类加载机制来说,不一定是必要的阶段。如果所运行的全部代码确认是安全的,可以使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间。
准备
为类变量(static 变量)分配内存和并初始化为默认值,它们被分配在方法区中。
准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
比如static int a = 1;
在准备阶段后 a 的值为 0,在初始化阶段后才变成 1,但是对常量字段(static final),在准备阶段会直接赋予用户设定的值。
解析
将常量池内的符号引用替换为直接引用,类似于将一个字面量 hash 到一个确定的槽中。
符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。指向类型、类变量、类方法的直接引用可能是指向方法区的本地指针。
常见的符号引用包括类或接口的全限定名、字段名和描述符、方法名和描述符。类型的直接引用可能简单地指向保存类型数据的方法区中的与实现相关的数据结构。类变量的直接引用可以指向方法区中保存的类变量的值。类方法的直接引用可以指向方法区中的一段数据结构方法区中包含调用方法的必要数据。指向实例变量和实例方法的直接引用都是偏移量。实例变量的直接引用可能是从对象的映像开始算起到这个实例变量位置的偏移量。实例方法的直接引用可能是到方法表的偏移量。
为了加快解析效率,可以对解析结果进行缓存,之后再解析符号引用时直接返回即可,但是对于 invokedynamic 则不能进行缓存。解析主要是针对 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info 七种常量类型。
- 类或接口的解析
将符号引用替换为直接引用包括如下几步。假设符号引用记为 S,当前类记为 C,S 对应的类或接口记为 I。- 若 S 不是数组类型,则把 S 传递给当前类 C 的类加载器进行加载,这个过程可能会触发其他的加载,这个过程一旦出现异常,则解析失败。
- 若 S 是数组类型,并且数组元素类型为对象,则 S 的描述符会形如[java/lang/String,按照第一条去加载该类型,如果 S 的描述符符合,则需要加载的类型就是 java.lang.String,接着有虚拟机生成一个代表此数组唯独和元素的数组对象。
- 若以上两个步骤没有出现异常,即 I 已经存在于内存中了,但是解析完成时还需要进行符号引用验证,确认 C 是否具备对 I 的访问权限。若不具备,则抛出 java.lang.IllegalAccessError 异常。
- 字段解析
首先将 CONSTANT_Fieldref_info 中的 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,即解析字段所在类或接口,若解析出现异常,则字段解析失败。如解析成功,则进行下面的解析步骤。假设该字段所属的类或接口标记为 C。- 如果 C 包含了字段的简单名和描述符与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- (字段解析对接口优先搜索)否则,如果 C 实现了接口,按照继承关系从下往上递归搜索各个接口和它的父接口,看是否存在相匹配的字段。存在,则返回直接引用,查找结束。
- 否则,如果 C 不是 Object 对象,按照继承关系从下往上递归搜索父类,看是否存在相匹配的字段。存在,则返回直接引用,查找结束。
- 否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。
- 类方法解析
首先将 CONSTANT_Methodref_info 中的 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,即解析方法所在的类或接口,若解析出现异常,则方法解析失败;如解析成功,则进行下面解析步骤。假设该方法所属的类标记为 C。- 如果在方法表中发现 CONSTANT_Class_info 中索引的 C 是一个接口而不是一个类,则抛出 java.lang.IncompatibleClassChangeError 异常。
- 否则,如果 C 中包含了方法的简单名和描述符与目标相匹配的字段,则返回这个方法的直接引用,查找结束。
- (方法解析对父类优先搜索)否则,在 C 的父类中递归搜索,看是否存在相匹配的方法,存在,则返回直接引用,查找结束。
- 否则,在 C 实现的接口列表及父接口中递归搜索,看是否存在相匹配的方法,存在,说明 C 是一个抽象类(没有实现该方法,否则,在第一步就查找成功),抛出 java.lang.AbstractMethodError 异常。
- 否则,查找失败,抛出 java.lang.NoSuchMethodError 异常。
- 若查找过程成功,则对方法进行权限验证,如果发现不具备对此方法的访问权限,则抛出 java.lang.lllegalAccessError 异常。
- 接口方法解析
首先将 CONSTANT_InterfaceMethodref_info 中的 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,即解析方法所在的类或接口,若解析出现异常,则方法解析失败;如解析成功,则进行下面解析步骤。假设该方法所属的类标记为 C。- 如果在方法表中发现 CONSTANT_Class_info 中索引的 C 是一个类而不是接口,则抛出 java.lang.IncompatibleClassChangeError 异常。
- 否则,如果 C 中包含了方法的简单名和描述符与目标相匹配的字段,则返回这个方法的直接引用,查找结束。
- 否则,在 C 的父接口中递归搜索,直到 Object 类,看是否存在相匹配的方法,存在,则返回直接引用,查找结束。
- 否则,查找失败,抛出 java.lang.NoSuchMethodError 异常。
- 若查找过程成功,不需要进行权限验证,因为接口方法为 public,不会抛出 java.lang.IllegalAccessError 异常。
初始化
类初始化是类加载过程的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制,到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。
初始化是执行类构造器<clinit>()的过程,
- 由编译器收集类中的所有类变量的赋值动作(如果仅仅只是声明,不会被收集)和静态语句块中的语句合并产生的,收集顺序按照语句在源文件中出现的顺序所决定;在静态语句块中只能访问定义在静态语句之前的变量;而对于定义在静态语句块之后的变量,可以进行赋值,但是不能够访问。
- 不需要显示调用父类构造器,虚拟机会保证在子类的
()方法执行之前,父类的 ()方法已经执行完毕,所以,第一个被执行的 ()方法的类肯定是 java.lang.Object。 - 父类中定义的静态语句块优先于子类的静态语句。
- 此方法对类和接口都不是必须的,若类中没有静态语句块和静态变量赋值操作,则不会生成
()方法。 - 接口会生成此方法,因为对接口的字段可以进行赋值操作。执行接口的
()方法不需要先执行父接口的 ()方法,只有在使用父接口的变量时,才会进行初始化;接口的实现类在初始化时也不会执行接口的 ()方法。 - 此方法在多线程环境中会被正确的加锁、同步。
使用
完成了初始化阶段后,我们就可以使用对象了,在程序中可以随意进行访问,只要类还没有被卸载。
卸载
GC 能够对方法区内无用对象进行回收,启动类加载的类型永远是可触及的,回收的是由用户自定义加加载器加载的类,具体内容等到 GC 部分再说。
接口的加载过程
接口的加载和类的加载是类似的,只是接口要初始化时并不会连带父接口一块初始化,只有在真正用到父接口时才会执行初始化。
定义类加载器和初始类加载器
我们知道不同类加载器加载的类位于不同的命名空间,它们之间是相互隔离的,这里说的隔离仅仅指它们存储位置隔离,并不是说一个自定义的类 A 使用了 java.util.List 类就会报错。
自定义的类 A 一般会使用系统类加载器加载,而 java.util.List 则会由启动类加载器加载,当加载类 A 时如果遇到了 java.util.List,会首先尝试通过系统类加载器加载,在它发现自己无法加载后,通过双亲委派模型交给父加载器加载。
如上图所示:
- A 是由系统类加载器加载的,因此系统类加载器是其定义类加载器兼初始类加载器;
- 系统类加载器加载过 java.util.List,因此是其初始类加载器;
- 启动类加载器实际加载了 java.util.List,因此是其定义类加载器。
QA
- 为什么下面的执行结果为 0?
说明类加载器在加载一个类时,父类的成员变量就算被覆盖,其存储空间依然还存在。1
2
3
4
5
6
7
8
9
10
11class A {
int a;
}
public class JavaTest extends A {
int a;
@Test
public void test() {
a = 1;
System.out.println(super.a);
}
} - 为什么下面的报错?
类的初始化阶段有一个细节:类初始化块不能访问定义在其之后的变量1
2
3
4
5
6
7public class JavaTest {
static {
i = 0;
System.out.println(i); // 报错
}
static int i = 1;
} - 为什么输出两个’A’?
当我们 new A()时,首先为 A 分配内存空间,此时 A 已经存在了,只是还未初始化,然后调用 A 的构造函数,A 的构造函数又隐式调用了父类的构造函数。
在父类构造函数中使用 this 调用 draw(),this 实际上指向了 a 对象,平常调用方法时 this 也是隐含的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22class B {
int a;
B() {
this.draw();
}
void draw() {
System.out.println("B");
}
}
class A extends B {
int a;
A() {
draw();
}
@Override
void draw() {
System.out.println("A");
}
public static void main(String[] args) {
A a = new A();
}
} - 为什么最后输出的 count2 为 0?这里有问题的应该是 static 变量的初始化和构造方法被调用的顺序,实际上构造方法是先被调用的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class SingleTon {
private static SingleTon singleTon = new SingleTon();
public static int count1;
public static int count2 = 0;
private SingleTon() {
count1++;
count2++;
}
public static SingleTon getInstance() {
return singleTon;
}
}
public class Test {
public static void main(String[] args) {
SingleTon singleTon = SingleTon.getInstance();
System.out.println("count1=" + singleTon.count1);
System.out.println("count2=" + singleTon.count2);
}
} - SingleTon singleTon = SingleTon.getInstance();调用了类的 SingleTon 调用了类的静态方法,触发类的初始化(主动引用)
- 类加载的时候在准备过程中为类的静态变量分配内存并初始化默认值 singleton=null count1=0,count2=0(准备)
- 类初始化,为类的静态变量赋值和执行静态代码快。singleton 赋值为 new SingleTon()调用类的构造方法(初始化)
- 调用类的构造方法后 count=1;count2=1
- 继续为 count1 与 count2 赋值,此时 count1 没有赋值操作,所有 count1 为 1,但是 count2 执行赋值操作就变为 0
- 读下面的类加载器应用代码,为什么输出 false?
myLoader 加载的类和虚拟机的默认类加载器(Bootstrap ClassLoader)将加载的类保存在不同的命名空间中,它们相当于不同的类。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public class JavaTest {
@Test
public void test() throws ClassNotFoundException, IllegalAccessException, InstantiationException {
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
String fileName = name.substring(
name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if(is == null) { return super.loadClass(name); }
try {
byte[] b = new byte[is.available()]; // 进行验证
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myLoader.loadClass("com.tallate.JavaTest")
.newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof com.tallate.JavaTest); // false?
}
} - JVM 怎么知道哪些类应该委托给父加载器加载?
每个类装载器都有一个 URLClassPath 对象用于保存类路径,在加载时会先在这个路径下查找该类,找不到再返回 null。 - 不同命名空间的类为什么能互相使用?
双亲委派模型中,一个类装载器总是会先委托父类去进行装载,所有这些被委托的类装载器都被称为初始类装载器,而实际装载的被称为定义类装载器,所有初始装载器间的类型是共享的 - 可以不可以自己写个 String 类?
不能,因为根据类加载的双亲委派机制,会去加载父类,父类发现冲突了 String 就不再加载了。 - Tomcat 的应用隔离原理是什么?
Tomcat 实现了两种隔离技术:用于线程隔离的线程池和用于代码隔离的 WebAppClassLoader。
前者不必赘述,对于后者,大家比较感兴趣的是 Tomcat 中对双亲委派模型的违背,因为它不是先委托父加载器去加载目标类,因为 Tomcat 一个进程可以运行多个 web 服务器,两个 web 项目中可能会出现两个声明完全一致的类,它们必须所处的命名空间必须隔离开,不然可能会发生一个项目启动后调到另一个项目中的代码的情况,这样就乱套了。