前提

今天(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实例加载不同的类文件。类的热部署这里举个简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// 此文件在项目类路径
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相关的核心代码块如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 线程实例的初始化方法,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中运行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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()));
}
}

控制台输出如下:

1
2
3
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,用于接收类加载成功的事件并且进行打印。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
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);
}
}

控制台输出:

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

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

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

解决方案

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

  1. 不需要使用自定义类加载器的线程(如事件派发线程等)优先初始化,那么一般它的线程上下文类加载器是应用类加载器。
  2. 新建后代线程的时候,手动覆盖它的线程上下文类加载器,参考Netty的做法,在线程初始化的时候做如下的操作:
1
2
3
4
5
6
7
8
// 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)



 评论