前提

今天(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]: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)

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

  1. 7. 通过JDBC源码来分析线程上下文类加载器以及SPI的使用

    目录 1. 什么是全盘负责委托机制 2. 为什么需要有线程上下文类加载器 2.1 使用JDBC的例子,分析为什么双亲委托机制不能实现要求 2.2 线程上下文类加载器的作用 3. 线程上下文类加载器的使 ...

  2. 深入理解Java类加载器(二):线程上下文类加载器

    摘要: 博文<深入理解Java类加载器(一):Java类加载原理解析>提到的类加载器的双亲委派模型并不是一个强制性的约束模型,而是Java设计者推荐给开发者的类加载器的实现方式.在Java ...

  3. JVM 线程上下文类加载器

    当前类加载器(Current ClassLoader) 每个类都会使用自己的类加载器(即加载自身的类加载器)来去加载其他类(指所依赖的类) 如果ClassX引用了ClassY,那么ClassX的类加载 ...

  4. 通过JDBC驱动加载深刻理解线程上下文类加载器机制

    关于线程上下文类加载器已经在之前学得比较透了,作为一个收尾,这里用平常J2EE开发时JDBC连接Mysql数据库常见的一段代码通过分析它的底层进一步加深对线程上下文类加载器的理解,所以先来将连接应用代 ...

  5. 【JVM学习笔记】线程上下文类加载器

    有许多地方能够看到线程上下文类加载的设置,比如在sun.misc.Launcher类的构造方法中,能够看到如下代码 先写一个例子建立感性认识 public class Test { public st ...

  6. 线程上下文类加载器(Context ClassLoader)

    1.线程上下文类加载器是从jdk1.2开始引入的,类Thread中的getContextClassLoader()与setContextClassLoader(ClassLoader c1),分别用来 ...

  7. 【Java虚拟机11】线程上下文类加载器

    前言 目前学习到的类加载的知识,都是基于[双亲委托机制]的.那么JDK难道就没有提供一种打破双亲委托机制的类加载机制吗? 答案是否定的. JDK为我们提供了一种打破双亲委托模型的机制:线程上下文类加载 ...

  8. java既然存在gc线程,为什么还存在内存泄漏?

    java既然存在gc线程,为什么还存在内存泄漏? 1.既然 Java 的垃圾回收机制能够自动的回收内存,怎么还会出现内存泄漏的情况呢?这个问题,我们需要知道 GC 在什么时候回收内存对象,什么样的内存 ...

  9. Webservice WCF WebApi 前端数据可视化 前端数据可视化 C# asp.net PhoneGap html5 C# Where 网站分布式开发简介 EntityFramework Core依赖注入上下文方式不同造成内存泄漏了解一下? SQL Server之深入理解STUFF 你必须知道的EntityFramework 6.x和EntityFramework Cor

    Webservice WCF WebApi   注明:改编加组合 在.net平台下,有大量的技术让你创建一个HTTP服务,像Web Service,WCF,现在又出了Web API.在.net平台下, ...

随机推荐

  1. linux清理函数

    每个非试验性的模块也要求有一个清理函数, 它注销接口, 在模块被去除之前返回所有资 源给系统. 这个函数定义为: static void   exit cleanup_function(void) { ...

  2. P1072 城市轰炸

    题目描述 一个大小为N*M的城市遭到了X次轰炸,每次都炸了一个每条边都与边界平行的矩形. 在轰炸后,有Y个关键点,指挥官想知道,它们有没有受到过轰炸,如果有,被炸了几次,最后一次是第几轮. 输入格式 ...

  3. 2018-8-10-win10-uwp-获取文件夹出错

    title author date CreateTime categories win10 uwp 获取文件夹出错 lindexi 2018-08-10 19:16:50 +0800 2018-2-1 ...

  4. MySQL查询语句积累

    #查询名字中带李且名字是两个字的所有学生信息 SELECT * FROM user_test WHERE user_name LIKE '李_';

  5. JS求数组最大值常用方法

    第一种方法: 循环数组 let ary = [1,2,22,3,99,100],maxNum = ary[0] function getMaxNum(ary){ for(let i = 1,len = ...

  6. 中文javadoc

    大量的中文注释,其实最大的目的是想生成中文的Javadoc.但是中文注释实在太不高级了,在各种编码下还会变成乱码.本着高端大气国际化的思路,还是想把它换成英文,但是又不想放弃中文的Javadoc,怎么 ...

  7. codeforces 1194F (组合数学)

    Codeforces 11194F (组合数学) 传送门:https://codeforces.com/contest/1194/problem/F 题意: 你有n个事件,你需要按照1~n的顺序完成这 ...

  8. mac笔记本安装Android sdk

    一.先下载android sdk for mac   给二个靠谱的网址: a). http://down.tech.sina.com.cn/page/45703.html b). http://mac ...

  9. 13.python基础试题(二)

    借鉴:https://www.cnblogs.com/shengyang17/p/8543712.html https://www.cnblogs.com/you-wei1/p/9693254.htm ...

  10. javaweb项目添加log4j日志

    谈到我们在Java程序中经常用的日志,Log4j应该是耳熟能详了.这里先提下slf4j,英文全名是Simple Logging Facade for Java,直面意思是:Java的简单日志门面.sl ...