0. 前言
感谢尚硅谷宋红康老师的讲授。
B站:https://www.bilibili.com/video/BV1PJ411n7xZ
本文的内容基本来源于宋老师的课件,其中有一些其他同学共享的内容,也有一些自己的理解内容。
本文包含以下内容
- 内存结构概述
- 类加载器与类的加载过程
- 类加载器分类
- ClassLoader的使用说明
- 双亲委派机制
- 其他
1. 内存结构概述
关于JVM的内存结构如下图:
对上图的介绍如下:
-
程序计数器
程序计数器(PC)是一块较小的内存空间,在当前线程执行字节码文件时,程序计数器就可以看作是一个行号指示器。通过改变计数器的值来选取下一个需要执行的字节码指令。由于Java是支持多线程操作,为了保证多个线程在轮流执行时还能够保持执行顺序的正确性,程序计数器是线程独享的数据区。
如果当前正在执行一个 Java 方法,那么程序计数器记录的是正在执行的虚拟机字节码指令的地址。如果执行的是一个 Native 方法,程序计数器的值为空(Undefined)。
当前区域也是唯一一个不会报错 OutOfMemoryError 的区域。
-
Java 虚拟机栈
虚拟机栈描述的是Java方法执行的线程内存模型,每个方法被执行的时候,Java虚拟机都会创建一个栈帧(Stack Frame)用来存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法从被调用到执行结束的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。该区域也是线程私有的一块区域。
-
Java 堆
这一块区域是用来存放对象实例的,被所有线程共享的一块内存区域。为了更好的对内存进行回收和分配,堆内存的设计使用了一种分代收集理论。这里不具体展开,后续在详细介绍。 -
本地方法【区/栈】
这部分与虚拟机栈的功能类似,区别就是虚拟机栈是服务于 Java 方法。本地方法栈服务于 Native 方法。 -
方法区
这部分内存是线程共享区域。它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。 -
运行时常量池(Runtime Constant Pool)
这部分区域属于方法区的一部分,用于存放编译期生成的各种字面量与符号引用。通过Javap -v XXX.class
就可以看到Constant Pool
区域的内容。 -
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
2. 类加载器与类的加载过程
- 类加载子系统负责加载 Class 文件,Class 文件在文件的开头有特定的标识。
- ClassLoader 只负责 Class 文件的加载,至于是否可以运行,则由 Execution Engine 决定。
- 加载的类信息存放于一块称为方法区的内存空间。除了类的本身信息以外,运行时常量池的信息也保存在方法区当中。
接下来聚焦单个步骤,先看加载部分(Loading)
- 通过类的全限定名称,获得定义此类的二进制字节流
- 将二进制字节流代表的静态存储结构转化为方法区的运行时数据结构
- 在内存当中生成一个
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
链接部分(Linking)
2.1 初始化过程
- 初始化阶段就是执行类的构造器方法
<clinit>()
的过程。 - 该方法不需要定义,是 javac 编译器自动搜集类中的所有类变量的赋值动作和静态代码块中的语句合并而来的。
<clinit>()
构造器方法中指令按语句在源文件中出现的顺序执行。<clinit>()
不同于类的构造器,<clinit>()
只负责 static 修饰的变量和代码块。而类的构造器在字节码文件中为<init>()
- 若该类具有父类,JVM会保证子类的
<clinit>()
执行前,父类的<clinit>()
已经执行完成。 - 虚拟机必须保证一个类的
<clinit>()
方法在多线程下被同步加锁,确保类的加载只加载一次。
下面通过 idea 的 jclasslib 插件查看<clinit>()
的内容。
private static int a = 1;
static {
a = 2;
}
上面的伪代码定义了一个类变量,通过静态代码块对其进行从新赋值。<clinit>()
的内容如下:
针对上述的第3点:<clinit>()
构造器方法中指令按语句在源文件中出现的顺序执行。可以参考下面的例子。
static {
a = 2;
// 在类加载的 linking 过程中 prepare过程会将 b 赋值为该类型的默认值 0
// 之后执行到 initial 过程时,<clinit>() 方法会从新赋值为 20
b = 20;
// 注意:在定义之前调用,报错:Illegal forward reference
System.out.println(b);
}
// 执行到此处会将 b 再次从新赋值为 10
private static int b = 10;
上面的伪代码中,先在静态代码块中为 b 赋值为 20,之后执行到变量 b 定义赋值的时候会再次从新赋值为 10。所以最后如果输出 b 的值是 10,而不是20。
3. 类加载器分类
JVM支持两种类型的加载器,引导类加载器(Bootstrap Class Loader)和自定义加载器(User Defined Class Loader)。虽然我们在谈起类加载器时,也会提到扩展类加载器(Extension Class Loader)和系统类加载器(System Class Loader),但是在 JVM 规范中认为所有继承了 ClassLoader
的类加载器都属于自定义类加载器。
注意:这里的四种类加载器之间是包含关系,不是子父类的继承关系,而是上下层的关系。
package com.hy;
public class TestClassLoader {
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
// result:sun.misc.Launcher$AppClassLoader@dad5dc
// 获取扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);
// result: sun.misc.Launcher$ExtClassLoader@a57993
// 获取 引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);
// result: null
// 获取用户自定义类的加载器
ClassLoader userDefinedClassLoader = TestClassLoader.class.getClassLoader();
System.out.println(userDefinedClassLoader);
// result: sun.misc.Launcher$AppClassLoader@dad5dc
// 获取Java核心类库的类加载器
ClassLoader classLoader2 = String.class.getClassLoader();
System.out.println(classLoader2);
// result : null
}
}
上了面的代码可以说明三个问题:
- 无法通过 Java 代码获取到
Bootstrap Class Loader
- 用户自定义的类是通过系统类加载器(AppClassLoader)进行加载,而 Java 的核心类库,比如
String
类,该类的加载是通过Bootstrap Class Loader
。 - 对比
userDefinedClassLoader
和systemClassLoader
可以发现类加载器是单例的。
3.1 扩展类加载器
- 由 Java 语言编写
- 属于ClassLoader 的派生类
- 父类加载器为引导类加载器
- 加载
jre/lib/ext
目录中的类库 - 若用户将自定义的
jar
包放在jre/lib/ext
目录下,也会由ExtClassLoader
进行加载
3.2 系统类加载器(应用程序加载器)
- 由 Java 语言编写
- 属于ClassLoader 的派生类
- 父类加载器为扩展类加载器
- 加载环境变量
ClassPath
或者系统属性java.class.path
指定的类。
4. ClassLoader的使用说明
ClassLoader的常用方法
获取 ClassLoader 的几种方式
5. 双亲委派机制
Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象。而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
工作原理:
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
例子1:自定义 Java 核心类库中的类
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("hello String");
}
}
/*
结果:
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application
*/
当我们创建了一个 java.lang.String
并且调用 main
方法时,会报错:在类 java.lang.String 中找不到 main 方法。这就是由于双亲委派机制的存在,在引导类加载器(BootstrapClassLoader)发现要加载的类为 java.lang
包下的类,就直接在 java 的核心API中寻找 java.lang.String
进行加载与执行,所以最终加载的 String
类并不是我们创建的 String
,而是 Java
核心类库中的 String
类。故而找不到 main
方法。
例子2:在 java.lang 包下创建非 Java 核心类库
package java.lang;
public class User {
public static void main(String[] args) {
System.out.println("hello User");
}
}
/*
结果:
java.lang.SecurityException: Prohibited package name: java.lang
*/
我们在 java.lang
包下创建一个 User
类,执行会报错:Prohibited package name: java.lang
。这说明 JVM 不允许我们在 java.lang
包下创建自己的类,这也是为了保护 Java 核心类库的安全——沙箱安全机制。
5.1 沙箱安全机制
自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载 jdk 自带的文件(rt.jar包中java\lang\String.class),报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 string 类。这样可以保证对 java 核心源代码的保护,这就是沙箱安全机制。
6. 其他
如何判断两个class对象是否相同?
在JVM中表示两个class对象是否为同一个类存在两个必要条件:
- 类的完整类名必须一致,包括包名。
- 加载这个类的 ClassLoader 的实例必须是同一个。
换句话说,在JVM中,即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。
对类加载器的引用:
JVM 判断一个类是用户自定义的,那么应该使用 AppClassLoader
来加载该类,同时会将类加载器的引用作为类的信息一起保存在方法区当中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的(动态链接部分会详细介绍)。
类的主动使用和被动使用:
Java程序对类的使用方式分为:主动使用和被动使用。
主动使用,又分为七种情况:
- 创建类的实例
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(比如:Class.forName(“com.atguigu.Test”))
- 初始化一个类的子类
- java虚拟机启动时被标明为启动类的类
- JDK 7 开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化
除了以上七种情况,其他使用 Java 类的方式都被看作是对类的被动使用,都不会导致类的初始化。也就是没有 <clinit>()
这个步骤。