快速上手JVM系列(一)——进一步认识Java与JVM
快速上手JVM系列(二)——JVM类加载机制
快速上手JVM系列(三)——JVM内存结构与堆区GC机制
引言
Java 源代码首先需要使用 Javac 编译器编译成 .class 文件,然后由
类加载器(ClassLoader)
把描述类的数据从Class文件加载到内存,并对数据进行一系列处理,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
总结:本小节就是是对以下这个图的详细学习整理
文章目录
.Class文件详解
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步
字节码文件结构
Class 文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全都是连续的 0/1
Class 文件中的所有内容被分为两种类型:无符号数、表
- 无符号数 无符号数表示 Class 文件中的值,这些值没有任何类型,但有不同的长度。u1、u2、u4、u8 分别代表 1/2/4/8 字节的无符号数。
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型
附🛄Class字节码文件结构表
名称 | 类型 | 名称 | 说明 |
---|---|---|---|
魔数 | u4 | magic | 魔数,识别Class文件格式 |
版本号 | u2 | minor_version | 副版本号(小版本) |
u2 | major_version | 主版本号(大版本) | 2个字节 |
常量池集合 | u2 | constant_pool_count | 常量池计数器 |
cp_info | constant_pool | 常量池表 | n个字节 |
访问标识 | u2 | access_flags | 访问标识 |
索引集合 | u2 | this_class | 类索引 |
u2 | super_class | 父类索引 | 2个字节 |
u2 | interfaces_count | 接口计数器 | 2个字节 |
u2 | interfaces | 接口索引集合 | 2个字节 |
字段表集合 | u2 | fields_count | 字段计数器 |
field_info | fields | 字段表 | n个字节 |
方法表集合 | u2 | methods_count | 方法计数器 |
method_info | methods | 方法表 | n个字节 |
属性表集合 | u2 | attributes_count | 属性计数器 |
attribute_info | attributes | 属性表 | n个字节 |
对其中几个字段做出解释:
魔数
- 每个Class文件开头的4个字节的无符号整数称为魔数(Magic Number)
- 它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。即:魔数是Class文件的标识符。
- 魔数值固定为0xCAFEBABE,不会改变。意思是cafe babe(本应该是Baby,但是16进制没有y,程序员的浪漫~)
文件版本号
- 紧接着魔数的4个字节存储的是Class文件的版本号
- 不同版本的Java编译器编译的Class文件对应的版本是不一样的。目前,高版本的Java虚拟机可以执行由低版本编译器生成的Class文件,但是低版本的Java虚拟机不能执行由高版本编译器生成的Class文件。否则JVM会抛出java.lang.UnsupportedClassVersionError异常。(向下兼容)
常量池
- 常量池对于Class文件中的字段和方法解析也有着至关重要的作用
- 常量池表项中,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放
怎么去打开.class
文件
由于.class文件是一个16进制文件,EXE等二进制/16进制文件一般是不能被记事本等纯文本编辑打开的,否则会乱码
- 要想查看这些16进制到底长什么样子,去下载
UltraEdit
这个工具,用这个打开文件后,右击选择16进制编辑,文件的数据就全变成16进制显示了,因为没什么作用(都是数字),我就不做展示了
要想查看二进制文件的16进制的内容 ,这里给出两种方法的详细步骤
javap命令——javap 是是JDK自带的反汇编器
根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息
辨析反汇编和反编译
反汇编:把目标代码转为汇编代码的过程,也可说是把机器语言转为汇编语言代码,低级转高级的意思
反编译:显示.class文件的Java源代码
进入字节码文件目录,执行以下命令,即可在命令行得到字节码文件的信息
javap -v [文件名].class
jclasslib
工具会更方便,IDEA有这个插件,可以生成可视化字节码文件(阅读体验好一点)
第一步,idea设置中下载插件
第二步,打开字节码文件并且在IDEA中找到图中选项
你可以在这里看到字节码文件的全部信息
类加载器
任意一个类,都由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间
如图所示,在.class文件->JVM->最终成为元数据模板被执行引擎使用,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员的角色
类加载器的分类
JVM将类加载器划分为两大类
-
启动类加载器
只有一个(Bootstrap ClassLoader)
-
自定义类加载器(User-Defined ClassLoader)
**Java虚拟机规范定义:**将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器
系统提供了 3 种类加载器
1、启动类加载器(
Bootstrap ClassLoader
)
-
加载
JAVA_HOME/jre/lib
目录下的类库,如rt.jar
、resources.jar
、charsets.jar
等。当然通过配置-Xbootclasspath
参数可以指定这些jar包的加载路径Jdk11以后默认不安装Jre,所以你在本地文件库中找不到这个jar包(可以自行安装寻找)
-
不继承
java.lang.ClassLoader
,没有父加载器 -
这个加载器是 C++ 编写的,无法直接引用,随着 JVM 启动,是虚拟机自身的一部分
-
出于安全考虑,Bootstrap启动类加载器只加载包名为java,javax,sun等开头的类
2、扩展类加载器(
Extension ClassLoader
)
- 主要用于加载
lib/ext
目录下的 jar 包和 .class 文件。同样的,通过系统变量java.ext.dirs
可以指定这个目录 - 这个加载器是个 Java 类,继承自
URLClassLoader
(由启动类加载器加载进内存的) - 可以直接引用
3、应用程序类加载器(
Application ClassLoader
)也叫做系统类加载器
- 用户自定义的 Java 类的默认加载器,一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,用户编写的代码,会首先尝试使用这个类加载器进行加载
- 可以直接引用
自定义类加载器
开发人员可以通过继承抽象类
java.lang.ClassLoader
类的方式,实现自己的类加载器,以满足一些特殊的需求,例如:
- 隔离加载类
修改类加载的方式
扩展加载源
防止源码泄漏(加壳)
类加载器的关系如图所示,这些加载器并不是实际意义上的继承关系,也就是父加载器并不是它的父类,只是它自己的上一层加载器的意思
注
.class
文件存在于本地硬盘上,ClassLoader只负责class文件的加载,至于它是否可以运行,则由ExecutionEngine决定- 当
.class
文件被加载到JVM中,被称为DNA元数据模板,放在方法区
代码层面分析
jdk版本为11
获取应用程序类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
//jdk.internal.loader.ClassLoaders$AppClassLoader@2437c6dc
System.out.println(systemClassLoader);
获取拓展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
//jdk.internal.loader.ClassLoaders$PlatformClassLoader@58ceff1
System.out.println(extClassLoader);
获取引导类加载器
String类是由Bootstrap类加载器加载的,返回的是个null,是因为Bootstrap类加载器是由C++来实现的,java里面并没有一个class和它直接对应,所以返回null,所以当我们看到null值的时候,代表类加载器已经到头了
ClassLoader bootStrapClassloader = extClassLoader.getParent();
//null--->(Bootstrap ClassLoader)
System.out.println(bootStrapClassloader);
双亲委派机制
什么是双亲委派模型
双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。(父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码)
工作过程
总结:交给父类加载,不行才自己加载
- 当我们通过自定义类加载器加一个类的时候,会先去自定义类加载器的缓存当中找(如果已经加载过一遍了就会存到缓存当中),如果从缓存中找到了就直接返回,没找到就委托父加载器找。
- 应用类加载器收到委托后去它的缓存当中找,找到就返回,没找到就委托它的父加载器
- 扩展类加载器收到委托后去它的缓存当中找,找到就返回,没找到就委托它的父加载器
- 启动类加载器收到委托后去它的缓存当中找,找到就返回,没找到就委派它的子加载器去寻找class文件并加载
- 扩展类加载器收到委派命令后尝试去加载,找到就返回,没找到就委派它的子加载器去寻找class文件并加载
- 应用类加载器收到委派命令后尝试去加载,找到就返回,没找到就委派它的子加载器去寻找class文件并加载
- 自定义加载器收到委派命令后尝试去加载,找到就返回,没找到就报错(classnotfound)
为什么需要双亲委派?
-
避免类的重复加载
当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类
-
保护程序安全,防止核心API被随意篡改
- 如自定义类:java.lang.String(报错:阻止创建 java.lang开头的类)
双亲委派机制
双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现并不复杂
实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中
源码阅读:jdk11–>ClassLoader类–>loadClass方法(与Jdk8的实现方式有很大的不同)
@Override
protected Class<?> loadClass(String cn, boolean resolve)
throws ClassNotFoundException
{
// for compatibility reasons, say where restricted package list has
// been updated to list API packages in the unnamed module.
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
int i = cn.lastIndexOf('.');
if (i != -1) {
sm.checkPackageAccess(cn.substring(0, i));
}
}
//调用父加载器
return super.loadClass(cn, resolve);
}
如何破坏双亲委派机制
首先来学习一下ClassLoader这个类
主要方法
- loadClass() 就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中。
- findClass() 根据名称或位置加载.class字节码
- definclass() 把字节码转化为Class
如何破坏双亲委派机制
- 需要自定义一个类加载器,并且需要破坏双亲委派原则时,我们会重写loadClass方法
- 想定义一个自己的类加载器,并且要遵守双亲委派模型,那么可以继承ClassLoader,并且在findClass中实现你自己的加载逻辑
JDK1.2之后已不再提倡用户直接覆盖loadClass()方法,而是建议把自己的类加载逻辑实现到findClass()方法中,因为在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载
破坏双亲委派机制的案例
-
在双亲委派出现之前
由于双亲委派模型是在JDK1.2之后才被引入的,而在这之前已经有用户自定义类加载器在用了。所以,这些是没有遵守双亲委派原则的。
-
JNDI、JDBC等需要加载SPI接口实现类的情况
-
为了实现热插拔热部署工具
为了让代码动态生效而无需重启,实现方式时把模块连同类加载器一起换掉就实现了代码的热替换。
-
第四种时Tomcat等web容器的出现
-
第五种时OSGI、Jigsaw等模块化技术的应用。
案例一:Tomcat破坏双亲委派机制
Tomcat是web容器,那么一个web容器可能需要部署多个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的
问题
如果采用默认的双亲委派类加载机制,那么是无法加载不同版本的相同的类
解决方式
Tomcat破坏双亲委派原则,提供隔离的机制,为每个web容器单独提供一个WebAppClassLoader加载器
Tomcat的类加载机制:为了实现隔离性,优先加载 Web 应用自己定义的类,所以没有遵照双亲委派的约定,每一个应用自己的类加载器——WebAppClassLoader负责加载本身的目录下的class文件,加载不到时再交给CommonClassLoader加载,这和双亲委派刚好相反
案例二:JDBC破坏双亲委派机制
我们日常开发中,大多数时候会通过API的方式调用Java提供的那些基础类,这些基础类时被Bootstrap加载的。但是,调用方式除了API之外,还有一种SPI的方式,首先来看一下这两个方式
API
Application Programming Interface
大多数情况下,都是实现方来制定接口并完成对接口的不同实现,调用方仅仅依赖却无权选择不同实现。
SPI
Service Provider Interface
如果是调用方来制定接口,实现方来针对接口来实现不同的实现。调用方来选择自己需要的实现方
如典型的JDBC服务,我们通常通过以下方式创建数据库连接:
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mysql", "root", "1234");
在以上代码执行之前,DriverManager会先被类加载器加载,因为java.sql.DriverManager类是位于rt.jar下面的 ,所以他会被根加载器加载。
类加载时,会执行该类的静态方法。其中有一段关键的代码是:
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
这段代码,会尝试加载classpath下面的所有实现了Driver接口的实现类
问题
DriverManager是被根加载器加载的,那么在加载时遇到以上代码,会尝试加载所有Driver的实现类,但是这些实现类基本都是第三方提供的,根据双亲委派原则,第三方的类不能被根加载器加载。
解决方案
JDBC中通过引入ThreadContextClassLoader(线程上下文加载器,默认情况下是AppClassLoader)的方式破坏了双亲委派原则。
JDK11源码阅读 --> ServiceLoader类 --> load方法
@CallerSensitive
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return new ServiceLoader<>(Reflection.getCallerClass(), service, cl);
}
解释:第一行,获取当前线程的线程上下⽂类加载器 AppClassLoader,⽤于加载 classpath 中的具体实现类
类加载的过程(生命周期)
类加载概述
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在内存中创建一个java.lang.Class
对象用来封装类在方法区(元空间)内的数据结构
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段
(其中验证、准备、解析 3 个阶段统称为连接)
这七个阶段发生的顺序如图所示
加载阶段
🌮 注意辨析这个加载和类加载的区别,这个是类加载的第一个阶段
什么是需要开始类第一个阶段“加载”,虚拟机规范没有强制约束,这点交给虚拟机的具体实现来自由把控
加载阶段虚拟机需要完成以下 3 件事情
-
通过一个类的全限定名来获取定义此类的二进制字节流。(可以从硬盘上的字节码文件读取,也可以从网络中读取,或者动态代理生成等等)
.Class
文件并非特指某个存在于具体磁盘中的文件,而应当是一串二进制字节流,无论其以何种形式存在,包括但不限于磁盘文件、 网络、数据库、内存或者动态产生等 -
将这个字节流所代表的 静态存储结构 转化为方法区的
instanceKlass
规范并未说明Class对象位于哪里,HotSpot虚拟机将其放在了方法区中
-
在堆中生成一个代表这个类的
InstanceMirrorKlass
对象,作为方法区的这个类的各种数据的访问入口
为什么有了instanceKlass还需要有InstanceMirrorKlass?
主要是为了安全性考虑,jvm的开发者不希望直接暴露instanceKlass里面类的全部元信息,而且作为Java程序员也没有必要去知道这些信息,使用权限标识符去控制就已经足够了。
如果暴露了,那么黑客可以使用C++或者JNI来写一些漏洞或者外挂,来绕过Java本身的权限判断,就会有很大的安全问题了
注:静态属性是存储在堆里面,也就是挂载到InstanceMirrorKlass上面的
类被加载后,就进入连接阶段。连接就是将经读入到内存的类的二进制数据合并到虚拟机的运行时环境中去
连接阶段
连接阶段分为三个小步骤: 1、验证 2、准备 3、解析
验证(Verify)
- 这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
- 验证阶段主要检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证
注:
验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了
如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用
-Xverify:none
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间
准备(Prepare)
- 为类变量分配内存并且设置该类变量的默认初始值,即零值
- 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化
- 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中
解析(Resolve)
- 将常量池内的符号引用转换为直接引用的过程。
- 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
- 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
- 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info等
初始化阶段
-
初始化阶段就是执行类构造器方法
<clinit>()
的过程。 -
此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
-
构造器方法中指令按语句在源文件中出现的顺序执行。
-
<clinit>()
不同于类的构造器。(关联:构造器是虚拟机视角下的()) -
若该类具有父类,JVM会保证子类的
<clinit>()
执行前,父类的<clinit>()
已经执行完毕。 -
虚拟机必须保证一个类的
<clinit>()
方法在多线程下被同步加锁