内存溢出之PermGen OOM深入分析

时间:2021-05-17 20:55:06
现在,网上关于讨论PermGen OOM的资料很多,但是深入分析PermGen区域内存溢出原因的资料很少。本篇文章尝试全面分析一下PermGen OOM的原因,其中涉及到了Java虚拟机运行时数据区、类型装载、类型卸载等,测试代码涉及到了JMX协议。相关前提知识如下:
       1、Java类加载的基本原理
       2、Java类型卸载相关的知识
       3、简要了解JMX协议,有关JMX协议可以参加sun公司发布的技术规范,对JMX协议做一定的了解对理解Java性能监控和调优功能的实现原理有很大帮助。

       
       【虚拟机运行时数据区介绍】
   
本部分将对Java虚拟机运行时数据区做一个简单的介绍,着重说明PermGen区域(永久存储区)存放的内容,并对运行时数据区的访问方式做一个归纳说明,为后面深入分析类型卸载和PermGen OOM做铺垫。为了更具有通用性,本部分将更多关注虚拟机协议本身,可能和具体的虚拟机实现有少许的出入。

        【运行时数据区分类】

        Java虚拟机的运行时数据区一般分类如下(不一定是物理划分):  

    堆:主要存放对象实例,线程共享     栈:主要存储特定线程的方法调用状态,线程独占     本地方法栈:存储本地方法的调用状态,线程独占     PC寄存器:学过操作系统课程的都知道,线程独占     方法区:主要存储了类型信息,线程共享 

        方法区可以简单的等价为所谓的PermGen区域(永久存储区),在很多虚拟机相关的文档中,也将其称之为"永久堆"(permanent heap),作为堆空间的一部分存在。介于此,我们可以简单说明一下我们常用的几个堆内存配置的参数关系:
    *-XX: PermSize:*永久堆(Pergen区域)大小默认值
    *-XX:MaxPermSize:*永久堆(Pergen区域)最大值
    *-Xms:*堆内存大小默认值
    *-Xmx:*堆内存最大值

        【运行时数据区访问方式总结】

    从开发者角度,虚拟机运行时数据区的访问方式简要归纳如下:

    活动的线程可以通过对应的栈来访问运行时数据区信息     栈是堆访问的入口     堆上Java.lang.Class实例是访问PermGen区域中类型信息的入口
        内存溢出之PermGen OOM深入分析 
  1. 一个类型装载之后会创建一个对应的java.lang.Class实例,这个实例本身和普通对象实例一样存储于堆中,我觉得之所以说是这是一种特殊的实例,某种程度上是因为其充当了访问PermGen区域中类型信息的代理者。
  2. 图中"Class类型实例"和"类加载器实例"分别是A类型对应的java.lang.Class实例和加载A类型的类加载器实例。
  3. 只要是有active的对象实例句柄,就能够访问到对应的Class类型实例和类加载器实例,分别通过Object.getClass()方法和Class.getClassLoader()方法。
  4. 只要是有active的Class类型实例句柄,就能够访问到对应的类加载器实例。

        【PermGen内存溢出深入分析】

        【前提知识】   

    由不同的类加载器实例加载的类型可以等价为完全不同的类型,哪怕时同一类型类加载器的不同实例加载的,都会在PermGen区域分配相应的空间来存储类型信息     新类型加载时,会在PermGen区域申请相应的空间来存储类型信息,类型被卸载后,PermGen区域上的垃圾收集会释放对应的内存空间。PermGen区域和普通的堆空间一样,也遵循垃圾收集的规律,所以,网上很多资料种关于PermGen区域空间的大小是只增不减的说法是不正确的,后面会用相应的测试代码来验证和分析。     一种类型被卸载的前提条件是:加载此类型的类加载器实例变为不可达( unreachable)状态,虚拟机协议中对应描述如下:
    A class or interface may be unloaded if and only if its class loader is unreachable. The bootstrap class loader is always reachable; as a result, system classes may never be unloaded.
