JVM 之 类加载过程

前言

这篇主要来记录 JVM 的类加载过程以及双亲委派机制。多数网上能找到的我就不复述了,侧重记录我的疑惑以及了解。(以及类加载过程和类对象创建过程的不同)

类加载过程

加载 : 将类加载到虚拟机中。这个加载类加载 需要注意区别 ,加载只是类加载的一个过程而已。

验证 : 这一阶段的目的是为了确保 Class 文件字节流中包含的信息符合当前虚拟机的要求,不会危害到虚拟的安全。验证大概分为三部分:

  1. 文件格式验证。验证Class 类的字节流是否以魔术开头 Caffe babe 、版本号是否在当前虚拟机处理的范围。
  2. 元数据验证。验证加载的类是否有父类,父类是否被final修饰(修饰不能被继承),如果父类是否为抽象类等,如果是需要实现抽象的方法。
  3. 字节码验证。来确保被加载的类方体体不会威胁到虚拟机。

准备 : 给类变量分配内存并且赋予初始值。注意在准备阶段的初始值为零值。如下:

1
public static int value = 123;

在准备阶段后value被附上了初始值(零值)0,只有在初始化阶段 value 才赋予123。

解析 : 这里算是一个映射,将位于常量池的符号引用转变为直接引用。

符合引用:是一组以符号来描述引用的目标。和内存布局是无关的。

直接引用:可以理解为指向目标的指针、相对偏移量。和内存布局是相关的。

初始化: 注意在准备阶段的时候,已经为类变量赋上一次系统要求的初始值这些动作都是虚拟机主导和控制的,到了初始化阶段,虚拟机才执行Java程序代码,主要是执行类中的静态块以及静态变量的赋值操作。

双亲委派机制

在上述类加载过程中的第一步加载将类加载到虚拟机是由类加载器来执行的。类的加载器大致有如下:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用类加载器(Application ClassLoader)。它们之间采用了双亲委派机制互相配合。

1.启动类加载器BootstrapClassLoader:

是嵌在JVM内核中的加载器,该加载器是用C++语言写的,主要负载加载JAVA_HOME/lib下的类库,启动类加载器无法被应用程序直接使用。

2.扩展类加载器Extension ClassLoader:

该加载器器是用JAVA编写,且它的父类加载器是Bootstrap,是由sun.misc.Launcher$ExtClassLoader实现的,主要加载JAVA_HOME/lib/ext目录中的类库。开发者可以这几使用扩展类加载器。

3.统类加载器App ClassLoader:

系统类加载器,也称为应用程序类加载器,负责加载应用程序classpath目录下的所有jar和class文件。它的父加载器为Ext ClassLoader。

当虚拟机接受到一个类的加载请求时,它将这个加载请求委派给父类加载器进行加载,只有当父类加载器自己无法完成加载请求时,子类加载器才会尝试自己加载。

为什么需要双亲委派机制

那么系统为什么要采用这个机制呢?都让一个加载器来加载不行吗?

1.避免重复加载。当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。
2.安全性。如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义类型,这样会存在非常大的安全隐患。

可否实现自己的 String 类

看到网上有一道面试题:自己定义了String类可以被加载进来吗?

这个需要看你自定义的类所在的包是哪里了。如果你的String类是在自定义的包下,是没问题的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package test;

/**
* Created by jintx on 2019/2/4.
*/
public class String {

public void sayHello(){
System.out.println("hello");
}

}


public static void main(java.lang.String args[]) throws Exception{
String string = new String();
string.sayHello();

}

如果是在String 创建在 java.lang下 就会报错。

如何破解双亲委派机制

findClass()用于写类加载逻辑、loadClass()方法的逻辑里如果父类加载器加载失败则会调用自己的findClass()方法完成加载,保证了双亲委派规则。

1、如果不想打破双亲委派模型,那么只需要重写findClass方法即可 2、如果想打破双亲委派模型,那么就重写整个loadClass方法

默认的loadClass方法是实现了双亲委派机制的逻辑,即会先让父类加载器加载,当无法加载时才由自己加载。这里为了破坏双亲委派机制必须重写loadClass方法,即这里先尝试交由System类加载器加载,加载失败才会由自己加载。它并没有优先交给父类加载器,这就打破了双亲委派机制。如果System类加载器加载失败,则会交给findClass 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class TestClassLoaderN extends ClassLoader {

private String name;

public TestClassLoaderN(ClassLoader parent, String name) {
super(parent);
this.name = name;
}

@Override
public String toString() {
return this.name;
}

// 重写了loadClass,如果loadClass失败了那就调用findClass方法
//默认情况下,loadClass是会使用双亲委派机制,所以要打破,需要重写该方法。
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
Class<?> clazz = null;
ClassLoader system = getSystemClassLoader();
try {
clazz = system.loadClass(name);
} catch (Exception e) {
// ignore
}
if (clazz != null)
return clazz;
clazz = findClass(name);
return clazz;
}

@Override
public Class<?> findClass(String name) {

InputStream is = null;
byte[] data = null;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
is = new FileInputStream(new File("d:/Test.class"));
int c = 0;
while (-1 != (c = is.read())) {
baos.write(c);
}
data = baos.toByteArray();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
is.close();
baos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return this.defineClass(name, data, 0, data.length);
}

public static void main(String[] args) {
TestClassLoaderN loader = new TestClassLoaderN(
TestClassLoaderN.class.getClassLoader(), "TestLoaderN");
Class clazz;
try {
clazz = loader.loadClass("test.classloader.Test");
Object object = clazz.newInstance();
} catch (Exception e) {
e.printStackTrace();
}
}
}

类对象创建过程

你想输入的替代文字

①类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

②分配内存:类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

③初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

④设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

⑤执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

这里说一下我疑惑:

Q:为什么类创建过程中还要初始化零值呢?这个不是在类加载过程不是已经初始化过了吗?

A:注意类对象创建过程的初始化零值针对的是实例字段,而类加载是针对类变量的零值赋予。

Q:怎么类创建过程中的执行init方法和类加载过程的初始化重复了?

A:注意对象创建过程执行的是方法,而类初始化执行的是方法。方法是实例变量的初始化、实例代码块的执行、构造函数的执行。而方法是是由类变量的初始化、静态代码块组成。

其中先执行实例变量的初始化和实例代码块的(两者是执行顺序看代码的顺序),然后再执行构造函数。

执行的顺序就是类变量和静态代码块在代码上的顺序执行。