概述本篇学习笔记基于bilibili尚硅谷的jvm课程整理而来。
下面是之前在第一张看到的类加载子系统简图:
完整图如下:
其中类加载过程分为三个阶段:
加载阶段:使用引导加载器、扩展加载器、系统类加载器加载不同的类;链接阶段:分为验证、准备、解析三个环节;初始化阶段:静态变量的显式初始化等。
接下来就是进入内存层面:
方法区堆虚拟机栈:就是平时所说的”栈“,每个线程栈中的小结构称为栈帧,栈帧中大致分为LV(局部变量表)、OS(操作数栈)、DL(动态链接)、RA(方法返回地址)等结构;PC寄存器本地方法栈:存放本地方法接口的栈
前两者所有进程共有,后三者每个进程分别独有
后面就是执行引擎阶段了,这些内容后面再细学。
类加载器子系统作用下面是类加载器子系统的图解:
它的作用可以分为如下两项:
类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识。
加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
需要注意的是:ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
下面来看看常量池包含什么,比如现在有一个StackStruTest类:
public class StackStruTest {
public static void main(String[] args) {
int i = 2 + 3;
}
}
运行该类的main方法,在终端输入javap -v StackStruTest.class命令,找到Constant pool部分,就是常量池的信息:
常量池加载到内存后,就称为运行时常量池
Class Loader的角色可以这么理解:
class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。
class file 加载到 JVM 中,被称为DNA元数据模板,放在方法区。通过类的class调用getClassLoader方法,就可以获得加载此类的类加载器。
在.class文件 -> JVM -> 最终成为元数据模板的过程,就要一个运输工具,这个工具就是类装载器Class Loader(快递员)。
下面是图示:
类的加载过程例如下面的一段简单的代码
public class HelloLoader {
public static void main(String[] args) {
System.out.println("我已经被加载啦");
}
}
它的加载过程:
具体的流程图:
加载阶段这里的加载阶段是类加载阶段中的第一步,指狭义的加载
加载阶段主要做以下三个工作:
通过一个类的全限定名获取定义此类的二进制字节流(字节码文件);
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
关于上面第一步加载字节码文件的方式:
从本地系统中直接加载;通过网络获取,典型场景:Web Applet;从zip压缩包中读取,成为日后jar、war格式文件的基础;运行时计算生成,使用最多的是:动态代理技术;由其他文件生成,典型场景:JSP应用从专有数据库中提取.class文件,比较少见;从加密文件中获取,典型的防Class文件被反编译的保护措施。 链接阶段
链接阶段分为三个子阶段:验证、准备、解析。
验证 Verify目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。
在学习类加载器子系统的作用时,我们了解到.class文件开头会有一个文件标识。现在用 Binary Viewer 查看之前StackStruTest.class文件的内容:
开头部分的CA FE BA BE(咖啡宝贝)就是所有能被jvm识别的字节码的有效起址
如果出现不合法的字节码文件,那么将会验证不通过。
准备 Prepare为类变量分配内存并且设置该类变量的默认初始值,即零值。
类变量就是静态变量
例如下面这个程序:
public class HelloApp {
private static int a = 1; // 准备阶段为0,在下个阶段,也就是初始化的时候才是1
public static void main(String[] args) {
System.out.println(a);
}
}
上面的变量a在准备阶段会赋初始值0,而不是1。
需要注意的是:
这里不包含用final修饰的static的常量,该常量会在编译的时候被分配值,在准备阶段会显式初始化;
这里不会为实例变量分配初始化(因为此时还没有创建对象),类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
解析 Resolve将常量池内的符号引用转换为直接引用的过程。
事实上,解析操作往往会伴随着 JVM 在执行完初始化之后再执行。
符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的 class 文件格式中。
直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
解析动作主要针对类、接口、字段、类方法、接口方法、方法类型等。对应常量池中的ConSTANT Class info、ConSTANT Fieldref info、ConSTANT Methodref info等
初始化阶段 概述初始化阶段就是执行类构造器法()的过程.
类构造器方法不是类的构造器
此方法不需定义,是javac编译器自动收集类中的所有类变量(静态变量)的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有clinit方法。
案例 先行案例这里写一个案例来理解上面那句话。
首先写一个Test类,代码如下:
public class Test {
private static int num = 1;
public static void main(String[] args) {
System.out.println(num);
}
}
这里我们采用的工具是ByteCode Viewer。
将编译好的.class文件拖到ByteCode Viewer中打开,即可看到如下图所示界面:
可以看到右侧显示了三个方法:、main、。第三个就是我们要找的方法,里面定义了变量1并引入静态变量的过程。
假如我们再假如静态代码块呢?
static {
num = 2;
}
重新编译Test类并打开.class文件:
可以看到的是,静态变量一开始赋值为1,后面赋值为2,最后return。并且构造器方法中的指令是按语句在源文件中出现的顺序执行的。
整体理解现在我们再对上面的案例进行修改,结合之前所学知识进行一个梳理。
代码如下:
public class Test {
private static int num = 1;
static {
num = 2;
nums = 20;
}
private static int nums = 10;
public static void main(String[] args) {
System.out.println(num);
System.out.println(nums);
}
}
现在的情况是变量 nums 在 static 语句的下方进行定义,当然结果我们都已经知晓:nums变量的值是10。此时的方法是怎样定义的呢?
打开.class文件:
在链接阶段的准备环节我们了解到,类加载子系统会在该阶段对nums变量(类变量)初始化为零值,也就是0;在初始化阶段,类加载子系统会对上面的变量采用bipush指令进行入栈,再由putstatic指令设置类中静态变量的值,此时nums的值被设置为20(静态代码),再接着,nums的变量被设置为10(定义语句)。
可见的是:java允许在static代码后面初始化static变量,因为这些static变量会在链接阶段的准备环节被统一赋零值,所以不会出现语法错误
当然,如果在static代码块中引用后定义的类变量,则会报错:Illegal forward reference(非法的前向引用)。
还需要注意的地方()方法对应的其实就是类的构造器。在Test类中我们没有声明构造器,所以生成的是默认的空参构造器。
假如我们显示定义了构造器,比如下面的代码:
public class Test {
private int a = 1;
private static int c = 3;
public static void main(String[] args) {
int b = 2;
}
public Test() {
a = 10;
int e = 20;
}
}
那么方法的内容会是下面这样的:
关于继承idea有bytecode viewer的插件,安装后在上方工具栏view选项即可找到对应选项
若该类具有父类,JVM会保证子类的()执行前,父类的()已经执行完毕。
这里我们修改一下Test类的代码:
public class Test {
static class Father {
public static int A = 1;
static {
A = 2;
}
}
static class Son extends Father {
public static int b = A;
}
public static void main(String[] args) {
System.out.println(Son.b);
}
}
输出结果为 2。
加载Test的main方法时,会加载Son类,执行Son类的初始化,但是Son继承了Father,因此需要先执行Father的初始化,在Father类中,A首先被定义初始化为1,然后在static代码块中被赋值为2,随后才在Son类的定义中,B被赋值为Father.A的值,最后回到Test类,调用Son.B变量的引用并输出。
关于锁虚拟机会保证一个类的()方法在多线程下被同步加锁。通俗点理解就是:假如有两个线程同时访问某一个类的static代码,那么它们的访问将会被限制为同步访问,假如线程一访问static代码块但此时并没有退出,这个时候线程二也来访问,那么static代码块此时只会被线程一加载一次,而不会被线程二再次加载(因为是同步地访问)。
下面编写一个Test类,创造两个线程并访问staticClass类的static代码:
public class Test {
public static void main(String[] args) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "t 线程t1开始");
new staticClass();
}, "t1").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "t 线程t2开始");
new staticClass();
}, "t2").start();
}
}
class staticClass {
static {
if (true) {
System.out.println(Thread.currentThread().getName() + "t 初始化当前类");
while(true) {}
}
}
}
上面的代码,输出结果为:
可以看到:t2抢到了static代码的访问,那么t1此时就无法访问static代码。
类加载器的分类 概述JVM支持两种类型的类加载器,分别为:引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined Class Loader)。
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类Class Loader的类加载器都划分为自定义类加载器。
无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:引导类加载器、扩展类加载器、系统类加载器(应用程序类加载器)。
如下图所示:
这里的四者之间是包含关系,不是上层和下层,也不是子系统的继承关系。
我们通过一个ClassLoaderTest类,尝试获取不同的加载器:
public class ClassLoaderTest {
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
// 获取其上层的:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);
// 试图获取 根加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);
// 获取自定义加载器
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);
// 获取String类型的加载器
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);
}
}
得到的结果:
从结果可以看出根加载器无法直接通过代码获取,而目前用户代码所使用的加载器为系统类加载器。同时我们尝试获取String类型的加载器,发现是null,那么说明String类型是通过根加载器进行加载的,也就是说Java的核心类库都是使用根加载器进行加载的。
下面对其中类加载器进行更加详细的描述。
虚拟机自带的加载器(具体分类) 启动类加载器(引导类加载器,Bootstrap Class Loader)这个类加载使用C/C++语言实现的,嵌套在JVM内部(JVM的一部分)。它用来加载Java的核心库( JAVA_HOME / jre / lib / rt.jar、resources.jar 或 sun.boot.class.path路径下的内容),用于提供JVM自身需要的类并不继承自ava.lang.ClassLoader,没有父加载器。加载扩展类和应用程序类加载器,并指定为他们的父类加载器。出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
扩展类加载器(Extension Class Loader)其实就是加载核心包
Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。派生于Class Loader类父类加载器为启动类加载器从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的 jre / lib / ext 子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
应用程序类加载器(系统类加载器,App/System Class Loader)其实就是加载java平台的扩展包
javI语言编写,由sun.misc.LaunchersAppClassLoader实现派生于Class Loader类父类加载器为扩展类加载器它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载通过classLoader.getSystemclassLoader()方法可以获取到该类加载器
用户自定义类加载器其实就是加载classpath中指定的jar包还有目录中的类
在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
为什么要自定义类加载器?
隔离加载类修改类加载的方式扩展加载源防止源码泄漏
用户自定义类加载器实现步骤:
开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖1oadclass()方法,而是建议把自定义的类加载逻辑写在findclass()方法中在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。 理解案例
为了更好地理解各类加载器,我们创建一个ClassLoaderTest类,用于查看类加载器所能提供的信息。
首先查看引导类加载器所能加载的api的路径:
public class ClassLoaderTest {
public static void main(String[] args) {
System.out.println("*********启动类加载器************");
//获取BootstrapClassLoader能够加载的api的路径
URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL element : urLs) {
System. out . println(element . toExternalForm());
}
}
}
环境要求jdk8或以下,更高版本的Launcher类将不会对用户进行开放,防止用户查看
得到的结果
我们在上面的路径中选择任意一个,这里选择jsse.jar,找到该jar包,解压到任意一个地方,在文件夹中找到Provider.class:



