线程上下文类加载器ContextClassLoader内存泄漏隐患

前提

今天(2020-01-18)在编写Netty相关代码的时候,从Netty源码中的ThreadDeathWatcherGlobalEventExecutor追溯到两个和线程上下文类加载器ContextClassLoader内存泄漏相关的Issuejava

两个Issue分别是两位前辈在2017-12的时候提出的,描述的是同一类问题,最后被Netty的负责人采纳,而且修复了对应的问题从而关闭了Issue。这里基于这两个Issue描述的内容,对ContextClassLoader内存泄漏隐患作一次复盘。git

ClassLoader相关的内容

  • 一个JVM实例(Java应用程序)里面的全部类都是经过ClassLoader加载的。
  • 不一样的ClassLoaderJVM中有不一样的命名空间,一个类实例(Class)的惟一标识是全类名 + ClassLoader,也就是不一样的ClassLoader加载同一个类文件,也会获得不相同的Class实例。
  • JVM不提供类卸载的功能,从目前参考到的资料来看,类卸载须要知足下面几点:
    • 条件一:Class的全部实例不被强引用(不可达)。
    • 条件二:Class自己不被强引用(不可达)。
    • 条件三:加载该ClassClassLoader实例不被强引用(不可达)。

有些场景下须要实现类的热部署和卸载,例如定义一个接口,而后由外部动态传入代码的实现。github

这一点很常见,最典型的就是在线编程,代码传到服务端再进行编译和运行。sql

因为应用启动期全部非JDK类库的类都是由AppClassLoader加载,咱们没有办法经过AppClassLoader去加载非类路径下的已存在同名的类文件(对于一个ClassLoader而言,每一个类文件只能加载一次,生成惟一的Class),因此为了动态加载类,每次必须使用彻底不一样的自定义ClassLoader实例加载同一个类文件或者使用同一个自定义的ClassLoader实例加载不一样的类文件。类的热部署这里举个简单例子:shell

// 此文件在项目类路径
package club.throwable.loader;
public class DefaultHelloService implements HelloService {

    @Override
    public String sayHello() {
        return "default say hello!";
    }
}

// 下面两个文件编译后放在I盘根目录
// I:\\DefaultHelloService1.class
package club.throwable.loader;
public class DefaultHelloService1 implements HelloService {

    @Override
    public String sayHello() {
        return "1 say hello!";
    }
}
// I:\\DefaultHelloService2.class
package club.throwable.loader;
public class DefaultHelloService2 implements HelloService {

    @Override
    public String sayHello() {
        return "2 say hello!";
    }
}

// 接口和运行方法
public interface HelloService {

    String sayHello();

    static void main(String[] args) throws Exception {
        HelloService helloService = new DefaultHelloService();
        System.out.println(helloService.sayHello());
        ClassLoader loader = new ClassLoader() {

            @Override
            protected Class<?> findClass(String name) throws ClassNotFoundException {
                String location = "I:\\DefaultHelloService1.class";
                if (name.contains("DefaultHelloService2")) {
                    location = "I:\\DefaultHelloService2.class";
                }
                File classFile = new File(location);
                ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                try {
                    InputStream stream = new FileInputStream(classFile);
                    int b;
                    while ((b = stream.read()) != -1) {
                        outputStream.write(b);
                    }
                } catch (IOException e) {
                    throw new IllegalArgumentException(e);
                }
                byte[] bytes = outputStream.toByteArray();
                return super.defineClass(name, bytes, 0, bytes.length);
            }
        };
        Class<?> klass = loader.loadClass("club.throwable.loader.DefaultHelloService1");
        helloService = (HelloService) klass.newInstance();
        System.out.println(helloService.sayHello());
        klass = loader.loadClass("club.throwable.loader.DefaultHelloService2");
        helloService = (HelloService) klass.newInstance();
        System.out.println(helloService.sayHello());
    }
}

// 控制台输出
default say hello!
1 say hello!
2 say hello!

若是新建过多的ClassLoader实例和Class实例,会占用大量的内存,若是因为上面几个条件没法所有知足,也就是这些ClassLoader实例和Class实例一直堆积没法卸载,那么就会致使内存泄漏(memory leak,后果很严重,有可能耗尽服务器的物理内存,由于JDK1.8+类相关元信息存在在元空间metaspace,而元空间使用的是native memory)。编程

线程中的ContextClassLoader

