100行代码搭建一个IO泄露监测框架

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 大家好,最近由于项目原因,对IO资源泄漏的监测进行了一番调研深入了解,发现IO泄漏监测框架实现成本比较低,效果很显著;同时由于IO监测涉及到反射,还了解到了通过一种巧妙的方式实现Android P以上非公开api的访问。

大家好,最近由于项目原因,对IO资源泄漏的监测进行了一番调研深入了解,发现IO泄漏监测框架实现成本比较低,效果很显著;同时由于IO监测涉及到反射,还了解到了通过一种巧妙的方式实现Android P以上非公开api的访问。

接下来本篇文章首先会带你了解一些前置知识,然后会带领从0到1手把手教你搭建一个IO泄漏监测框架。

一. 为什么要做IO泄漏检测?

IO一般就是指的常见的文件流读写、数据库读写,相信每个人都知道,完成读写后都应该手动调用流的close()方法关闭,一旦忘记就引起了io泄漏了

如果项目中这种问题场景比较多,就会导致fd无节制的增加,导致应用内存紧张,严重甚至引发OOM,非常影响用户体验。

为了避免操作完读写流忘记close,java和kotlin两种编程语言分别给我们提供了以下语法糖:

1. 实现java的AutoCloseable并搭配try-with-resource

看一段常见的代码:

publicstaticvoidmain(String[] args) {
try (FileInputStreamfis=newFileInputStream(newFile("test.txt"))) {
byte[] data=newbyte[1024];
intread=fis.read(data);
//执行其他操纵    } catch (Exceptione) {
e.printStackTrace();
    }
}

FileInputStream实现了AutoCloseable接口,并重写了接口的close()方法,通过上面的try-with-resource语法,我们就不需要显示调用close方法关闭io,java会自动帮助我们完成这个操作:

常见的InputStream、OutputStream 、Scanner 、PrintWriter都实现了AutoCloseable接口,所以文件读写时可以非常方便的使用上面的语法糖。

2. 使用kotlin中的use()扩展

kotlin针对Closeable(实现了AutoCloseable)接口提供了下面的扩展:

我们常见的InputStream、OutputStream 、Scanner 、PrintWriter等都是支持这个扩展函数的:

overridefuncreate(context: Context) {
FileInputStream("").use {
//执行某些操作    }
}

虽然kotlin和java都从语言层面上帮助尽可能我们读写io流实现安全关闭,但是真正到写代码时忘了是真的忘了;而且项目中还可能存在历史代码也忘记了关闭流,查找起来也是毫无头绪的。

面对上面这中情况,就需要一种io泄漏的检测机制,不管是针对项目的历史代码还是新写的代码,能够检测文件流是否关闭,没有关闭则获取流创建的堆栈并上报帮助开发定位问题,接下来我们来一步步的实现这种能力吧。

二. IO泄漏检测的实现思路

头脑风暴一下,想要检测流有没有关闭,关键就是检测诸如FileInputStream等操作文件流的类close方法有没有调用;那什么时机才应该去检测呢,当FileInputStream等流类准备销毁的时候就可以去检测了,而类销毁的时候会调用finalize()方法(PS:暂时不考虑finalize()特殊场景下的表现,这里认为都会被正常执行),所以检测的最佳时机就是在流类的finalize()方法执行的时候

经过上面的分析,我们可以写出下面的代码:

publicclassFileInputStream {
privateObjectflag=null;
publicvoidopen() {
//打开文件流时赋值flag="open";
    }
publicvoidclose() throwsException {
//关闭文件流置空flag=null;
    }
@Overrideprotectedvoidfinalize() throwsThrowable {
super.finalize();
//flag等于null,说明忘记执行close方法关闭流,io泄漏if (flag!=null) {
Throwablethrowable=newThrowable("io leak");
//执行异常日志的打印,或者回调给外部。//兜底流的关闭close();
        }
    }
}

代码中有非常详细的注释,这里就不再一一进行讲述。

所以如果能在我们常见的FileInputStreamFileOutputStreamRandomAccessFile等流类中也增加上面的代码,io泄漏监测这不就成了!!

