一、简介
从本章开始,我们将介绍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进行内存快照分析的方式来排查。
后续章节,我们将通过实际案例和代码来分析和解决各类常见的内存溢出问题。