【问答】JVM哪些区域会触发OOM?实践检验一下

共 5500字,需浏览 11分钟

 ·

2021-03-11 16:24

作者:z小赵

★ 

一枚用心坚持写原创的“无趣”程序猿,在自身受益的同时也让朋友们在技术上有所提升。


前言

「君子需严于律己,一日三省吾身」

在生产环境中,一向以求稳的心态去慎重升级技术栈。新版本的发布,贸然升级可能会使得生产环境服务不稳定甚至出现故障,但这不能成为你不去学习更加优秀技术的借口。

现在你的大脑中是否存有这样的直观认知?比如:「Synchronized」 就是重量级锁,在并发环境中应该摒弃掉它?比如:「ConcurrentHashMap」 还是以分段加锁的模式来保证线程安全?在如:ArrayList 在删除元素的时候就比 LinkedList 慢?

不知道有没有人调查过目前 JVM 的各个垃圾回收器在市场中的占有率?如果目前你还停留在 「CMS(Concurrent Mark Sweep)」 阶段,那么请和作者一起反思下,为什么 「G1(Garbage First)」 已经悄悄占领了垃圾回收的高地?你却毫不知情,更有甚者,有些技术团队已经在尝试使用「ZGC」垃圾回收器了。

其实在很大一部分情况下,如果你愿意去深入了解技术升级后带来的优势(不管是服务响应速度更快,还是能够解决一些当下系统存在的不足),可能你会更加愿意去做技术升级,而不是一味的求稳而容忍系统存在的不足。

基于此,我们更应该回头重新审视自己的技术认知在当下是否还是正确的。后续文章我想重新认识下 JVM 开发团队为了提高 JVM 的工作效率所作出的不断改进,并结合一些实际操作姿势来深入学习一下。如果你有同样的想法,那请跟随我的文章来一起学习。

JVM 运行时内存数据区分布

JDK7 以前的运行时数据区分布图如下:

JDK8 以后的运行时数据区分布图如下:

JVM 运行时,整个内存大概被分为以上几大块,其中黄色部分是线程私有的,而绿色区域为线程共享内存的,粉色部分是机器自带的方法库,不在 JVM 的管理范围内,而元数据空间是 JDK8 引入的,用于替代方法区。

从上面两个不同的 JDK 版本对应的运行时数据区图可以看出,JDK8 以后 JVM 的运行时数据区发生了一些变化,JDK8 取消了方法区并使用元数据空间进行替代,元数据空间的内存是在运行时数据区外分配的一块内存。

接下来就每个区域所扮演的角色和功能,分析下运行时数据区每部分的区域要实现的功能是什么?每部分会发生哪些内存溢出情况,并通过具体示例演示对应的内存溢出情况,以便在生产环境中出现内存溢出时更快定位问题。

程序计数器

「记录行号,指示虚拟机下一条应该执行的命令」。在单线程环境中,虚拟机可以按照顺序、跳转、分支等不同逻辑,选择相应的下一条该执行的命令是没有问题的;但是在多线程环境下,由于系统采用时间片的方式,导致多条线程的上下文会不断的切换,如果线程当前执行到的位置没有被记录下来,此时线程让出 CPU 轮到其他线程执行,当再次轮到当前线程执行的时候,由于不知道上一次中断的位置,也就意味着不知道该从哪里开始接着执行了。所以需要一个能够记录线程中断位置的存储器,即程序计数器。

由于每条线程都需要一个对应的程序计数器用于记录线程中断时执行到的位置,也就意味着程序计数器是和线程绑定的,所以程序计数器是线程私有的。

试想记录每条线程执行过程中被中断的位置,需要占用的内存是非常少的;另外随着线程的销毁,对应的程序计数器占用的内存也就跟着被回收了;所以 Java 虚拟机规范规定此区域为唯一一块不会出现任何运行时异常的内存区域,如 StackOverflowError、OutOfMemoryError。

虚拟机栈

「存储方法执行时的局部变量表、操作数栈、动态连接、方法返回等信息」。方法开始执行,创建对应的栈帧,随着方法的执行过程变化,栈帧中的数据不断进行入栈出栈操作,当方法执行完后栈空并销毁。

通过栈帧的创建与销毁的过程可以看出,栈帧和方法是相对应的,而虚拟机栈存储了一个个栈帧,当栈帧全部出栈以后,对应的线程工作也就完成了,也就是说虚拟机栈是线程私有的。