关于实例的*unreachable*状态,大致可以理解为不能通过特定活动线程对应的栈出发通过引用计算来到达对应的实例,虚拟机协议中对应描述如下:
    _A reachable object is any object that can be accessed in any potential continuing
computation from any live thread._
    结合上面的 [虚拟机运行时数据区的介绍|],可以得出结论:类型对应的普通实例、类型对应的java.lang.Class实例、加载此类型的ClassLoader实例,三者中有任何一种或者多种是reachable状态的,那么此类型就不可能被卸载。     JMX协议提供了相应的API接口,用来在运行时查询当前虚拟机实例的内存使用和类型加载等信息。这也是很多Java性能监控和分析工具的基础,后面的测试程序中也有相应的代码使用了JMX协议。

       
            【测试程序分析】      

虚拟机器参数设置如下:
    -XX: PermSize=4M -XX:MaxPermSize=4M -verbose -verbose:gc
    设置-verbose参数是为了获取类型加载和卸载的信息
    设置-verbose:gc是为了获取垃圾收集的相关信息 在D:/classes目录下有一个简单的类型XXX对应的class字节码,测试代码中用URLClassLoader来加载此类型

        【测试程序一:模拟PermGen OOM】

1 try {
2    // 准备url
3    URL url = new File( " D:/classes " ).toURL();
4    URL[] urls = {url};
5
6    // 获取有关类型加载的JMX接口
7     ClassLoadingMXBean loadingBean = ManagementFactory.getClassLoadingMXBean();
8
9    // 用于缓存类加载器
10     List < ClassLoader > classLoaders = new ArrayList < ClassLoader > ();
11
12    while ( true ) {
13      // 加载类型并缓存类加载器实例
14        ClassLoader classLoader = new URLClassLoader(urls);
15       classLoaders.add(classLoader);
16       classLoader.loadClass( "XXX " );
17
18      // 显示数量信息(共加载过的类型数目,当前还有效的类型数目,已经被卸载的类型数目)
19       System.out.println( " total: " + loadingBean.getTotalLoadedClassCount());
20 System.out.println( " active: " + loadingBean.getLoadedClassCount());
21      System.out.println( " unloaded: " + loadingBean.getUnloadedClassCount());
22    }
23 } catch (Exception e) {
24    e.printStackTrace();
25 }
26
27

     【 测试程序一分析
