问题

线上长时刻存在一个跟异步 inflate 相关的量级较大的内存走漏,如下所示:

编译内联导致内存泄漏的问题定位&修复

第一次剖析

从内存走漏大略看有几个信息:

  1. 被走漏的Activity有许多,所以或许跟某个详细事务的关系不大
  2. 引证链特别短,并且能够看出 gc root 是 Java Frame 中的BasicInflater实例,然后它经过 mContext 字段持有了 Activity

从上面的这个信息推测导致内存走漏的原因是:

  1. 事务代码触发了一些布局的异步 Inflate
  2. 当时页面退出,destroy
  3. 之前宣布的异步 Inflate 恳求还没有履行完,还在子线程中inflate,所以会时刻短的持有 context
  4. 假如这个时分dump hprof 剖析,就会发现 context(activity)走漏了
public void runInner() {
    InflateRequest request;  
    try {  
        request = mQueue.take();  
    } catch (InterruptedException ex) {  
        // Odd, just continue  
        Log.w(TAG, ex);  
        return;  
    }  
// 子线程需要把 mQueue 里边的 request 处理完,而request 持有 inflater,inflater 持有 context
    try {  
        request.view = request.inflater.mInflater.inflate(  
                request.resid, request.parent, false);  
    } catch (RuntimeException ex) {  
        // Probably a Looper failure, retry on the UI thread  
        Log.w(TAG, "Failed to inflate resource in the background! Retrying on the UI"  
                + " thread", ex);  
    }  
    Message.obtain(request.inflater.mHandler, 0, request)  
            .sendToTarget();  
}  
@Override  
public void run() {  
    while (true) {  
        runInner();  
    }  
}

依据上面的剖析,这种走漏理论上是存在的,可是 inflate 一个layout一般很快,几毫秒、几十毫秒、最多几百毫秒,这种属于短时走漏,并且时刻特别短,影响不大,所以第一次简单看了下这个问题后觉得影响不大不用处理

第2次剖析

尽管上面判别这个“影响不大,且走漏时刻很短”,可是每个版本都会触发报警,并且是量级很大的走漏,所以继续排查一下这个走漏是否有其他原因~

从内存走漏的量级看,之前的判别好像有点说不通:

  1. 走漏时刻窗口这么短,hprof 刚好就在这期间dump的概率极低,量怎会这么大?
  2. 短时走漏问题其实许多,比方事务 postDelay 一个几秒的Runnable(持有外部类引证),在这期间 activity destroy了,也会出现短时走漏,这个时刻几秒,比 inflate 要长多了,并且事务上这类状况许多,可是线上抓到的这类状况很少。

因而第一次剖析的状况好像不太对,所以捞个 hprof 再剖析一下看看:

编译内联导致内存泄漏的问题定位&修复
咱们先经过内存信息来验证下咱们第一次剖析的猜测原因(activity destroy的时分,异步 inflate 使命还没履行完结)是否正确:

  1. 看下 InflateThread 的 mQueue 中还有多少 InflateRequest 待处理:

    编译内联导致内存泄漏的问题定位&修复
    捞了许多个hprof,成果都是如此令人惊奇:InflateRequest 行列都是空的,里边没有使命待处理!!!

  2. 再一次猜测:会不会是每次dump hprof的时分,刚好最终一个 InflateRequest 被从行列中取出来了,可是还没有履行完结呢?其实想想这种概率现已不能再低了,可是现在也没有其他怀疑点,换个视点看,假设是这种状况,会不会是某个布局有点问题,导致 inflate 耗时特别长,然后增加了被抓到的概率呢?

    先来看下那个持有activity的 BasicInflater 信息:

    编译内联导致内存泄漏的问题定位&修复
    咱们知道LayoutInflater在inflate开端前会把当时要用的context存到他的 mConstructorArgs[0] 中,inflate 完结后再把 mConstructorArgs[0] 康复,能够参考如下代码:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    synchronized (mConstructorArgs) {  
        // ......
        Context lastContext = (Context) mConstructorArgs[0];  
        mConstructorArgs[0] = inflaterContext;  
		try {
         // ...... do inflate
        } finally {  
            // Don't retain static reference on context.  
            mConstructorArgs[0] = lastContext;  
            mConstructorArgs[1] = null;  
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);  
        }  
        return result;  
    }  
}

