一.了解JVM
1.1什么是JVM
JVM是Java Virtual Machine(Java虚拟机)的缩写,是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟计算机功能来实现的,JVM屏蔽了与具体操作系统平台相关的信息,Java程序只需生成在Java虚拟机上运行的字节码,就可以在多种平台上不加修改的运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。
虚拟机可以分为系统虚拟机和程序虚拟机
- 系统虚拟机是一种虚拟化技术,它模拟整个计算机硬件环境,包括处理器、内存、存储和外部设备。它的主要目标是在单个物理计算机上同时运行多个操作系统。每个虚拟机都具有独立的操作系统和应用程序,就像在不同的物理计算机上运行一样。系统虚拟机的例子包括VMware、VirtualBox和Hyper-V。
- 程序虚拟机是一种虚拟化技术,它仅模拟计算机上的一个单独的应用程序运行环境,而不是整个操作系统。它的主要目标是提供一个独立的运行环境,使应用程序能够在不同的操作系统上运行而无需修改。程序虚拟机通常用于解决跨平台兼容性的问题,模拟一个应用程序的运行环境,使应用程序能够跨平台运行。常见的程序虚拟机包括Java虚拟机(JVM)。
1.2JRE/JDK/JVM
- JDK(Java Development Kit) 是整个JAVA的核心,包括了Java运行环境JRE(Java Runtime Envirnment),一堆Java工具(javac/java/jdb等)和Java基础的类库(即Java API)。
- JRE(Java Runtime Environment,Java运行环境), 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,主要包括 Java 虚拟机(JVM)、Java 基础类库(Class Library)。JRE是Java运行环境,并不是一个开发环境,所以没有包含任何开发工具(如编译器和调试器)
- JVM(Java Virtual Mechinal)是JRE的一部分,叫做JAVA虚拟机,它是整个java实现跨平台的最核心的部分,负责解释执行并运行字节码文件(.class)。
1.3JVM的功能
1.4常见的JVM
1.5JVM的整体结构
二.字节码文件
2.1字节码文件的组成
这里重点看一下基本信息、常量池和方法
2.1.1基本信息
Magic魔数
主副版本号
主版本号不兼容会引发以下错误:
2.1.2常量池
字节码文件中常量池的作用:避免相同的内容重复定义,节省空间。
我们看下面一个案例
public class ConstantPoolTest2 {
public static final String a1 = "abc";
public static final String a2 = "abc";
public static final String abc = "abc";
public static void main(String[] args) {
ConstantPoolTest2 constantPoolTest = new ConstantPoolTest2();
}
}
首先看字段,我们看a1的常量值实际上指向了常量池中的8号
这实际上是常量池中的一个String_info,但是里面并没有存储真正的字符串字面量,而是一个10号常量的索引
10号常量存储的就是真正的字符串字面量
为什么字段不直接存储常量池里字符串字面量的索引?而要先找String_info,然后再找字符串字面量
因为字节码文件被加载的时候会把常量池中String_info加载到字符串常量池中,所以String_info需要存一份引用
那为什么String_info里不直接存储字符串字面量,而是存一份索引?
字段中的变量名也可能要引用常量池里的字符串字面量,如果用String_info存储字符串字面量则不合理,因为字段中的变量名并不是一个字符串的对象
符号引用
2.1.3方法
字节码中的方法区域是存放字节码指令的核心位置,字节码指令的内容存放在方法的Code属性中。
注意:
- iload是将值复制一份,局部变量表中的值还在
- istore是将操作数栈的栈顶弹出,并放入局部变量表的某个位置,此时操作数栈的值是不存在的
i=i++的执行流程
iinc x by y:将局部变量表的x号位置增加y
很明显操作数栈中的值一直都是0,只要istore,那么局部变量表中的值也会被覆盖,所以最终i为0
i=++i的执行流程
由于iinc指令在iload指令之前,所以i的最终值是1
2.2字节码文件常用工具
2.2.1 Java 字节码的字节码查看器:javap -v
2.2.2 阿里arthas
Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,大大提升线上问题排查效率。
详细请看官网:简介 | arthas (aliyun.com)
dashboard显示当前系统的实时数据面板,-i刷新实时数据的时间间隔 (ms),-n刷新实时数据的次数
dump已加载类的字节码文件到特定目录:
dump -d 特定目录 类的全限定名(即包名+类名)
反编译已加载类,得到源码:
jad 类的全限定名(即包名+类名)
三.类的生命周期
3.1加载阶段
- 类加载器ClassLoader根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息
- 类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的方法区中。生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。
- 同时,Java虚拟机还会在堆中生成一份与方法区中数据类似的java.lang.Class对象。作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中)
对于开发者来说,只需要访问堆中的Class对象而不需要访问方法区中所有信息。这样Java虚拟机就能很好地控制开发者访问数据的范围。
加载阶段过后,字节码文件就已经被读取到了内存中,并且会创建一个代表该类的Class
对象。
3.2连接阶段
- 第一个环节是验证,验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。
- 准备阶段为静态变量(static)分配内存并设置初始值。准备阶段只会给静态变量赋初始值,而每一种基本数据类型和引用数据类型都有其初始值。但注意如果字段是
final
修饰的基本类型或者字符串常量(经过编译器优化),则会在准备阶段直接赋予最终值。 - 解析阶段主要是将常量池中的符号引用替换为直接引用。 符号引用就是在字节码文件中使用编号来访问常量池中的内容。 直接引用不再使用编号,而是使用内存中地址进行访问具体的数据。
3.3初始化阶段
初始化阶段会执行字节码文件中 clinit 部分的字节码指令。
<clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
以下几种方式会导致类的初始化:
- 访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化。
- 调用Class.forName(String className),注意 Class<?> forName(String name, boolean initialize, ClassLoader loader) 这个构造器可以指定不初始化
- new一个该类的对象时。
- 执行Main方法的当前类。
我们可以在JVM设置里添加 -XX:+TraceClassLoading 参数,这样可以看到有哪些类被加载
通过测试以下程序可以发现
类被加载时不一定会被初始化,而是在需要初始化的时候才初始化。
类加载但不初始化的情况
- Class<?> forName(String name, boolean initialize, ClassLoader loader) 这个构造器可以指定不初始化
- ClassLoader的loadClass(String className);方法也只会加载并编译某类,并不会对其执行初始化
- 类名.class
面试题分析
clinit指令在以下情况下不会出现
- 无静态代码块且无静态变量赋值语句。
- 有静态变量的声明,但是没有赋值语句。如 public static int a;
- 静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。如 public final static int a = 1;
当出现继承关系时
- 直接访问父类的静态变量,不会触发子类的初始化。
- 子类的初始化clinit调用之前,会先调用父类的clinit初始化方法。
如果把new B02()去掉会怎么样呢?
数组的创建不会导致数组中元素的类进行初始化。
四.类加载器
类加载器的设计JDK8和8之后的版本差别较大,JDK8及之前的版本中默认的类加载器有如下几种:
4.1启动类加载器(引导类加载器,根加载器,Bootstrap)
启动类加载器是最底层的类加载器,是虚拟机的一部分,它是由C++语言实现的,无法在Java代码中直接获取到,且没有父加载器(这里形容的是父子关系的层次结构,并非继承关系),也没有继承java.lang.lassLoader类。
它主要负责加载由系统属性 “sun.boot.cass.path” 指定的路径下的核心类库(即<JAVA_HOME>/jre/lib),包含了Object、String、Math、装箱类型、日期类等核心类
public class Demo {
public static void main(String[] args) {
//Bootstrap 引导类加载器
//打印为null,是因为Bootstrap是C++实现的
ClassLoader classLoader = Object.class.getClassLoader();
System.out.println(classLoader);
//查看引导类加载器会加载那些jar包
URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
for (URL urL : urLs) {
System.out.println(urL);
}
}
}
4.2扩展类加载器(ExtClassLoader)
- 全类名:sum.misc.Launch$ExtClassLoader,Java语言实现。
- 扩展类加载器的父加载器是Bootstrap启动类加载器 (注:不是继承关系)
- 扩展类加载器负责加载<JAVA_HOME>\jre\lib\ext目录下的类库。
注: JDK9是jdk.internal.loader.ClassLoaders$PlatformClassLoader类
4.3应用程序类加载器(系统类加载器,AppClassLoader)
- 全类名: sun.misc.Launcher$AppClassLoader
- 系统类加载器的父加载器是ExtClassLoader扩展类加载器 (注:不是继承关系)
- 系统类加载器负责加载 classpath环境变量所指定的类库,包括项目中自己编写的类文件以及第三方jar包中的类文件,是用户自定义类的默认类加载器。
注: JDK9是jdk.internal.loader.ClassLoaders$AppClassLoader类
4.4三者之间的关系
- AppClassLoader的父加载器是ExtClassLoader
- ExtClassLoader的父加载器是Bootstrap
- Bootstrap是根加载器
- AppClassLoader和ExtClassLoader都实现了抽象类ClassLoader
三者之间是没有继承关系的,而是一种组合关系
- 抽象类ClassLoader有一个字段parent, AppClassLoader和ExtClassLoader通过设置该字段引用,指定父加载器。(是组合关系)
- AppClassLoader 的parent指向 ExtClassLoader
- ExtClassLoader 的parent指向 null,(null的原因是因为Bootstrap是C++实现的,通过代码中逻辑判断来转向Bootstrap)
4.5双亲委派机制
双亲委派机制指的是:自底向上查找是否加载过,再由顶向下进行加载。
双亲委派机制的好处
- 避免类的重复加载:当父加载器已经加载该类时,就没有必要子加载器再加载一遍,保证被加载类的唯一性。
- 避免核心类篡改:通过双亲委派机制,让顶层的类加 载器去加载核心类,避免恶意代码 替换JDK中的核心类库,比如 java.lang.String,确保核心类 库的完整性和安全性。
我们可以看看下面的程序
这里我自定义了一个String类,并且类的全限定名和JDK内置的String类完全一样。但运行结果如下:
异常提示:在类 java.lang.String 中找不到 main 方法。
why?这里程序在执行时识别的是src中的java.lang.String,src就是classpath,因此会调用系统加载器。但根据双亲委派机制,系统加载器会逐层委派上层加载器来加载此类,在委派的时候,最上层的加载器是根加载器,即根加载器优先级最高。而根加载器能够在jre\lib\rt.jar包中找到一个重名的java.lang.String(即jdk自带的String),因此根据双亲委派最终会由最顶层的根加载器来执行jdk自带的java.lang.String。显然,jdk中的String并没有main()方法,因此报错找不到main()
4.6自定义类加载器
Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类。
loadClass默认实现如下:
再看看loadClass(String name, boolean resolve)函数,双亲委派机制的核心代码就位于这里
从上面代码可以明显看出,loadClass(String, boolean)函数即实现了双亲委派模型!整个大致过程如下:
- 首先,检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
- 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name, false);)或者是调用bootstrap类加载器来加载。
- 如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。
整个调用过程如下图所示
在自定义ClassLoader的子类时候,我们常见的会有两种做法:
- 重写loadClass()方法:这样会打破双亲委派模型,可能会导致一些Java的核心类无法加载,不建议重写
- 重写findClass()方法:是在双亲委派模型的框架下进行小范围的改动,建议重写
实战代码如下:
public class MyClassLoader extends ClassLoader {
private String root;
@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) {
String fileName = root + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = Files.newInputStream(Paths.get(fileName));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
classLoader.setRoot("D:\\");
Class<?> testClass = null;
try {
//需要为com.字节码文件.classloader.A 格式,否则defineClass方法会抛异常
testClass = classLoader.loadClass("com.字节码文件.classloader.A");
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}
注意
- 实例中的 class文件不能放在classpath下,否则根据双亲委派机制会被应用程序类加载器加载,而不会通过我们自定义类加载器来加载。
- 这里传递的文件名需要是类的全限定性名称,即 com.字节码文件.classloader.A 格式的,因为 defineClass 方法是按这种格式进行处理的。
4.7Launcher类
AppClassLoader和ExtClassLoader是Launcher的静态内部类,在程序启动时JVM会创建Launcher对象,Launcher构造器会同时会创建扩展类加载器和应用类加载器。