JVM 系列(一)、 Java 类加载机制


1. JVM 概况介绍

1.1 JVM 是什么?

JVM 是 Java Virtual Machine( Java 虚拟机 )的缩写, JVM 是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。 Java 虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。

1.2 JRE / JDK / JVM的关系?

JRE ( Java RuntimeEnvironment, Java 运行环境),也就是 Java 平台。所有的 Java 程序都要在 JRE 下才能运行。普通用户只需要运行已开发好的 Java 程序,安装 JRE 即可。

JDK ( Java Development Kit ) 是程序开发者用来来编译、调试 Java 程序用的开发工具包。 JDK 的工具也是 Java 程序,也需要 JRE 才能运行。为了保持 JDK 的独立性和完整性,在 JDK 的安装过程中, JRE 也是 安装的一部分。所以,在 JDK 的安装目录下有一个名为 JRE 的目录,用于存放 JRE 文件。

JVM ( Java VirtualMachine, Java 虚拟机 ) 是 JRE 的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。 JVM 有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。 Java 语言最重要的特点就是跨平台运行。使用 JVM 就是为了支持与操作系统无关,实现跨平台。

1.3 JVM 执行程序的过程

1) 加载 .class 文件

2) 管理并分配内存

3) 执行垃圾收集

JRE ( Java 运行时环境 )是 JVM 构造的 Java 程序的运行环境,操作系统的一个应用程序或一个进程,也有他自己的运行的生命周期,也有自己的代码和数据空间。 JVM 在整个 JDK 中处于最底层,负责与操作系统的交互,用来屏蔽操作系统环境,提供一个完整的 Java 运行环境,因此也叫虚拟计算机。操作系统装入 JVM 是通过 JDK 中 Java .exe来完成,通过下面4步来完成 JVM 环境:

1) 创建 JVM 装载环境和配置

2) 装载 JVM.dll

3) 初始化 JVM.dll并挂界到 JNIENV ( JNI调用接口 ) 实例

4) 调用 JNIEnv 实例装载并处理 class 类。

2. 什么是类加载?

类的加载是指将类的 .class 文件中的二进制数据读取到内存中,将其放在运行时数据区的方法区内,然后在堆区中创建一个 Java .lang .class 对象,用来封装类在方法区内的数据结构。 类的加载的最终产品是位于堆区中的 Class 对象,该 Class 对象封装了类在方法区内的数据结构,并且向 Java 程序员提供类访问方法区内的数据结构的接口。

类加载器并不需要等到某个类被 “ 首次主动使用 ” 时再加载它, JVM 规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了 .class 文件缺失或存在错误,类加载器会在程序首次主动使用该类时报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。

  • 加载 .class 文件有以下几种方式:
  1. 从本地系统中直接加载

  2. 通过网络下载 .class 文件

  3. 从zip、jar等归档文件中加载 .class 文件

  4. 从专有数据库中提取 .class 文件

  5. 将 Java 源文件动态编译为 .class 文件

3. 类的生命周期

类的生命周期

