关于类加载器内存泄露的分析

Published: 26 Apr 2014 Category: GC

从上个世纪90年代Java诞生之日起,Java的类和资源的加载就一直是个问题。由于它增加了启动和初始化时间,因此这个问题在Java应用服务器上则尤为明显。为了缓解这个问题,大家试过了不同的访问,比如说以exploaded方式部署,但这只对简单的应用有效;还有2001年发明的Java热插拔的机制。启用热插拔的话,你在一个现有的方法内的改动马上就会生效。由于方法的边界限制,这个方法并不是特别有用,通常它只是在调试的阶段使用。对于现在的应用来说,编译,部署以及重启,等待个5到15分钟已经不是什么稀奇事儿了。越大型的应用服务器,这种情况可能就越明显。

存在的问题

一旦某个Java类被类加载器加载了,它就是不可变的,只要类加载器还存在,它也会一直存在下去。类的唯一标识是它的类名以及类加载器的标识,要重启一个应用的话,你需要创建一个新的类加载器,并加载最新版本的类。你不能把一个已经存在的对象映射到一个新类上面,因此重新加载时的状态迁移非常重要。这意味着你得初始化应用和配置的状态,拷贝用户的会话信息,以便重新生成整个应用的对象图。通常来说这非常耗时并很容易产生内存泄露。

说到类加载器的内存泄露,由于Java使用的内存模型的原因,哪怕是一小行代码的泄露都会产生很大的影响。比如说,一个类加载器的实例,它拥有自己加载的所有类的引用,以及这些类生成的所有对象的引用。因此在应用重启过程的状态迁移中,哪怕一个很小的泄露,都可能会产生极大的影响。

那这些对开发人员来说意味着什么?它意味即使是普通的编译,构建,打包,部署,应用重启,这些琐事都会极大的分散你的注意力,影响你的开发效率。

本文试图揭秘对开发人员而言JRebel所带来的威力,看一下这个产品背后究竟有什么奥妙,以及深入了解下JVM的那些你可能会忽略的地方 。本文主要关注JRebel所试图要解决的那些问题。

认识类加载器

类加载器只是一个普通的Java对象

是的,它并不是什么了不起的东西,除了JVM的系统类加载器,剩下的全都是一个普通的Java对象而已!ClassLoader是一个抽象类,你可以自己创建一个类来实现它。下面是它的API:

public abstract class ClassLoader {  public Class loadClass(String name);
  protected Class defineClass(byte[] b);
  public URL getResource(String name);
  public Enumeration getResources(String name);
  public ClassLoader getParent();
} 

看起来相当简单,对吧?我们来逐个看下这些方法。最核心的方法是loadClass,它接受一个String类型的类名,并且返回实际的Class对象。如果你之前用过类加载器的话,这可能是你最熟悉的一个方法了,因为你可能每天都会用到它。defineClass是一个final类型的方法,它接受一个来自文件或者网络的byte数组,返回的也是一个Class对象。

类加载器还会从类路径中加载资源。它的工作方式和loadClass方法差不多。类似的方法有好几个,比如getResource和getResources,它返回的是一个URL对象,或者是一个URL的Enumeration。这些URL指向的是方法参数name中对应的资源。

每个类加载器都会有一个父类加载器,getParent方法返回的就是这个父加载器,它和Java的继承没有什么关系,只是用一个链表将它们串联起来而已。后面我们会稍微深入的了解下它。

类加载器是懒加载模式的,因此类只有在运行时被请求加载的话才会被加载进来。类是由调用到它的对象加载的,因此在运行时一个类可能会被多个类加载器加载,这取决于具体是哪个类引用到了它们以及哪个类加载器加载了引用了它们的类。。。好吧,我自己都有点绕晕了。我们来看段代码吧。

public class A {
  public void doSmth() {
    B b = new B();
    b.doSmthElse();
  }
}

这里有一个A类,它在doSmth()方法里调用了B类的构造方法。实际上底层会触发这样的调用:

A.class.getClassLoader().loadClass(B);

加载了A类的类加载器会去加载B类。

