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

前提

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

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

ClassLoader相关的内容

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

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

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

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

// 此文件在项目类路径
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()));
    }
}

控制台输出如下:

ContextClassLoader of [main]:[email protected]
ContextClassLoader of [son-thread]:[email protected]
ContextClassLoader of [grand-son-thread]:[email protected]

印证了前面的结论,主线程、子线程、孙子线程的线程上下文类加载器都是AppClassLoader类型,并且指向同一个实例[email protected]

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)

原文地址:https://www.cnblogs.com/throwable/p/12216546.html

时间: 2024-10-02 20:51:28

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

虚拟机类加载机制(3)——线程上下文类加载器

之所以将线程上下文类加载器(Thread Context ClassLoader)单独拿出来写,确实是因为它涉及的东西比较多,既然带有线程两个字,一定也是非常重要的一个东西. 我们首先来回顾一下类加载器的双亲委派模型. 在上一章<虚拟机类加载机制(2)——类加载器>中我们解释了何为类加载器的“双亲委派模型”,知道了双亲委派模型给我们带了一个好处就是Java类随着它的类一起具备了一种带有优先级的层次关系.简单的例子就是Object类在程序的各种类加载环境中都会由启动类加载器来加载,换言之,它无论

线程上下文类加载器与服务器类加载原理

双亲委派机制以及类加载器的问题 一般情况下.保证同一个类中所关联的其他类都是由当前类的类加载器所加载的. 比如,class A本身在Ext下找到.那么他里面new出来的一些类也就只能用Ext去查找了(不会低一个级别).所以有些明明App可以找到的,却找不到了. JDBC API他有实现的driver部分(mysql,sql server).我们的JDBC APl都是由Boot或者Ext来载入的.但是JDBC driver却是由Ext或者App来载入,那么就有可能找不到driver了.在Java领

线程上下文类加载器

Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现.常见的 SPI 有 JDBC.JCE.JNDI.JAXP 和 JBI 等. 这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里.SPI接口中的代码经常需要加载具体的实现类.那么问题来了,SPI的接口是Java核心库的一部分,是由启动类加载器来加载的:SPI的实现类

线程上下文类加载器分析与实现

在上一次[https://www.cnblogs.com/webor2006/p/9246850.html]分析源码中发现有两处设置线程上下文类加载器的代码,如下: 因为它是非常重要的东东,所以这次专门对它进行主题展开,主要的作用为了改变委托双亲模式在某些场景不太适用或者是无法满足需求的,下面先写一个简单的测试代码: 那输出是啥呢? 也就是说当前线程的上下文类加载器是应用类加载器,而第二输出null不足为奇,因为Thread是JDK中的系统类当然是由启动类加载器加载喽. 对于上面的例子先有一个初

SGI STL内存配置器存在内存泄漏吗?

阅读了SGI的源码后对STL很是膜拜,很高质量的源码,从中学到了很多.温故而知新!下文中所有STL如无特殊说明均指SGI版本实现. STL 内存配置器 STL对内存管理最核心部分我觉得是其将C++对象创建过程分解为构造.析构和内存分配.释放两类操作分离开来!摆脱了对频繁调用new或malloc函数想操作系统申请空间而造成的低效.其中析构操作时对具有non-trival.trival 析构函数的class区别对待也提高了效率.SGI 的两级配置器结构属于锦上添花. STL内存配置器有没有内存泄漏?

优雅的App全然退出方案(没有不论什么内存泄漏隐患)

在Android开发过程中,特别是界面比較多的情况下,用寻常的退出方式往往是不能全然退出这个应用,网络上也好多各种退出方案.当中一种应该是被广大开发人员採纳使用,也很的清晰方便.就是在Application中维护一个单例的List<Activity>管理容器,每次打开新的Activity就把当前Activity增加到容器中,然后在须要全然退出的时.就循环遍历该容器分别finish()每一个Activity.即达到全然退出功能. 尽管这样的退出方式非常好.但却存在内存泄漏的隐患.所以,我们还须要

优雅的App完全退出方案(没有任何内存泄漏隐患)

在Android开发过程中,特别是界面比较多的情况下,用平常的退出方式往往是不能完全退出这个应用,网络上也好多各种退出方案.其中一种应该是被广大开发者采纳使用,也非常的清晰方便,就是在Application中维护一个单例的List<Activity>管理容器,每次打开新的Activity就把当前Activity加入到容器中,然后在需要完全退出的时,就循环遍历该容器分别finish()每个Activity,即达到完全退出功能. 虽然这种退出方式很好,但却存在内存泄漏的隐患,所以,我们还需要再找更

EntityFramework Core依赖注入上下文方式不同造成内存泄漏了解一下?

前言 这个问题从未遇见过,是一位前辈问我EF Core内存泄漏问题时我才去深入探讨这个问题,刚开始我比较惊讶,居然还有这种问题,然后就有了本文,直接拿前辈的示例代码并稍加修改成就了此文,希望对在自学EF Core过程中的童鞋能有些许帮助. EntityFramework Core内存泄漏回顾 接下来我将用简单示例代码来还原整个造成EntityFramework Core内存泄漏的过程,同时在这个过程中您也可思考一下其中的原因和最终的结果是否一致. public class TestA { pub

JAVA类加载器概念与线程类加载器

类加载器的功能:通过一个类的全限定名来获取描述此类的二进制字节流的过程 java的类加载器大致可以分为两类,一类是系统提供的,一类是由应用开发人员编写的.系统提供的类加载器有以下三种: 引导类加载器(bootstrap class loader):用来加载 Java 的核心库(rt.jar),是用原生代码来实现的,并不继承自 java.lang.ClassLoader. 扩展类加载器(extensions class loader):用来加载 Java 的扩展库.Java 虚拟机的实现会提供一个