类加载和字节码技术
Java字节码(类文件的结构)
包含字节码指令,编译期处理: 源代码编译为字节码文件过程中做的优化处理;字节码文件要运行要经过类加载器运行,类加载器加载到JVM中,就可以执行文件中的字节码指令,执行需要其中执行引擎中的解释器解释执行,解释过程中会对热点代码进行运行期的编译处理
1.类文件结构
1.1.魔数
不同文件有自己的魔数信息,用来表示文件的类型,可理解为文件的扩展名信息
1.2.版本
类文件中十六进制的 00 34 (十进制: 52) 表示 Java 8
1.3.常量池
…
2.字节码指令
javap工具
自己分析类文件结构太麻烦了,Oracle 提供了 javap 工具来反编译 class 文件Oracle 提供了 javap 工具来反编译 class 文件,使用命令:javap -v HelloWorld.class
跟之前讲的常量池结构表分析类似
2.3.图解方法执行流程
分析方法中字节码的执行流程
例子代码:
/**
* @author tiga
* @date 2021-10-27
* 演示:字节码指令,操作数栈,常量池的关系
*/
public class Demo3_1 {
public static void main(String[] args) {
// 数字比较小的数值,不存放在常量池中,而是字节码指令存在一起
int a = 10;
// Short.MAX_VALUE: 32767,Short范围内的数值跟字节码存储在一起
// Short.MAX_VALUE+1,当超过Short类型整数的最大值,则将该变量的值存储在常量池中
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}
结论:
- 数字比较小的数值,不存放在常量池中,而是字节码指令存在一起
- 当超过Short类型整数的最大值,则将该变量的值存储在运行时常量池中
1.常量池载入运行时常量池
当这段Java代码被执行时,会由Java虚拟机的类加载器把main方法所在的类进行类加载(把类文件中的字节数据读取到内存中)操作,其中常量池的数据被放在运行时常量池中(属于方法区的组成部分,但是比较特殊,所以单独分出来分析
将来的方法引用,成员变量引用,具体数值,都可以到运行时常量池中找
2.方法字节码载入方法区
方法的字节码指令会存入到方法区
3.Main线程开始运行,分配栈帧内存
Main方法准备开始运行,运行之前会启动主线程,给主线程分配栈帧内存
根据方法的字节码结构:(stack=2 ,locals=4 ) 栈帧中包含操作数栈有2个空间(每个空间宽度是4个字节),局部变量表中有4个槽位在内:
栈帧: 局部变量表,操作数栈,动态链接,方法出口
4.执行引擎开始执行main方法中的字节码指令,开始运行
bipush 10:将一个byte的数字压入操作数栈()~
- 将一个 byte 压入操作数栈,(操作数栈的宽度为4个字节,因此byte的数入栈时,其长度会补齐 4 个字节),类似的指令还有:
- sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
- ldc 将一个 int 压入操作数栈
- ldc2_w 将一个 long 压入操作数栈(操作数栈的宽度为4个字节,因此long的数入栈时,分两次压入,long 是 8 个字节)
- 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池
2.13.synchronized 关键字原理
public class Demo3_13 {
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("ok");
}
}
反编译查看字节码文件的信息:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #2 // new Object
3: dup
4: invokespecial #1 // invokespecial <init>:()V
7: astore_1 // lock引用 -> lock
8: aload_1 // <- lock (synchronized开始)
9: dup
10: astore_2 // lock引用 -> slot 2
11: monitorenter // monitorenter(lock引用)
12: getstatic #3 // <- System.out
15: ldc // <- "ok
17: invokevirtual #5 // invokevirtual println:
(Ljava/lang/String;)V
20: aload_2 // <- slot 2(lock引用)
21: monitorexit // monitorexit(lock引用)
22: goto 30
25: astore_3 // any -> slot 3
26: aload_2 // <- slot 2(lock引用)
27: monitorexit // monitorexit(lock引用)
28: aload_3
29: athrow
30: return
Exception table:
from to target type
12 22 25 any
25 28 25 any
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 lock Ljava/lang/Object;
StackMapTable: ...
MethodParameters: ..
注意:方法级别的 synchronized 不会在字节码指令中有所体现
4.类加载阶段
4.1 加载阶段
Java类编译成字节码后,运行通过加载器把类的字节码加载到方法区中,方法区中底层通过c++数据结构的instanceKlass描述Java类,Java程序不能直接访问instanceKlass,中间要转换过程,instanceKlass数据结构中有一些重要的field:
-
_java_mirror 即 java 的类镜像,起到作为c++的数据结构与Java对象的桥梁作用,Java对象要通过类镜像(
xxx.class
)访问instanceKlass,例如对 String 来说,就是 String.class(相当于instanceKlass数据结构的一个镜像,类镜像和互相持有对方的指针),作用是把 klass 暴 露给 java 使用 -
_super 即当前Klass的父类
-
_fields 即Klass成员变量
-
_methods 即方法
-
_constants 即常量池的引用
-
_class_loader 即哪个类加载器加载的当前Klass
-
_vtable 虚方法表的构造方法的入口地址
-
_itable 接口方法表
-
如果这个类还有父类没有加载,则先触发父类的加载。
-
加载和链接可能是交替运行的,并不是顺序运行的
通过Person类和Person对象表示实例对象,类对象和instanceKlass的关系
类的字节码被加载到方法区(元空间中),加载的过程会在堆内存中生成一个java_mirror的类镜像(Person.class对象),该类镜像持有了instanceKlass区域内存的指针地址,instanceKlass里的java_mirror也存有Person.class的内存地址;以后通过new对象生成的Person实例,每个实例对象都有自己的对象头(占16个字节),其中8个字节对应class对象的内存地址,若想通过Person对象获取它的class信息,它会通过class地址找到Person.class(类对象/类镜像),然后间接通过类对象到元空间找到instanceKlass地址,就可以通过调用类的方法获取类的相关信息
注意:
- instanceKlass 这样的【元数据】是存储在方法区(1.8 后的元空间内),但 _java_mirror 是存储在堆中
- 可以通过前面介绍的 HSDB 工具查看
4.2 链接
4.2.1 验证
验证类的字节码是否符合 JVM规范,安全性检查,阻止不合法的类继续运行
4.2.2 准备
- 为 static 变量分配空间,设置默认值,例如: 给静态的int类型变量分配四个字节的空间,默认值为0
- jdk1.8 后静态变量存储在堆空间中,和类对象存储在一起,跟在类对象后面
- 早期jdk1.6之前,静态变量存储在instanceKlass后面(方法区)
- static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
static int a;
static int b = 10;
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶 段完成
static final int c = 20;
static final String d = "hello";
- 如果 static 变量是 final 的,但属于引用类型(new对象),那么赋值也会在初始化阶段完成
static final e = new Object();
4.2.3.解析
类的加载都是懒惰的,没用到的类不会主动加载
将常量池中的符号引用(类,方法,属性)解析为直接引用:
/**
* 解析的含义
*/
public class Load2 {
public static void main(String[] args) throws ClassNotFoundException,IOException {
ClassLoader classloader = Load2.class.getClassLoader();
// loadClass 方法只会导致类C的加载不会导致类C的解析和初始化,从而导致类D也不会加载,解析,初始化
Class<?> c = classloader.loadClass("cn.itcast.jvm.t3.load.C");
// new C();
System.in.read();
}
}
class C {
D d = new D();
}
class D {
}
类D未解析的情况:
ClassLoader classloader = Load2.class.getClassLoader(); // loadClass 方法只会导致类C的加载不会导致类C的解析和初始化,从而导致类D也不会加载,解析,初始化 Class<?> c = classloader.loadClass("cn.itcast.jvm.t3.load.C");
在类C常量池里,但是类C不知道类D在常量池的位置,还是个未经解析的类,仅仅只是个符号,因为类D还未加载和解析
加载类D的情况:
new C();
类D的符号引用变成真实的内存地址,可以知道类D的信息的位置
4.3 初始化
< init()> V 方法
初始化即调用 < cinit>()V ,虚拟机会保证这个类的『构造方法』的线程安全。
发生的时机
概括得说,类初始化是【懒惰的】
类初始化的时机:
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时,即 触发该类的初始化
- 子类初始化,如果父类还没初始化,会引发父类的初始化
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new 会导致初始化
不会导致类初始化的情况:
- 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化,在类的链接的准备阶段就完成
- 类对象.class 不会触发初始化,在类加载时就创建类镜像
- 创建该类类型的数组不会触发初始化
- 类加载器的 loadClass 方法
例子:
package com.tiga.jvm;
/**
* @author tiga
* @create 2021-10-31 18:27
*/
public class Load3 {
public static void main(String[] args) throws ClassNotFoundException {
// 1. 访问静态常量(基本类型和字符串)不会触发初始化
System.out.println(B.b);
// 2. 访问类对象.class 不会触发初始化
System.out.println(B.class);
// 3. 创建该类的数组不会触发初始化
System.out.println(new B[0]);
// 4. 不会初始化类 B,但会加载 B、A
ClassLoader cl = Thread.currentThread().getContextClassLoader();
cl.loadClass("com.tiga.jvm.B");
// 5. 不会初始化类 B,但会加载 B、A
ClassLoader c2 = Thread.currentThread().getContextClassLoader();
Class.forName("com.tiga.jvm.B", false, c2);
//会初始化的情况
// 1. 首次访问这个类的静态变量或静态方法时,会初始化
System.out.println(A.a);
// 2. 子类初始化,如果父类还没初始化,会引发
System.out.println(B.c);
// 3. 子类访问父类静态变量,只触发父类初始化
//子类访问父类的静态常量,两个类都不会初始化
System.out.println(B.a);
// 4. 会初始化类 B,并先初始化类 A
Class.forName("com.tiga.jvm.B");
}
}
class A{
static int a = 0;
//子类访问父类的静态常量,两个类都不会初始化
//static final int a1 = 1;
static {
System.out.println("a init");
}
}
class B extends A{
//static int a = 1;
static final double b = 5.0;
static boolean c = false;
static {
System.out.println("b init");
}
}
4.4.练习
从字节码分析,使用 a,b,c 这三个常量是否会导致 E 初始化:
包装类型的变量是在类初始化阶段赋值,不是在链接-准备阶段赋值
所以访问变量c会导致E初始化
public class Load4 {
public static void main(String[] args) {
System.out.println(E.a);
System.out.println(E.b);
System.out.println(E.c);
}
}
class E {
public static final int a = 10;
public static final String b = "hello";
// 包装类型的变量是在类初始化阶段赋值,不是在链接-准备阶段赋值
public static final Integer c = 20;
}
练习:
典型应用 - 懒惰初始化的单例模式懒汉式:
package com.tiga.jvm;
/**
* @author tiga
* @create 2021-10-31 21:55
*/
public class Load9 {
public static void main(String[] args) {
//访问类的静态方法不会导致静态内部类的初始化,因为没用到getInstance方法
//Singleton.test();
//第一次使用到静态内部类的静态方法就会触发静态内部类的加载,链接,初始化
Singleton.getInstance();
//第二次不会初始化类
Singleton.getInstance();
}
}
class Singleton{
public static void test() {
System.out.println("test");
}
private Singleton() {
}
//静态内部类可以访问外部类的资源
//线程安全可以保障: 静态内部类在初始化的过程中,静态代码块和静态变量赋值是由类加载器保证线程安全性的
private static class LazyHolder{
static {
System.out.println("LazyHolder init");
}
//在LazyHolder静态内部类的初始化时候对该静态常量进行赋值操作,把单例对象创建出来
private static final Singleton SINGLETON = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.SINGLETON;
}
以上的实现特点是:
- 懒惰实例化
- 初始化时的线程安全是有保障的
5.类加载器
双亲委派加载模式: 先由下到上询问,后由上到下加载
jdk中的类加载器有层级关系,以jdk1.8为例:
最上级为启动类加载器,每个类加载器各管一块,只加载指定目录下的所有类,出了指定目录,其他类不由该类负责
双亲委派加载模式:
类加载器加载类也有层级关系,比如当应用层序类加载器加载类的时候会先委托上级类加载器看是否有加载过,没有加载过再委托上上级类加载器,若上两级类加载器都没加载器才能轮到应用程序类加载器加载
Bootstrap ClassLoader 类加载器不是Java代码写的是c++代码写的,Java代码无法直接访问,所以获取启动类加载器显示为null
5.1 启动类加载器
可以通过配置虚拟机参数将自己写的类交由启动类加载器加载
java -Xbootclasspath/a:.com.tiga.jvm.Load5_1
-Xbootclasspath
: 表示设置启动类加载器加载的类路径/a:.
: 表示给启动类路径追加指定路径,在不改变原有启动类加载的目录下
5.3 双亲委派模式
所谓的双亲委派,就是指调用类加载器的 loadClass 方法时,查找类的规则。
注意:这里的双亲,翻译为上级似乎更为合适,因为它们并没有继承关系
双亲委派类加载源码分析:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查该类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2. 有上级的话,委派上级,递归调用上级的 loadClass
c = parent.loadClass(name, false);
} else {
// 3.委托启动类加载器到JAVA_HOME/jre/lib 下找 H 这个类,内部调用的是本地方法(c++实现)
BootstrapClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
// 4. 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载
c = findClass(name);
// 5. 记录耗时
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
类加载执行流程:
sun.misc.Launcher$AppClassLoader
// 1 处, 开始查看已加载的类,结果没有sun.misc.Launcher$AppClassLoader
// 2 处,委派上级sun.misc.Launcher$ExtClassLoader.loadClass()
sun.misc.Launcher$ExtClassLoader
// 1 处,查看已加载的类,结果没有sun.misc.Launcher$ExtClassLoader
// 3 处,没有上级了,则委派BootstrapClassLoader
查找BootstrapClassLoader
是在JAVA_HOME/jre/lib
下找 H 这个类,显然没有sun.misc.Launcher$ExtClassLoader
// 4 处,调用自己的findClass
方法,是在JAVA_HOME/jre/lib/ext 下找 H 这个类,显然没有,回到sun.misc.Launcher$AppClassLoader
的 // 2 处- 继续执行到
sun.misc.Launcher$AppClassLoader
// 4 处,调用它自己的 findClass 方法,在 classpath 下查找,找到了
5.4 线程上下文类加载器
虽然 DriverManager 本身是启动类加载器加载的,但是其内部的ServiceLoader使用的是线程上下文类加载器,内部将应用程序类加载器赋值给线程上下文类加载器,ServiceLoader中的load方法实际使用的就是线程上下文类加载器完成类加载破坏了双亲委派机制,没用到启动类加载器加载mysql驱动,所以 DriverManager 默认还是用应用程序类加载器
5.5 自定义类加载器
定义自定义类加载器的场景:
1)想加载非 classpath 随意路径中的类文件
2)都是通过接口来使用实现,希望解耦时,常用在框架设计
3)这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
步骤:
继承 ClassLoader 父类
要遵从双亲委派机制,重写 findClass 方法 注意不是重写 loadClass 方法,否则不会走双亲委派机制
在findClass方法中读取类文件的字节码
调用父类的 defineClass 方法来加载类
类加载器的使用者调用该类加载器的 loadClass 方法,能实现类的加载了
不同的类加载器加载同一个类,加载的结果类不是相同的,因为不同类加载器是相互隔离的,不会产生冲突