类加载器是分层的,不过跟孩子们不一样,它们不会总听父母的话

每个类加载器都会有一个父加载器。当请求一个类加载器加载类时,它通常会先调父类加载器的loadClass方法,而它的父类加载器也会再去找自己的父加载器,这么一直下去。如果同一个父加载器下面有两个类加载器,它们又同时被请求加载同一个类,类加载器只会加载一次。如果两个类加载器分别加载了同一个类,事情就会变得非常麻烦,下面我们会看到这种情况。

Java应用服务器在实现Java EE规范的时候,有的实现是先委托给父加载器进行加载,有的实现则会先看下本地的Web应用类加载器底下有没有。我们来深入分析下这种情况,下面用图1作为例子。

类加载器分层图

在这个例子中,模块WAR1有自己的类加载器,它会优先用它来加载类,而不是委托给自己的双亲,也就是App1.ear的类加载器。这意味着不同的WAR模块,比如WAR1和WAR2,它们互相看不到对方的类。App1.ear模块有自己的类加载器,并且它是WAR1和WAR2类加载器的父加载器。当WAR1和WAR2的类加载器需要向上委派加载请求时,它会去请求App1.ear的类加载器,这意味着要加载的类在WAR类加载器的作用域外。如果某个类在WAR和app1中同时在在的话,WAR中的会覆盖掉APP的。最后EAR的类加载器的双亲就是容器的类加载器。EAR类加载器会把请求委派给容器的类加载器,不过它和WAR的做法并不一样,它会优先委派给父加载器。正如你所看到的,现在情况变得有点复杂了,这和普通的Java SE中的类加载行为并不一致。

那么在应用中如何重新加载类呢?

从前面的ClassLoader的API那可以知道,它只能用来加载类。也就是说,它没法用来卸载,或者重新加载类,因此如果要在运行时重新加载一个类的话,你得把现有的整个类结构体系全部扔掉,然后再重新加载使用,就像图2中那样。

重新加载类

如果你已经用过一段时间的Java了,你肯定会知道这要发生内存泄露了。一般的内存泄露是因为集合里面引用了许多需要要被清除的对象,但最终却没有被清理掉。类加载器也是这种情况,不过它更特殊一点。不幸的是,从Java平台的当前情况来看,这种情况不可避免并且开销极大。在经过几次重新部署后最终会抛出OutOfMemoryErrors异常。

每一个对象都会有一个指向自己对应类的引用,而这个类又会引用它的类加载器。关键在于类加载器又有它加载过的所有类的引用,每个类里面又会有一些静态的字段,像图3中那样。

类加载器引用结构图

这意味着:

  1. 如果类加载器泄露了,它所持有的所有类对象以及它们的静态字段也都会泄露。静态字段一般来说是些缓存,单例对象,以及不同的配置及应用状态信息。就算你的程序本身并没有任何大的静态缓存,这并不意味着你的框架不会替你缓存些什么东西(比如说log4j,它一般都在容器的类路径底下)。这同时也说明了为什么类加载器一旦泄露就会非常严重。

  2. 只要有一个对象泄露了,那么它对应的类的类加载器就会跟着一起泄露。尽管这个对象可能看起来占不了什么地方(它可能连一个字段都 没有),但它仍会引用到它自己的类加载器,最终引用到所有相关的应用状态信息。在应用重新部署的过程中,只要有一个地方发生了泄露,没有正确的清理掉,就会导致严重的泄露问题。通常一个应用中会有好几处类似会泄露的地方,由于一些第三方库本身构建的问题,有一些泄露的问题几乎无法解决。因此,类加载器的泄露十分常见。

这就是类加载器背后的技术难点,也就是说为了能在运行时刷新我们的代码,通常都得重新编译打包,部署甚至重启服务才能看到更新的代码。下篇文章中我们将会讲到Java中的这个难题的一些解决方案,包括使用Java 1.4中引入的一个类热插拔的框架,以及JRebel。

原创文章转载请注明出处:关于类加载器内存泄露的分析

英文原文链接