也便是说假如正在 inflate,mConstructorArgs[0]应该持有context,可是咱们看到这个时分 BasicInflater 中的 mConstructorArgs 的2个element都是 null,也便是说当时它处于闲暇状况,并非正在 inflate!!!

开始定论

依据现在的信息来看,走漏的Activity是被一个闲暇的 BasicInflater 持有的。

进一步排查,发现一个更奇怪的现象:在dump出来的 hprof中,AsyncLayoutInflater$BasicInflater 的实例数一直比 AsyncLayoutInflater 刚好多一个,而多出来的那一个便是导致 activity 走漏的那个实例

编译内联导致内存泄漏的问题定位&修复

从代码上看,AsyncLayoutInflater$BasicInflater 都是在 AsyncLayoutInflater的结构函数中创建的,按理说AsyncLayoutInflater$BasicInflater不会比AsyncLayoutInflater更多才对️

public AsyncLayoutInflater(@NonNull Context context) {
    mInflater = new BasicInflater(context);  
    mHandler = new Handler(mHandlerCallback);  
    mInflateThread = InflateThread.getInstance();  
}

难道导致走漏的这个 BasicInflater 是其他地方创建出来的? 首要反射创建不大或许,由于这个类没有keep,那么最有或许的便是下面这个途径了:

@Override
public LayoutInflater cloneInContext(Context newContext) {  
    return new BasicInflater(newContext);  
}

而这个办法好像只在ViewStub中使用:

// layoutinflater
public final View createView(@NonNull Context viewContext, @NonNull String name,  
        @Nullable String prefix, @Nullable AttributeSet attrs)  
        throws ClassNotFoundException, InflateException {  
    // ... 
                final ViewStub viewStub = (ViewStub) view;  
                viewStub.setLayoutInflater(cloneInContext((Context) args[0]));  
    // ...
}

这个clone出来的 inflater 会被 ViewStub.mInflater 持有,可是从内存数据来看,它自己是gc root,并且没有其他目标持有它

编译内联导致内存泄漏的问题定位&修复

而这个 BasicInflater 之所以是 gc root,是由于它是在当时Java frame的本地变量表中,再回头看一下相关代码:

public void runInner() {
    InflateRequest request;  
    try {  
        request = mQueue.take();  
    } catch (InterruptedException ex) {  
        // Odd, just continue  
        Log.w(TAG, ex);  
        return;  
    }  
    try {  
        request.view = request.inflater.mInflater.inflate(  
                request.resid, request.parent, false);  
    } catch (RuntimeException ex) {  
        // Probably a Looper failure, retry on the UI thread  
        Log.w(TAG, "Failed to inflate resource in the background! Retrying on the UI"  
                + " thread", ex);  
    }  
    Message.obtain(request.inflater.mHandler, 0, request)  
            .sendToTarget();  
}  
@Override  
public void run() {  
    while (true) {  
        runInner();  
    }  
}
第2次猜测

倘若 runInner()被内联到 run()中,那么正好便是这个状况,并且能解说上面的一切现象,所以找了个线下包看了下,成果惊呆了:没有内联。。。

无奈,好像没有思路了,可是也不能不处理啊,翻了下事务代码后发现有个LiveAsyncLayoutInflater 它便是从 AsyncLayoutInflater中copy出来的,唯一的改动便是在 runInner() 中,取出 request 履行 inflate之前先判别一下 context(activity)是否现已destroy,假如是的话,直接丢弃这个 request。跟这个代码的作者聊了下,他这样做也是由于担心Activity销毁后还有没处理完的request。

第一次测验修正

尽管咱们上面剖析过导致这个内存走漏的原因好像不是 由于“Activity销毁后还有没处理完的request”,可是我搜了一下发现却真没有跟 LiveAsyncLayoutInflater 相关的走漏,事务用法也都相同。。。 没有其他思路,所以也这样改一下试试吧,起码不会有坏处。并且咱们还加了一个逻辑: 监听Activity destroy,然后遍历 request 行列,把和其相关的 request 移除。这样的目的是由于有些 request 是用的 Application context 来处理的,只在 runInner 中判别,或许会影响后边 request 处理的及时性。

