Java内存溢出(OOM)异常完全指南2

时间:2021-08-30 20:54:51

3.java.lang.OutOfMemoryError:Permgen space

Java中堆空间是JVM管理的最大一块内存空间,可以在JVM启动时指定堆空间的大小,其中堆被划分成两个不同的区域:新生代(Young)和老年代(Tenured),新生代又被划分为3个区域:EdenFrom SurvivorTo Survivor,如下图所示。


图片来源:并发编程网

java.lang.OutOfMemoryError: PermGen space错误就表明持久代所在区域的内存已被耗尽。

原因分析

要理解java.lang.OutOfMemoryError: PermGen space出现的原因,首先需要理解Permanent Generation Space的用处是什么。持久代主要存储的是每个类的信息,比如:类加载器引用运行时常量池(所有常量、字段引用、方法引用、属性)字段(Field)数据方法(Method)数据方法代码方法字节码等等。我们可以推断出,PermGen的大小取决于被加载类的数量以及类的大小。

因此,我们可以得出出现java.lang.OutOfMemoryError: PermGen space错误的原因是:太多的类或者太大的类被加载到permanent generation(持久代)。

示例

①、最简单的示例

正如前面所描述的,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,值越小越好。需要注意的是JDK8已经完全移除持久代空间,取而代之的是元空间(Metaspace),所以示例最好的JDK1.7或者1.6下运行。

代码在运行时不停的生成类并加载到持久代中,直到撑满持久代内存空间,最后抛出java.lang.OutOfMemoryError:Permgen space。代码中类的生成使用了javassist库。

②、Redeploy-time

更复杂和实际的一个例子就是Redeploy(重新部署,你可以想象一下你开发时,点击eclipse的reploy按钮或者使用idea时按ctrl + F5时的过程)。在从服务器卸载应用程序时,当前的classloader以及加载的class在没有实例引用的情况下,持久代的内存空间会被GC清理并回收。如果应用中有类的实例对当前的classloader的引用,那么Permgen区的class将无法被卸载,导致Permgen区的内存一直增加直到出现Permgen space错误。

不幸的是,许多第三方库以及糟糕的资源处理方式(比如:线程、JDBC驱动程序、文件系统句柄)使得卸载以前使用的类加载器变成了一件不可能的事。反过来就意味着在每次重新部署过程中,应用程序所有的类的先前版本将仍然驻留在Permgen区中,你的每次部署都将生成几十甚至几百M的垃圾。

就以线程和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错误并崩溃。

解决方案

① 解决初始化时的OutOfMemoryError

当在应用程序启动期间触发由于PermGen耗尽引起的OutOfMemoryError时,解决方案很简单。 应用程序需要更多的空间来加载所有的类到PermGen区域,所以我们只需要增加它的大小。 为此,请更改应用程序启动配置,并添加(或增加,如果存在)-XX:MaxPermSize参数,类似于以下示例:

java -XX:MaxPermSize=512m com.yourcompany.YourClass
② 解决Redeploy时的OutOfMemoryError

分析dump文件:首先,找出引用在哪里被持有;其次,给你的web应用程序添加一个关闭的hook,或者在应用程序卸载后移除引用。你可以使用如下命令导出dump文件:

jmap -dump:format=b,file=dump.hprof <process-id>

如果是你自己代码的问题请及时修改,如果是第三方库,请试着搜索一下是否存在"关闭"接口,如果没有给开发者提交一个bug或者issue吧。

③ 解决运行时OutOfMemoryError

首先你需要检查是否允许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 <process-id>

当你拿到生成的堆转储文件,并利用像Eclipse Memory Analyzer Toolkit这样的工具来寻找应该卸载却没被卸载的类加载器,然后对该类加载器加载的类进行排查,找到可疑对象,分析使用或者生成这些类的代码,查找产生问题的根源并解决它。

4、java.lang.OutOfMemoryError:Metaspace

前文已经提过,PermGen区域用于存储类的名称和字段,类的方法,方法的字节码,常量池,JIT优化等,但从Java8开始,Java中的内存模型发生了重大变化:引入了称为Metaspace的新内存区域,而删除了PermGen区域。请注意:不是简单的将PermGen区所存储的内容直接移到Metaspace区,PermGen区中的某些部分,已经移动到了普通堆里面。


OOM-example-metaspace,图片来源:Plumbr

原因分析

Java8做出如此改变的原因包括但不限于:

  • 应用程序所需要的PermGen区大小很难预测,设置太小会触发PermGen OutOfMemoryError错误,过度设置导致资源浪费。

  • 提升GC性能,在HotSpot中的每个垃圾收集器需要专门的代码来处理存储在PermGen中的类的元数据信息。从PermGen分离类的元数据信息到Metaspace,由于Metaspace的分配具有和Java Heap相同的地址空间,因此MetaspaceJava Heap可以无缝的管理,而且简化了FullGC的过程,以至将来可以并行的对元数据信息进行垃圾收集,而没有GC暂停。

  • 支持进一步优化,比如:G1并发类的卸载,也算为将来做准备吧

正如你所看到的,元空间大小的要求取决于加载的类的数量以及这种类声明的大小。 所以很容易看到java.lang.OutOfMemoryError: Metaspace主要原因:太多的类或太大的类加载到元空间。

示例

正如上文中所解释的,元空间的使用与加载到JVM中的类的数量密切相关。 下面的代码是最简单的例子:

public class Metaspace {
   static javassist.ClassPool cp = javassist.ClassPool.getDefault();    public static voidmain(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多个类时就会死机。

3102331024Exception 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(初始限制值),一旦达到这个限制值,FullGC将被触发进行类卸载,并且这个限制值将会被重置,新的限制值依赖于Metaspace的剩余容量。如果没有足够空间被释放,这个限制值将会上升,反之亦然。在技术上Metaspace的尺寸可以增长到交换空间,而这个时候本地内存分配将会失败(更具体的分析,可以参考:Java PermGen 去哪里了?)。

你可以通过修改各种启动参数来“快速修复”这些内存溢出错误,但你需要正确区分你是否只是推迟或者隐藏了java.lang.OutOfMemoryError的症状。如果你的应用程序确实存在内存泄漏或者本来就加载了一些不合理的类,那么所有这些配置都只是推迟问题出现的时间而已,实际也不会改善任何东西。