Android官方自然也能够想到,并且还干了,常见的官方流类FileInputStreamFileOutputStreamRandomAccessFileCursorWindow等都增加了上面类似监控逻辑,接下来我们以FileInputStream为例进行分析。

三 瞅瞅官方FileInputStream源码

这里我们先提前说下,官方监控流类是否泄漏,并不是直接在里面增加逻辑代码,想想也是,那么多流类,一个个增加过去导致模板代码太多,不如封装一个工具类供各个流类使用,这里的工具类就是CloseGuard

说清了上面,我们就看下FileInputStream的源码:

1. 获取工具类CloseGuard

由于CloseGuard的源码无法直接在AS中查看,这里我们借助http://aospxref.com/android-12.0.0_r3/xref/libcore/dalvik/src/main/java/dalvik/system/CloseGuard.java网站查看下该类的源码:

CloseGuard.get()方法就是创建了一个CloseGuard对象。

2. 打开文件流

FileInputStream构造方法主要干了两件事情:

  • 通过传入的文件路径调用IoBridge.open()打开文件流(这个底层最终会调用了open(const char *pathname,int flags,mode_t mode),做io监控时一般需要hook该方法)。  
  • 同时还会调用CloseGuard.open()方法:

这个方法主要干的事情就是创建了一个Throwable对象,获取当前流创建的堆栈,并赋值给CloseGuardcloserNameOrAllocationInfo字段。

3. 关闭文件流

FileInputStreamclose()方法主要干了两件事:

  • 调用CloseGuardclose()方法:

很简单,就是将上面赋值的closerNameOrAllocationInfo字段重新置空。

  • 关闭文件流;

4. 重写finalize()监控FileInputStream的销毁

FileInputStreamfinalize()方法主要干了两件事:

  • 调用CloseGuardwarnIfOpen()方法:

如果closerNameOrAllocationInfo字段不为空,说明FileInputStreamclose()关闭文件流的方法漏了调用,发生了io泄漏,调用reporter.report()方法并传入closerNameOrAllocationInfo参数(这个参数上面有说:保存了流创建时的堆栈,一旦获取到我们就能很快知道哪个地方创建的流发生了泄漏)。

  • 兜底关闭流;

通过上面的分析可以得知,一旦发生io泄漏,就会通过reporter.report()上报,这就是我们监控应用整体io泄漏的关键。

看下reporter是个啥:

reporter是一个静态变量,本质上是一个实现了Reporter接口的默认实现类DefaultReporter,默认通过report()方法打印io泄漏的系统日志。

同时外部可以注入自定义的实现了Reporter接口的类:

讲到这里大家是不是明白了,如果实现应用层的io泄漏检测,只要我们通过动态代理+反射代理掉reporter这个静态变量,替换成我们自定义实现的Reporter接口的类,并在自定义类中实现io泄漏异常上报的逻辑,不就完美实现监听了吗!!

想象很美好,现实很残酷,CloseGuard是个系统类,且被@hide隐藏,同时上面的setReporter()方法被@UnsupportedAppUsage注解,所以这个是官方非公开的api。在Android P以下自然可以通过反射调用,但是在Android P及以上使用反射就会报错,所以还得探索一种高版本能够成功反射系统非公开api的方法。

四. Android P及以上非公开api访问的实现

想要访问系统非公开api,那就只有系统api才能调用,一般有两种方式:

  1. 将我们自己的类的classloader转换为系统的classloader去调用系统非公开api;
  2. 借助于系统类方法去调用系统非公开api,即双反射实现机制;

这里我们不做过多的讲解,详细内容可以参考weishu大佬的文章:另一种绕过 Android P以上非公开API限制的办法

这里我们采用的是第二种双反射实现方式,并且weishu大佬提供了一个github库方面我们拿来使用:

dependencies {
implementation'com.github.tiann:FreeReflection:3.1.0'}

然后在Application.attachBaseContext()方法中调用;

@OverrideprotectedvoidattachBaseContext(Contextbase) {
super.attachBaseContext(base);
Reflection.unseal(base);
}