如上图所示,虚拟机栈是有一个一个的栈帧组成,随着一个方法被调用,此时会创建出一个对应的栈帧,并将其加入到虚拟机栈中。虚拟机栈栈顶的栈帧称之为当前栈帧,线程只会操作栈顶的栈帧(被操作的栈帧也称之为活动栈帧),对应的方法被称之为当前方法,每一个方法的执行开始到结束对应着一个栈帧在虚拟机栈中的入栈出栈操作。每个栈帧又由局部变量表、操作数栈、动态连接、方法返回地址等几部分组成。

局部变量表

局部变量表用于存储方法入参,方法内部定义的局部变量等信息。局部变量是通过变量槽来表示的,每个变量槽可以保存的数据类型有 boolean、byte、char、short、int、float、reference 等,如果变量是 64 位 的话,会占用两个变量槽,如果是小于等于 32 位的话,只占用一个变量槽。变量槽的数量在 Java 文件被编译后就确定了,但是局部变量表具体占用多大内存是由不同虚拟机机制决定的。

操作数栈

顾名思义,操作数栈是一个存放操作数的栈(先进后出的数据结构)。那么操作数是什么? 简单来说就是指令(在JVM中就是字节码指令)操作的对象(操作的数),比如说JVM的iadd 指令的作用就是将操作数栈中栈顶的两个int类型的数值相加,然后将结果压入操作数栈。此时这两个int类型的数值就是针对 iadd 指令的操作数。

所以你编写的那些方法里的语句,最终会编译成一个个字节码指令,然后由JVM去执行,执行的过程中就会用到操作数栈。

趣谈编程注:JVM是基于栈的指令架构。

动态连接

试想当程序需要执行某个方法时,如何确定被调用的方法的内存位置呢?首先需要通过符号引用(只是一个符号,代表了这个方法)转化为直接引用(可以简单理解为可以实际操作目标的引用,比如指向目标的指针),符号引用被存储在方法区的运行时常量区(JDK7 以前)或者元数据空间中(JDK8 以后)。

对于符号引用,有的是在类加载阶段就将其转为直接引用,此类连接称之为静态连接;而有的是在方法调用时动态的转为直接引用,这类连接称之为动态连接。

注意:符号引用不仅仅存在于方法,类和字段也有符号引用。

方法返回地址

当一个方法执行完毕或发生异常时,方法退出后需要返回到之前被调用的方法的位置,然后程序在继续执行;当方法正常返回时,需要在当前栈帧中记录一些信息,如返回值信息等,帮助调用者恢复执行状态。

虚拟机栈异常

Java 虚拟机规范规定了栈是有深度的,当栈深度超过了指定大小后会抛出 StackOverflowError。为什么 Java 虚拟机要规定栈的深度呢?细想一下,假设不规定栈的深度的话,线程在执行方法的时候,如果方法内部出现了死循环,比如在方法内部在调用自己,导致不停的创建新的栈帧被压入虚拟机栈中,随着栈帧被不断被加入到栈中,必然需要申请更多的内存来存储数据,当内存被不停的占用,最终导致整个虚拟机内存被使用完,那将是一个灾难性的事情,所以虚拟机栈里规定了栈深度。

在虚拟机栈区域内,Java 虚拟机规范还规定了如果此区域的内存大小是动态可扩展的话,那么当内存不够使用的时候,虚拟机栈想要申请更多的内存来存储元素,但如果申请不到足够多的内存来存储变量的话,就会触发 OutOfMemoryError(目前生产环境中,大部分都是用的是 HotSpot 虚拟机,其不支持动态可扩展,所以一般不会出现 OOM)。

说明:后续关于各种区域内存异常演示,没有特殊说明情况下,都是基于 jdk1.8 下,并采用 CMS 垃圾收集器环境进行演示的。

下面通过示例代码来演示一下虚拟机栈异常的情况。

  1. 「默认情况下栈溢出」

如下图,默认情况下栈深度达到 15970 后抛出了 StackOverflowError 错误。(注意:默认时每次测试栈抛出异常时对应的栈最大深度都不相同,所以这个默认值的大小和测试机本身还有一些关系,不过一般方法的栈深度基本不会达到这么大,如果感兴趣的朋友可以在研究下为什么每次测试对应的栈最大深度都不一样,作者暂时还没搞清楚是为什么):

  1. 「配置 -Xss160k 参数」

指定栈容量大小后,栈溢出情况如下图:

  1. 「配置 -Xss128k」

如果指定栈内存小于 160k 会报如下图所示错误,即 JVM 的内存大小是有限制的:

本地方法栈

本地方法栈和虚拟机栈类似,区别在于虚拟机栈是服务于 Java 方法的,而本地方法栈服务于本地的方法的。

