虚拟机内存管理
概述
虚拟机就是在物理计算机上,以软件形式提供的"虚拟计算"机,为程序运行提供平台。JVM就是一个为java(包括Kotlin、Clojure、JRuby、Groovy)软件运行提供必要环境的平台。java虚拟机由1995年的第一款Classic VM到2018年的Graal VM。当前它不再只是支持基于基于java虚拟机之上的语言,还包括了C,C++,Rust等基于LLVM(构架编译器)的语言。
jvm架构模型
- 🚛类装载器子系统
Class Loader SubSystem
通过二进制流的形式把.class
文件加载到运行时数据区Runtime Data Areas
(方法区),对象几乎全部在堆上。🍳生成对应的java.lang.Class,这个对象作为方法区各个类的访问数据入口。这个过程主要包括验证、准备、解析、初始化。 - 🧏类的初始化完成,执行引擎从运行时数据区读取字节码,将字节码指令解释为对应平台上的本地机器指令,执行程序。
- 执行引擎在解释执行的过程中会调用本地方法接口,进而需要本地方法库。
运行时数据区
java虚拟机在运行java程序时会把它所管理的内存划分为多个不同的数据区。这些区域有着不同的用途,创建销毁时间也不相同。分为线程隔离数据区,和线程共享数据区。
程序计数器
当前线程所执行字节码的行号指示器。字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令(分支、循环、跳转、异常处理、中断、线程恢复等)。
虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式来实现的,任何时刻都只会执行一条线程中的指令。因此为了线程切换后能够恢复到正确的执行位置,每个线程都需要一个独立的程序计数器,各线程的程序计数器互不影响。
程序计数器特点:
- 线程隔离
- 执行java方法时程序计数器记录的是正在执行的虚拟机字节码指令的地址。
- 如果执行的是native方法,这个计数器的值则为空(Undefined)。Native方法是java通过JNI直接调用本地c/c++库。自然无法产生相应的字节码,并且c/c++执行时内存分配是由自己语言决定的,而不是由虚拟机决定的。此区域是唯一一个在java虚拟机规范中没有规定任何OutOfMerryError情况的区域。
- 程序计数器占用内存很小,在进行jvm内存计算时可以忽略不计
虚拟机栈
虚拟机栈描述的是java方法执行的内存模型,是一个后入先出的。每创建一个线程,虚拟机就会为这个线程创建一个虚拟机栈。虚拟机栈的生命周期和线程是相同的。
栈帧结构
虚拟机栈的栈元素是栈帧Stack Frame
,每调用一个方法,虚拟机就会为该方法生成一个栈帧,一个方法的调用和完成,对应着一个栈帧入栈出栈的过程。
局部变量表
既然方法的内存模型是栈帧,那么方法的参数、局部变量都存储在栈帧的局部变量表中。Java源文件在编译Class文件时,就已经在方法Code属性的max_locals数据项中确定了该方法所需分配的局部变量表最大容量。
局部变量是有索引的,从零开始到最大的Slot数。对于64位数据类型,虚拟机会以高位对齐的方式分配连个连续的Slot空间(和long、double非原子性协定类似)。Java语言明确的64位数据类型只有long和double。Reference可能是32也可能是64。如果访问的是:
☀32位数据类型变量:索引n-1就代表使用第n个Slot。
☀64位:则会访问n-1和n这两个Slot。不允许任何方式访问其中的某一个。
Slot复用
方法参数个数+局部变量个数≠Slot数量。因为有32位和64位数据类型。即使全部是32位,Slot是可以复用的。方法体中定义的变量,其作用域并不一定会覆盖整个方法体,当pc计数器的值超出某个变量的作用域时,这个变量对应的Slot就可以交给其他变量使用。这样的设计除了节省栈空间,还会伴随着一些副作用。某些情况下影响GC的垃圾收集行为。
从上面也可以看出,这个gc()函数的作用只是提醒虚拟机:程序员希望进行一次垃圾回收。但是它不能保证垃圾回收一定会进行,而且具体什么时候进行是取决于具体的虚拟机的,不同的虚拟机有不同的对策。Slot复用也是一种策略,pc计数器超出变量作用域,还得有来复用的新变量。
placeholder能否被回收取决于,局部变量表Slot中是否还存在于关于placeholder对象的引用,第二张图虽然已经离开了placeholder的作用域,但后续没有任何对局部变量表的读写操作,Slot还没有被其他变量所复用。
下面三张图更加证明了Slot有需要占用才会复用,gc才收集。
操作数栈
操作数栈是一个后入先出的栈,它的最大栈深度也是在编译期就写入Code
属性的max_stacks
数据项之中。操作数栈中的每一个元素都可以是任意的java数据类型,32位占一个深度,64位占两个深度。方法执行运算或调用其他方法的参数传递的媒介。概念模型中两个栈帧完全独立的,但在大多数虚拟机实现里都会优化处理,让两个栈帧出现部分重叠。上面栈帧的部分局部变量表与下面栈帧的部分操作数栈重叠。节省了空间,方法调用时直接共用一部分数据,无需额外的参数复制传递了。
动态链接
每个栈帧都包含一个指向运行时常量池
中该栈帧所属方法的引用。持有这个引用是为了支持方法调用过程中的动态链接。
方法返回地址
方法退出有两种方式:
- 正常退出:当程序运行遇到返回的字节码指令时,这时候需要把返回值传递给上层方法调用者。
- 异常退出:出现了异常,在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法异常退出。
无论是正常退出还是异常退出,方法退出后都要回到最初方法被调用时的位置。
附加信息
调试,性能收集相关的信息,取决于具体虚拟机的实现。一般方法返回地址、动态链接、附件信息统称为栈帧信息。
本地方法栈
和虚拟机栈相似,它提供本地方法执行的内存模型。同样会有栈溢出和内存溢出。
堆
堆是虚拟机管理内存中最大的一块。被所有线程共享。虚拟机启动时创建,唯一目的就是存放对象实例。栈上分配,标量替换导致一些微妙的变化,所有对象都分配在堆上也不是那么“绝对”。
它是垃圾收集管理的主要区域。可以处于物理上不连续,逻辑连续即可。可以固定大小,也可以扩展。(—Xmx和—Xms)。这里还可以分配线程私有的分配缓冲区(TLAB)。
参数 | 描述 | 推荐值 | 说明 |
-Xms | 初始分配堆内存大小 | Xms=Xmx虚拟机 清理堆区后就 不需要重新分割计 算堆区大小提高性能3550 | 默认物理内存的1/64 |
-Xmx | 最大分配堆内存大小 | 默认物理内存的1/4 | |
-Xss | 设置线程栈大小 | 128k | 深度较大用256k 太大线程数就少 了影响性能 |
-Xmn | 设置年轻代大小 | 2g | 官方推荐配置为 整个堆的3/8 |
-XX:NewSize=n | 设置年轻代大小 | 设置一样大相当于 设置了NewSize | 官方推荐配置为 整个堆的3/8 |
-XX:MaxNewSize=n | 设置年轻代大小 | 官方推荐配置为 整个堆的3/8 | |
-XX:SurvivorRatio=n | Eden:Survivor0 :Survivor1 | 8 | 8:1:1和两个Survivor比值 |
方法区
方法区和堆一样是线程共享的内存区域。存储着被虚拟机加载的类型信息、运行时常量池、静态变量、JIT代码缓存、域信息、方法信息。
类型信息
对每个加载的类型Type
(class
、inteface
、enum
、annotation
),JVM必须在方法区中存储以下信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型的直接父类完整有效名称(接口和java.lang.Object没有父类)
- 这个类型的修饰符(public,abstract,final的某个子集)
- 这个类型直接接口一个有序列表
域(Field)信息
也叫做成员变量信息:
- 域名称
- 域类型
- 域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
non-final类型变量
被static
修饰的变量,即类变量。准备阶段被赋初值,会随着Class对象一起存放在Java堆中,实例变量将会在对象实例化时随着对象一起分配在Java堆中。所以类变量即使没有实列,通过类也可以访问。类加载阶段在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
public class MethodAreaTest {
public static void main(String[] args) {
Order order = null;
//不会空指针
order.hello();
//不会空指针
System.out.println(order.count);
}
}
class Order {
public static int count = 1;
public static final int number = 2;
public static void hello() {
System.out.println("hello!");
}
}
变量之间区别
- 实例变量在实例初始化阶段(实例构造器
<init>
中,通过putfield
)赋值; - 静态变量在准备阶段赋初值(类加载源码),类初始化阶段(类构造器
<clinit>
中,通过字节码pustatic
)赋值。 - [x]
public class FiledTest {
private int num = 18;
}
public class FiledTest {
private static int num = 18;
}
public class FiledTest {
private final int num = 18;
}
public class FiledTest {
private static final int num = 18;
}
应用实列
java编译器启动时,文件管理器注册上下文之前JavacFileManager.preRegister(context)
,构建相应文件管理器支持的options
选项。
/**com.sun.tools.javac.file.BaseFileManager
* 由于编译过程先加载类JavacFileManager,再进行上下文注册,BaseFileManager
* 是JavacFileManager,所以先加载BaseFileManager,它的静态常量set就需要
* 在类准备阶段赋初,类初始化阶段(类构造器<clinit>中通过字节码pustatic)赋值
*/
protected static final Set<Option> javacFileManagerOptions =
Option.getJavacFileManagerOptions();
运行时常量池
java文件被编译后,成为.class文件。虚拟机运行之后,类被加载到虚拟机内存中,这些数据进入了运行时数据区。对应的Class文件中的常量池占用的内存,就叫做运行时常量池。
方法信息
- 方法名
- 返回值类型(或void)
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public, private, protected, static, final,synchronized, native , abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native 方法除外)
- 异常表(abstract和native方法除外)每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。