2023-08-10
原文作者:Ressmix 原文地址:https://www.tpvlog.com/article/97

一、简介

从本章开始,我们将介绍JVM中的内存溢出异常——Out of Memory。我们运行Java程序时,本质是创建了一个JVM进程,然后在里面执行Java字节码。既然是进程,就一定有内存限制,当Java程序使用的内存空间超过限制时,就可能发生内存溢出异常。

在JVM中,一共有三种可能出现OOM的地方:方法区(元数据区)、Java栈内存、Java堆内存。本章,我们就来一一看一下各个区域内存溢出的情况。

二、方法区溢出

JVM内存模型中,我们介绍过JVM内存模型,JVM中有一块区域叫“方法区”,里面主要保存着从”.class“文件里加载进来的类,包括 类的名称方法信息字段信息静态变量常量 以及 编译器编译后的代码 等。

JDK1.8及以后这块区域叫做“元数据区",元数据区直接使用本地内存。默认情况下,元数据区会根据使用情况动态调整,避免了在JDK1.8以前由于加载类过多从而出现 java.lang.OutOfMemoryError: PermGen。但也不能无限扩展,因此可以使用-XX:MaxMetaspaceSize来控制最大内存。

既然元数据区有大小,那这里就可能发生内存溢出。

2.1 溢出原因

元数据区中对象一般是不会被回收的,JVM进行Full GC时,会尝试对元数据区中的垃圾对象进行回收。但是元数据区中的对象回收的条件是相当苛刻的:比如加载这个类的类加载器先要被回收、这个类的所有对象实例都要被回收等等。所以,即使Full GC针对元数据区进行垃圾回收,也未必能够回收掉很多垃圾对象。

JVM启动时,元数据区默认情况只会分配几十MB空间,所以生产环境一定要显式指定该区域的大小,一般512MB就足够了:

--XX:MetaspaceSize=512m --XX:MaxMetaspaceSize=512m

针对元数据区,避免内存溢出的最好办法就是预估系统运行模型,然后合理分配Metaspace区域的内存大小,同时避免无限制的通过动态代理生成类。

三、栈溢出

JVM中,每个线程都有自己的虚拟机栈,就是所谓的 栈内存 。线程只要执行某个方法,就会为该方法创建一个栈帧,然后将栈帧入栈到虚拟机栈中。这个栈帧中存放着方法的各种局部变量。

我们通过参数JVM启动的-Xss参数设置栈内存的大小,比如我们之前的示例中,一般栈内存大小都指定为1MB——-Xss1M。所以,既然栈内存有大小,那这里也可能发生内存溢出,我们通过示例来看下。

3.1 溢出原因

Java虚拟机栈的内存大小是有限的,如果一个线程不停的层层调用方法,每次调用就会创建栈帧入栈,因为栈帧是有大小的,所以当虚拟机栈满了以后,就会出现栈内存溢出。

一般来说,除非是一些递归调用,否则线程不会一直只入栈不出栈,而且1MB的栈大小也足够容纳递归调用所需的栈内存。所以,引发栈内存溢出的往往都是程序bug,比如递归调用时没有终结条件等。

四、堆溢出

Java堆内存,应该是我们进行JVM调优接触最多的一部分区域了。这里存放着我们程序代码里创建的各种各样的对象。一般来说,我们给Java堆内存分配空间时,是固定的大小,所以这里也是最容易出现内存溢出的区域。

4.1 溢出原因

我们知道,Young GC过后的存活对象首先会先尝试进行一块Survivor区,如果Survivor区无法容纳,则尝试进入老年代,如果此时老年代也满了就会触发Full GC。但是, 如果Full GC之后,老年代的空间还是不够呢? 这时只能抛出内存溢出异常了。

所以,堆内存溢出的原因,总结起来就是一句话:有限的内存中放了过多的对象,而且大多数对象是存活的,此时要继续放入更多对象已经不可能了,只能抛出内存溢出异常。

通常,能引发堆内存溢出的场景主要有两种:

  • 系统承载高并发请求,因为请求量过大,导致大量对象都是存活的,所以要继续放入新的对象实在是不行了,只能抛OOM。
  • 程序存在bug导致内存泄漏,这即使触发GC也无法回收掉这些泄漏的对象,导致内存占用越来越多,直到OOM。

五、总结

本章我们介绍了JVM中可能出现内存溢出的几个区域,以及引发OOM的基本原因。一般来说,元数据区和Java虚拟机栈是不会出现OOM的,而Java堆内存则是最容易出现内存溢出的区域。

针对各类内存溢出问题,生产环境的系统需要有配套的监控系统对OS、JVM的状态进行监控,重点关注CPU、内存、JVM的GC频率这三个指标,一般来说成熟的公司都会有Zabbix、Open-Falcon之类的监控平台,小型公司则可以通过日志结合jhat进行内存快照分析的方式来排查。

后续章节,我们将通过实际案例和代码来分析和解决各类常见的内存溢出问题。

阅读全文