上面图示中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下解析阶段可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(也称为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常是相互交叉地融合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,对 Java 来说,绑定分为静态绑定和动态绑定:

  • 静态绑定:即前期绑定,在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。针对 Java ,简单的可以理解为程序编译期的绑定。 Java 当中的方法只有 final,static,private 和 构造方法 是前期绑定的。
  • 动态绑定:即晚期绑定,也叫运行时绑定。在运行时根据具体对象的类型进行绑定。在 Java 中,几乎所有的方法都是晚期绑定的。

3.1 加载

查找并加载类的二进制数据,加载是 类加载 过程 的 第一个阶段,在加载阶段 虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取其定义的二进制字节流。

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  3. 在 Java 堆中生成一个代表这个类的结构信息包括字段、方法、常量池等

加载阶段 中 获取类的二进制字节流的动作 是 可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,而且 Java 堆中创建一个 Java.lang .class 类的对象,这样便可以通过该对象访问方法区中的这些数据。

关于 Java 中的加载器,我们在下面的 类加载器 章节中会介绍到

3.2 验证

验证确保被用户加载的类的正确性,是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验工作:

  1. 文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以 0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。

  2. 元数据验证:对字节码描述的信息进行语义分析(注意:对比 Java c编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如:这个类是否有除了 Java .lang.Object 之外的父类。

  3. 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。

  4. 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验,确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响的。如果所引用的类经过反复验证,那么可以考虑采用 -Xverify:none 参数来关闭大部分的验证措施,以缩短虚拟机类加载的时间。

3.3 准备

为类的 静态变量 分配内存,并将其初始化为默认值。

准备阶段 是正式为类变量分配内存并设置类变量初始化值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

1.这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。

2.这里所设置的初始化值在通常情况下都是类型默认的零值(如:0、0L、null、false等),而不是被在 Java 代码中被显式地赋予的值。

假设一个类变量的定义为:


public static int value = 1;

那么变量 value 在准备阶段过后的初始值为 0,而不是 1 ,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为1的 public static 指令是在程序编译后,存放于类构造器 <clinit()> 方法之中的,所以把value赋值为1的动作将在初始化阶段才会执行。

下表列出了 Java 中所有基本数据类型以及 reference 类型的默认零值:

数据类型 默认零值
int 0
long 0L
short (short)0
char ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

这里还需要注意如下几点:

  • 默认赋予零值
  1. 对于类变量(static)和全局变量的基本类型来说,如果不显式地对其赋值而直接使用,系统会为其赋值默认的零值。
  2. 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
  3. 如果在数组初始化时没有对数组中的各个元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
  • 使用前需要显式地赋值
  1. 对于局部变量的基本类型来说来说,在使用前必须显式地为其赋值,否则编译时不通过。
  2. 只被 final 修饰的常量既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋值默认零值。
  • 声明时即被显式赋值
  1. 同时被 static 和 final 修饰即 常量 属性,必须在声明的时候就为其显式地赋值,否则编译时不通过,常量属性在准备阶段该常量就会被初始化为其对应的值。

假设上面的类变量value被定义为:

public static final int value = 1;

编译时 Javac 将会为 value 生成常量属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为1。我们可以理解为 static final常量在编译期就将其结果放入了调用它的类的常量池中。

3.4 解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。前面说解析阶段可能开始于初始化之前,也可能在初始化之后开始,虚拟机会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析(初始化之前),还是等到一个符号引用将要被使用前才去解析它(初始化之后)。
对同一个符号引用进行多次解析请求是很常见的事情,虚拟机实现可能会对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标示为已解析状态),从而避免解析动作重复进行。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的CONSTANT_Class_info(类)、CONSTANT_Fieldref_info(字段)、CONSTANT_Methodref_info(方法)、CONSTANT_InterfaceMethodref_info(接口方法)四种常量类型。

  1. 类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
  2. 字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束,查找流程如下所示
/**
 * 对于解析的测试
 */
public class StaticTest {
    public static void main(String[] args) {
        System.out.println(Child.m);

    }
}

class Child extends Father{
    static{
        System.out.println("执行 Child 类静态语句块");
    }
}

class Father extends Super {
    public static int m = 123;

    static {
        System.out.println("执行 Father 静态语句块");
    }
}

class Super{
    public static int m = 456;
    static{
        System.out.println("执行了 Super 类静态语句块");
    }
}

运行结果如下:
执行了 Super 类静态语句块
执行 Father 静态语句块
123

如果我们将 Father 类中的 m 定义的那行注释掉,则会输出如下结果
执行了 Super 类静态语句块
456

从上面的代码我们可以得出查找流程图如下:

查找流程图

这里我们便可以分析如下:static 变量发生在静态解析阶段,也即是初始化之前,此时已经将字段的符号引用转化为了内存引用,也便将它与对应的类关联在了一起,由于在子类中没有查找到与m相匹配的字段,那么m便不会与子类关联在一起,因此并不会触发子类的初始化。
最后需要注意:理论上是按照上述顺序进行搜索解析,但在实际应用中,虚拟机的编译器实现可能要比上述规范要求的更严格一些。如果有一个同名字段同时出现在该类的接口和父类中,或同时在自己或父类的接口中出现,编译器可能会拒绝编译。如果对上面的代码做些修改,将Super改为接口,并将Child类继承Father类且实现Super接口,那么在编译时会报出如下错误:


public class StaticTest2 {
    public static void main(String[] args) {
        System.out.println(Child.m);
//                               ^ 报错原因是对 m 的引用不明确。因为 Father 中的变量 m 和 Super 中的变量 m 都匹配
    }
}

class Child extends Father implements Super{
    static{
        System.out.println("执行 Child 类静态语句块");
    }
}

class Father  {
    public static int m = 123;

    static {
        System.out.println("执行 Father 静态语句块");
    }
}

interface Super {
     int m = 456;

}
  1. 类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。
  2. 接口方法解析:与类方法解析步骤类似,知识接口不会有父类,因此,只递归向上搜索父接口就行了。

3.5 初始化

在初始化阶段,是类加载过程的最后一步,此阶段才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值。而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源。

具体地说,在初始化阶段,会执行类的构造器<clinit>()方法。这个方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并而来的。<clinit>()方法中的代码会按照它们在源代码中的顺序依次执行。

需要注意的是,<clinit>()方法是由JVM保证线程安全的,在多线程环境下,一个类只会被初始化一次。JVM会确保在多线程环境下,类的初始化是按照线程安全的顺序进行的。

因此,初始化阶段是执行类构造器<clinit>()方法的过程,其中包括了类变量的赋值和静态语句块的执行。这些操作会按照源代码中的顺序依次执行,并由JVM保证线程安全。

  1. <clinit>()方法是由编译器自动收集类中的所有 类变量 的赋值动作和 静态语句块 中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
  • 静态语句块中只能访问到定义在静态语句块之前的变量,
  • 定义在静态语句块之后的变量,在前面的静态语句中可以赋值,但是不能访问。
  1. <clinit>()方法与实例构造器<init>方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是 Java .lang.Object。
  2. <clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  3. 接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口与类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  4. 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

/**
 * 初始化阶段测试代码
 */
public class ClinitTest {

    public static void main(String[] args) {
        System.out.println(ClinitChild.b);
        //a=2
    }

}

class ClinitFather{
    public static int a = 1;
    static{
        a = 2;
    }
}

class ClinitChild extends ClinitFather{
    public static int b = a;
}

执行上面的代码,会打印出2,也就是说b的值被赋为了2。
我们来看得到该结果的步骤。

  1. 在准备阶段为类变量分配内存并设置类变量初始值,这样a和b均被赋值为默认值0,
  2. 在调用方法时给他们赋予程序中指定的值。当我们调用ClinitChild.b时,触发ClinitChild的方法,
  3. 根据规则2,在ClinitChild的方法执行之前,要先执行完其父类Father的方法,
  4. 根据规则1,在Father执行方法时,需按static语句或static变量赋值操作在代码中出现的顺序来执行相关的static语句
  5. 当触发执行Father的方法时,会先将a赋值为1,
  6. 执行Father中static语句块中语句,将a赋值为2,
  7. 执行Child类的方法,这样便会将b的赋值为2.

如果我们颠倒一下Father类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。
很明显是根据规则1,执行Father的方法时,根据顺序先执行了static语句块中的内容,后执行了“public static int a = 1;”语句。
代码如下:


/**
 * 初始化阶段测试代码
 */
public class ClinitTest {

    public static void main(String[] args) {
        System.out.println(ClinitChild.b);
        //a=1
    }

}

class ClinitFather{
    static{
        a = 2;
    }
    public static int a = 1;
}

class ClinitChild extends ClinitFather{
    public static int b = a;
}

另外,在颠倒二者的顺序之后,如果在static语句块中对a进行访问(比如将a赋给某个变量),在编译时将会报错,因为根据规则1,它只能对a进行赋值,而不能访问。
代码如下:


/**
 * 初始化阶段测试代码
 */
public class ClinitTest {

    public static void main(String[] args) {
        System.out.println(ClinitChild.b);
    }

}

class ClinitFather{
    static{
        a = 2;
        System.out.println(a);
//                         ^  在编译时将会报错,因为根据规则1,它只能对a进行赋值,而不能访问
    }
    public static int a = 1;
}

class ClinitChild extends ClinitFather{
    public static int b = a;
}
  • 初始化阶段 JVM 主要对类变量进行初始化。在 Java 中对类变量进行初始化设定有两种方式:

1.声明类变量时指定初始值

2.使用静态代码块为类变量指定初始值

  • JVM 初始化步骤

1.假如这个类还没有被加载和连接,则程序先加载并连接该类。

2.假如该类的直接父类还没被初始化,则先初始化其直接父类。

3.假如类中有初始化语句,则系统一次执行这些初始化语句。

  • 类初始化时:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种情况:

1.创建类的实例,也就是new的方式

2.访问某个类或接口的静态方法,或者对该静态变量赋值

3.调用类的静态方法

4.反射(如 Class.forName(“com.hello.Test”))

5.初始化某个类的子类,则其父类也会被初始化

  1. Java 虚拟机启动时被标明为启动类的类( Java Test ),直接使用 Java .exe 命令来运行某个类

为这个新生对象在 Java 堆中分配内存空间,其中 Java 堆分配内存空间的方式主要有以下两种

  1. 指针碰撞:
    • 分配内存空间包括开辟一块内存和移动指针两个步骤,把指针向空闲对象移动与对象占用内存大小相等的距离
    • 非原子步骤可能出现并发问题, Java 虚拟机采用 [CAS](https://www.xilidou.com/2018/02/01/ Java -cas/) 配上失败重试的方式保证更新操作的原子性。
  2. 空闲列表:
    • 分配内存空间包括开辟一块内存和修改空闲列表两个步骤,虚拟机维护一个列表,记录可用的内存块,分配给对象列表中一块足够大的内存空间
    • 非原子步骤可能出现并发问题, Java 虚拟机采用 [CAS](https://www.xilidou.com/2018/02/01/ Java -cas/) 配上失败重试的方式保证更新操作的原子性。

采用何种方式要基于虚拟机堆内存是否规整,这又由采用的垃圾收集器是否带有压缩整理功能决定,所以类似Serial、ParNes等收集器时采用指针碰撞,而采用CMS这种基于Mark-Sweep算法的收集器时采用空闲列表。

3.6 生命周期的结束

在如下几种情况下, Java 虚拟机将结束生命周期:

1.执行类 System.exit() 方法。

2.程序正常执行结束。

3.程序在执行过程中遇到了异常或者错误而异常终止。

4.由于操作系统出现错误而导致 Java 虚拟机进程终止。

3.7 总结

类的生命周期

4. 类加载器

类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在 Java 虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals、isAssignableFrom、isInstance等方法的返回结果,也包括了使用instanceOf关键字对对象所属关系的判定结果。

站在 Java 虚拟机的角度来讲,只存在两种不同的类加载器:

加载器名称 说明
启动类加载器 它使用C++实现(这里仅限于Hotspot,也就是 JDK 1.5之后默认的虚拟机,有很多其他的虚拟机是用 Java 语言实现的),是虚拟机自身的一部分。
所有其他的类加载器 这些类加载器都由 Java 语言实现,独立于虚拟机之外,并且全部继承自抽象类 Java .lang .class Loader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。

站在 Java 开发人员的角度来看,类加载器可以大致划分为以下三类

加载器名称 加载的类的路径 说明
启动类加载器
Bootstrap ClassLoader
1. 加载存放在** JDK \ JRE \lib下的jar( JDK 代表 JDK 的安装目录,下同),
2. 被
-Xbootclasspath*参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的 Java .开头的类均被Bootstrap ClassLoader加载)
启动类加载器是无法被 Java 程序直接引用的。
扩展类加载器
Extension ClassLoader
1. 加载** JDK \ JRE \lib\ext下的jar
2. 由
Java .ext.dirs*系统变量指定的路径中的所有类库(如 Java x.开头的类)
该加载器由sun.misc.Launcher$ExtClassLoader实现,开发者可以直接使用扩展类加载器
应用程序类加载器
Application ClassLoader
加载用户类路径(ClassPath)所指定的类 该类加载器由sun.misc.Launcher$AppClassLoader来实现,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为 JVM 自带的ClassLoader只是懂得从本地文件系统加载标准的 Java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:

  1. 在执行非置信代码之前,自动验证数字签名。
  2. 动态地创建符合用户特定需要的定制化构建类。
  3. 从特定的场所取得 Java class,例如数据库中和网络中。

我们接着看下面一段代码

public class ClassLoaderTest {
    public static void main(String[] args) {
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        System.out.println(loader);
        System.out.println(loader.getParent());
        System.out.println(loader.getParent().getParent());

        /**
         * 运行后输出结果
         * sun.misc.Launcher$AppClassLoader@18b4aac2
         * sun.misc.Launcher$ExtClassLoader@61bbe9ba
         * null
         */
    }
}

从上面的结果可以看出,并没有获取到 ExtClassLoader 的父Loader, 原因是 Bootstrap Loader(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null.
这几种类加载器的层次关系如下图所示:

类加载器层次图

注意:这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的。

5. 类的加载

5.1 JVM 类加载机制

  1. 全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖和引用的其他Class也将由该类加载器负责载入,除非显式使用另外一个类加载器载入。

  2. 父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。

  3. 缓存机制,缓存机制将会保证所有加载过的CLass都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区,这就是为什么修改了Class后,必须重启 JVM ,程序的修改才会生效。

5.2 类的加载方式

  1. 命令行启动应用的时候由 JVM 初始化加载。

  2. 通过Class.forName()方法动态加载

  3. 通过ClassLoader.loadClass()方法动态加载

如下代码:


public class LoaderTest {
    public static void main(String[] args) {
        try{
            //以下三种加载方式,会有不同的输出结果:
            ClassLoader loader = LoaderTest .class .getClassLoader();
            System.out.println(loader);
            //使用ClassLoader.loadClass()来加载类,不会执行初始化块
            loader.loadClass("com.hello. JVM .Test2");

            //使用Class.forName()来加载类,默认会执行初始化块
            Class.forName("com.hello. JVM .Test2");

            //使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块
            Class.forName("com.hello. JVM .Test2", false, loader);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}


public class Test2 {
    static {
        System.out.println(" Test2 静态初始化块执行了!");
    }
}

5.3 Class.forName() 和 ClassLoader.loadClass()区别

加载方式 说明
Class.forName() 将类 .class 文件加载到 JVM 中之外,还会对类进行解释,执行类中的static块。
相当于进行了,加载,链接,初始化三个步骤
ClassLoader.loadClass() 只干了一件事,就是将 .class 文件加载到 JVM 中,不会执行static中的内容,只有在newInstance才回去执行static块。
只进行了加载的部分
Class.forName(name,initialize,loader) 带参函数可控制是否加载static块,并且只有调用了newInstance()方法才会调用构造函数,去创建类的对象。

6. 双亲委派模型

双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

双亲委派机制:

1.当AppClassLoader 加载一个Class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。

2.当ExtClassLoader 加载一个Class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader 去完成。

3.如果BootStrapClassLoader加载失败(例如$ Java _HOME/ JRE /lib里未查找到该class),会使用ExtClassLoader 来尝试加载。

4.如果ExtClassLoader 也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常 ClassNotFoundException。

6.1 ClassLoader 源码分析


public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
}

protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
        // 首先判断该类型是否已经被加载
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
            try {
                if (parent != null) {
                     //如果存在父类加载器,就委派给父类加载器加载
                    c = parent.loadClass(name, false);
                } else {
                //如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
                    c = findBootstrapClass0(name);
                }
            } catch (ClassNotFoundException e) {
             // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

6.2 双亲委派模型意义

  1. 系统类防止内存中出现多份同样字节码。
  2. 保证 Java 程序安全稳定运行。

7. 自定义类加载器

通常情况下,我们都是直接使用系统类加载器。但有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输 Java 类字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要我们自定义类加载器来实现。自定义类加载器一般都是继承自ClassLoader类,从上面对loadClass方法来分析来看,我们只需要重写findClass方法即可。下面我们通过一个示例来演示自定义类加载器的流程:

// Test.  Java 
//先编写 要加载的类,并编译class 文件
import    Java .io.PrintStream;

public class Test
{
  static
  {
    System.out.println(" MyClassLoaderTest 静态初始化块执行了!");
  }
}


// MyClassLoaderTest.  Java 
import    Java .io.*;

/**
 * 测试类加载器
 */
public class MyClassLoaderTest {
    public static void main(String[] args) {
        // 设置获取文件的路径
        MyClassLoader classLoader = new MyClassLoader("/Users/hnbian/Documents/GitHub/  Java Demos/ JVM Test/target/classes/com/hnbian/loader/");
        try {
            Class<?> clazz = classLoader.loadClass("com.hnbian.loader.Test");
            Object object = clazz.newInstance();
            System.out.println(clazz.getClassLoader());
            System.out.println(clazz.getClassLoader().getParent());
            System.out.println(clazz.getClassLoader().getParent().getParent());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

}

/**
 * 自定义 类加载器
 */
class MyClassLoader extends ClassLoader {
    private String root;

    public MyClassLoader(String root) {
        this.root = root;
    }

    public MyClassLoader() {
    }


    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] loadClassData(String className) {
        System.out.println("loadClassData");
        String fileName = root + File.separatorChar
                + className.replace('.', File.separatorChar) + " .class ";
        InputStream ins = null;
        ByteArrayOutputStream out = null;
        try {
            ins = new FileInputStream(fileName);
            out = new ByteArrayOutputStream();
            int bufferSize = 1024;
            byte[] buffer = new byte[bufferSize];
            int length = 0;
            while ((length = ins.read(buffer)) != -1) {
                out.write(buffer, 0, length);
            }

            return out.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                ins.close();
                out.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    public String getRoot() {
        return root;
    }

    public void setRoot(String root) {
        this.root = root;
    }
}

自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,并未对class文件进行加密处理,因此没有解密过程。这里有几点需要注意:

  1. 这里传递的文件名需要时类的全限定性名称,即 com.hello. JVM .Test2 格式的,因为defineClass方法是按照这种格式进行处理的。

  2. 最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。

  3. 这类Test类本身可以被AppClassLoader类加载,因此我们不能把com.hello. JVM .Test2 .class 放在类路径下。否则,由于双亲委派机制的存在,会直接导致该类由AppClassLoader加载,而不会通过我们自定义的类加载器去加载。

8. Java 对象的内存布局

对象头Header 实例数据Instance Data 对齐填充padding
对象头用户存储对象的元数据信息,包括对象运行时数据和类型指针
- Mark Word (对象运行时数据)部分存储的对象自身的运行时数据如哈希值、GC 分代年龄、锁状态标识、线程持有锁、偏向锁ID、偏向锁时间戳
- 类型指针指向它的类元数据指针,用于判断对象属于哪个类型的实例
实例数据存储的是真正有效的数据,如各种字段内容,父类定义的变量会出现在子类定义的变量前面 对齐填充部分仅仅起到占位符的作用,并非必须

9. Java 对象访问方式

当我们在堆上创建一个对象实例后,就要通过虚拟机栈中的reference类型数据来操作堆上的对象,现在主流的访问方式有两种(HotSpot虚拟机采用的是直接指针访问)

  1. 句柄访问对象
    使用句柄访问对象。即reference中存储的对象句柄的地址,而句柄中包含了对象实例数据与类型数据的具体地址信息,相当于二级指针。

    句柄访问对象
  2. 直接指针访问对象
    即reference中存储的就是对象地址,相当于一级指针

    直接指针访问
    • 对比
      对比 句柄访问对象 直接指针访问对象
      垃圾回收 当垃圾回收移动对象时,reference中存储的地址是稳定的,不需要修改,仅需要修改对象句柄的地址 垃圾回收时需要修改reference中存储的地址
      访问效率 效率低因为需要进行二次指针定位 效率高,因为只进行了一次指针定位,节省了时间开销,HotSpot采用了该实现方式

文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
 上一篇
Intellij IDEA修改默认 Target bytecode version Intellij IDEA修改默认 Target bytecode version
1. 问题现象使用Intellij IDEA 新建Java项目,使用jdk1.8版本,设置好Target bytecode version 为1.8,Language level为8。但是,每次新加一个module,所有的设置都变成默认的1
2019-12-23
下一篇 
Spark中parallelize函数和makeRDD函数的区别 Spark中parallelize函数和makeRDD函数的区别
我们知道,在Spark中RDD的创建方式大概可以分为三种: 从集合中创建RDD, 从外部存储中创建RDD, 从其他RDD创建 而从集合中创建RDD,Spark主要提供了两种函数:parallelize 和 makeRDD。我们可以先看看
2019-12-18
  目录