本地方法是什么?本地方法可以简单理解成是被 native 关键字修饰的方法,也许平时你对本地方法调用的使用非常少甚至没有用过,但如果你看过 Unsafe 类,那么应该对 native 方法就不陌生了,这里不会展开讲述 Unsafe 类,感兴趣的朋友可以自行研究。

通常我们会把本地方法栈和虚拟机栈混为一个东西,在一定程度上也没有太大的问题,所以本地方法栈同虚拟机栈一样,同样是线程私有的,同时 Java 虚拟机规范规定此区域可以抛出 StackOverflowError 和 OutOfMemoryError 两种异常。

Java 堆

Java 堆是用于存储程序运行时创建的对象,也是 JVM 虚拟机重点关注的一块地方。

比如通过 new 关键字创建一个对象,那么该对象就会在堆区中为其分配一部分内存存储该对象,一个对象可以被多个引用去指向,可以类比成 C 和 C++ 中的指针,不同线程内部的某个变量都可以指向堆中的同一个对象,所以堆并不是某个线程私有的,而是公共的。

堆中的对象不停产生,同时伴随着对象引用的取消,导致创建出来的对象不被 JVM 中任何变量引用,此时认为该对象可以被垃圾回收器回收。对于堆中对象的回收,不同垃圾收集器采用了不同的方式进行回收,以目前使用最为广泛的两种收集器为例。

CMS 垃圾回收器采用分代回收思想进行垃圾回收,根据对象的产生和生存时间的不同,将堆分为新生代、老年代、永久代,其中新生代又被分为一个 Eden 区和两个 Survivor 区,默认比例为 8:1:1。

G1 垃圾回收器将堆内存切分成一个一个的 Region 块,每个 Region 内的对象可能会包含了任何年代的对象(新生代,老年代,幸存区),每次 G1 垃圾回收器会根据回收所获得空间大小以及回收所需要的时间来进行回收。

Java 堆的大小我们可以通过 -Xmx 和 -Xms 两个参数来控制,其中 -Xms 参数指定了堆内存的最小值, -Xmx 指定了当堆内存不够时,可以扩展到最大 -Xmx 指定大小的内存。Java 虚拟机规范规定当扩展到 -Xmx 时指定的容量时,还没有足够的内存去容纳新产生的对象时,就会触发 OutOfMemoryError 的异常。

下面通过指定 -Xmx15m -Xms10m,堆内存异常时的情况如下图:

可见,当堆内存不足以容纳新产生的对象时,会抛出 OutOfMemoryError 异常,并且指定说明了是 Java heap space 区。

如果指定 -Xmx 值小于 -Xms 时,程序会在初始化时直接抛出如下图异常:

方法区

用于存放已经被虚拟机加载的类型信息、常量、静态变量等等信息。在 Java8 之前还有永久代的概念时,方法区就是在永久代实现的,而在 Java8 之后,已经不在有永久代的概念,而是使用了元数据空间进行了替代。

直接内存(Direct Memory)

直接内存又称之为堆外内存,这块内存不被JVM所管理,其不属于 Java 虚拟机运行时数据区的一部分,可以通过 DirectByteBuffer 对象去操作堆外内存。

使用直接内存在一些场景下可以显著提高性能。比如Netty在接收和发送数据的时候使用了 DirectByteBuffer,避免了堆内存与直接内存之间的拷贝。如果使用传统的HeapByteBuffer来进行Socket读数据的话,则需要将Socket缓冲区的内容先拷贝到本地内存中,然后再将本地内存中的内容拷贝到堆内存中。DirectByteBuffer则不用堆内存与直接内存之间的拷贝。见下图:

Java 虚拟机规定,直接内存在没有足够的空间容纳新产生的对象时,同样也会产生 OutOfMemoryError 异常。默认情况下直接内存和 Java 堆内存大小相等,也可以通过 「-XX:MaxDirectMemorySize」 参数指定直接内存的大小。

指定 -XX:MaxDirectMemorySize=15m,直接内存异常异常情况如下图,从报错信息可以看出,是 Native 方法抛出的 OOM:

总结

通过比对不同 JDK 版本发现,JVM 团队为提高垃圾回收工作效率而做出的一些努力;同时结合具体的示例验证 JVM 不同区域发生异常时的情况,从而加深对 JVM 不同区域的理解。

PS: 问答栏目专注于程序员平时遇到的大大小小的问题,偏实战,如果你平时有遇到什么问题,或者你乐于帮助别人解答问题。欢迎加我微信(QuTanBianCheng_Tao)拉你进问答社区群,加我时备注问答社区



趣谈编程

让天下没有

难懂的技术

浏览 48
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报