五. 从0到1搭建IO泄露监测框架

上面的准备知识都讲解完毕了,接下来我们从0到1开始我们的io泄漏检测框架搭建之旅吧。

1. 创建名称为ResourceLeakCanary的一个module,并引入下面两个依赖

dependencies {
implementation'com.github.tiann:FreeReflection:3.1.0'implementation("androidx.startup:startup-runtime:1.1.1")
}

2. 通过startup实现SDK的自动初始化,并借助FreeReflection库解除系统非公开api访问限制

classIOLeakCanaryInstall : Initializer<Unit> {
overridefuncreate(context: Context) {
//android p及以上非公开api允许调用Reflection.unseal(context)
//初始化核心io泄漏监测IOLeakCanaryCore().init(context.applicationContext)
Log.i(IOLeakCanaryCore.TAG, "IOLeakCanaryInstall install success!")
    }
overridefundependencies(): MutableList<Class<outInitializer<*>>>=mutableListOf()
}

如果想要了解SDK无侵入初始化并获取Application,可以参考之前写的一篇文章:SDK无侵入初始化并获取Application

3. 创建IOLeakCanaryCore,里面实现核心的hook CloseGuard#Reporter的逻辑

classIOLeakCanaryCore {
companionobject {
constvalTAG="IOLeakCanary"lateinitvarAPPLICATION: Context    }
/*** CloseGuard原始的Reporter接口实现类DefaultReporter*/privatevarmOriginalReporter: Any?=nullfuninit(application: Context) {
APPLICATION=applicationvalhookResult=tryHook()
Log.i(TAG, "init: hookResult = $hookResult")
    }
@SuppressLint("SoonBlockedPrivateApi")
privatefuntryHook(): Boolean {
try {
valcloseGuardCls=Class.forName("dalvik.system.CloseGuard")
valcloseGuardReporterCls=Class.forName("dalvik.system.CloseGuard\$Reporter")
//拿到CloseGuard原始的Reporter接口实现类DefaultReportervalmethodGetReporter=closeGuardCls.getDeclaredMethod("getReporter")
mOriginalReporter=methodGetReporter.invoke(null)
//获取setReporter的Method实例,便于后续反射该方法注入我们自定义的Report对象valmethodSetReporter=closeGuardCls.getDeclaredMethod("setReporter", closeGuardReporterCls)
//将CloseGuard的stackAndTrackingEnabled字段置为true,否则为false将不会调用自定义的Reporter对象valmethodSetEnabled=closeGuardCls.getDeclaredMethod("setEnabled", Boolean::class.java)
methodSetEnabled.invoke(null, true)
//借助动态代理+反射注入我们自定义的Report对象valclassLoader=closeGuardReporterCls.classLoader?: returnfalsemethodSetReporter.invoke(
null,
Proxy.newProxyInstance(
classLoader,
arrayOf(closeGuardReporterCls),
IOLeakReporter()
                )
            )
returntrue        } catch (e: Throwable) {
Log.e(TAG, "tryHook error: message = ${e.message}")
        }
returnfalse    }
/*** 拦截report并收集堆栈*/innerclassIOLeakReporter : InvocationHandler {
overridefuninvoke(proxy: Any?, method: Method?, args: Array<outAny>?): Any? {
if (method?.name=="report") {
//io泄漏,收集堆栈并上报,其中args[1]就代表着上面的//CloseGuard#closerNameOrAllocationInfo字段,保存了流打开时的堆栈详细valstack=args?.get(1) as?Throwable?: returnnullvalstackTraceToString=stackTraceToString(stack.stackTrace)
//这里只是通过日志进行打印,有需要的可以定制这块逻辑,比如加入异常上报机制Log.i(TAG, "IOLeakReporter: invoke report = $stackTraceToString")
returnnull            }
returnmethod?.invoke(mOriginalReporter, args)
        }
/*** 处理堆栈*/privatefunstackTraceToString(arr: Array<StackTraceElement>?): String {
valstacks=arr?.toMutableList()?.take(8) ?: return""valsb=StringBuffer(stacks.size)
for (stackTraceElementinstacks) {
sb.append(stackTraceElement.toString()).appendLine()
            }
returnsb.toString()
        }
    }
}