测验修正上线后发现跟预期的根本“共同”,能够说是毫无作用

第三次剖析

测验一下看看线下能不能复现,看看复现的场景是什么。

现在咱们线下包在 activity destroy的时分都会去剖析走漏,所以改了下代码,当发现跟当时问题引证链相同的时分就将额定弥补的一些信息一同上报上来排查。

然而用这个包测试了一段时刻,没复现。。。然后又看了下线下的内存走漏监控,这个每天也会上报不少的走漏问题,成果这个问题一个都没有。。。

线上量很大,线下一个都没有,难道是某处逻辑线上包跟线下包不相同触发了这个问题?

回想一开端剖析的时分有个判别:假如runInner被内联到run里边,那问题就能够解说,当时反编译现已排除了,可是忽然想到当时包用错了。。。拿的是线下包,线下包都是 fast 模式,是不会走 optimized 的,所以必定不会内联。相关装备如下:

if (BuildContext.isFast) {
    // ...
    proguardFile 'proguard_not_opt.pro'
    // ...
}
// ... proguard_not_opt.pro
-dontoptimize
// ...

由于 optimize 特别耗时,线下包封闭也是很合理的。可是线上包是没有这个装备的,也便是翻开了 optimize。另外混杂装备中没有封闭 unique method inline:

# 下面的没有配:
# -optimizations !method/inlining/unique

runInner 也只要一处调用,在翻开optimize且没有制止 unique method inline 时是或许inline的。所以找了个线上包再来看看,果然 内 联 了 !!!

既然内联了,那么 runInner 的本地变量表中的目标就被合到了 run 中,而 run 里边是个 while (true) 死循环,生命周期无限长,所以假如这儿边的本地变量表中持有 BasicInflater 那么它便是gc root,并且进一步导致它的 context 走漏,咱们来看下AsyncLayoutInflater的这段代码:

.method public final run()V
.registers 6
.prologue
:catch_0
:goto_0
:try_start_0
iget-object v0, p0, LX/pJX;->LIZIZ:Ljava/util/concurrent/ArrayBlockingQueue;
invoke-virtual {v0}, Ljava/util/concurrent/ArrayBlockingQueue;->take()Ljava/lang/Object;
move-result-object v4
check-cast v4, LX/pJZ;
const/4 v3, 0x0
:try_end_9
.catch Ljava/lang/InterruptedException; {:try_start_0 .. :try_end_9} :catch_0
:try_start_9
iget-object v0, v4, LX/pJZ;->LIZ:LX/pJW;
iget-object v2, v0, LX/pJW;->LIZ:Landroid/view/LayoutInflater;
iget v1, v4, LX/pJZ;->LIZJ:I
iget-object v0, v4, LX/pJZ;->LIZIZ:Landroid/view/ViewGroup;
invoke-virtual {v2, v1, v0, v3}, Landroid/view/LayoutInflater;->inflate(ILandroid/view/ViewGroup;Z)Landroid/view/View;
move-result-object v0
iput-object v0, v4, LX/pJZ;->LIZLLL:Landroid/view/View;
:try_end_17
.catch Ljava/lang/RuntimeException; {:try_start_9 .. :try_end_17} :catch_17
:catch_17
iget-object v0, v4, LX/pJZ;->LIZ:LX/pJW;
iget-object v0, v0, LX/pJW;->LIZIZ:Landroid/os/Handler;
invoke-static {v0, v3, v4}, Landroid/os/Message;->obtain(Landroid/os/Handler;ILjava/lang/Object;)Landroid/os/Message;
move-result-object v0
invoke-virtual {v0}, Landroid/os/Message;->sendToTarget()V
goto :goto_0
.end method
  1. iget-object v2, v0, LX/pJW;->LIZ:Landroid/view/LayoutInflater; 知道 BasicInflater 被赋值到了 v2寄存器
  2. invoke-virtual {v2, v1, v0, v3}, Landroid/view/LayoutInflater;->inflate(ILandroid/view/ViewGroup;Z)Landroid/view/View; 经过 v2中的BasicInflater去inflate 布局
  3. v2寄存器没有复用,当经过 goto :goto_0 进行下一次循环,并且取出下一个 request,v2 被赋予下一个 BasicInflater 实例引证之前,v2 一直持有者上一个 BasicInflater 引证,而这个便是导致走漏的引证。 当时处于while循环中,等候下一个request,跟咱们上面剖析的“导致走漏的BasicInflater处于闲暇状况共同”,并且长时刻处于这个状况,所以抓到的概率就很大了。

