学习Java的过程, 一个绕不过去的桩便是Java虚拟机JVM. JVM的存在赋予了Java的一次编写跨平台部署强大特性, 程序员编写的Java代码通过编译后形成字节码, 直接由虚拟机执行, 程序员也只需要和JVM打交道, JVM负责下层操作系统和硬件架构的差异性. 而掌握Java的执行和对Java性能进行分析调优的前提, 都是对JVM内部的结构, 逻辑, 设计思想的了解.
本文带大家一起梳理一下JVM的基础部分, 掌握一个Java程序能够在JVM上运行的基本流程.
内存结构VS内存模型
首先要明确两个概念上的区别: 内存结构和内存模型.
- 内存结构描述的是JVM进程的内存空间是如何布置的, 包括了我们常常听到的堆, 栈, 常量池等概念;
- 内存模型则是一个和并发紧密相关的概念, 描述的是Java中如何保证缓存一致性的机制. 这部分内容会在未来的文章中分析.
JVM的内存结构
首先开局一张图, 以下是JVM的内存结构的基本面貌, 主要分为运行时数据区, 类加载器, 执行引擎几个部分, 红色的Math
表示了一个类从外部加载到JVM的基本流程, 后面会对每个一部分进行更加详细的分析.
运行时数据区
首先来聊最大的这块运行时数据区,运行时数据区上的内存分为两类, 一类是线程共享的(黄色), 包括了堆和方法区; 另一类是线程私有的(淡紫), 包括了虚拟机栈, 本地方法栈和程序计数器. 这和操作系统中线程拥有的资源(TCB, 执行栈, 计数器与寄存器)是对应的, 至于线程本地的存储空间, Java中这个部分是采用ThreadLocal类实现的, 关于ThreadLocal的细节与实现, 参考这篇资料. 上述五个区域, 除了程序计数器之外, 都可能会出现OOM. 以下对这五个部分分别进行分析.
虚拟机栈
上面说到, 每个线程都拥有自己的虚拟机栈, 这个栈是Java方法的执行栈, 在图中左边的绿色部分展示了一个虚拟机栈的内部结构. 栈的特点就是FILO, 这和方法执行和退出的逻辑关系是一致的【尾递归方法可以用栈代替】. 在虚拟机栈中, 执行的每个方法都是一个栈帧, 当调用一个新的方法时, 就向栈中压入一个新的栈帧,当方法执行完成时, 栈帧从栈里被弹出. 每一个栈帧包括了上图中展示的四个部分, 操作数栈, 局部变量表, 动态链接和方法出口.
操作数栈
我们常听到说Java的解释执行引擎是基于栈的执行引擎, 这里说的栈就是栈帧上的操作数栈. 操作栈上的元素同样遵循FILO, 用于字节码命令写入和提取内容, 例如在做算术运算时, 栈中的就是数值, 调用其他方法时通过操作数栈传参. 操作数栈的相关概念, 还有以下的注意点:
- 操作数栈的元素可以是任意的Java数据类型, long和double占两个栈容量.
- 概念上独立的栈帧, 实际上有重合部分, 上一个栈的局部变量表会和下一个栈帧的操作数栈部分重合, 避免传参时的重复复制.
局部变量表
局部变量变量表中存储的当前方法用到的所有局部变量, 包括基本类型和引用, 在编译时期方法的局部变量表的大小就能够计算出来, 并且在运行时通常不发生改变. 局部变量表中包含8种数据类型, byte, short, int, long, float, double, char, boolean, reference和returnAddress. 其中returnAddress不太常用; reference的实现要求满足:
- 能够指向所引用的对象的堆上起始地址
- 能够指向方法区的类型数据;
关于局部变量表的其他需要注意的点:
- long和double占两个slot, ref的大小与虚拟机的位数有关
- 局部变量表的空间是允许复用的
- 基本类型的局部变量是直接分配在栈上的, 所以不进行初始化的话其值是未定义的
动态连接
动态连接是Java实现多态的基础之一【重写的底层实现】.
class文件中的方法名作为符号引用放在文件的常量池位置, 方法调用指令就以指向方法的符号引用作为参数, 这些符号引用一部分在编译时或第一次运行时转换为直接引用【静态连接】, 另一部分在运行时转换为直接引用【动态连接】. 关于静态连接, 动态连接的细节可以查看资料, 我们目前需要知道:
- 通过静态连接转换为直接引用的主要包括构造器, 静态方法, 私有方法这些无法被覆盖的方法, 方法的特点是编译期可知, 运行期不变. 这种方法的调用称为解析调用resolution
- 在动态连接过程转换为直接引用的是虚方法, 这种方法的调用成为动态分派dispatch
- 采用静态还是动态连接的依据是是否在编译期完成解析; 采用静态分派/动态分派的依据是按照静态类型还是实际类型分派
- 调用的方法是在jvm中通过
invokevirtual
调用, 则会按照实际类类型从实际类到父类查找能够匹配的方法, 因为invokevirtrual
会去查看Receiver
的实际类型; 通过invokespecial
或invokestatic
调用则会按照变量的静态类型进行方法匹配 - 解析和分派并不是二选一, 例如静态方法重载就是静态分派, 解析调用
关于什么时候按照静态类型分派什么时候按照实际类型分派, 可以举一个静态分派的例子:
public class TestStatic {
public static class Human {
}
public static class Man extends Human {
}
public static class Woman extends Human {
}
public static void sayHello(Human m) {
System.out.println("hello human");
}
public static void sayHello(Man m) {
System.out.println("hello man");
}
public static void sayHello(Woman m) {
System.out.println("hello woman");
}
public static void main(String[] args) {
Human hm1 = new Man();
Human hm2 = new Woman();
Human hm = new Human();
sayHello(hm); // j结果: hello human
sayHello(hm1);// 结果: hello human
sayHello(hm2);// 结果: hello human
}
}
上述代码的解释: 首先来看下字节码(下方), 调用的是invokestatic
, 静态方法不会被继承重写, 所以一旦类确定了就可以确定调用的是哪个类的方法, 满足编译期可知, 运行期不变, 在编译期转换符号引用转换为符号引用【解析】, 但是因为该方法有重载, 因此还需要选择一个方法关联【分派】,因为是在编译期进行的, 所以这个过程称为静态分派. 在编译时我们并不知道hm1, hm2的实际类型, 只能够知道它的静态类型(也称外观类型), 因此这时最匹配的方法是sayHello(Human)
.
24 aload_3
25 invokestatic #13 <TestStatic.sayHello>
28 aload_1
29 invokestatic #13 <TestStatic.sayHello>
32 aload_2
33 invokestatic #13 <TestStatic.sayHello>
36 return
方法出口
方法出口就是该方法执行完时从哪里继续执行. 方法出口包括了正常完成出口和异常退出出口. 正常完成出口通常是调用者的程序计数器指向的下一行指令, 而异常退出出口一般被被保存在栈帧, 直接由异常处理器处理.
本地方法栈
Java 方法的执行栈称为虚拟机栈, 而本地方法的执行栈则称为本地方法栈, 本地方法栈是个逻辑概念, 例如Hotspot中就将本地方法栈和虚拟机栈合并实现.
程序计数器
程序计数器类似cpu执行中的pc寄存器, 指向的是当前所执行的字节码的行号.
堆
堆是存放Java对象的区域, 这里存放着线程共享的几乎所有对象, 对象在堆上的空间分配有可行的两种方案, 当对象内存申请是按照顺序申请时, 只需要用一个指针指向当前使用的内存的末尾, 当新对象申请时, 则将指针向后移动一个对象的内存大小, 这种方式称为指针碰撞; 但是如果内存使用空间并不是连续的, 则无法使用上述方法, 转而可以采用空闲列表的方式.
在对象空间申请这个问题上, 当多个线程并发的申请空间时, 如何保证指针能够正确移动也是问题. 同样, 这里有两种方法, 一种是通过CAS的方式移动指针, 保证操作的原子性, 另一种是为每个线程预先分配一个区域(本地线程分配缓冲TLAB), 线程申请空间放新的对象时优先使用自己的区域, 使用自己的区域则不会有竞争.
方法区
方法区用于存放加载的类的类信息, 常量, 静态变量和jit编译的代码等数据, 具体包括:
- JVM中类的元数据在Java堆中的存储区域。
- Java类对应的HotSpot虚拟机中的内部表示也存储在这里。
- 类的层级信息,字段,名字。
- 方法的编译信息及字节码。
- 变量
- 常量池和符号解析
通过反射能够拿到的数据都存储在方法区. 常听到的永久代是JDK1.6以前Hotspot虚拟机对方法区的实现, 目的是将这部分内存纳入统一的垃圾回收管理中, 方法区的回收的目标是常量池和类的卸载. 但是采用永久代管理方法区存在以下问题: 永久代区是有大小限制的, 当有大量常量或运行时会产生大量新类(代理类)时, 会导致这个区域更容易OOM. 而其他一些虚拟机的实现采用了保证这个区域没有触碰到以使用堆内存等方案来处理.
JDK8以后的元数据区
从JDK8开始, Hotspot彻底告别了永久代, 将字符串常量移到了Heap中, 方法区移到了元数据区metaspace. metaspace存放在本地内存, 是进程内存的一部分, 摆脱了堆大小的限制, 其大小只受到操作系统的限制. 从StackOverflow上搬了一张图过来, 这个Native memory
和NIO中的DirectByteBuffer
使用的区域是同一个区域.
我们已经弄清楚了元数据区在内存中的位置,接下来讨论一下元数据区里有什么.
首先插入一小段题外话, class文件的加载. JVM规范中定义了class文件加载的至少三个步骤:
- 通过一个类的全限定名获得这个类的二进制字节流
- 将这个字节流代表的静态存储结构转化为运行时数据结构(klass)
- 在内存中生成一个代表这个类的java.lang.Class对象, 作为方法区这个类的各个数据的访问入口
在JDK1.6以前运行时数据结构和Class对象都是存放在永久代中的, 但是JDK1.8之后, Class对象存放到了heap中, klass数据和其他和该类相关的数据存放在元数据区.
元数据区分为两个部分:
- klass Metaspace: 非必须, 紧接着heap的连续空间, 目的是提高性能, 当不开启压缩指针开关时或-Xmx大于32G(自动关闭压缩指针)时, 这个空间都不存在, klass数据也放到no-klass中.
- no-klass Metaspace: 必须, 在native memory中的非连续空间, 存放klass以外的其他数据(比如method,constantPool等), 也可以存放klass
元数据空间的大小可以通过以下参数调整:
参数 | 说明 |
---|---|
-XX:MetaspaceSize | 初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。 |
-XX:MaxMetaspaceSize | 最大空间,默认是没有限制的 |
-XX:MinMetaspaceFreeRatio | 在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集 |
-XX:MaxMetaspaceFreeRatio | 在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集 |
这些参数的具体调优策略可以参考这个资料.
总结一下metaspace的特点和好处:
- 位置移到了堆外使用native memory, 取消了原来的大小限制, 采用新的参数进行控制
- 字符串常量和Class对象移到了heap中管理
- 类和类加载器的生命周期一致, 不需要单独回收某个类的空间, 当类加载器被回收时, 所属metaspace的内存一并回收
以上总结了JVM内存结构的运行时数据区的结构, 各个部分的功能和特点, 接下来还有两篇文章, 分别从类加载的角度和GC的角度分析总结JVM的内存结构和管理, 最后以一个类从加载到实例化到销毁的过程进行总结.
参考资料: