Java jvm基础篇
jvm基础01
1.jvm介绍
2.字节码文件介绍
3.字节码工具
4.类的生命周期
5.双亲委派机制
6.三种打破双亲委派机制方法
jvm介绍
1.什么是jvm
一份源码-hell.java-javac编译
成为字节码文件-class文件
->java虚拟机 -机器码文件能够被计算执行
在运行时将字节码转换为机器码执行。
就是java虚拟机的作用
2.即时编译
-为什么java需要即时编译
cpp-编译和连接-机器码
java-class-jvm(即使编译)-机器码
再性能上不如cpp-
优点
跨平台性-只需要class满足jvm规范 那么在任何操作系统都能被使用
将字节码转机器码-
例如 一个函数-经常被执行-就将该机器码存储在内存中再次调用直接拿取
无需转换
3.jvm功能概述
1.解释和运行-对字节码中的指令转换为机器码运行
2.内存管理-自动为对象方法-等分片内存 -自动的垃圾回收机制 回收不使用对象
3.即使编译 对热点代码进行优化
4.虚拟机版本

虚拟机历史

字节码文件详解
1.java虚拟机组成
字节码->加载
类加载器 -运行时候数据区-执行引擎-本地接口

2.字节码文件查看
使用jcalsslib工具-或者其他工具打开都可以
3.字节码文件组成0一般信息
字节码文件:可以分为以下几个部分

- 基础信息:魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口信息
- 常量池****: 保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用
- 字段: 当前类或接口声明的字段信息
- 方法: 当前类或接口声明的方法信息,核心内容为方法的字节码指令
- 属性: 类的属性,比如源码的文件名、内部类的列表等
详细解释
基础信息
1.魔术头-对等于PE结构-决定了文件的真正属性-class-属性就是cafebabe
2.主副版本号
-值的是编译字节码的jdk版本号
主版本-标识大版本号 jdk1.0 -1.1 45-45.3
jdk1.2后-是46 每升级大版本就+1
副版本是主版本号都相同时-进行区别
一搬都关心主版本号

所有类共同父类-
4.字节码常量池
常量池内保存了字符串常量 类或接口名 字段名 -再class内存储
例

我们创建的变量 String id=null;
id这个字段名-也存储在常量池内


还是指针思想
5.方法
例
字节码中的方法区是存放字节码指令的核心文件
字节码指令的内容存放在方法的code属性中

1.字节码阅读
再此之前 我们要抽象出一个
操作数栈(及时存放区域)
局部变量表
再汇编内存角度来看-大家其实都在同一个栈空间内-由编译器来找到谁是谁
1 | |

代码反编译
操作数 0(弹出) 0(复制)
局部变量表 main 0
1
0
1 | |

疑问

1 | |
1 | |
字节码工具
java-v 命令
idea jclasslib
1.Arthas

是一款线上监控诊断产品
通过全局视角实时查看应用load 内存 gc 线程的状态信息
监控面板

线程区域
内存区域
运行过程信息
dump
dump命令可以将字节码文件保存到本地

jad命令可以将类的字节码文件进行反编译成源代码
jad arthas.demo
类的生命周期
1.基础了解
一个类再虚拟机中被加载的过程-
对此来学习类加载过程-虚拟机

2.加载阶段
1.类加载器根据类的全限命名通过不同的渠道以二进制流获取字节码信息
2.加载完所有类后-虚拟机将字节码的信息保存到内存的方法区
(生成InstancekLass对象)->保存类的信息 等等等

4.再堆中生成一份与方法区数据类似的java lang class对象

关联-也就是指针指向了对方的地址
开发者就只需要访问堆中的class对象-就无需访问方法区
3.连接阶段
1.验证阶段
验证是否符合规范
例 类必须有父类

代码分析

2.准备阶段
准备阶段为静态变量(static)分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值。

再堆区开辟空间了但是没有存值
final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。

为什么会这样
因为final修饰的变量后续不会发生值的变更。
3.解析
解析阶段主要是将常量池中的符号引用替换为直接引用,符号引用就是在字节码文件中使用编号来访问常量池中的内容。

直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。

a->地址->真实数据
a->真实数据
4.初始化阶段
初始化阶段会执行字节码文件中clinit(class init 类的初始化)方法的字节码指令,包含了静态代码块中的代码,并为静态变量赋值。

- init方法,会在对象初始化时执行
- main方法,主方法
- clinit方法,类的初始化阶段执行

代码变换
1 | |
字节码指令的位置也会发生变化:

最后静态变量的值就是1
以下几种方式会导致类的初始化:
1.访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化。
1 | |
2.调用Class.forName(String className)。
1 | |
3.new一个该类的对象时。
4.执行Main方法的当前类。
添加-XX:+TraceClassLoading 参数可以打印出加载并初始化的类
clinit不会执行的几种情况
1.无静态代码块且无静态变量赋+值语句。
2.有静态变量的声明,但是没有赋值语句。