为什么AsyncLayoutInflater$BasicInflater 的实例数一直比 AsyncLayoutInflater多一个呢?,原因是:AsyncLayoutInflater 的引证是存在 v0 寄存器中的,而v0寄存器被屡次复用,所以AsyncLayoutInflater的引证并没有被一直持有。

到此问题根本就剖析清楚了,可是还有一个遗留问题,上面提到的事务中copy出来的 LiveAsyncLayoutInflater为什么没有走漏呢?原因是:

  1. 他的 BasicInflater 引证也是放到 v2寄存器中的
  2. 可是这个类中的办法有插桩,runInner被内联之后,插桩代码也被内联了,在进行while(true)的下一轮循环时,首要会去履行插桩代码,而插桩代码复用了v2寄存器,因而就不再持有BasicInflater的引证了,因而没有走漏

所以这块代码没有导致走漏,其实是有点运气在其间的。寄存器的分配自身比较复杂,并且D8在寄存器分配上也不是十分完善的,几年前在西瓜也曾遇到一个D8寄存器分配导致的一个crash问题:D8编译器“bug”导致简单代码crash

修正

问题现已清晰,修正就比较简单了,只要让 runInner 不内联就行了。而之所以它会被内联,是由于 proguard/R8 有个优化,假如某个办法只要一处调用(当然还要满意许多其他条件),那么就将它内联,并删去原办法。因而咱们改一下,找一个其他地方调一下就能够躲避,比方:

if (/* 此处返回 false,让if block不履行就行 */) {
    runInner();// Make sure never reach here  
}

这样静态剖析不是一处调用,不会内联,实际上也不会走到,也不影响逻辑。

不过线上我没有这样改,由于还有其他办法能够不用改代码:给 runInner keep 一下就也不会内联了,原因也好了解:假如办法被keep了,那么原办法不能删,而这个又不是个小办法,要是内联的话,字节码变大了,办法数也没少,必然负向了,那还内联干啥。

-keepclassmembers class androidx.asynclayoutinflater.view.AsyncLayoutInflater$InflateThread {
    public void runInner();  
}

改了之后,走漏处理了~

顺便提一句

runInner 里边判别context(activity)是否destroy,假如destroy的话,就阻拦不处理这个 request还有个小麻烦:onInflateFinished 这个回调是否要触发?

public interface OnInflateFinishedListener {
    void onInflateFinished(@NonNull View view, @LayoutRes int resid,
                           @Nullable ViewGroup parent);
}
  1. 假如要回调onInflateFinished,那么 view 如何获取?null 必定不可,由于原本接口中有 @NonNull,导致许多事务代码不会判空
  2. 不回调也不可,由于有些事务有个“优化”逻辑,假如上一个 inflate 没有回来,后续就走同步 inflate,所以假如不回调,相当于封闭了异步 inflate 功能。。。

所以当时咱们加了个 onCancel 回调,事务能够在这儿处理被阻拦的状况:

public interface OnInflateFinishedListener {
    void onInflateFinished(@NonNull View view, @LayoutRes int resid,
                           @Nullable ViewGroup parent);
    /**
     * if context (activity) destroyed, InflateRequest will be cancel, and this method will be invoked.
     *
     * It can be invoked on different thread
     * @param resid
     */
    default void onCancel(@LayoutRes int resid) {}
}

比方:

override fun onCancel(resid: Int) {
    isAsyncInflating = false
}

可是这样也不优雅,并且也不是一切事务都知道有这么个 onCancel api,假如改成非default接口,又要改动许多地方的代码。

好在上面也看到了这个走漏并非由于 “Activity destroy后还有没处理完的 InflateRequest,或许导致时刻短走漏”,阻拦的必要性也不大。