Java 内存溢出 (OOM 异常完全指南),原文见:http://www.jianshu.com/p/2fdee831ed03
作者 CHEN 川是从这里翻译来的:https://plumbr.io/outofmemoryerror
笔者后来是独立翻译实验
Java 应用程序被允许使用有限的内存。这个限制在程序开始运行的时候就被说明了。为了便于处理,Java 内存被分成两个区域,分别称为:堆内存/堆空间 (Heap space) 和永久代 (Permgen, Permanent Generation)。
这两个区域的大小可以在 JVM(Java 虚拟机) 启动时通过参数-Xmx 和-XX:MaxPermSize 设置。如果你不显示指定大小,将使用特定平台的默认值。
当应用程序试图添加更多的数据到堆空间区域,却没有足够空间,此时 java.lang.OutOfMemoryError: Java heap space error 将被触发。注:系统可能有许多未使用的物理内存,但是当 JVM 到达堆空间大小限制的时候,java.lang.OutOfMemoryError: Java heap space error 异常仍然会被抛出。
注意 (编者加):上面的 JVM 内存模型是 JDK7 的模型,在 JDK8 中已经移除了永久代,取而代之的是 MetaSpace(主要存放类的元数据)。
触发 java.lang.OutOfMemoryError: Java heap space error 异常的常见原因:应用程序需要 XXL 号的堆内存,但是却提供了一个 s 号的堆内存。也就是说:应用程序需要比它所能得到的更大的堆内存。其它引发 OutOfMemory 的原因更加复杂,也有可能是程序原因。
第一个应用程序非常简单 -- 下面的 java 代码试图申请一个 2M 的数组。当编译并以一个 12M(java -Xmx12m OOM) 的堆内存运行的时候,将会失败,并且提示:Java.lang.OutOfMemoryError: Java heap space message。如果给与 13M 的堆内存,程序将运行很好。
class OOM {
static final int SIZE=2*1024*1024;
public static void main(String[] a) {
int[] i = new int[SIZE];
}
}
运行结果如下:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at OOM_heapspace.main(OOM_heapspace.java:7)
在 Java 中,当开发者创建和使用新的对象,例如 new Integer(5),他们不用自己分配内存 -- 这个工作是 Java 虚拟机 (JVM) 来做的。在整个应用程序生命周期中,JVM 会定期检查,内存中的哪些对象仍然在使用,哪些对象没有继续使用。不再使用的内存对象会被回收,并被重新分配和再使用。这个过程称之为垃圾回收(Garbage Collection)。JVM 执行这个功能的模块被称为 Garbage Collector(GC)。
Java 的自动内存管理依赖于 GC 定期的寻找不再使用的对象并且移除他们。 简单来说,我们可以说:在 Java 中的内存泄漏是这样一种情况,一些对象不再被应用程序使用,但是 GC 却没办法识别他们。因此这些不再被使用的对象仍然无限期的保留在 Java 的堆空间中。这样的堆积最终将触发 java.lang.OutOfMemoryError: Java heap space 异常。
很容易新建一个 java 程序来满足内存泄露的定义。
class KeylessEntry {
static class Key {
Integer id;
Key(Integer id) {
this.id = id;
}
@Override
public int hashCode() {
return id.hashCode();
}
}
public static void main(String[] args) {
Map m = new HashMap();
while (true)
for (int i = 0; i < 10000; i++)
if (!m.containsKey(new Key(i)))
m.put(new Key(i), "Number:" + i);
}
}
当执行上面的代码,我们可能会认为程序会一直跑下去,没有任何问题,认为存储在缓存中的 Map 将扩展到 10000 个元素,再往下,所有的 Key 都已经在 HashKey 中存在了。然而,实际情况是 Key class 的元素并不包含 equals 这个方法的实现。
因此,随着时间的继续,泄露内存代码的持续运行,会导致消耗大量 java 堆空间。当持续占满所有可用的堆空间,并且 GC 不能清除的时候,就会触发 java.lang.OutOfMemoryError: Java heap space 异常。
解决方案非常简单 -- 添加 equals() 方法的实现,这样代码就能很好的运行。
@Override
public boolean equals(Object o) {
boolean response = false;
if (o instanceof Key) {
response = (((Key)o).id).equals(this.id);
}
return response;
}
某些情况,我们分配给 JVM 堆空间的内存数量不能满足程序运行的需求。此时,我们应该分配更多的堆空间,看本文的结尾如何做。
然而,在更多情况下,提供更多堆空间并不能解决问题。例如应用程序有内存泄漏,添加更多的堆内存只会推迟 java.lang.OutOfMemoryError: Java heap space 异常。另外,增加堆空间数量也会增加 GC 暂停的次数进而影响应用程序的吞吐量或者延迟 (latency).
如果希望解决 Java 堆空间的潜在问题,而不是掩盖问题,那么我们需要搞清楚代码的哪一部分负责申请内存。换句话说,必须回答以下问题:
在这一点上,一定要确信搞清楚。下面是一个大致的大纲,这个大纲将帮助我们回答上面的问题:
解决方案就是增大堆空间-Xmx1024m
再例如所有下面的配置具有同样功能,因为我们可以使用 g/G/m/M/k/K。例如所有如下配置都是相同的,最大堆空间是 1GB:
java -Xmx1073741824 com.mycompany.MyClass
java -Xmx1048576k com.mycompany.MyClass
java -Xmx1024m com.mycompany.MyClass
java -Xmx1g com.mycompany.MyClass
JRE(Java Runtime Environment) 包含了一个自带/内嵌的 GC(Garbage Collection) 程序。在许多其它编程语言中,开发者需要自己申请和释放内存。
另一方面,Java 程序只需要申请内存即可。当内存中一个空间不再使用的时候,一个独立的称作 GC 的进程将清除这些不再使用的内存。GC 是如何检测内存中特殊区域,详细情况请见:Garbage Collection Handbook,但是我们应该信任 GC 能做好它的工作 (内存回收)。
java.lang.OutOfMemoryError: GC overhead limit exceeded 异常将被触发,当应用程序消耗了所有可用的内存,GC 还在不停的清除内存,并且清除内存一直失败。
java.lang.OutOfMemoryError: GC overhead limit exceeded 异常,是 JVM 发出的一个信号,表明:应用程序花费了太多时间在做内存回收的工作,回收结果却不好。默认情况下,JVM 将会报错,如果花费超过 98% 时间在执行 GC 操作,却仅仅回收了不到 2% 的内存。
如果 GC overhead limit 不存在,将会发生什么事情?java.lang.OutOfMemoryError: GC overhead limit exceeded 异常只有在这种情况下才会被触发,经过几次 GC 循环操作之后,只释放了 2% 的内存。这意味着只有少量的堆空间能被清除,这些空间将会被很快再次用掉,强迫 GC 再次重新开始清除进程。这就形成了一个恶性循环,CPU100% 被用于 GC 操作,没办法做其他事情了。应用的终端用户感觉非常慢 - 通常毫秒级别完成的操作,现在却需要数分钟才能完成。
于是"java.lang.OutOfMemoryError: GC overhead limit exceeded"提示可以看做"fail fast"规则一个非常棒的例子。
下面的例子,我们将创建一个 GC overhead limit exceeded 异常,通过初始化一个 MAP,通过无限循环添加 key-value 对到 map 中。
class Wrapper {
public static void main(String args[]) throws Exception {
Map map = System.getProperties();
Random r = new Random();
while (true) {
map.put(r.nextInt(), "value");
}
}
}
你有可能猜到,这段代码可能不能很好的结束。确实,当用下列配置运行这段代码的时候,
java -Xmx100m -XX:+UseParallelGC Wrapper
java -Xmx10m -XX:+UseParallelGC Wrapper
java -Xmx20m -XX:+UseParallelGC Wrapper
错误信息如下 (使用 JDK7):
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Hashtable.rehash(Hashtable.java:402)
at java.util.Hashtable.addEntry(Hashtable.java:426)
at java.util.Hashtable.put(Hashtable.java:477)
at GC_ole.main(GC_ole.java:11)
当使用如下参数 java -Xmx2m -XX:+UseParallelGC Wrapper,错误信息如下:
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.util.Hashtable.put(Hashtable.java:541)
at GC_ole.main(GC_ole.java:11)
不久,我们就将看到 java.lang.OutOfMemoryError: GC overhead limit exceeded 异常。但是如果我们配置不同的堆空间大小或者不同的 GC 算法,结果会有不同。例如,用如下方式在 ubuntu16.04,用 Hotspot1.7.0_80 运行:
java -Xmx10m -XX:+UseParallelGC Wrapper
我们将看到如下错误:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Hashtable.rehash(Hashtable.java:471)
at java.util.Hashtable.put(Hashtable.java:532)
at GC_ole.main(GC_ole.java:11)
使用以下 GC 算法:-XX:+UseConcMarkSweepGC 或者-XX:+UseG1GC,启动命令如下:
java -Xmx100m -XX:+UseConcMarkSweepGC Wrapper
java -Xmx100m -XX:+UseG1GC Wrapper
得到的结果是这样的:
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
错误已经被默认的异常处理程序捕获,并且没有任何错误的堆栈信息输出。
以上这些变化可以说明,在资源有限的情况下,你根本无法无法预测你的应用是怎样挂掉的,什么时候会挂掉,所以在开发时,你不能仅仅保证自己的应用程序在特定的环境下正常运行。
如果我们仅仅想去掉 java.lang.OutOfMemoryError: GC overhead limit exceeded 这个消息,添加下列到启动脚本里面就可以做到:
-XX:-UseGCOverheadLimit
烈建议不要这样做 -- 应该修复解决这个问题,而不是将这个不可避免的问题推迟或者延后;因为应用程序将会把内存用尽。错误信息也变成了更加熟悉的 java.lang.OutOfMemoryError: Java heap space 而已。
某些情况下,GC overhead limit exceeded 异常被触发,因为申请的内存不能满足程序运行的需要。此时,应该申请更多的内存 -- 看本文末尾如何做到这一点。例如应用程序存在内存泄漏,将推迟 java.lang.OutOfMemoryError: Java heap space 异常。另外增加内存将增加 GC 暂停的时间长度,影响到应用程序的吞吐量和延迟等。
如果希望解决 Java 堆空间的潜在问题,而不是掩盖问题,那么我们需要搞清楚代码的那一部分负责申请内存。换句话说,必须回答以下问题:
在这一点上,一定要确信搞清楚。下面是一个大致的大纲,这个大纲将帮助我们回答上面的问题:
Java 应用程序只被允许使用有限的内存。确切使用的内存数量只能在程序开始运行时才能知道。Java 内存被分成不同的区域,如下图所示:
注:此为 JDK7 的 jvm 内存模型。
上图所有的区域包括永久代都是在 JVM 开始运行的时候被设定。如果不设置,将会使用特定平台的默认值。
java.lang.OutOfMemoryError: PermGen space 消息表明:内存中的永久代区域已经被用完了。
要想理解 java.lang.OutOfMemoryError: PermGen space 的原因,我们需要知道这块内存区域的用途:
为了实践目的,永久代包含大多数的类定义,也就是类/方法的名字和字段,全局不可变变量池,对象数组,类相关的对象数组,实时编译优化等。
从上面的定义,我们可以看出,永久代的大小取决于类申明的数量和装载的类的数量。因为,我们可以说:java.lang.OutOfMemoryError: PermGen space 的主要原因:要么太多的类,要么太大的类被分配到了永久带空间。
综上所述,永久带空间的使用是和加载到 jvm 的类的数量强相关的。下面是一个简明的例子:
import javassist.ClassPool;
public class MicroGenerator {
public static void main(String[] args) throws Exception {
for (int i = 0; i < 100_000_000; i++) {
generate("com.myown.demo.Generated" + i);
}
}
public static Class generate(String name) throws Exception {
ClassPool pool = ClassPool.getDefault();
return pool.makeClass(name).toClass();
}
}
在这个示例中,通过循环逐个生成运行时类。类的生成是 javassist 库负责。
运行上面的代码将持续生成新类并把他们的定义装载到永久代空间,直到空间被全部使用,java.lang.OutOfMemoryError: PermGen space 异常就会被触发。
实际测试发现 (Ubuntu16.04 JDK7,设置-XX:MaxPermSize=512m,否则内存空间占据太多),报错如下:
Exception in thread "main"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
为什么不是 java.lang.OutOfMemoryError: PermGen space 异常,从监控看,PermGen 空间确实满了,heap 有剩余。
再说一个更加复杂和更加实际的例子,在程序重新部署发布的时候,我们经历一次 java.lang.OutOfMemoryError: PermGen space 异常的发生。当我们重新发布应用程序的时候,我们希望 GC 能清除以前的旧的已经装载的类,并重新装载新版本的类。
不幸的是,许多第三方类和资源的处理,例如线程,JDBC 驱动或者文件系统处理,不能卸载旧类。这也意味着:每一次重新发布,所有先前版本的类仍然存在于永久代空间,并且每次重新发布都会生成几十 M 的垃圾数据。
想象一个例子,应用使用 JDBC 连接到一个关系型数据库。当应用开始的时候,代码初始化 JDBC 驱动去连接数据库。根据说明,JDBC 驱动会注册他自己为 java.sql.DriverManager。注册会存储一个引用,这个引用指向到一个 DrvierManager 实例的静态字段。
现在,当从服务器上卸载应用程序的时候,java.sql.DriverManager 仍将持有那个驱动程序的引用,进而持有用于加载应用程序的 classloader 的一个实例的引用,通常会占有数十兆的永久代空间。这个 classloader 现在仍然引用着应用程序的所有类。这意味着经历过几次重新部署,就会触发 java.lang.OutOfMemoryError: PermGen space 错误。
当 OutOfMemoryError 因永久代耗尽被触发的时候 (应用程序启动的时候),解决方案非常简单。应用程序需要更多的空间去家在所有的类到永久代区域。我们增大永久代的大小,提示应用程序增大永久带空间。-XX:MaxPermSize 相似的参数:
java -XX:MaxPermSize=512m com.yourcompany.YourClass
上面的配置告诉 JVM,永久代最大空间可以到 512M。
当重新发布应用的时候,OutOfMemoryError 恰好发生了,说明应用程序遭遇了 classloader 泄露。此时我们应该执行堆 dump 分析 -- 是用类似如下命令去执行堆 dump 的工作:
jmap -dump:format=b,file=dump.hprof
然后用熟悉的工具去分析 dump 文件。如果是第三方库的原因,可以去 Google/StackOverflow 检查下是否是一个已知问题,如是已知问题,可以下载一个补丁或者解决方案。如果是自己代码的问题,需要及时修改。
首先你需要检查是否允许 GC 从 PermGen 卸载类,JVM 的标准配置相当保守,只要类一创建,即使已经没有实例引用它们,其仍将保留在内存中,特别是当应用程序需要动态创建大量的类但其生命周期并不长时,允许 JVM 卸载类对应用大有助益,你可以通过在启动脚本中添加以下配置参数来实现:
-XX:+CMSClassUnloadingEnabled
默认情况下,这个配置是未启用的,如果你启用它,GC 将扫描 PermGen 区并清理已经不再使用的类。但请注意,这个配置只在 UseConcMarkSweepGC 的情况下生效,如果你使用其他 GC 算法,比如:ParallelGC 或者 Serial GC 时,这个配置无效。所以使用以上配置时,请配合:
-XX:+UseConcMarkSweepGC
如果你已经确保 JVM 可以卸载类,但是仍然出现内存溢出问题,那么你应该继续分析 dump 文件,使用以下命令生成 dump 文件:
jmap -dump:file=dump.hprof,format=b
当你拿到生成的堆转储文件,并利用像 Eclipse Memory Analyzer Toolkit 这样的工具来寻找应该卸载却没被卸载的类加载器,然后对该类加载器加载的类进行排查,找到可疑对象,分析使用或者生成这些类的代码,查找产生问题的根源并解决它。
Java 应用程序只被允许使用有限的内存。确切使用的内存数量只能在程序开始运行时才能知道。Java 内存被分成不同的区域,如下图所示:
上面所有的区域,包括元数据区域,都是在 JVM 启动的时候被设定。如果没有指定,那么特定平台的默认数据将被使用。
java.lang.OutOfMemoryError: Metaspace 预示着内存中的元数据空间消耗殆尽。
如果不是 Java 新手,那么你可能熟悉另外一个称作 PermGen 的 Java 内存管理的概念。从 Java8 开始,内存模型有显著的改变。一个新的被称为 Metaspace 的内存区域被引进,PermGen 被移除了。这个改变有多种原因,包括但不限于以下:
正如你所看到的,元空间大小的要求取决于加载的类的数量以及这种类声明的大小。 所以很容易看到 java.lang.OutOfMemoryError: Metaspace 主要原因:太多的类或太大的类加载到元空间。
正如前面解释的,Metaspace 是和加载的类的数量密切相关。下面的代码是一个简单明了的说明:
public class Metaspace {
static javassist.ClassPool cp = javassist.ClassPool.getDefault();
public static void main(String[] args) throws Exception{
for (int i = 0; ; i++) {
Class c = cp.makeClass("com.myown.demo.Generated" + i).toClass();
}
}
}
在这个例子中,源代码在运行时遍历循环生成类。所有生成的类定义都位于 Metaspace 中。类生成的工作交由 javassist 负责。
代码持续生成新类,并把它们的定义装载到 Metaspace 中,直到元数据空间被全部使用,java.lang.OutOfMemoryError: Metaspace 异常被触发。当用-XX:MaxMetaspaceSize=64m 在 Ubuntu16.04.3, java 1.8.0_151,一共加载了 66126 个类,程序才挂掉。试验得出的完整错误信息如下:
Exception in thread "main" javassist.CannotCompileException: by java.lang.OutOfMemoryError: Metaspace
at javassist.ClassPool.toClass(ClassPool.java:1085)
at javassist.ClassPool.toClass(ClassPool.java:1028)
at javassist.ClassPool.toClass(ClassPool.java:986)
at javassist.CtClass.toClass(CtClass.java:1079)
at Metaspace.main(Metaspace.java:8)
Caused by: java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at javassist.ClassPool.toClass2(ClassPool.java:1098)
at javassist.ClassPool.toClass(ClassPool.java:1079)
... 4 more
第一个解决方案,毫无疑问:OutOfMemoryError 是因为 Metaspace。如果应用程序将 metaspace 消耗殆尽,我们可以增大 Metaspace 的空间。更改程序配置,按照如下进行:
-XX:maxMetaspaceSize=512m
上面的配置告诉 JVM:Metaspace 空间最大允许到 512MB。
另外一个解决方案初一看更加简单。我们可以移除 Metaspace 的大小限制 (删除这个参数即可)。但是这样做,将会导致沉重的交换负担,会导致内存分配失败。
你可以通过修改各种启动参数来 “快速修复” 这些内存溢出错误,但你需要正确区分你是否只是推迟或者隐藏了 java.lang.OutOfMemoryError 的症状。如果你的应用程序确实存在内存泄漏或者本来就加载了一些不合理的类,那么所有这些配置都只是推迟问题出现的时间而已,实际也不会改善任何东西。
Java 应用程序天然支持多线程的。这意味着 java 实现的程序能一次做几件事情 (几乎同时)。甚至只有一个 cpu--在从一个窗口拉数据到另外一个窗口的时候,视频同时也在后台不停的播放,因为可以同时执行多个操作。
一个思考多线程的方式就是把他们认为是我们能提交任务给他们执行的工人。如果只有一个工人,那么他/她一次只能做一件事情。但是如果有很多工人的话,他们就能根据你的命令同时做事。
就像这些工人都在物理世界,JVM 中的线程完成自己的工作也是需要一些空间的,当有足够多的线程却没有那么多的空间时就会像这样:
java.lang.OutOfMemoryError: Unable to create new native thread 意味着: Java 应用已经到了它所能启动的线程的极限。
当 JVM 请求操作系统去创建一个新的线程,操作系统却不能再申请一个新的线程,此时 OutOfMemoryError(java.lang.OutOfMemoryError: Unbale to crate new thread)) 就被触发。线程数目的确切数字是依赖于平台的。如果想找到线程数目的限制,可以运行下面将要提到的例子。
一般来说, 引发 java.lang.OutOfMemoryError: Unable to create new native thread 异常有以下几种情况:
下面的代码循环创建和开始线程。当运行代码时,很快就到达操作系统的限制,java.lang.OutOfMemoryError: Unable to create new native thread 异常被触发。
while(true){
new Thread(new Runnable(){
public void run() {
try {
Thread.sleep(10000000);
} catch(InterruptedException e) { }
}
}).start();
}
确切的线程数目限制是依赖于平台的,例如 Windows,Linux 和 Mac OS X 如下:
64-bit Mac OS X 10.9, Java 1.7.0_45 – JVM dies after #2031 threads have been created
64-bit Ubuntu Linux, Java 1.7.0_45 – JVM dies after #31893 threads have been created
64-bit Windows 7, Java 1.7.0_45 – due to a different thread model used by the OS,250000,交换文件到 10G,程序非常慢
通过一个小测试可以知道线程的极限数目。
实际 Ubuntu16.04.3 x64, Java 1.8.0_151–JVM dies after 11658 threads have been created
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:717)
at OOM_TestThread.main(OOM_TestThread.java:9)
实际的 ulimit 限制有 6w+(63812),也修改过 idea 的 VM 的 Xmx,由原先的 750m 调整到 6168,效果也是一样。
lj@lj-HP-ProBook-640-G1:~/Downloads/source/java-study/javaUnversailTest$ ulimit -a
max user processes (-u) 63812
也使用命令行运行,实际数值差不多,都在 116xx 左右,差别不大。
有时,我们可以通过增加 OS 级限制,绕过 Unable to create new native thread issue 错误。例如你限制了 JVM 可在用户空间创建的线程数,那么你可以检查并增加这个限制:
lj@lj-HP-ProBook-640-G1:~/Downloads$ ulimit -a
core file size (blocks, -c) 0
...... ...... ...... ...... ......
max user processes (-u) 63812
到达线程限制预示着程序错误。当应用产生数以千计的线程,有时会产生异常可怕的错误 -- 并没有太多的应用需要巨量的线程数目。
解决这个问题的一个方式:执行线程 dump,可以理解当时的状况。
java 程序在启动的时候是有内存限制的。这个限制是用参数-Xmx 和其他相似的参数说明。有些情况下,JVM 要求的内存比可用的物理内存还大,OS(操作系统) 开始交换内存的内容到硬盘。
java.lang.OutOfMemoryError: Out of swap space 异常表明:交换空间同样耗尽,并且新的内存申请失败,因为缺少物理内存和交换空间。
当从堆空间申请字节内存失败,并且堆空间也耗尽的时候,java.lang.OutOfMemoryError: Out of swap space 异常将被触发。该错误消息中包含分配失败的大小(以字节为单位)和请求失败的原因。
这个问题往往发生在 Java 进程已经开始交换的情况下,现代的 GC 算法已经做得足够好了,当时当面临由于交换引起的延迟问题时,GC 暂停的时间往往会让大多数应用程序不能容忍。
java.lang.OutOfMemoryError:Out of swap space?往往是由操作系统级别的问题引起的,例如:
还有可能是本地内存泄漏导致应用程序失败,比如:应用程序调用了 native code 连续分配内存,但却没有被释放回操作系统。
解决这个问题有几个办法,通常最简单的方法就是增加交换空间,不同平台实现的方式会有所不同,比如在 Linux 下可以通过如下命令实现:
先用 free -m 查看交换空间定义的是多少
swapoff -a --关闭交换区
dd if=/dev/zero of=swapfile bs=1024 count=655360--根目录下创建一个名为 swapfile,大小 640M
mkswap swapfile--将 swapfile 设置为 swap 区
swapon swapfile--启用交换区
Java GC 会扫描内存中的数据,如果是对交换空间运行垃圾回收算法会使 GC 暂停的时间增加几个数量级,因此你应该慎重考虑使用上文增加交换空间的方法。
如果你的应用程序部署在 JVM 需要同其他进程激烈竞争获取资源的物理机上,建议将服务隔离到单独的虚拟机中
但在许多情况下,您唯一真正可行的替代方案是:
当您转向优化路径时,使用内存转储分析程序来检测内存中的大分配是一个好的开始。
Java 对应用程序可以分配的最大数组大小有限制。不同平台限制有所不同,但通常在 1 到 21 亿个元素之间。
当你遇到 Requested array size exceeds VM limit 错误时,意味着你的应用程序试图分配大于 Java 虚拟机可以支持的数组。
该错误由 JVM 中的 native code 抛出。 JVM 在为数组分配内存之前,会执行特定于平台的检查:分配的数据结构是否在此平台中是可寻址的。
你很少见到这个错误是因为 Java 数组的索引是 int 类型。 Java 中的最大正整数为 2 ^ 31 - 1 = 2,147,483,647。 并且平台特定的限制可以非常接近这个数字,例如:我的环境上 (64 位 macOS,运行 Jdk1.8) 可以初始化数组的长度高达 2,147,483,645(Integer.MAX_VALUE-2)。如果再将数组的长度增加 1 到 Integer.MAX_VALUE-1 会导致熟悉的 OutOfMemoryError:
Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
但是,在使用 OpenJDK 6 的 32 位 Linux 上,在分配具有大约 11 亿个元素的数组时,您将遇到 Requested array size exceeded VM limit 的错误。 要理解你的特定环境的限制,运行下文中描述的小测试程序。
for (int i = 3; i >= 0; i--) {
try {
int[] arr = new int[Integer.MAX_VALUE-i];
System.out.format("Successfully initialized an array with %,d elements.\n", Integer.MAX_VALUE-i);
} catch (Throwable t) {
t.printStackTrace();
}
}
该示例重复四次,并在每个回合中初始化一个长原语数组。 该程序尝试初始化的数组的大小在每次迭代时增加 1,最终达到 Integer.MAX_VALUE。 现在,当使用 Hotspot 7 在 64 位 Mac OS X 上启动代码片段时,应该得到类似于以下内容的输出:
java.lang.OutOfMemoryError: Java heap space
at eu.plumbr.demo.ArraySize.main(ArraySize.java:8)
java.lang.OutOfMemoryError: Java heap space
at eu.plumbr.demo.ArraySize.main(ArraySize.java:8)
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
at eu.plumbr.demo.ArraySize.main(ArraySize.java:8)
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
at eu.plumbr.demo.ArraySize.main(ArraySize.java:8)
注意,在出现 Requested array size exceeded VM limit 之前,出现了更熟悉的 java.lang.OutOfMemoryError: Java heap space。 这是因为初始化 2 ^ 31-1 个元素的数组需要腾出 8G 的内存空间,大于 JVM 使用的默认值。
java.lang.OutOfMemoryError:Requested array size exceeds VM limit 可能会在以下任一情况下出现:
数组增长太大,最终大小在平台限制和 Integer.MAX_INT 之间
你有意分配大于 2 ^ 31-1 个元素的数组
在第一种情况下,检查你的代码库,看看你是否真的需要这么大的数组。也许你可以减少数组的大小,或者将数组分成更小的数据块,然后分批处理数据。
在第二种情况下,记住 Java 数组是由 int 索引的。因此,当在平台中使用标准数据结构时,数组不能超过 2 ^ 31-1 个元素。事实上,在编译时就会出错:error:integer number too large。
为了理解这个错误,我们需要补充一点操作系统的基础知识。操作系统是建立在进程的概念之上,这些进程在内核中作业,其中有一个非常特殊的进程,名叫 “内存杀手(Out of memory killer)”。当内核检测到系统内存不足时,OOM killer 被激活,然后选择一个进程杀掉。哪一个进程这么倒霉呢?选择的算法和想法都很朴实:谁占用内存最多,谁就被干掉。如果你对 OOM Killer 感兴趣的话,建议你阅读参考资料 2 中的文章。
OOM Killer,
当可用虚拟虚拟内存 (包括交换空间) 消耗到让整个操作系统面临风险时,就会产生 Out of memory:Kill process or sacrifice child 错误。在这种情况下,OOM Killer 会选择 “流氓进程” 并杀死它。
默认情况下,Linux 内核允许进程请求比系统中可用内存更多的内存,但大多数进程实际上并没有使用完他们所分配的内存。这就跟现实生活中的宽带运营商类似,他们向所有消费者出售一个 100M 的带宽,远远超过用户实际使用的带宽,一个 10G 的链路可以非常轻松的服务 100 个 (10G/100M) 用户,但实际上宽带运行商往往会把 10G 链路用于服务 150 人或者更多,以便让链路的利用率更高,毕竟空闲在那儿也没什么意义。
Linux 内核采用的机制跟宽带运营商差不多,一般情况下都没有问题,但当大多数应用程序都消耗完自己的内存时,麻烦就来了,因为这些应用程序的内存需求加起来超出了物理内存(包括 swap)的容量,内核(OOM killer)必须杀掉一些进程才能腾出空间保障系统正常运行。就如同上面的例子中,如果 150 人都占用 100M 的带宽,那么总的带宽肯定超过了 10G 这条链路能承受的范围。
当你在 Linux 上运行如下代码:
public static void main(String[] args){
List<int[]> l = new java.util.ArrayList();
for (int i = 10000; i < 100000; i++) {
try {
l.add(new int[100000000]);
} catch (Throwable t) {
t.printStackTrace();
}
}
}
在 Linux 的系统日志中/var/log/kern.log 会出现以下日志:
Jun 4 07:41:59 plumbr kernel: [70667120.897649] Out of memory: Kill process 29957 (java) score 366 or sacrifice child
Jun 4 07:41:59 plumbr kernel: [70667120.897701] Killed process 29957 (java) total-vm:2532680kB, anon-rss:1416508kB, file-rss:0kB
注意:你可能需要调整交换文件和堆大小,否则你将很快见到熟悉的 Java heap space 异常。在作者的测试用例中,使用-Xmx2g 指定的 2g 堆,并具有以下交换配置:
解决这个问题最有效也是最直接的方法就是升级内存,其他方法诸如:调整 OOM Killer 配置、水平扩展应用,将内存的负载分摊到若干小实例上..... 我们不建议的做法是增加交换空间。当您回想起 Java 是一种垃圾收集的语言时, 这个解决方案似乎已经不那么有利可图了。现代 GC 算法在物理内存中运行时效率很高, 但是在处理交换空间分配时, 效率很差。交换可以增加几个数量级的 GC 暂停的长度, 因此在跳转到此解决方案之前, 您应该三思而后行。