3.静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。

类加载器详解
1.类加载器的作用
对类的加载-虚拟机有多套类加载器选择
类加载器将加载过程中的字节码获取并加载到内存

类加载器-通过二进制流记载字节码内容-将数据交给java虚拟机-由虚拟机在方法区和堆上生成对应的对象
2.类加载器的分类
JDK8及之前的版本,默认的类加载器有如下几种:

类加载器的详细信息可以通过Arthas的classloader命令查看:

- BootstrapClassLoader是启动类加载器,numberOfInstances是类加载器的数量只有1个,loadedCountTotal是加载类的数量1861个。
- ExtClassLoader是扩展类加载器
- AppClassLoader是应用程序类加载器
1.启动类加载器
启动类加载器 BootStrap ClassLoader 由hotspot虚拟机提供 使用c++编写
默认加载 java jre/lib 下类文件
比如rt.jar,tools.jar,resources.jar等
1 | |
打印null
因为由cpp编写拿不到

用户扩展启动类
用户扩展
如果用户想扩展一些比较基础的jar包,让启动类加载器加载,有两种途径:
- 放入jre/lib下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容,会出现即时放进去由于文件名不匹配的问题也不会正常地被加载。
- 使用参数进行扩展。推荐,使用-Xbootclasspath/a:jar包目录/jar包名 进行扩展,参数中的/a代表新增。
下图,在IDEA配置中添加虚拟机参数,就可以加载D:/jvm/jar/classloader-test.jar这个jar包了。

2.扩展类 应用类加载器
- 它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。具备通过目录或者指定jar包将字节码文件加载到内存中。
继承关系图如下:

- ClassLoader类定义了具体的行为模式,简单来说就是先从本地或者网络获得字节码信息,然后调用虚拟机底层的方法创建方法区和堆上的对象。这样的好处就是让子类只需要去实现如何获取字节码信息这部分代码。
- SecureClassLoader提供了证书机制,提升了安全性。
- URLClassLoader提供了根据URL获取目录下或者指定jar包进行加载,获取字节码的数据。
- 扩展类加载器和应用程序类加载器继承自URLClassLoader,获得了上述的三种能力。
1.扩展类加载器
扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。默认加载Java安装目录/jre/lib/ext下的类文件。
1 | |
ScriptEnvironment是nashorn框架中用来运行javascript语言代码的环境类,他位于nashorn.jar包中被扩展类加载器加载
扩展加载
- 放入/jre/lib/ext下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容。
- 使用参数进行扩展使用参数进行扩展。推荐,使用-Djava.ext.dirs=jar包目录 进行扩展,这种方式会覆盖掉原始目录,可以用;(windows):(macos/linux)追加上原始目录
如下图中:

使用引号将整个地址包裹起来,这样路径中即便是有空格也不需要额外处理。路径中要包含原来ext文件夹,同时在最后加上扩展的路径。
应用程序加载器
应用程序类加载器会加载classpath下的类文件,默认加载的是项目中的类以及通过maven引入的第三方jar包中的类。
如下案例中,打印出Student和FileUtils的类加载器:
1 | |
类加载器的加载路径可以通过classloader –c hash值 查看:

双亲委派机制
1.了解双亲委派机制
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,
再由顶向下进行加载。

在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器。
2.双亲委派机制作用
1.保证类加载的安全性。通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。
2.避免重复加载。双亲委派机制可以避免同一个类被多次加载。
3.指定类加载器加载
1 | |
类的双亲委派机制是什么?
- 当一个类加载器去加载某个类的时候,会自底向上查找是否加载过,如果加载过就直接返回,如果一直到最顶层的类加载器都没有加载,再由顶向下进行加载。
- 应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。
- 双亲委派机制的好处有两点:第一是避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。第二是避免一个类重复地被加载。
打破双亲委派机制
- 1.自定义类加载器并且重写loadClass方法。Tomcat通过这种方式实现应用之间类隔离,《面试篇》中分享它的做法。
- 2.线程上下文类加载器。利用上下文类加载器加载类,比如JDBC和JNDI等。
- 3.Osgi框架的类加载器。历史上Osgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载,目前很少使用。
三种方法
1.自定义类加载器
案例 tomcats
应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了。

Tomcat使用了定义类加载器来实现应用之间类的隔离。 每一个应用会有一个独立的类加载器加载对应的类。

