Java 应用程序在启动时会指定所需要的内存大小,它被分割成两个不同的区域:Heap space
(堆空间)和Permgen
(永久代):
这两个区域的大小可以在 JVM(Java 虚拟机)启动时通过参数-Xmx
和-XX:MaxPermSize
设置,如果你没有显式设置,则将使用特定平台的默认值。
当应用程序试图向堆空间添加更多的数据,但堆却没有足够的空间来容纳这些数据时,将会触发java.lang.OutOfMemoryError: Java heap space
异常。需要注意的是:即使有足够的物理内存可用,只要达到堆空间设置的大小限制,此异常仍然会被触发。
触发java.lang.OutOfMemoryError: Java heap space
最常见的原因就是应用程序需要的堆空间是 XXL 号的,但是 JVM 提供的却是 S 号。解决方法也很简单,提供更大的堆空间即可。除了前面的因素还有更复杂的成因:
java.lang.OutOfMemoryError: Java heap space
异常。java.lang.OutOfMemoryError: Java heap space
错误。首先看一个非常简单的示例,下面的代码试图创建2 x 1024 x 1024
个元素的整型数组,当你尝试编译并指定 12M 堆空间运行时(-Xmx12m
)将会失败并抛出java.lang.OutOfMemoryError: Java heap space
错误,而当你指定 13M 堆空间时,将正常的运行。
class OOM {
static final int SIZE=2*1024*1024;
public static void main(String[] a) {
int[] i = new int[SIZE];
}
}
运行如下:
D:\>javac OOM.java
D:\>java -Xmx12m OOM
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at OOM.main(OOM.java:4)
D:\>java -Xmx13m OOM
在 Java 中,当开发者创建一个新对象(比如new Integer(5)
)时,不需要自己开辟内存空间,而是把它交给 JVM。在应用程序整个生命周期类,JVM 负责检查哪些对象可用,哪些对象未被使用。未使用对象将被丢弃,其占用的内存也将被回收,这一过程被称为垃圾回收。JVM 负责垃圾回收的模块集合被称为垃圾回收器。
Java 的内存自动管理机制依赖于 GC 定期查找未使用对象并删除它们。Java 中的内存泄漏是由于 GC 无法识别一些已经不再使用的对象,而这些未使用的对象一直留在堆空间中,这种堆积最终会导致java.lang.OutOfMemoryError: Java heap space
错误。
我们可以非常容易的写出导致内存泄漏的 Java 代码:
public 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<Key,String> m = new HashMap<Key,String>();
while(true) {
for(int i=0;i<10000;i++) {
if(!m.containsKey(new Key(i))) {
m.put(new Key(i), "Number:" + i);
}
}
}
}
}
代码中HashMap
为本地缓存,第一次while
循环,会将 10000 个元素添加到缓存中,后面的while
循环中,由于key
已经存在于缓存中,缓存的大小将一直会维持在 10000。但事实真的如此吗?由于Key
实体没有实现equals()
方法,导致for
循环中每次执行m.containsKey(new Key(i))
结果均为false
,其结果就是HashMap
中的元素将一直增加。
随着时间的推移,越来越多的Key
对象进入堆空间且不能被垃圾收集器回收(m
为局部变量,GC 会认为这些对象一直可用,所以不会回收),直到所有的堆空间被占用,最后抛出java.lang.OutOfMemoryError: Java heap space
。
上面的代码直接运行可能很久也不会抛出异常,可以在启动时使用
-Xmx
参数,设置堆内存大小,或者在for
循环后打印HashMap
的大小,执行后会发现HashMap
的size
一直再增长。
解决方法也非常简单,只要Key
实现自己的equals()
方法即可:
@Override
public boolean equals(Object o) {
boolean response = false;
if (o instanceof Key) {
response = (((Key)o).id).equals(this.id);
}
return response;
}
第一个解决方案是显而易见的,你应该确保有足够的堆空间来正常运行你的应用程序,在 JVM 的启动配置中增加如下配置:
-Xmx1024m
上面的配置分配 1024M 堆空间给你的应用程序,当然你也可以使用其他单位,比如用 G 表示 GB,K 表示 KB。下面的示例都表示最大堆空间为 1 GB:
java -Xmx1073741824 com.mycompany.MyClass
java -Xmx1048576k com.mycompany.MyClass
java -Xmx1024m com.mycompany.MyClass
java -Xmx1g com.mycompany.MyClass
然后,更多的时候,单纯地增加堆空间不能解决所有的问题。如果你的程序存在内存泄漏,一味的增加堆空间也只是推迟java.lang.OutOfMemoryError: Java heap space
错误出现的时间而已,并未解决这个隐患。除此之外,垃圾收集器在 GC 时,应用程序会停止运行直到 GC 完成,而增加堆空间也会导致 GC 时间延长,进而影响程序的吞吐量。
如果你想完全解决这个问题,那就好好提升自己的编程技能吧,当然运用好 Debuggers、Profilers 和 Heap Dump Analyzers 等工具,可以让你的程序最大程度的避免内存泄漏问题。
Java 运行时环境(JRE
)包含一个内置的垃圾回收进程,而在许多其他的编程语言中,开发者需要手动分配和释放内存。
Java 应用程序只需要开发者分配内存,每当在内存中特定的空间不再使用时,一个单独的垃圾收集进程会清空这些内存空间。垃圾收集器怎样检测内存中的某些空间不再使用已经超出本文的范围,但你只需要相信 GC 可以做好这些工作即可。
默认情况下,当应用程序花费超过 98% 的时间用来做 GC 并且回收了不到 2% 的堆内存时,会抛出java.lang.OutOfMemoryError: GC overhead limit exceeded
错误。具体的表现就是你的应用几乎耗尽所有可用内存,并且 GC 多次均未能清理干净。
java.lang.OutOfMemoryError: GC overhead limit exceeded
错误是一个信号,示意你的应用程序在垃圾收集上花费了太多时间但却没有什么卵用。默认超过 98% 的时间用来做 GC 却回收了不到 2% 的内存时将会抛出此错误。那如果没有此限制会发生什么呢?GC 进程将被重启,100% 的 CPU 将用于 GC,而没有 CPU 资源用于其他正常的工作。如果一个工作本来只需要几毫秒即可完成,现在却需要几分钟才能完成,我想这种结果谁都没有办法接受。
所以java.lang.OutOfMemoryError: GC overhead limit exceeded
也可以看做是一个Fail-Fast
(快速失败)实战的实例。
下面的代码初始化一个map
并在无限循环中不停的添加键值对,运行后将会抛出GC overhead limit exceeded
错误:
public 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.lang.OutOfMemoryError: GC overhead limit exceeded
错误。但如果在启动时设置不同的堆空间大小或者使用不同的 GC 算法,比如这样:
java -Xmx10m -XX:+UseParallelGC Wrapper
我们将看到如下错误:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Hashtable.rehash(Unknown Source)
at java.util.Hashtable.addEntry(Unknown Source)
at java.util.Hashtable.put(Unknown Source)
at cn.moondev.Wrapper.main(Wrapper.java:12)
使用以下 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
的错误信息,可以在应用程序启动时添加如下 JVM 参数:
-XX:-UseGCOverheadLimit
但是强烈建议不要使用这个选项,因为这样并没有解决任何问题,只是推迟了错误出现的时间,错误信息也变成了我们更熟悉的java.lang.OutOfMemoryError: Java heap space
而已。
另一个解决方案,如果你的应用程序确实内存不足,增加堆内存会解决GC overhead limit
问题,就如下面这样,给你的应用程序 1G 的堆内存:
java -Xmx1024m com.yourcompany.YourClass
但如果你想确保你已经解决了潜在的问题,而不是掩盖java.lang.OutOfMemoryError: GC overhead limit exceeded
错误,那么你不应该仅止步于此。你要记得还有 Profilers 和 Memory Dump Analyzers 这些工具,你需要花费更多的时间和精力来查找问题。还有一点需要注意,这些工具在 Java 运行时有显著的开销,因此不建议在生产环境中使用。
Java 中堆空间是 JVM 管理的最大一块内存空间,可以在 JVM 启动时指定堆空间的大小,其中堆被划分成两个不同的区域:新生代(Young)和老年代(Tenured),新生代又被划分为 3 个区域:Eden、From Survivor 和 To Survivor,如下图所示:
java.lang.OutOfMemoryError: PermGen space
错误就表明永久代所在区域的内存已被耗尽。
要理解java.lang.OutOfMemoryError: PermGen space
出现的原因,首先需要理解 Permanent Generation Space 的用处是什么。永久代主要存储的是每个类的信息,比如:类加载器引用、运行时常量池(所有常量、字段引用、方法引用、属性)、字段(Field
)数据、方法(Method
)数据、方法代码、方法字节码等等。我们可以推断出,PermGen 的大小取决于被加载类的数量以及类的大小。
因此,我们可以得出出现java.lang.OutOfMemoryError: PermGen space
错误的原因是:太多的类或者太大的类被加载到永久代。
正如前面所描述的,PermGen 的使用与加载到 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("cn.moondev.User" + i);
}
}
public static Class generate(String name) throws Exception {
ClassPool pool = ClassPool.getDefault();
return pool.makeClass(name).toClass();
}
}
运行时请设置 JVM 参数-XX:MaxPermSize=5m
,值越小越好。需要注意的是 JDK 8 已经完全移除永久代,取而代之的是元空间(Metaspace
),所以示例最好的 JDK 1.7 或者 1.6 下运行。
代码在运行时不停的生成类并加载到永久代中,直到撑满永久代内存空间,最后抛出java.lang.OutOfMemoryError: Permgen space
。代码中类的生成使用了javassist
库。
更复杂和实际的一个例子就是 Redeploy(重新部署,你可以想象一下你开发时,点击 Eclipse 的Reploy
按钮或者使用 Idea 时按Ctrl + F5
时的过程)。在从服务器卸载应用程序时,当前的 Classloader 以及加载的class
在没有实例引用的情况下,永久代的内存空间会被 GC 清理并回收。如果应用中有类的实例对当前的 Classloader 的引用,那么 Permgen 区的class
将无法被卸载,导致 Permgen 区的内存一直增加直到出现Permgen space
错误。
不幸的是,许多第三方库以及糟糕的资源处理方式(比如:线程、JDBC 驱动程序、文件系统句柄)使得卸载以前使用的类加载器变成了一件不可能的事。反过来就意味着在每次重新部署过程中,应用程序所有的类的先前版本将仍然驻留在 Permgen 区中,你的每次部署都将生成几十甚至几百兆的垃圾。
就以线程和 JDBC 驱动来说说。很多人都会使用线程来处理一下周期性或者耗时较长的任务,这个时候一定要注意线程的生命周期问题,你需要确保线程不能比你的应用程序活得还长。否则,如果应用程序已经被卸载,线程还在继续运行,这个线程通常会维持对应用程序的 Classloader 的引用,造成的结果就不再多说。多说一句,开发者有责任处理好这个问题,特别是如果你是第三方库的提供者的话,一定要提供线程关闭接口来处理清理工作。
让我们想象一个使用 JDBC 驱动程序连接到关系数据库的示例应用程序。当应用程序部署到服务器上的时:服务器创建一个 Classloader 实例来加载应用所有的类(包含相应的 JDBC 驱动)。根据 JDBC 规范,JDBC 驱动程序(比如com.mysql.jdbc.Driver
)会在初始化时将自己注册到java.sql.DriverManager
中。该注册过程中会将驱动程序的一个实例存储在DriverManager
的静态字段内,代码可以参考:
// com.mysql.jdbc.Driver 源码
package com.mysql.jdbc;
public class Driver extends NonRegisteringDriver implements java.sql.Driver {
public Driver() throws SQLException {
}
static {
try {
DriverManager.registerDriver(new Driver());
} catch (SQLException var1) {
throw new RuntimeException("Can\'t register driver!");
}
}
}
// 再看下DriverManager对应代码
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
public static synchronized void registerDriver(java.sql.Driver driver,DriverAction da) throws SQLException {
if(driver != null) {
registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
} else {
throw new NullPointerException();
}
}
现在,当从服务器上卸载应用程序的时候,java.sql.DriverManager
仍将持有那个驱动程序的引用,进而持有用于加载应用程序的 Classloader 的一个实例的引用。这个 Classloader 现在仍然引用着应用程序的所有类。如果此程序启动时需要加载 2000 个类,占用约 10MB 永久代(PermGen
)内存,那么只需要 5~10 次重新部署,就会将默认大小的永久代(PermGen
)塞满,然后就会触发java.lang.OutOfMemoryError: PermGen space
错误并崩溃。
当在应用程序启动期间触发由于 PermGen 耗尽引起的OutOfMemoryError
时,解决方案很简单。 应用程序需要更多的空间来加载所有的类到 PermGen 区域,所以我们只需要增加它的大小。 为此,请更改应用程序启动配置,并添加(或增加,如果存在)-XX:MaxPermSize
参数,类似于以下示例:
java -XX:MaxPermSize=512m com.yourcompany.YourClass
分析dump
文件:首先,找出引用在哪里被持有;其次,给你的 Web 应用程序添加一个关闭的hook
,或者在应用程序卸载后移除引用。你可以使用如下命令导出dump
文件:
jmap -dump:format=b,file=dump.hprof <process-id>
如果是你自己代码的问题请及时修改,如果是第三方库,请试着搜索一下是否存在“关闭”接口,如果没有给开发者提交一个 bug 或者 issue 吧。
首先你需要检查是否允许 GC 从 PermGen 卸载类,JVM 的标准配置相当保守,只要类一创建,即使已经没有实例引用它们,其仍将保留在内存中,特别是当应用程序需要动态创建大量的类但其生命周期并不长时,允许 JVM 卸载类对应用大有助益,你可以通过在启动脚本中添加以下配置参数来实现:
-XX:+CMSClassUnloadingEnabled
默认情况下,这个配置是未启用的,如果你启用它,GC 将扫描 PermGen 区并清理已经不再使用的类。但请注意,这个配置只在UseConcMarkSweepGC
的情况下生效,如果你使用其他 GC 算法,比如ParallelGC
或者SerialGC
时,这个配置无效。所以使用以上配置时,请配合:
-XX:+UseConcMarkSweepGC
如果你已经确保 JVM 可以卸载类,但是仍然出现内存溢出问题,那么你应该继续分析dump
文件,使用以下命令生成dump
文件:
jmap -dump:file=dump.hprof,format=b <process-id>
当你拿到生成的堆转储文件,并利用像Eclipse Memory Analyzer Toolkit
这样的工具来寻找应该卸载却没被卸载的类加载器,然后对该类加载器加载的类进行排查,找到可疑对象,分析使用或者生成这些类的代码,查找产生问题的根源并解决它。
前文已经提过,PermGen 区域用于存储类的名称和字段,类的方法,方法的字节码,常量池, JIT 优化等,但从 Java 8 开始,Java 中的内存模型发生了重大变化:引入了称为 Metaspace 的新内存区域,而删除了 PermGen 区域。请注意:不是简单的将 PermGen 区所存储的内容直接移到 Metaspace 区,PermGen 区中的某些部分,已经移动到了普通堆里面。
Java 8 做出如此改变的原因包括但不限于:
PermGen OutOfMemoryError
错误,过度设置导致资源浪费。正如你所看到的,元空间大小的要求取决于加载的类的数量以及这种类声明的大小。 所以很容易看到java.lang.OutOfMemoryError: Metaspace
主要原因:太多的类或太大的类加载到元空间。
正如上文中所解释的,元空间的使用与加载到 JVM 中的类的数量密切相关。 下面的代码是最简单的例子:
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("eu.plumbr.demo.Generated" + i).toClass();
System.out.println(i);
}
}
}
程序运行中不停的生成新类,所有的这些类的定义将被加载到 Metaspace 区,直到空间被完全占用并且抛出java.lang.OutOfMemoryError: Metaspace
。当使用-XX:MaxMetaspaceSize = 32m
启动时,大约加载 30000 多个类时就会死机。
31024
Exception in thread "main" javassist.CannotCompileException: by java.lang.OutOfMemoryError: Metaspace
at javassist.ClassPool.toClass(ClassPool.java:1170)
at javassist.ClassPool.toClass(ClassPool.java:1113)
at javassist.ClassPool.toClass(ClassPool.java:1071)
at javassist.CtClass.toClass(CtClass.java:1275)
at cn.moondev.book.Metaspace.main(Metaspace.java:12)
.....
第一个解决方案是显而易见的,既然应用程序会耗尽内存中的 Metaspace 区空间,那么应该增加其大小,更改启动配置增加如下参数:
// 告诉 JVM:Metaspace 允许增长到 512,然后才能抛出异常
-XX:MaxMetaspaceSize = 512m
另一个方法就是删除此参数来完全解除对 Metaspace 大小的限制(默认是没有限制的)。默认情况下,对于 64 位服务器端 JVM,MetaspaceSize
默认大小是 21M(初始限制值),一旦达到这个限制值,Full GC 将被触发进行类卸载,并且这个限制值将会被重置,新的限制值依赖于 Metaspace 的剩余容量。如果没有足够空间被释放,这个限制值将会上升,反之亦然。在技术上 Metaspace 的尺寸可以增长到交换空间,而这个时候本地内存分配将会失败(更具体的分析,可以参考:Java PermGen 去哪里了?)。
你可以通过修改各种启动参数来“快速修复”这些内存溢出错误,但你需要正确区分你是否只是推迟或者隐藏了java.lang.OutOfMemoryError
的症状。如果你的应用程序确实存在内存泄漏或者本来就加载了一些不合理的类,那么所有这些配置都只是推迟问题出现的时间而已,实际也不会改善任何东西。
一个思考线程的方法是将线程看着是执行任务的工人,如果你只有一个工人,那么他同时只能执行一项任务,但如果你有十几个工人,就可以同时完成你几个任务。就像这些工人都在物理世界,JVM 中的线程完成自己的工作也是需要一些空间的,当有足够多的线程却没有那么多的空间时就会像这样:
出现java.lang.OutOfMemoryError: Unable to create new native thread
就意味着 Java 应用程序已达到其可以启动线程数量的极限了。
当 JVM 向 OS 请求创建一个新线程时,而 OS 却无法创建新的native
线程时就会抛出Unable to create new native thread
错误。一台服务器可以创建的线程数依赖于物理配置和平台,建议运行下文中的示例代码来测试找出这些限制。总体上来说,抛出此错误会经过以下几个阶段:
native
线程native
线程,这时需要分配内存给新的线程Unable to create new native thread
错误将被抛出下面的示例不能的创建并启动新的线程。当代码运行时,很快达到 OS 的线程数限制,并抛出Unable to create new native thread
错误。
while(true){
new Thread(new Runnable(){
public void run() {
try {
Thread.sleep(10000000);
} catch(InterruptedException e) { }
}
}).start();
}
有时,你可以通过在 OS 级别增加线程数限制来绕过这个错误。如果你限制了 JVM 可在用户空间创建的线程数,那么你可以检查并增加这个限制:
// macOS 10.12上执行
$ ulimit -u
709
当你的应用程序产生成千上万的线程,并抛出此异常,表示你的程序已经出现了很严重的编程错误,我不觉得应该通过修改参数来解决这个问题,不管是 OS 级别的参数还是 JVM 启动参数。更可取的办法是分析你的应用是否真的需要创建如此多的线程来完成任务?是否可以使用线程池或者说线程池的数量是否合适?是否可以更合理的拆分业务来实现?
Java 应用程序在启动时会指定所需要的内存大小,可以通过-Xmx
和其他类似的启动参数来指定。在 JVM 请求的总内存大于可用物理内存的情况下,操作系统会将内存中的数据交换到磁盘上去。
Out of swap space?
表示交换空间也将耗尽,并且由于缺少物理内存和交换空间,再次尝试分配内存也将失败。
当应用程序向 JVM Native Heap 请求分配内存失败并且 Native Heap 也即将耗尽时,JVM 会抛出Out of swap space
错误。该错误消息中包含分配失败的大小(以字节为单位)和请求失败的原因。
Native Heap Memory 是 JVM 内部使用的 Memory,这部分的 Memory 可以通过 JDK 提供的 JNI 的方式去访问,这部分 Memory 效率很高,但是管理需要自己去做,如果没有把握最好不要使用,以防出现内存泄露问题。JVM 使用 Native Heap Memory 用来优化代码载入(JTI 代码生成),临时对象空间申请,以及 JVM 内部的一些操作。
这个问题往往发生在 Java 进程已经开始交换的情况下,现代的 GC 算法已经做得足够好了,当面临由于交换引起的延迟问题时,GC 暂停的时间往往会让大多数应用程序不能容忍。
java.lang.OutOfMemoryError: Out of swap space?
往往是由操作系统级别的问题引起的,例如:
还有可能是本地内存泄漏导致应用程序失败,比如:应用程序调用了 Native Code 连续分配内存,但却没有被释放。
解决这个问题有几个办法,通常最简单的方法就是增加交换空间,不同平台实现的方式会有所不同,比如在 Linux 下可以通过如下命令实现:
# 原作者使用,由于我手里并没有 Linux 环境,所以并未测试
# 创建并附加一个大小为 640MB 的新交换文件
swapoff -a
dd if=/dev/zero of=swapfile bs=1024 count=655360
mkswap swapfile
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,运行 Jdk 1.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 感兴趣的话,建议你阅读参考资料 ② 中的文章。
当可用虚拟虚拟内存(包括交换空间)消耗到让整个操作系统面临风险时,就会产生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 堆,并具有以下交换配置:
# 注意:原作者使用,由于我手里并没有 Linux 环境,所以并未测试
swapoff -a
dd if=/dev/zero of=swapfile bs=1024 count=655360
mkswap swapfile
swapon swapfile
解决这个问题最有效也是最直接的方法就是升级内存,其他方法诸如:调整 OOM Killer 配置、水平扩展应用,将内存的负载分摊到若干小实例上,我们不建议的做法是增加交换空间,具体原因已经在前文说过。参考资料 ② 中详细的介绍了怎样微调 OOM Killer 配置以及 OOM Killer 选择进程算法的实现,建议你参考阅读。
参考资料:
① 想要了解更多 PermGen 与 Metaspace 的内容推荐你阅读:
② 如果你对 OOM Killer 感兴趣的话,强烈建议你阅读这篇文章: