【简介】
之前在做一个无埋点SDK相关开发的时候,由于上报逻辑比较复杂,故而想到了用hander队列的形式处理事件的上报,但是SDK上线之后,发现出了一个句柄泄漏的bug,百思不得其解,后来看了Handler的底层源码,又做了一些句柄数的追踪和分析,解决了这个问题。
一、Handler在java层的机制
如下图,handler的应用层机制很简单,不同的线程通过handler,发送message到messageQueue里面,对应的Looper开启一个死循环,然后一直轮询,如果队列里有待处理的消息,就处理消息;如果没有消息,则开始休眠,节约资源。个中细节就不在赘述,如有需要,可以自行搜索相关资料。
二、Handler的native层机制
我们首先来看Looper.loop()的源码
我们发现,在这个死循环里,它调用了MessageQueue下的next方法,那么我们再看看这个next方法干了些什么:
关键方法是这个nativePollOnce():当运行到这里时,会调用native层的方法,系统去轮询一次,如果MessageQueue当前没有要处理的Message,则线程休眠,不占用资源,这里为什么休眠不会产生ANR呢,我们后面会分析。
我们现在注意一下nextPollTimeoutMillis这个变量,这个变量代表MessageQueue下次被唤醒的时间。我们知道,MessageQueue里Message在加入队列的时候,会按照执行的时间顺序排列;每次消息入队列时,MessageQueue都会尽量计算出一个精确的时间,假如这个时间是计算出来是2000ms,此时消息队列中没有消息需要马上处理时,会判断用户是否设置了Idle Handler,如果有的话,则会尝试处理mIdleHandlers中所记录的所有Idle Handler,此时会逐个调用这些Idle Handler的queueIdle()成员函数,只会会再次调用nativePollOnce()方法,线程阻塞住,不占用资源。当时间到了,会往管道流中写入字节流,唤醒线程,处理Message。
我们知道,安卓的底层是Linux系统。当Looper休眠时,用的是底层的epoll机制来完成阻塞动作,故而不会产生ANR。
源码的唤醒调用如图:
最终调用了Looper.cpp源码的wake()方法
我们可以看到,唤醒只是往管道流里写了一个"w"的字符流。所以唤醒机制,我们可以直观的理解为:
在Linux底层,每个线程所能操作的句柄上限是1024个,一旦超过了这个值,则会报句柄泄漏的错误,导致崩溃。
所以,是不是我们的上报无埋点的数据时,频繁的休眠唤醒导致句柄数超过了上限呢?
三、查看线程的句柄数
我们需要一个root了的手机,如果手头没有能用的测试机,可以使用模拟器。
我用了一个低版本的模拟器(5.0版本),因为高版本的模拟器也不好直接获取root。
我们先要获取进程的id,这个可以通过AS的Logcat查看。
之后通过adb shell进入模拟器,cd到/proc/进程id/fd文件夹下,然后ls -al,就可以在Logcat中打印出当前线程所消耗的句柄。如果你没有root权限,fd文件夹是访问不了的。
笔者在启动APP后,正常使用了一下APP,接着打印出了当前程序占用的句柄数,发现有800多个句柄开销,大多数是数据库所持有的,搞了半天原来是数据库的问题。但是为了防止意外,笔者已经把SDK中的所有Handler替换掉了。
四、总结
在android开发中,我们的这些操作会消耗句柄数:
1.数据库的读写,不关及时关流会导致句柄开销增大;
2.文件流的读写,如果操作不好,也容易导致句柄消耗过大;
3.Handler频繁唤醒等。
一旦出现句柄泄漏的问题,核心思想就去排查流的读写有没有出问题。因为在安卓底层,Linux对于句柄的操作很多都是通过流来体现的,如果句柄数超过了上限,肯定会出问题。