ClassLoader中包含了4个核心方法,双亲委派机制的核心代码就位于loadClass方法中。
1 | |
1 | |
1.双亲委派机制源码分析
1 | |
false-静态变量查看
1 | |
1 | |
find class方法
1 | |
由类加载器子类实现,获取二进制数据调用defineClass ,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。重要
2.打破双亲委派机制
1 | |
自定义类加载器父类怎么是AppClassLoader呢?
默认情况下自定义类加载器的父类加载器是应用程序类加载器:

以Jdk8为例,ClassLoader类中提供了构造方法设置parent的内容:

这个构造方法由另外一个构造方法调用,其中父类加载器由getSystemClassLoader方法设置,该方法返回的是AppClassLoader。

回到问题-两个自定义加载器加载相同限定名字的类-冲突问题
不会冲突,在同一个Java虚拟机中,只有相同类加载器+相同的类限定名才会被认为是同一个类。
2.线程上下文加载器
1 | |
利用上下文类加载器加载类,比如JDBC和JNDI等。
JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql驱动、oracle驱动。
DriverManager类位于rt.jar包中,由启动类加载器加载。

依赖中的mysql驱动对应的类,由应用程序类加载器来加载。
–mysql是否打破双亲委派-
跳过双亲委派规则-直接给了应用启动器
从结果上看没打破-过程上打破了

那么问题来了,DriverManager怎么知道jar包中要加载的驱动在哪儿?
spi机制


在类的初始化代码中有这么一个方法LoadInitialDrivers:

2、这里使用了SPI机制,去加载所有jar包中实现了Driver接口的实现类。

3、SPI机制就是在这个位置下存放了一个文件,文件名是接口名,文件里包含了实现类的类名。这样SPI机制就可以找到实现类了。


4、SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。

总结:

3.Osgi框架的类加载器

4.Jdk9 后类加载器
JDK8及之前的版本中,扩展类加载器和应用程序类加载器的源码位于rt.jar包中的sun.misc.Launcher.java。

由于JDK9引入了module的概念,类加载器在设计上发生了很多变化。
1.启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。
Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。
启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一。
2、扩展类加载器被替换成了平台类加载器(Platform Class Loader)。
平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。
Jvm02 运行时数据区
Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区。

线程不共享区
ps:单独的线程-
1.程序计数器
EIP寄存器
程序计数器(Program Counter Register)也叫PC寄存器,每个线程会通过程序计数器记录当前要执行的的字节码指令的地址。
程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。不管是分支、跳转、异常,只需要在程序计数器中放入下一行要执行的指令地址即可。
在多线程执行情况下,Java虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行。
程序计数器不会出现内存溢出
java虚拟机栈
1.认识java虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。
1 | |

循环调用–
再return前-会将当前开辟的栈帧的地址置0
return 会返回上一个函数的起始地址

Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。
栈帧数据探究
Java虚拟机栈的栈帧中主要包含三方面的内容:
- 局部变量表,局部变量表的作用是在运行过程中存放所有的局部变量
- 操作数栈,操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据,帧数据主要包含动态链接、方法出口、异常表的引用
1.局部变量表
局部变量表的作用是在方法执行过程中存放所有的局部变量。局部变量表分为两种,一种是字节码文件中的,另外一种是栈帧中的也就是保存在内存中。
栈帧中的局部变量表是根据字节码文件中的内容生成的。
1 | |
test1方法的局部变量表如下:

局部变量表中保存了字节码指令生效的偏移量:

比如i这个变量,它的起始PC是2,代表从lconst_1这句指令开始才能使用i,长度为3,也就是2-4这三句指令都可以使用i。为什么从2才能使用,因为0和1这两句字节码指令还在处理int i = 0这句赋值语句。j这个变量只有等3指令执行完之后也就是long j = 1代码执行完之后才能使用,所以起始PC为4,只能在4这行字节码指令中使用。
2.栈帧中的局部变量
栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot) ,long和double类型占用两个槽,其他类型占用一个槽。

i占用数组下标为0的位置,j占用数组下标1-2的位置。
刚才看到的是静态方法,实例方法中的序号为0的位置存放的是this,指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址。

方法参数也会保存在局部变量表中,其顺序与方法中参数定义的顺序一致。局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。

test3方法中包含两个参数k,m,这两个参数也会被加入到局部变量表中
ps:
槽在我看来也是抽象出来的概念-包括字节码文件中的局部变量
1 | |
实例
以下代码的局部变量表中会占用几个槽?
1 | |
局部变量表数值的长度为6。这一点在编译期间就可以确定了,运行过程中只需要在栈帧中创建长度为6的数组即可。
数组再汇编中也是如上
数组在反汇编中的本质
1 | |
3.操作数栈
操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。他是一种栈式的数据结构,如果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值。
在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小。

比如之前的相加案例中,操作数栈最大的深入会出现在这个时刻:

所以操作数栈的深度会定义为2。
4.帧数据
帧数据主要包含动态链接、方法出口、异常表的引用。
1.动态链接
当前类的字节码指令引用了其他类的属性或者方法时
需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。
动态链接就保存了编号到运行时常量池的内存地址的映射关系。

2.方法出口
return的数据
方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。


再进入函数前-会将下一条指令压入栈中
后续开栈-恢复后 return自然就到了当时压栈的那个返回地址
3.异常表
异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

如下案例:i=1这行源代码编译成字节码指令之后,会包含偏移量2-4这三行指令。其中2-3是对i进行赋值1的操作,4的没有异常就跳转到10方法结束。如果出现异常的情况下,继续执行到7这行指令,7会将异常对象放入操作数栈中,这样在catch代码块中就可以使用异常对象了。接下来执行8-9,对i进行赋值为2的操作。

所以异常表中,异常捕获的起始偏移量就是2,结束偏移量是4,在2-4执行过程中抛出了java.lang.Exception对象或者子类对象,就会将其捕获,然后跳转到偏移量为7的指令。
栈溢出问题探究
Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出。Java虚拟机栈内存溢出时会出现StackOverflowError的错误。
JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。
要修改Java虚拟机栈的大小,可以使用虚拟机参数 -Xss 。
- 语法:-Xss栈大小
- 单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
1 | |
一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为-Xss256k节省内存。
本地方法栈
本地方法栈存储的是native本地方法的栈帧。
在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。

执行之后发生异常,会打印出所有栈帧的名字:

open0是一个本地方法,所以创建了本地方法的栈帧。本地方法和Java虚拟机方法的栈帧在一个栈上。
线程共享区
堆内存
1.了解堆内存
一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
1 | |
这段代码中通过new关键字创建了两个Student类的对象,这两个对象会被存放在堆上。在栈上通过s1和s2两个局部变量保存堆上两个对象的地址,从而实现了引用关系的建立。

堆内存大小是有上限的,当对象一直向堆中放入对象达到上限之后,就会抛出OutOfMemory错误。
2.堆内存特性
堆空间有三个需要关注的值,used、total、max。used指的是当前已使用的堆内存,total是java虚拟机已经分配的可用堆内存,max是java虚拟机可以分配的最大堆内存。

当内存不够->total向max扩张
那么是不是当used = max = total的时候,堆内存就溢出了呢?
不是,堆内存溢出的判断条件比较复杂,在下一章《垃圾回收器》中会详细介绍。
3.设置堆大小
要修改堆的大小,可以使用虚拟机参数 –Xmx(max最大值)和-Xms (初始的total)。
语法:-Xmx值 -Xms值
单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
限制:Xmx必须大于 2 MB,Xms必须大于1MB
1 | |
这样可以将max和初始的total都设置为4g,在启动后就已经获得了最大的堆内存大小。运行过程中不需要向操作系统申请。
Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向java虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。-Xmx具体设置的值与实际的应用程序运行环境有关,在《实战篇》中会给出设置方案。
方法区
方法区是存放基础信息的位置,线程共享,主要包含三部分内容:
- 类的元信息,保存了所有类的基本信息
- 运行时常量池,保存了字节码文件中的常量池内容
- 字符串常量池,保存了字符串常量
1,类的元信息
方法区是用来存储每个类的基本信息(元信息),一般称之为InstanceKlass对象。在类的加载阶段完成。其中就包含了类的字段、方法等字节码文件中的内容,同时还保存了运行过程中需要使用的虚方法表(实现多态的基础)等信息。

2.运行时常量池
常量池中存放的是字节码中的常量池内容。
字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。
3.方法区的实现
JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制。
JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。
4.方法区的溢出
- JDK7将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数-XX:MaxPermSize=值来控制。
- JDK8将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。可以使用-XX:MaxMetaspaceSize=值将元空间最大大小进行限制。
5.字符串常量池–堆的特性
字符串常量池存储在代码中定义的常量字符串内容。比如“123” 这个123就会被放入字符串常量池。
如下代码执行时,代码中包含abc字符串,就会被直接放入字符串常量池。在堆上创建String对象,并通过局部变量s1引用堆上的对象。
接下来通过s2局部变量引用字符串常量池的abc。

所以s1和s2指向的不是同一个对象,打印出false。

6.静态变量在哪
- JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代。
- JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代。具体源码可参考虚拟机源码:BytecodeInterpreter针对putstatic指令的处理。
直接内存
在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:
1、Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
2、IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。
现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。
使用堆创建对象的过程:

使用直接内存创建对象的过程,不需要进行复制对象,数据直接存放在直接内存中:

要创建直接内存上的数据,可以使用ByteBuffer。
语法: ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
特性了解
方法区-根据版本不同-在元空间或者永久代中
元空间 jdk1.8提出的概念-再直接内存中
永久代-堆中位于永久代中-老概念