运行测试程序一,输出信息如下(摘取了部分):
......
[Loaded XXX from [file:/D:/classes/]]
total: 2914
active: 2914
unloaded: 0
[Loaded XXX from [file:/D:/classes/]]
total: 2915
active: 2915
unloaded: 0
[Full GC 4852K->4852K(8720K), 0.0993780 secs]
[Full GC 4852K->4829K(8720K), 0.0999775 secs]
[Full GC 4829K->4829K(8720K), 0.0989805 secs]
[Full GC 4829K->4829K(8720K), 0.0997261 secs]
......
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
......
[Unloading class XXX]
......
[Loaded java.lang.Shutdown from D:\eos6\jdk1.5.0_09\jre\lib\rt.jar]
[Loadedjava.lang.Shutdown$Lockfrom D:\eos6\jdk1.5.0_09\jre\lib\rt.jar

   
        针对以上摘录的虚拟机器运行时信息,分析结论如下:

    一直在持续的加载类型XXX,而且一直没有卸载,直到PermGen OOM发生。类型XXX无法卸载的原因,前面说明过,是由于对应的类加载器实例一直是reachaable状态,缓存对象实例或者java.lang.Class实例同样可以达到无法卸载类型的效果。     在PermGen OOM发生前,虚拟机进行了非常频繁的垃圾收集,效果甚微     在PermGen OOM发生后,卸载了类型XXX,当前虚拟机实例退出


        【测试程序二:PermGen区域垃圾收集】

            和测试程序一相比,删除了类加载器实例缓存的代码
1 try {
2      // 准备url
3      URL url = new File( " D:/classes " ).toURL();
4      URL[] urls = {url};
5
6      // 获取有关类型加载的JMX接口
7       ClassLoadingMXBean loadingBean = ManagementFactory.getClassLoadingMXBean();
8
9      while ( true ) {
10      // 加载类型,不缓存类加载器实例
11       new URLClassLoader(urls).loadClass( " XXX " );
12      // 显示数量信息(共加载过的类型数目,当前还有效的类型数目,已经被卸载的类型数目)
13       System.out.println( " total: " + loadingBean.getTotalLoadedClassCount());
14      System.out.println( " active: " + loadingBean.getLoadedClassCount());
15      System.out.println( " unloaded: " + loadingBean.getUnloadedClassCount());
16     }
17 } catch (Exception e) {
18     e.printStackTrace();
19 }
20
21

测试程序二分析
运行测试程序二很长时间,一直没有发生PermGen OOM异常,输出信息如下(摘取了部分):
...
[Loaded XXX from [file:/D:/classes/]]
total: 19540
active: 1052
unloaded: 18488
[Full GC 1563K->259K(2112K), 0.1758958 secs]
......
[Unloading class XXX]
[Unloading class XXX]
[Unloading class XXX]
......
[GC 1968K->1563K(2112K), 0.0025266 secs]
......
[Loaded XXX from [file:/D:/classes/]]
total: 21098
active: 440
unloaded: 20658
...
针对以上摘录的虚拟机器运行时信息,分析结论如下:

  1. 类型XXX在频繁被加载的同时,也在频繁被卸载,当被加载的类型达到了21098时,并没有发生PermGen OOM,20658已经被卸载,堆内存的占用比测试代码一中小的多
  2. 中间进行的垃圾并不是特别频繁,但是垃圾收集的效果较为明显
  3. 类型被卸载之后,伴随着PermGen区域上的垃圾收集和新类型的不断被加载,PermGen区域中类型信息占有的堆内存大小在有序的增大减小

        【PermGen OOM原因总结】
            通过上面的 [测试程序分析|],我们发现PermGen OOM发生的原因和类型装载、类型卸载有直接的关系,可以对PermGen OOM发生的原因做如下大致的总结:
        1、PermGen区域分配的堆空间过小,可以通过设置-XX: PermSize参数和-XX:MaxPermSize参数来解决。

        2、类型卸载不及时,过时无效的类型信息占用了空间,我们不妨称其为"永久堆"的内存泄漏,需要通过深入分析类型卸载的原理来寻找对应的防范措施


        【常见的类加载器和类型卸载的可能性总结】
        通过前面的讨论,我们知道如果加载某种类型的类加载器实例没有处于unreachable状态,则该类型就不会被卸载,该类型不被卸载,则对应的类型信息在PermGen区域中占有的堆内存就不会被释放。下面,针对典型的Java应用分类,分析一下常用类加载器加载的类型被下载的可能性。

        【普通Java应用】
        启动类加载器:由于其负责加载虚拟机的核心类型,所以由其加载的类型在整个程序运行期间不可能被卸载,对应类型信息占用的PermGen区域堆空间不可能得到释放。
        扩展类加载器:负责加载JDK扩展路径下的类型,扩展类加载器同时又作为系统类加载器的父类加载器,所以,由其加载的类型在整个程序运行期间基本上不可能被卸载,对应类型信息占用的PermGen区域堆空间基本不可能得到释放。
        系统类加载器:负责加载程序类路径上面的类型,由其加载的类型在整个程序运行期间基本上不可能被卸载,对应类型信息占用的PermGen区域堆空间基本不可能得到释放。
        用户自定义类加载器:对于其加载的类型,满足类型卸载要求的可能性比较容易控制,只要是其实例本身处于unreachable状态,其加载的类型会被卸载,PermGen区域中对应的空间占有也会被释放。


        【插件开发】
        系统类加载器:由于其负责加载虚拟机的核心类型,所以由其加载的类型在插件应用运行期间不可能被卸载,对应类型信息占用的PermGen区域堆空间不可能得到释放。
        插件类加载器:系统插件类加载器负责加载OSGI实现的相关类型,所以由其加载的类型在插件应用运行期间不可能被卸载;用户开发的插件所使用的默认插件类加载器,和特定的插件本身进行域绑定,插件之间存在一定的类型引用关系,并且特定插件在整个插件应用的运行时被停止的可能性也很小,所以类型卸载发生几率极小。
        用户自定义类加载器:对于其加载的类型,满足类型卸载要求的可能性比较容易控制,只要是其实例本身处于unreachable状态,其加载的类型会被卸载,PermGen区域中对应的空间占有也会被释放。

        【PermGen内存溢出的应对措施】
    
通过上面的PermGen OOM的原因的分析,不难看出对应的应对措施:

    合理的设置-XX: PermSize和-XX:MaxPermSize参数(主要的有效措施)     有效的利用的虚拟机类型卸载的机制(针对程序进行调优)


        【合理设置参数(针对普通用户和开发者)】
        通过设置合理的XX: PermSize和-XX:MaxPermSize参数值是减少和有效避免PermGen OOM发生的最有效最主要的措施,尤其是针对普通用户而言,这基本上是唯一的办法。关于合理设置这两个参数,建议如下:

     XX: PermSize参数的设置尽量建立在基准测试的基础之上,可以利用监控工具对稳定运行期间PermGen区域的大小进行统计,取合理的平均值。网上的很多资料中,建议XX: PermSize和XX:MaxPermSize设置为相同的数值,个人觉得这是不正确的,因为两个参数的出发点是不一样的。XX: PermSize设置的过大肯定会在应用运行的大部分时间中浪费堆内存,有可能会明显增加存放普通对象实例的堆空间的垃圾收集的次数。

    XX:MaxPermSize参数的设置应该着眼于PermGen区域使用的峰值,因为这是避免PermGen OOM的最后一道屏障,其设置最好也是建立在性能监控工具的统计结果之上。     和虚拟机有关的性能参数较多的分为两类,一类是初始值或默认值,一类是峰值。如果该性能参数是会涉及到的虚拟机垃圾收集机制的,关于初始值或者默认值的设置尽量要建立在测试基础之上,尽量做到在单次垃圾收集时间和垃圾收集频率之间保持一个平衡,否则很有可能适得其反。


         【有效利用虚拟机类型卸载机制(针对开发者)】
        此部分的建议可以作为开发者进行性能调优或者日常开发时候的参考,尽量能够配合相应的性能监控工具进行:  

    检查是否由于程序设计本身上的缺陷,导致加载了大量实际上并不需要的类型。较新版本的Java虚拟机实现,一般都遵循动态解析的建议,所以不是人为设计的缺陷,一般不会诱发加载了大量实际上并不需要的类型。结合插件开发的应用场景,个人觉得插件功能模块的划分(其中包括了插件依赖关系的设计和有关扩展点的扩展收集等)和第三方jar的使用可能是诱发此问题的两个重要根源。     对象缓存的使用是否得当,通过前面的分析,我们知道这可能是导致类型不能被卸载的重要原因。缓存的使用,既要认识到其可以提高时间性能的有点,也要分析其可能会给普通对象堆空间和PermGen区域造成的负担。     自定义类加载器的合理使用,相关的几个注意要点包括:
  1. 是否不恰当的利用的类型更新的特性,也就是说是否对类加载器实例的unreachable状态做了有效的判断。考虑如下场景,假设用户开发了一个自定义类加载器来加载工程输出目录下的临时类型,对临时类型做了不必要的缓存,这肯定会导致所有被加载过的临时类型都不会得到卸载,会直接加重PermGen区域的负担。
  2. 自定义类加载器和其他已有类加载器的协作关系是否合理,是否合理的利用了Java类加载的双亲委派机制。我们知道,不同的类加载器实例(哪怕是同一种类加载器类型的不同实例)加载的同一种自定义类型在虚拟机内部都会被放置到不同的命名空间中作为不同类型来处理,所以合理的设置父类加载器变得很重要,不合理的设置会导致大量不必要的"新"类型被创造出来,况且这些不必要的"新"类型是否能够被及时卸载还是个未知数。
    慎重检查自定义类加载器实例是否被不恰当的缓存了,原因不言而喻。