ContextClassLoader其实指的是线程类java.lang.Thread中的contextClassLoader属性,它是ClassLoader类型,也就是类加载器实例。有些场景下,JDK提供了一些标准接口须要第三方提供商去实现(最多见的就是SPIService Provider Interface,例如java.sql.Driver),这些标准接口类是由启动类加载器(Bootstrap ClassLoader)加载,可是这些接口的实现类须要从外部引入,自己不属于JDK的原生类库,没法用启动类加载器加载。为了解决此困境,引入了线程上下文类加载器Thread Context ClassLoader。线程java.lang.Thread实例在初始化的时候会调用Thread#init()方法,Thread类和contextClassLoader相关的核心代码块以下:性能优化

// 线程实例的初始化方法,new Thread()的时候必定会调用
private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
    // 省略其余代码
    Thread parent = currentThread();
    // 省略其余代码
    if (security == null || isCCLOverridden(parent.getClass()))
        this.contextClassLoader = parent.getContextClassLoader();
    else
        this.contextClassLoader = parent.contextClassLoader;
    // 省略其余代码
}

public void setContextClassLoader(ClassLoader cl) {
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        sm.checkPermission(new RuntimePermission("setContextClassLoader"));
    }
    contextClassLoader = cl;
}

@CallerSensitive
public ClassLoader getContextClassLoader() {
    if (contextClassLoader == null)
        return null;
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        ClassLoader.checkClassLoaderPermission(contextClassLoader, Reflection.getCallerClass());
    }
    return contextClassLoader;
}

首先明确两点:服务器

  • Thread实例容许手动设置contextClassLoader属性,覆盖当前的线程上下文类加载器实例。
  • Thread在初始化实例(调用new Thread())的时候必定会调用Thread#init()方法,新建的子线程实例会继承父线程的contextClassLoader属性,而应用主线程[main]contextClassLoader通常是应用类加载器(Application ClassLoader,有时也称为系统类加载器),其余用户线程都是主线程派生出来的后代线程,若是不覆盖contextClassLoader,那么新建的后代线程的contextClassLoader就是应用类加载器。

分析到这里,笔者只想说明一个结论:后代线程的线程上下文类加载器会继承父线程的线程上下文类加载器,其实这里用继承这个词语也不是太准确,准确来讲应该是后代线程的线程上下文类加载器和父线程的上下文类加载器彻底相同,若是都派生自主线程,那么都是应用类加载器。对于这个结论能够验证一下(下面例子在JDK8中运行):并发

public class ThreadContextClassLoaderMain {

    public static void main(String[] args) throws Exception {
        AtomicReference<Thread> grandSonThreadReference = new AtomicReference<>();
        Thread sonThread = new Thread(() -> {
            Thread thread = new Thread(()-> {},"grand-son-thread");
            grandSonThreadReference.set(thread);
        }, "son-thread");
        sonThread.start();
        Thread.sleep(100);
        Thread main = Thread.currentThread();
        Thread grandSonThread = grandSonThreadReference.get();
        System.out.println(String.format("ContextClassLoader of [main]:%s", main.getContextClassLoader()));
        System.out.println(String.format("ContextClassLoader of [%s]:%s",sonThread.getName(), sonThread.getContextClassLoader()));
        System.out.println(String.format("ContextClassLoader of [%s]:%s", grandSonThread.getName(), grandSonThread.getContextClassLoader()));
    }
}

控制台输出以下:ide

ContextClassLoader of [main]:sun.misc.Launcher$AppClassLoader@18b4aac2
ContextClassLoader of [son-thread]:sun.misc.Launcher$AppClassLoader@18b4aac2
ContextClassLoader of [grand-son-thread]:sun.misc.Launcher$AppClassLoader@18b4aac2

印证了前面的结论,主线程、子线程、孙子线程的线程上下文类加载器都是AppClassLoader类型,而且指向同一个实例sun.misc.Launcher$AppClassLoader@18b4aac2

ContextClassLoader设置不当致使内存泄漏的隐患

只要有大量热加载和卸载动态类的场景,就须要警戒后代线程ContextClassLoader设置不当致使内存泄漏。画个图就能比较清楚:

父线程中设置了一个自定义类加载器,用于加载动态类,子线程新建的时候直接使用了父线程的自定义类加载器,致使该自定义类加载器一直被子线程强引用,结合前面的类卸载条件分析,全部由该自定义类加载器加载出来的动态类都不能被卸载,致使了内存泄漏。这里仍是基于文章前面的那个例子作改造:

  • 新增一个线程X用于进行类加载,新建一个自定义类加载器,设置线程X的上下文类加载器为该自定义类加载器。
  • 线程X运行方法中建立一个新线程Y,用于接收类加载成功的事件而且进行打印。
