内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了JVM 的高效稳定运行。不同的JVM 对于内存的划分方式和管理机制存在着部分差异。结合JVM 虚拟机规范,来探讨一下经典的JVM 内存布局,如下图所示
![](https://img.haomeiwen.com/i1702706/6bde806c412520a5.png)
1.Heap(堆区)
Heap是OOM 故障最主要的发源地,它存储着几乎所有的实例对象,堆由垃圾收集器自动回收,堆区由各子线程共享使用。通常情况下,它占用的空间是所有内有区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间。堆的内存空间既可以固定大小,也可以在运行时动态地调整,通过如下参数设定初始值和最大值,比如-Xms256M-Xmx1024M,其中-X表示它是JVM运行参数,ms是memorystart的简称,mx是memory max 的简称,分别代表最小堆容量和最大堆容量。但是在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力,所以在线上生产环境中,JVM的Xms和Xmx设置成一样大小,避免在GC 后调整堆大小时带来的额外压力。
堆分成两大块:新生代和老年代。对象产生之初在新生代,步入暮年时进入老年代,但是老年代也接纳在新生代无法容纳的超大对象。新生代=1个 Eden 区 +2个Survivor 区。绝大部分对象在 Eden 区生成,当 Eden 区装填满的时候,会触发 Young Garbage Collection,即YGC。垃圾回收的时候,在 Eden 区实现清除策略,没有被引用的对象则直接回收。依然存活的对象会被移送到 Survivor 区,这个区真是名副其实的存在。Survivor 区分为 SO和 S1 两块内存空间,送到哪块空间呢? 每次YGC的时候,它们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除,交换两块空间的使用状态。如果YGC 要移送的对象大于 Survivor 区容量的上限,则直接移交给老年代。假如一些没有进取心的对象以为可以一直在新生代的Survivor 区交换来交换去,那就错了。每个对象都有一个计数器,每次YGC都会加1。-XX:MaxTenuringThreshold 参数能配置计数器的值到达某个值的时候,对象从新生代晋升至老年代。如果该参数配置为 1,那么从新生代的 Eden 区直接移至老年代。默认值是 15,可以在 Survivor 区交换 14 次之后,晋升至老年代。与上图 匹配的对象晋升流程图如下图 所示。
![](https://img.haomeiwen.com/i1702706/ba36072b102b59b3.png)
上图中,如果 Survivor区无法放下,或者超大对象的阙值超过上限,则尝试在老年代中进行分配;如果老年代也无法放下,则会触发Full Garbage Collection,即FGC。如果依然无法放下,则抛出OOM。堆内存出现OOM的概率是所有内存耗异常中最高的。出错时的堆内信息对解决问题非常有帮助,所以给JVM设置运行参数XX:+HeapDumpOnOutOfMemoryError,让JVM遇到OOM异常时能输出堆内信息特别是对相隔数月才出现的OOM异常尤为重要。
在不同的JVM 实现及不同的回收机制中,堆内存的划分方式是不一样的。
2.Metaspace(元数据区)
本文章源码解析和示例代码基本采用JDK11版本,JVM 则为 Hotspot。早在JDK8版本中,元空间的前身Perm 区已经被淘汰。在JDK7及之前的版本中,只有Hotspo才有 Perm 区,译为永久代,它在启动时固定大小,很难进行调优,并且FGC 时会移动类元信息。在某些场景下,如果动态加载类过多,容易产生 Perm 区的OOM。比如某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很的类,经常出现致命错误:
'Exception in thread dubbo client x.x connector’java.lang.OutOfMemoryError: PermGenspace"
为了解决该问题,需要设定运行参数-XX:MaxPermSize=1280m,如果部署到新机器上,往往会因为JVM 参数没有修改导致故障再现。不熟悉此应用的人排查问题时往往苦不堪言,除此之外,永久代在垃圾回收过程中还存在诸多问题。所以,JDK8使用元空间替换永久代。在JDK8及以上版本中,设定MaxPermSize参数,JVM 在启动时并不会报错,但是会提示: Java HotSpot 64Bit Server VM waming:ignoring option MaxPermSize-2560m; support was removed in 8.0.
区别于永久代,元空间在本地内存中分配。在JDK8里,Perm 区中的所有内容中字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内,比如Object 类元信息、静态属性 System.out、整常量10000000等。在常量池中的String,其实际对象是被保存在堆内存中的。
3.JVM Stack ( 虚拟机栈)
栈( Stack )是一个先进后出的数据结构,就像子弹的弹夹,最后压入的子弹先发射压在底部的子弹最后发射,撞针只能访问位于顶部的那一颗子弹。
相对于基于寄存器的运行环境来说,JVM 是基于栈结构的运行环境。栈结构移植性更好,可控性更强。JVM 中的虚拟机是描述 Java 方法执行的内存区域,它是线程私有的。栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程。在活动线程中,只有位于栈顶的赖才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈进行操作。而 StackOverflowError表示请求的栈溢出,导致内存耗尽,通常出现在递归方法中。JVM 能够横扫千军,虑拟机栈就是它的心腹大将,当前方法的栈帧,都是正在战斗的战场,其中的操作栈是参与战斗的士兵。操作栈的压栈与出栈如下图所示。
![](https://img.haomeiwen.com/i1702706/3ea18cf30af56ac4.png)
虚拟机栈通过压栈和出栈的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上。在执行的过程中,如果出现异常,进行异常回溯,返回地址通过异常处理表确定。栈帧在整个JVM 体系中的地位颇高包括局部变量表、操作栈、动态连接、方法返回地址等。
- 局部变量表
局部变量表是存放方法参数和局部变量的区域。相对于类属性变量的准备阶段和初始化阶段来说,局部变量没有准备阶段,必须显式初始化。如果是非静态方法,在index[0]位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变量写回局部变量表存储空间内。 - 操作栈
操作栈是一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack属性中下面用一段简单的代码说明操作栈与局部变量表的交互:
public int simpleMethod() {
int x = 13;
int y = 14;
int z = x + y;
return z;
}
详细的字节码操作顺序如下(Intellij IDEA 中查看字节码 View -> Show bytecode):
// access flags 0x1
public simpleMethod()I
L0
LINENUMBER 25 L0
BIPUSH 13 // 常量13压入操作栈
ISTORE 1 // 并保存到局部凌量表的 slot 1中 (第1处)
L1
LINENUMBER 26 L1
BIPUSH 14 //常量14压入操作找,注意是BIPUSH
ISTORE 2 //并保存到局部变量表的sIot 2中
L2
LINENUMBER 27 L2
ILOAD 1 //把局部变量表的 slot 1尤素 (int x)压入操作栈
ILOAD 2 // 把局部变量表的 slot 2 元素 (int y)压入操作栈
IADD //把上方的两个数都取出来,在 CPU 里加一下,并压回操作栈的栈顶
ISTORE 3 //把栈顶的结果存储到局部变量表的 slot 3 中
L3
LINENUMBER 28 L3
ILOAD 3
IRETURN //返回栈顶元素值
L4
LOCALVARIABLE this Lcom/linkmiao/iot/demo/test/d202311/TestWhoLoad; L0 L4 0
LOCALVARIABLE x I L1 L4 1
LOCALVARIABLE y I L2 L4 2
LOCALVARIABLE z I L3 L4 3
MAXSTACK = 2 // // 最大 深度为 2,局部变量个数为4
MAXLOCALS = 4
第1处说明:局部变量表就像一个中药柜,里面有很多抽展,依次编号为 0,1,2.3,·,n,字节码指令ISTORE1就是打开1号抽屉,把栈顶中的数13存进去。栈是一个很深的竖桶,任何时候只能对桶口元素进行操作,所以数据只能在栈顶进行存取。某些指令可以直接在抽屉里进行,比如 iinc 指令,直接对抽屉里的数值进行 +1操作。
- 动态连接
每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接。 - 方法返回地址
方法执行时有两种退出情况:第一,正常退出,即正常执行到任何方法的返回字节码指令,如RETURN、IRETURN、ARETURN等:第二,异常退出。无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式
- 返回值压入上层调用栈帧。
- 异常信息抛给能够处理的栈帧。
- PC计数器指向方法调用后的下一条指令。
4.Native Method Slacks(本地方法栈)
本地方法栈(Native Method Stack)在JVM 内存布局中,也是线程对象私有的,但是虚拟机栈“主内”,而本地方法栈“主外”。这个“内外”是针对JVM 来说的,本地方法栈为 Native方法服务。线程开始调用本地方法时,会进入一个不再受JVM约束的世界。本地方法可以通过JNI (Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和JVM相同的能力和权限。当大量本地方法现时,势必会削弱JVM 对系统的控制力,因为它的出错信息都比较黑盒。对于内有不足的情况,本地方法栈还是会抛出native heap OutOfMemory。
重点说一下JNI类本地方法,最著名的本地方法应该是Systen.currentTimeMillis(),JNI使 Java 深度使用操作系统的特性功能,复用非 Java代码。但是在项自过程中,如果大量使用其他语言来实现JNI,就会丧失跨平台特性,威胁到程序运行的稳定性。假如需要与本地代码交互,就可以用中间标准框架进行解耦.这样即使本地方法崩溃也不至于影响到JVM的稳定。当然,如果要求极高的执行效率偏底层的跨进程操作等,可以考虑设计为JNI调用方式。
5.Program Counter Register ( 程序计数寄存器)
在程序计数寄存器(Program Counter Register,PC)中,Register 的命名源于CPU的寄存器,CPU只有把数据装载到寄存器才能够运行。寄存器存储指令相关的现场信息,由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常。
最后,从线程共享的角度来看,堆和元空间是所有线程共享的,而虚拟机栈、本地方法栈、程序计数器是线程内部私有的,从这个角度看一下Java内存结构,如下图所示。
![](https://img.haomeiwen.com/i1702706/a8fc24ad2077baf5.png)
网友评论