热加载与卸载已经成为RASP的标配,而涉及到插件或者脚本的卸载问题,却少有技术文档提及, 主要原因是RASP开发人员更多的偏向安全,即使是经验丰富的Java工程师,遇到内存泄露问题,也会感到棘手。
类卸载的条件十分苛刻,要同时满足下面的三个条件:
类所有的实例对象已经被回收;
加载该类的Classloder已经被回收;
该类对应的java.lang.Class对象没有任何对方被引用;
一般的,无需关心class对象的引用关系,但是有些场景(如反射场景)会缓存类的Class对象,使得class对象的引用存在,导致JRASP无法卸载。
以JDK8为例子,Java虚拟机自带的类加载器有BootstrapClassLoader、ExtensionClassLoader和SystemClassLoader, 这些类加载器在JVM整个生命周期中都不会被置为空,因此它们加载的类也不会被卸载。
而用户自定义的类加载器,可以在使用完成之后将加载器的对象置空,从而满足类卸载的三个条件之一。
以JRASP代码为例子,执行卸载关闭操作之后,将自定义类加载器置空。
如果自定义类加载器没有正确的置空,JRASP将不会被完全的清理,从而引发内存泄漏。 现在我们做一个测试,将上面代码截图的第90行的 raspClassLoader=null;
注释掉(即类加载器不置空)。
打包编译后加载JRASP后再执行卸载操作,主动Full GC jmap-histo:live50730|grep com.jrasp.agent.core
,结果如下所示:
206: 50 3200 com.jrasp.agent.core.log.LogRecord
307: 12 1344 com.jrasp.agent.core.classloader.ModuleJarClassLoader
460: 12 480 com.jrasp.agent.core.module.CoreModule
542: 12 288 [Lcom.jrasp.agent.core.classloader.RoutingURLClassLoader$Routing;
548: 12 288 com.jrasp.agent.core.classloader.RoutingURLClassLoader$Routing
// 其他类省略...
在Full GC之后,JRASP实例个数不为空,存在内存泄漏。使用性能诊断(jprofile,eclipse的MAT工具也是可以)工具,查看JRASP的对象:
查看其中一个对象的引用关系,如下所示:
从上图的引用关系可以明显看出,存在一条引用链路,链路从 RaspClassloader
开始指向 LogRecord
(上图中两个红色圈的之间的灰色线) (黄色为class对象,红色为GC Roots)
内存泄漏的原因:classloader没有置为空,导致内存泄漏。
以JRASP为例说明,这里包括线程池关闭、自定义线程、定时器停止、shutdownHook移除、ClassFileTransformer移除和threadlocal线程变量清除等.
完全停止 java.lang.timer
,需要将定时器线程停止,并将任务执行队列清空。
在Java进程关闭之前,能够即时的清理rasp占用的磁盘等资源,shutdownHook可以执行指定的操作。
如果主动关闭rasp,没有清理shutdownHook,将会导致内存泄漏。
3.3 线程池关闭
如果RASP使用到了线程池,在卸载时需要关闭。即使关闭了线程池,由于jvm线程池重写了 finalize
方法,一次FullGC依然无法清除残留的对象。 JRASP 1.2.x(商业版本) 已经把线程池替换为多个 java.lang.timer
,卸载时非常清爽干净。
在JRASP中,使用线程变量threadlocal关联请求上下文与具体的hook类,来辅助检测功能。
在RASP卸载时,需要将线程thread中缓存的threadlocal对象。
在介绍JRASP实现方案之前,先来看下tomcat是如何实现热卸载的和内存泄漏检测的。
tomcat在卸载war包时,调用war的类加载器 WebappClassLoaderBase
对象的stop方法完成资源的关闭与清理操作。
具体的引用清除实现来在 clearReferences
中,主要有:注销JDBC驱动、关闭应用创建的线程、检查线程变量的内存泄漏等,关闭连接和线程的操作容易实现,本节主要针对线程变量的内存泄漏清理与检测。
线程变量泄漏检测
在线程Thread对象中使用两个字段保存该线程使用的 threadlocal
对象:
public class Thread implements Runnable {
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
/*
* InheritableThreadLocal values pertaining to this thread. This map is
* maintained by the InheritableThreadLocal class.
*/
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
// 其他代码省略...
}
threadLocals的类型是 ThreadLocalMap
,ThreadLocalMap中用数组table保存threadlocal变量的key、value。 因此最终我们需要清理的是这个table里面的Entry。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 保存threadlocal变量的key、value
private Entry[] table;
}
tomcat中线程变量的内存泄漏检测代码在 checkThreadLocalsForLeaks
中。
主要是反射threadLocals、inheritableThreadLocals
一般的在使用完线程变量之后,要及时的调用 threadlocal.remove()
将线程变量移除。 但是对于RASP来说,业务线程池线程复用机制,并且无法确定什么时候任务执行完成,也就无法在任务执行完成之后清除。
JRASP采用类似于tomcat线程变量内存泄漏的检测方式,即反射调用 threadlocal.remove()
方法。
cleanThreadLocals的实现:
上面的方案存在一些限制,JDK17以上禁止了跨模块的反射,上面的反射调用执行会报错,需要业务在JVM参数中增加 --add-opens=java.base/java.lang=ALL-UNNAMED
解除限制。(增加参数成本较低,业务使用了三方包也会开启该参数)
本文介绍了JRASP卸载时的一些坑,并给出了解决方案,特别是线程池的threadlocal内存泄漏, 给出了检测和卸载代码,该方案在JRASP上使用较为成功。