类上面有非常丰富的注释,我这里就不再进行一一讲解,大家仔细阅读下上面的代码自然会明白。

以上就是全部的代码了,总共也就100行左右,我们可以在上面的IOLeakReporterinvoke方法中对于io泄漏接入告警机制,非常适合在debug环境下进行对项目进行一个全面的io泄漏检测。代码写完了,接下来我们就做一个测试吧。

4. io泄漏检测测试

我们写一段测试代码,获取cpu相关详细,并且故意不释放文件流:

运行下项目,查看logcat日志输出:

可以看到有告警日志打印,并通过日志直接就定位到了异常逻辑:代码第35行创建的FileInputStream流使用完之后没有被关闭,这样我们就可以很快去修复了。

六. 总结

其实,如果了解过matrix-io-canary源码的人,应该很快就可以发现,这不就是matrix-io-canary中io泄漏监测的实现源码吗!笔者只是在通读了matrix-io-canary之后,通过整理涉及到的相关知识点,以一种更加通俗的方式进行了讲解,希望本篇文章能对你有所帮助。

不过请注意,以上CloseGuard是基于Android12的源码进行的分析,不同的系统版本比如Android8实现是不同的;而且涉及到系统非公开api的访问也是借助了FreeReflection进行了实现,本身Android官方是禁止使用这些非公开api的,所以为了应用的稳定性,建议大家只在debug环境下使用上述逻辑。

七. 参考链接

另一种绕过 Android P以上非公开API限制的办法

matrix-io-canary

Java必须懂的try-with-resources

CloseGuard

SDK无侵入初始化并获取Application

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
目录
相关文章
|
3月前
|
JavaScript 前端开发 Ubuntu
如何在 VPS 上安装 Express(Node.js 框架)并设置 Socket.io
如何在 VPS 上安装 Express(Node.js 框架)并设置 Socket.io
49 0
|
存储 缓存 Java
Java基础知识第二讲:Java开发手册/JVM/集合框架/异常体系/Java反射/语法知识/Java IO
Java基础知识第二讲:Java开发手册/JVM/集合框架/异常体系/Java反射/语法知识/Java IO
228 0
Java基础知识第二讲:Java开发手册/JVM/集合框架/异常体系/Java反射/语法知识/Java IO
|
12月前
|
Web App开发 存储 前端开发
Golang微服务框架kratos实现Socket.IO服务
Socket.IO 是一个面向实时 web 应用的 实时通讯库。它使得服务器和客户端之间实时双向的通信成为可能。底层使用EngineIO。SocketIO的的客户端使用Engine.IO-Client,服务端使用Engine.IO实现。
118 0
|
缓存 网络协议 Dubbo
高性能IO框架Netty一-第一个Netty程序
高性能IO框架Netty一-第一个Netty程序
155 0
|
安全 Java 关系型数据库
高性能IO框架Netty五 - Netty内置的编解码器
高性能IO框架Netty五 - Netty内置的编解码器
125 0
|
缓存 网络协议 算法
高性能IO框架Netty四 - 解决粘包/半包问题
高性能IO框架Netty四 - 解决粘包/半包问题
110 0
|
存储 Java API
高性能IO框架Netty三 - ByteBuf详解
高性能IO框架Netty三 - ByteBuf详解
175 0
|
缓存 前端开发 网络协议
高性能IO框架Netty二-Netty重要组件介绍(下)
高性能IO框架Netty二-Netty重要组件介绍(下)
79 0
|
编解码 安全 Java
高性能IO框架Netty二-Netty重要组件介绍(上)
高性能IO框架Netty二-Netty重要组件介绍
82 0
|
监控 Java API
Android IO 框架 Okio 的实现原理,如何检测超时?
在上一篇文章里,我们聊到了 Square 开源的 I/O 框架 Okio 的三个优势:精简且全面的 API、基于共享的缓冲区设计以及超时机制。前两个优势已经分析过了,今天我们来分析 Okio 的超时检测机制。
165 0