前言
本章主要讲解 JVM 是如何进行内存管理,如何通过栈帧分析字节码的运行过程,HSDB大揭秘;
JVM(Java Virtual Machine)
JVM 与 操作系统的关系
JVM 运行 Java字节码,Java 字节码本身是操作系统不能识别的,所以我们通过 JVM 翻译成操作系统可以识别的机器码;
所以说:JVM与操作系统的关系就是翻译;
JVM 可以跨平台,我们写一个 Java 程序,可以通过 JVM 运行在 Linux、Windows、Mac OS
JVM 可以跨语言, 不支持 Java 语言,还支持 Kotlin 语言,本质就是任何语言只要可以编译成字节码(.class),那么就可以运行在 JVM 上;
Java SE体系架构
我们写一个 Hello World 程序,它的运行,其实依赖一个大的体系架构,这个架构就是 Java SE 体系架构;
JVM 它仅仅是一个翻译,那么它进行翻译需要的材料从哪里获取呢?就是从 JRE 获取,JRE 提供了基础类库(IDL、JDBC、Preferences API、Image I/O)等等,我们编写一个 Java 程序,依赖这个基础类库,那么 JVM 翻译的时候依赖 JRE,对于操作系统来说,需要 JRE 就够了,但是对于开发者来说,这还不够,JVM 只识别字节码,那么对于开发者来说我们需要把 Java 语言编译成字节码,那么使用什么来编译成字节码呢?就是使用 JDK,JDK提供了这些工具(javac、javap等等)帮助开发者可以将 Java 编译成 class;
JVM整体架构
JVM运行时的整体流程
.java 文件经过 JDK 提供的 javac 工具编译成 .class 文件(字节码),这个字节码经过类加载器(classloader)把它加载到运行时数据区(JVM 所管理的内存),字节码如果要被执行,就需要一个执行引擎,这个执行引擎的作用就是把放到运行时数据区的字节码进行解释执行或者 JIT执行;
所有的字节码都需要翻译成机器码来执行;
解释执行:翻译(翻译)一行,执行一行;
JIT:热点数据(热点方法)直接翻译成机器码执行;
运行时数据区
定义:JVM 在执行 Java 程序的时候,会把它所管理的内存划分为若干个不同的数据区域,大体的可以分为两类,一类是线程共享区,一类是线程私有区;
线程私有区:JVM 可以运行多线程,如果有三个线程,那么在 JVM 的运行时数据区的线程私有区就有三份,每份都含有虚拟机栈 、本地方法栈 、程序计数器;
线程共享区:所有线程都可以访问的区域,不受线程的控制,包含方法区 和堆;
线程私有区
程序计数器
- 指向当前线程正在执行的字节码指令的地址;
- 当某一个线程被 CPU 挂起时,需要记录代码已经执行到的位置,方便 CPU 重新执行此线程时,知道从哪行指令开始;
- 是虚拟机中一块较小的内存空间,主要用来记录当前线程执行的位置;
- 线程私有的,每条线程内部都有一个私有程序计数器。它的生命周期随着线程的创建而创建,随着线程的结束而死亡;
- 当一个线程正在执行一个 Java 方法的时候,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是 Native 方法,这个计数器值则为空;
- 每一个线程都拥有一个程序计数器;
- JVM 内存区域中,唯一会不 OOM 的区域,很小的一块区域;
字节码反汇编之后,Code 下对应的这些行号,就是针对方法体(carMoney)的一个偏移量;
大体认为 == 程序计数器,记录字节码的地址;我们可以看到里面其实是没有9的,因为不同的指令,它的偏移量是不一样的;
为什么在 JVM 中需要这个程序计数器?
就是因为 CPU 的时间片轮转机制; 如何应对Android面试官->线程与进程、手写ThreadLocal 有介绍什么是 CPU 时间片轮转机制;
在 JVM 中程序计数器为什么不会 OOM?
因为程序计时器是一块很小的区域,只需要用来记录我们所说的地址,所以我们用一个 int 类型的长度来记录就完全够了;
虚拟机栈(也可以叫作 Java栈)
什么是栈?
FILO(First In Last Out)先进后出的一种数据结构;
虚拟机栈
-
存储当前线程运行方法所需要的数据、指令、返回地址;
-
此区域会抛出异常(StackOverflowError、OutOfMemoryError)
-
StackOverflowError 当线程请求栈深度超出虚拟机栈所允许的深度时抛出;
-
OutOfMemoryError 当 Java 虚拟机动态扩展到无法申请足够内存时抛出;
-
线程私有,与线程的生命周期同步;
-
JVM 是基于虚拟机栈(栈帧中的操作数栈)的解释器执行的;
-
虚拟机栈的初衷是用来描述 Java 方法执行的内存模型,每个方法被执行的时候,JVM 在虚拟机栈中都会创建一个对应的栈桢;
栈帧
-
局部变量表;
-
用来存储局部变量,但是局部变量只能存储 8 大基础数据类型和引用;
-
操作数栈;
-
用来存放方法的执行;
-
动态链接;
-
多态情况下,确定最终的执行逻辑;
ini
Person mars = new Man();
mars.work();
Person mars = new Woman();
mars.work();
-
动态链接就是来确定 最终执行的是 man 的 work 方法还是 woman 的 work 方法;
-
完成出口(返回地址);
-
用来标记当前方法执行完了,用这个返回地址进行标记,继续执行这个方法下面的代码;
虚拟机栈是有大小限制的,默认情况下绝大多数的操作系统它有一个默认参数 -Xss,它的大小是 1M;
当我们执行一个方法的时候,虚拟机栈是如何工作的?
Java 字节码相关指令可以查看这个文档:字节码指令
Java 的解释执行是基于虚拟机栈(栈帧中的操作数栈)的,兼容性好,但效率偏低;C 是基于寄存器(硬件)的,运算快,但移植性差(make install)
-Xss
JVM 的参数可以查看相关文档:JVM 参数参考
本地方法栈
- native 方法被调用的时候,就会创建一个本地方法栈,保存的是 native 方法的信息;
- 当一个 JVM 创建的线程调用 native 方法后,JVM 不在为其在虚拟机栈中创建栈帧,JVM 只是简单的动态链接并调用 native 方法;
- 在 HostSpot 中,本地方法栈和虚拟机栈是同一个;
线程共享区
方法区
用来存放 Run-time Constant Pool(常量池)、静态变量,类的描述信息(时间,作者,版本等)、即时编译期编译后的代码;
堆
用来存放几乎所有的对象实例、数组;
线程共享区 JVM 为什么要用两个来区分一下,而不是直接用一个?
可以理解为一种动静分离的思想,方法区中存放的是class、常量、静态变量,不经常变动的,不需要频繁回收的;堆中存放的是经常动态创建和回收的,分开存放便于垃圾回收的高效;
Java堆的大小参数设置
-Xmx 堆区内存可被分配的最大上限;
-Xms 堆区内存初始内存分配的大小;
不同 JDK 版本的 方法区 中的实现
<=1.7 叫永久代 **,**1.7 之前 JVM 不仅回收堆还会回收方法区,也就是这个永久代,而永久代中存放的都是一些静态变量、常量、类的描述信息、class 等等,这些东西回收效率很低,虽然划分了方法区,又划分了堆,但是在垃圾回收的时候没有分别对待,就会产生问题,并且永久代会受制于堆的大小;
>=1.8 叫元空间,元空间可以使用机器内存,默认情况下不受限制,只受制于我们的机器,方便拓展,但是会挤压堆空间;
挤压堆空间的意思就是:因为元空间不受限制了,假设我们的机器是 20G,我们给堆设置的最大上限 10G,初始为 2G,但是元空间因为不受限制了,占了机器 15G,那么堆的空间就会被挤压了,我们设置的堆的参数就没有意义了;
直接内存(堆外内存)
- 不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域;
- 如果使用率 NIO,这块区域会被频繁使用,在 java 堆内可以用 directByteBuffer 对象直接引用并操作,就会在本地内存中创建一块区域,类似元空间;
- 这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过 MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常;
从底层深入理解运行时数据区
我们来看一段代码
arduino
// -Xms30m -Xmx30m -XX:+UseConcMarkSweepGC -XX:-UseCompressedOops
public class BasketballPlayer {
public static final String MAN_TYPE = "man"; // 常量
public static final String WOMAN_TYPE = "woman"; // 静态变量
public static void main(String[] args) throws InterruptedException { // 栈帧
Player p1 = new Player(); // 堆内存 局部变量
p1.setName("Kobe");
p1.setSexType(MAN_TYPE);
p1.setAge(40);
for (int i = 0; i < 20; i++) {
System.gc(); // 垃圾回收
}
Player p2 = new Player();
p2.setName("Jordan");
p2.setSexType(MAN_TYPE);
p2.setAge(50);
Thread.sleep(Integer.MAX_VALUE); // 线程休眠很久很久
}
static class Player{
String name;
String sexType;
int age;
public void setName(String name) {
this.name = name;
}
public void setSexType(String sexType) {
this.sexType = sexType;
}
public void setAge(int age) {
this.age = age;
}
}
}
然后我们给 vm 设置上面注释中的那行参数
arduino
// -Xms30m -Xmx30m -XX:+UseConcMarkSweepGC -XX:-UseCompressedOops
运行上面的那段代码,JVM 会执行一些操作
- 根据配置的参数,向操作系统申请内存;
- 类加载,将 BaseketballPlayer.class 和 Player.class 加载到方法区;
- 常量 MAN_TYPE 静态变量 WOMAN_TYPE 加载到方法区;
- 创建虚拟机栈-main,并压入一个栈帧-main方法;
- 堆中创建 p1 对象,虚拟机栈中创建局部变量表 p1 的引用;
- 垃圾收集器从堆中进行 20 次垃圾回收,p1 由新生代进入老年代
- 堆中创建 p2 对象,虚拟机栈中创建局部变量表 p2 的引用;
执行到 Thread.sleep(Integer.MAX_VALUE) 之后,内存区域就会形成上面图中这样的形式;
那么我们可以真真实实如何看到这些存在呢?或者说怎么验证上面的结论是正确的呢?HSDB来了~~
HSDB 大揭秘
可以查看这篇博客 HSDB 在mac下的如何启动
然后运行我们的 Java 程序,在 Terminal 中输入 jps ,然后回车,查看进程 ID;
进入 HSDB 点击 File -》 Attach to HotSpot process -》 输入进程ID 64825 -》 回车,显示下图结果;
选中 main 点击第二个 tab(Stack Memory),查看当前进程的栈(虚拟机栈)内存信息;
上图这个就是我们的虚拟机栈;
左侧 0x00007000065d9978 等等,这些就是内存地址;
上图可以看到 BasketballPlyer.main,Interpreted frame 这片这蓝色区域就是 main 方法的栈帧,虚拟机栈栈帧就是对物理地址的一个虚拟化;
Thread.sleep(Interger.MAX_VALUE); 执行之后 产生的 sleep 方法栈帧;
这就解释了前面说的,在 HostSpot 中本地方法栈和虚拟机栈是同一个;
那么 我们的 class 存放在了那里,可以点击 Tools -》Object Histogram
就可以查看 JVM 中的所有的 class
我们在搜索框中输入全类名,com.company.BasketballPlayer,回车;
搜索到了方法区中的 class,那么我们创建的两个局部变量表的引用 p1、p2 存放在哪里呢? 点击 com.company.BasketballPlayer$Player 这一行;
选中其中一个,点击 Inspect,查看详细的对象信息;
我们创建的 Jordan 对象和 Kobe 对象;
接下来我们查看下 堆 中是怎么存放这两个对象的,点击 Tools -》Heap Parameters
选择 Heap Parameters
上半部分是 新生代包含 eden、from、to;
下半部分是 老年代 Tenured;
可以看到 内存地址是连续的;
eden 是从 0x000000010b000000 -> 0x000000010b800000
from 是从 0x000000010b800000 -> 0x000000010b900000
to 是从 0x000000010b900000 -> 0x000000010ba00000
Tenured 是从 0x000000010ba00000 -> 0x000000010ce00000
Kobe 对象经过 20 次 GC 进入了 老年代,可以看下 Kobe 的内存地址:0x000000010ba77960,这个地址在 Tenured 的区间内(0x000000010ba00000 - 0x000000010ce00000)
Jordan 的内存地址:0x000000010b000000,就在 eden 区间内;
内存溢出
栈溢出
typescript
public class StackOverFlow {
public void test(){
test();
}
public static void main(String[] args) {
StackOverFlow stackOverFlow = new StackOverFlow();
stackOverFlow.test();
}
}
执行就会栈溢出,无穷递归,无穷的压栈,造成了栈溢出;
堆溢出
typescript
public class HeapOOM {
// -Xms30m -Xmx30m -XX:+PrintGCDetails
public static void main(String[] args) {
String[] strings = new String[35 * 1024 * 1024];
}
}
设置分配的 堆内存 为 30m,但是创建了一个 35m 的数组,直接堆溢出
方法区溢出
java
/**
* cglib动态生成
* Enhancer中 setSuperClass和setCallback, 设置好了SuperClass后, 可以使用create制作代理对象了
* 限制方法区的大小导致的内存溢出
* VM Args: -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
* */
public class MethodOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MethodOOM.TestObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object arg0, Method arg1, Object[] arg2, MethodProxy arg3) throws Throwable {
return arg3.invokeSuper(arg0, arg2);
}
});
enhancer.create();
}
}
public static class TestObject {
private double a = 34.53;
private Integer b = 9999999;
}
}
需要引入 cglib 包和asm包,直接百度下载即可,然后导入,运行;
指向元空间(Metaspace)的方法区溢出;
本机直接内存溢出
arduino
/**
* VM Args:-XX:MaxDirectMemorySize=100m
* 堆外内存(直接内存溢出)
*/
public class DirectOom {
public static void main(String[] args) {
//直接分配128M的直接内存(100M)
ByteBuffer bb = ByteBuffer.allocateDirect(128*1024*1204);
}
}
解决方案
排查参数、排查代码,根据堆栈尽可能的找到问题原因;
JVM优化技术
编译优化技术
方法内联,减少一次方法调用,带来性能提升;
方法内联:比如在编译下面这段代码的时候
arduino
public class MethodInline {
public static void main(String[] args) {
max(1, 2);
}
public static boolean max(int a, int b) {
return a > b;
}
}
编译的时候,如果 a 和 b 的值已经确定了,就是1 和 2,但是在调用 max 方法的时候,虚拟机栈需要进行一次入栈(max 栈帧)和出栈操作,所以 JVM 做了编译优化,把目标方法的代码原封不动直接复制到调用方法中,也就是下面这样
typescript
public static void main(String[] args) {
boolean flag = 1 > 2;
}
避免这个多余一次的方法调用,从而带来性能提升;
栈顶优化技术
栈桢之间数据共享 内存用的越少,效率越高;
例如 两个方法之间发生了数据传递,例如 A 方法调用 B 方法,要传入一个参数,那么这个参数在 A 方法的局部变量表中,在 B 方法的操作数栈中,所以它其实可以共享这块区域来传递参数;
我们可以通过 HSDB 来看下,执行下面这段代码
java
public class ExpressionStack {
public static void main(String[] args) throws Exception {
ExpressionStack expression = new ExpressionStack();
expression.push(10);
}
public int push(int x) throws Exception {
int y = (x+8)*10;
Thread.sleep(Integer.MAX_VALUE);
return y;
}
}
打开 HSDB 查看到
圈中的右边部分就是操作数栈,00b4 的内存地址就是共享区域;
简历润色
简历上可写:通过HSDB查看底层运行时数据区,深入理解 JVM 内存管理;
下一章预告
带你玩转垃圾回收;
欢迎三连
来都来了,点个关注,点个赞吧~~