public interface HelloService {

    String sayHello();

    BlockingQueue<String> CLASSES = new LinkedBlockingQueue<>();

    BlockingQueue<String> EVENTS = new LinkedBlockingQueue<>();

    AtomicBoolean START = new AtomicBoolean(false);

    static void main(String[] args) throws Exception {
        Thread thread = new Thread(() -> {
            ClassLoader loader = new ClassLoader() {

                @Override
                protected Class<?> findClass(String name) throws ClassNotFoundException {
                    String location = "I:\\DefaultHelloService1.class";
                    if (name.contains("DefaultHelloService2")) {
                        location = "I:\\DefaultHelloService2.class";
                    }
                    File classFile = new File(location);
                    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                    try {
                        InputStream stream = new FileInputStream(classFile);
                        int b;
                        while ((b = stream.read()) != -1) {
                            outputStream.write(b);
                        }
                    } catch (IOException e) {
                        throw new IllegalArgumentException(e);
                    }
                    byte[] bytes = outputStream.toByteArray();
                    Class<?> defineClass = super.defineClass(name, bytes, 0, bytes.length);
                    try {
                        EVENTS.put(String.format("加载类成功,类名:%s", defineClass.getName()));
                    } catch (Exception ignore) {

                    }
                    return defineClass;
                }
            };
            Thread x = new Thread(() -> {
                try {
                    if (START.compareAndSet(false, true)) {
                        Thread y = new Thread(() -> {
                            try {
                                for (; ; ) {
                                    String event = EVENTS.take();
                                    System.out.println("接收到事件,事件内容:" + event);
                                }
                            } catch (Exception ignore) {

                            }
                        }, "Y");
                        y.setDaemon(true);
                        y.start();
                    }
                    for (; ; ) {
                        String take = CLASSES.take();
                        Class<?> klass = loader.loadClass(take);
                        HelloService helloService = (HelloService) klass.newInstance();
                        System.out.println(helloService.sayHello());
                    }
                } catch (Exception ignore) {

                }
            }, "X");
            x.setContextClassLoader(loader);
            x.setDaemon(true);
            x.start();
        });
        thread.start();
        CLASSES.put("club.throwable.loader.DefaultHelloService1");
        CLASSES.put("club.throwable.loader.DefaultHelloService2");
        Thread.sleep(5000);
        System.gc();
        Thread.sleep(5000);
        System.gc();
        Thread.sleep(Long.MAX_VALUE);
    }
}

控制台输出:

接收到事件,事件内容:加载类成功,类名:club.throwable.loader.DefaultHelloService1
1 say hello!
接收到事件,事件内容:加载类成功,类名:club.throwable.loader.DefaultHelloService2
2 say hello!

打开VisualVMDump对应进程的内存快照,多执行几回GC,发现了全部动态类都没有被卸载(这里除非主动终止线程Y释放自定义ClassLoader,不然永远都不可能释放该强引用),验证了前面的结论。

固然,这里只是加载了两个动态类,若是在特殊场景之下,例如在线编码和运行代码,那么有可能极度频繁动态编译和动态类加载,若是出现了上面相似的内存泄漏,那么很容易致使服务器内存耗尽。

解决方案

参考那两个Issue,解决方案(或者说预防手段)基本上有两个:

  1. 不须要使用自定义类加载器的线程(如事件派发线程等)优先初始化,那么通常它的线程上下文类加载器是应用类加载器。
  2. 新建后代线程的时候,手动覆盖它的线程上下文类加载器,参考Netty的作法,在线程初始化的时候作以下的操做:
// ThreadDeathWatcher || GlobalEventExecutor
AccessController.doPrivileged(new PrivilegedAction<Void>() {
    @Override
    public Void run() {
        watcherThread.setContextClassLoader(null);
        return null;
    }
});

小结

这篇文章算是近期研究得比较深刻的一篇文章,ContextClassLoader内存泄漏的隐患归根究竟是引用使用不当致使一些原本在方法栈退出以后须要释放的引用没法释放致使的。这种问题有些时候隐藏得很深,而一旦命中了一样的问题而且在并发的场景之下,那么内存泄漏的问题会恶化得十分快。这类问题归类为性能优化,而性能优化是十分大的专题,之后应该也会遇到相似的各种问题,这些经验但愿能对将来产生正向的做用。

参考资料:

  • 《深刻理解Java虚拟机 - 3rd》

个人我的博客

(本文完 c-2-d e-a-20200119)

相关文章
相关标签/搜索