經(jīng)典 OOM 問題|pthread_create

一栋齿、背景

近期版本上線后收到不少用戶反饋(大多是華為用戶)崩潰苗胀,日志上總體表現(xiàn)為 pthread_create (1040KB stack) failed: XXX。

java.lang.OutOfMemoryError
pthread_create (1040KB stack) failed: Out of memory
1 java.lang.Thread.nativeCreate(Native Method)
2 java.lang.Thread.start(Thread.java:743)
3 java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:941)
4 java.util.concurrent.ThreadPoolExecutor.processWorkerExit(ThreadPoolExecutor.java:1009)
5 java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1151)
6 java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:607)
7 java.lang.Thread.run(Thread.java:774)
java.lang.OutOfMemoryError
pthread_create (1040KB stack) failed: Try again
1 java.lang.Thread.nativeCreate(Native Method)
2 java.lang.Thread.start(Thread.java:733)
3 java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:975)
4 java.util.concurrent.ThreadPoolExecutor.processWorkerExit(ThreadPoolExecutor.java:1043)
5 java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1185)
6 java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
7 java.lang.Thread.run(Thread.java:764)

二瓦堵、問題分析

2.1 初步推斷

Android的內(nèi)存管理策略

OOM 并不等于 RAM 不足基协,這和 Android 的內(nèi)存管理策略有關(guān)。

我們知道菇用,內(nèi)存分為虛擬地址和物理地址澜驮。通過 malloc 或 new 分配的內(nèi)存都是虛擬地址空間的內(nèi)存。虛擬地址空間比物理的地址空間要大的多惋鸥。在較多進(jìn)程同時(shí)運(yùn)行時(shí)杂穷,物理地址空間有可能不夠,這該怎么辦卦绣?

Linux 采用的是 “進(jìn)程內(nèi)存最大化” 的分配策略耐量,用 Swap 機(jī)制來保證物理內(nèi)存不被消耗殆盡,把最近最少使用的空間騰到外部存儲(chǔ)空間上滤港,假裝還是存儲(chǔ)在 RAM 里廊蜒。

雖然 Android 基于 Linux,但是在內(nèi)存策略上有自己的套路 —— 沒有交換區(qū)溅漾。

Android 的進(jìn)程分配策略是每個(gè)進(jìn)程都有一個(gè)內(nèi)存占用限制山叮,這個(gè)具體大小由手機(jī)具體配置決定。目的就是為了讓更多的進(jìn)程都保留在 RAM 中添履,這樣每個(gè)進(jìn)程被喚起的時(shí)候可以避免外部存儲(chǔ)到內(nèi)部存儲(chǔ)的數(shù)據(jù)讀寫的消耗屁倔,加快更多的 App 恢復(fù)的響應(yīng)速度,也避免了流氓 App 搶占所有內(nèi)存暮胧。隨之而然锐借,Android 采用了自己的 LowMemoryKill 策略來控制RAM中的進(jìn)程。如果 RAM 真的不足叔壤,MemoryKiller 就會(huì)殺死一些優(yōu)先級比較低的進(jìn)程來釋放物理內(nèi)存瞎饲。

所以觸發(fā)OOM口叙,只可能是使用的虛擬內(nèi)存地址空間超過分配的閾值炼绘。

那 Android 為每個(gè)應(yīng)用分配多少內(nèi)存呢?這個(gè)因手機(jī)而異妄田,以手頭的測試機(jī)舉例俺亮,系統(tǒng)正常分配的內(nèi)存最多為 192 M驮捍;當(dāng)設(shè)置 largeHeap 時(shí),最多可申請 512M脚曾。

2.2 代碼分析

那這個(gè)溢出是怎么被系統(tǒng)拋出的东且?通過 Android 源碼可以看到,是由 runtime/thread.cc內(nèi)拋出的異常本讥。

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon)
image

線程創(chuàng)建有以下兩個(gè)關(guān)鍵的步驟:

  • 第一列中的創(chuàng)建線程私有的結(jié)構(gòu)體JNIENV(JNI執(zhí)行環(huán)境珊泳,用于C層調(diào)用Java層代碼)
  • 第二列中的調(diào)用posix C庫的函數(shù)pthread_create進(jìn)行線程創(chuàng)建工作
image

而這兩步均有可能拋出OOM,基本定位 —— 創(chuàng)建線程導(dǎo)致了OOM拷沸。

Android 創(chuàng)建線程源碼與OOM分析
該文分析了創(chuàng)建線程的原理色查,其實(shí)就是調(diào)用mmap分配棧內(nèi)存(虛擬內(nèi)存),再通過 Linux 的 mmap 調(diào)用映射到用戶態(tài)虛擬內(nèi)存地址空間撞芍。創(chuàng)建線程過程中發(fā)生OOM是因?yàn)檫M(jìn)程內(nèi)的虛擬內(nèi)存地址空間耗盡了秧了。

那什么時(shí)候會(huì)虛擬內(nèi)存地址空間不足呢 ?

方向一:fd 過多

Linux 系統(tǒng)中一切皆文件序无,網(wǎng)絡(luò)是文件验毡,打開文件、新建 tcp 連接也是文件帝嗡,都會(huì)占用 fd晶通。fd是一種資源,是資源就會(huì)有限制哟玷。每個(gè)進(jìn)程最大打開文件的數(shù)目有一個(gè)上限录择。

而fd的增加的時(shí)機(jī)有:

  • 創(chuàng)建socket網(wǎng)絡(luò)連接
  • 打開文件
  • 創(chuàng)建HandlerThread
  • 創(chuàng)建NIO的Channel(讀寫各占用一個(gè)fd)
  • 通過命令:ls -l /proc/<pid>/fd/ 來查看某個(gè)進(jìn)程打開了哪些文件
  • cat /proc/<pid>/limits 命令查看進(jìn)程的fd限制,或其它限制 如Max open files
  • lsof -p <pid> |wc -l 查看進(jìn)程所有的fd總數(shù)
image

如上圖碗降,Max open files表示每個(gè)進(jìn)程最大打開文件的數(shù)目隘竭,進(jìn)程每打開一個(gè)文件就會(huì)產(chǎn)生一個(gè)文件描述符fd(記錄在/proc/pid/fd中)

驗(yàn)證也很簡單,通過觸發(fā)大量的網(wǎng)絡(luò)連接或者文件打開讼渊,每打開一個(gè) socket 都會(huì)增加一個(gè) fd动看。

private Runnable increaseFDRunnable = new Runnable() {
      @Override
      public void run() {
          try {
              for (int i = 0; i < 1000; i++) {
                  new BufferedReader(new FileReader("/proc/" + Process.myPid() + "/status"));
              }
              Thread.sleep(Long.MAX_VALUE);
          } catch (InterruptedException e) {
              //
          } catch (FileNotFoundException e) {
              //
          }
      }
  };
方向二:線程過多

已用邏輯空間地址可以查看 /proc/<pid>/status 中的 VmPeak / VmSize

無非兩個(gè)原因:
1、進(jìn)程的棧內(nèi)存超過了虛擬機(jī)的最大內(nèi)存數(shù)爪幻;
2菱皆、線程數(shù)達(dá)到了系統(tǒng)最大限制數(shù);

排查工具

  • profilter CPU 查看當(dāng)前所有線程列表

  • 使用CPU分析器監(jiān)視CPU使用情況和線程活動(dòng)使用太重了挨稿?無法分類統(tǒng)計(jì)仇轻?可以使用 adb shell ps -T -p <pid>,還可以使用 | grep xxx 過濾奶甘,使用wc -l來統(tǒng)計(jì)線程數(shù)量篷店。

  • 直接dump進(jìn)程內(nèi)存,來查看內(nèi)存情況:

    adb shell dumpsys meminfo [pacakgename]
    
  • 也可以查看線程等匯總數(shù)據(jù):

    adb shell
    cat /proc/19468/status
    

Linux在 /proc/sys/kernel/threads-max 中有描述線程限制,可以通過命令cat /proc/sys/kernel/threads-max 查看疲陕,華為在線程限制上非常嚴(yán)苛方淤,在 7.0+ 手機(jī)上已將最大線程數(shù)修改成了 500。

那么是哪里代碼導(dǎo)致了線程爆發(fā)呢蹄殃?我們使用 watch1s打印一下當(dāng)前的線程數(shù)再通過頁面交互來定位問題携茂,觀察看看哪類的線程名字在增多。

watch -n 1 -d 'adb shell ps -T | grep XXX | wc -l'
image

觀察后發(fā)現(xiàn)诅岩,線程總數(shù)在進(jìn)入直播間時(shí)讳苦,輕而易舉就達(dá)到了 290多,而且有大量 RxCachedThreadSchedule 線程(也就是 Rx 的Scheduler.io調(diào)度器)被創(chuàng)建吩谦,IO線程數(shù)暴漲到 46医吊。停留在直播間一段時(shí)間,線程數(shù)只增不減逮京,并不會(huì)過期清理卿堂。

2.3 驗(yàn)證推斷,定位原因

寫個(gè)demo來驗(yàn)證懒棉,用 Kotlin 協(xié)程和 RxJava IO 調(diào)度器草描,模擬密集并發(fā)IO的環(huán)境

 for (i in 0..100) {
            GlobalScope.launch(Dispatchers.IO) {
                delay(100)
                Log.e("IOExecute", "協(xié)程 - 當(dāng)前線程:"
                      + Thread.currentThread().name)
            }
        }
image
  for (i in 0..100) {
            ThreadExecutor.IO.execute {
                Thread.sleep(100)
                Log.e("IOExecute", "RxJava IO - 當(dāng)前線程:"
                      + Thread.currentThread().name)
            }
        }
image

看起來 IO 線程沒有復(fù)用,有點(diǎn)奇怪策严,我們知道 Rx 的調(diào)度器其實(shí)就是封裝的線程池穗慕,我們也早已對線程池的流程滾瓜爛熟。如下圖:

難道是工作隊(duì)列滿了妻导?難道是線程無上限逛绵?難道是飽和策略有問題?

疑點(diǎn)

  1. 初進(jìn)直播間倔韭,密集IO术浪,沒有復(fù)用,線程突增

  2. 停留超過 keepAliveTime寿酌,IO線程沒有銷毀

源碼探尋

那到底哪里出了問題呢胰苏?本著挖掘機(jī)專業(yè)畢業(yè)的精神,我們來看看Scheduler.io的源碼定位原因醇疼,看源碼前硕并,我們先提出疑問和設(shè)想,帶著問題看源碼才不容易迷失方向:

疑問

  • 工作隊(duì)列是怎么管理的秧荆,容量多大倔毙?
  • 線程池策略是什么?什么時(shí)候新建線程乙濒?什么時(shí)候銷毀陕赃?

我們先來看看 RxJava 線程模型圖,理清楚類之間的關(guān)系:Scheduler 是 RxJava 的線程任務(wù)調(diào)度器,Worker 是線程任務(wù)的具體執(zhí)行者凯正。不同的Scheduler類會(huì)有不同的Worker實(shí)現(xiàn),因?yàn)?code>Scheduler類最終是交到Worker中去執(zhí)行調(diào)度的豌蟋。

image

可以看到廊散,Schedulers.io()中使用了靜態(tài)內(nèi)部類的方式來創(chuàng)建出了一個(gè)單例IoScheduler對象出來,這個(gè)IoScheduler是繼承自Scheduler的梧疲。

@NonNull
static final Scheduler IO;

@NonNull
public static Scheduler io() {
    //1.直接返回一個(gè)名為IO的Scheduler對象
    return RxJavaPlugins.onIoScheduler(IO);
}

static {
    //省略無關(guān)代碼
    
    //2.IO對象是在靜態(tài)代碼塊中實(shí)例化的允睹,這里會(huì)創(chuàng)建按一個(gè)IOTask()
    IO = RxJavaPlugins.initIoScheduler(new IOTask());
}

static final class IOTask implements Callable<Scheduler> {
    @Override
    public Scheduler call() throws Exception {
        //3.IOTask中會(huì)返回一個(gè)IoHolder對象
        return IoHolder.DEFAULT;
    }
}

static final class IoHolder {
    //4.IoHolder中會(huì)就是new一個(gè)IoScheduler對象出來
    static final Scheduler DEFAULT = new IoScheduler();
}

IoScheduler 的父類 Scheduler 在 scheduleDirect()、schedulePeriodicallyDirect() 方法中創(chuàng)建了 Worker幌氮,然后會(huì)分別調(diào)用 worker 的 schedule()缭受、schedulePeriodically() 來執(zhí)行任務(wù)。

public abstract class Scheduler {
    
    //檢索或創(chuàng)建一個(gè)代表操作串行執(zhí)行的新{@link Scheduler.Worker}该互。工作完成后米者,應(yīng)使用{@link Scheduler.Worker#dispose()}取消訂閱。 return一個(gè)Worker宇智,它代表要執(zhí)行的一系列動(dòng)作蔓搞。
    @NonNull
    public abstract Worker createWorker();

    @NonNull
    public Disposable scheduleDirect(@NonNull Runnable run, long delay, @NonNull TimeUnit unit) {
        final Worker w = createWorker();

        final Runnable decoratedRun = RxJavaPlugins.onSchedule(run);

        DisposeTask task = new DisposeTask(decoratedRun, w);

        w.schedule(task, delay, unit);

        return task;
    }

    @NonNull
    public Disposable schedulePeriodicallyDirect(@NonNull Runnable run, long initialDelay, long period, @NonNull TimeUnit unit) {
        final Worker w = createWorker();
        //省略無關(guān)代碼
        Disposable d = w.schedulePeriodically(periodicTask, initialDelay, period, unit);
        //省略無關(guān)代碼
    }
}

前面我們說到,不同的Scheduler類會(huì)有不同的Worker實(shí)現(xiàn)随橘,我們看看 IoScheduler 這個(gè)實(shí)現(xiàn)類對應(yīng)的 Worker 是什么:

final AtomicReference<CachedWorkerPool> pool;

public Worker createWorker() {
    //就是new一個(gè)EventLoopWorker喂分,傳一個(gè) CachedWorkerPool 對象(Worker緩存池)
    return new EventLoopWorker(pool.get());
}

static final class EventLoopWorker extends Scheduler.Worker {
    private final CompositeDisposable tasks;
    private final CachedWorkerPool pool;
    private final ThreadWorker threadWorker;

    final AtomicBoolean once = new AtomicBoolean();
    
    //構(gòu)造方法
    EventLoopWorker(CachedWorkerPool pool) {
        this.pool = pool;
        this.tasks = new CompositeDisposable();
        //從緩存Worker池中取一個(gè)Worker出來
        this.threadWorker = pool.get();
    }

    @NonNull
    @Override
    public Disposable schedule(@NonNull Runnable action, long delayTime, @NonNull TimeUnit unit) {
        //省略無關(guān)代碼
        
        //Runnable交給threadWorker去執(zhí)行
        return threadWorker.scheduleActual(action, delayTime, unit, tasks);
    }
}

接下來是Worker緩存池的操作:

CachedWorkerPool的get()
static final class CachedWorkerPool implements Runnable {
    ThreadWorker get() {
        if (allWorkers.isDisposed()) {
            return SHUTDOWN_THREAD_WORKER;
        }
        while (!expiringWorkerQueue.isEmpty()) {
            //如果緩沖池不為空,就從緩沖池中取threadWorker
            ThreadWorker threadWorker = expiringWorkerQueue.poll();
            if (threadWorker != null) {
                return threadWorker;
            }
        }

        //如果緩沖池中為空机蔗,就創(chuàng)建一個(gè)并返回蒲祈。
        ThreadWorker w = new ThreadWorker(threadFactory);
        allWorkers.add(w);
        return w;
    }
}

ThreadWorker到底做了什么呢?追進(jìn)去父類NewThreadWorker

NewThreadWorker 的構(gòu)造函數(shù)
public class NewThreadWorker extends Scheduler.Worker implements Disposable {
    private final ScheduledExecutorService executor;
        volatile boolean disposed;

        public NewThreadWorker(ThreadFactory threadFactory) {
            //構(gòu)造方法中創(chuàng)建一個(gè)ScheduledExecutorService對象萝嘁,可以通過ScheduledExecutorService來使用線程池
            executor = SchedulerPoolFactory.create(threadFactory);
        }
    }
SchedulerPoolFactory.create
public final class SchedulerPoolFactory {
        /**
     * Creates a ScheduledExecutorService with the given factory.
     * @param factory the thread factory
     * @return the ScheduledExecutorService
     */
    public static ScheduledExecutorService create(ThreadFactory factory) {
        // 此處創(chuàng)建了線程0鸬А!
        final ScheduledExecutorService exec = Executors.newScheduledThreadPool(1, factory);
        if (PURGE_ENABLED && exec instanceof ScheduledThreadPoolExecutor) {
            ScheduledThreadPoolExecutor e = (ScheduledThreadPoolExecutor) exec;
            POOLS.put(e, exec);
        }
        return exec;
    }
}

所以牙言,IoScheduler 使用 CachedWorkerPool 作為線程池沥潭,其內(nèi)部維護(hù)了一個(gè)阻塞隊(duì)列,用于記錄所有可用線程嬉挡,當(dāng)有新的任務(wù)需求時(shí)钝鸽,線程池會(huì)查詢阻塞隊(duì)列中是否有可用線程,沒有的話就新建一個(gè)庞钢。

我們想要知道為什么線程突增沒有復(fù)用拔恰,就要看看所有使用過的那些空閑線程什么時(shí)機(jī)會(huì)被回收到阻塞隊(duì)列中去。

CachedWorkerPool 的 release()
void release(ThreadWorker threadWorker) {
            // Refresh expire time before putting worker back in pool
                    // 刷新線程的到期時(shí)間 將執(zhí)行完畢的 Worker 放入緩存池中
            threadWorker.setExpirationTime(now() + keepAliveTime);
            expiringWorkerQueue.offer(threadWorker);
        }

調(diào)用此處代碼只有一處:

    @Override
        public void dispose() {
            if (once.compareAndSet(false, true)) {
                tasks.dispose();
                pool.release(threadWorker);
            }
        }

對于這一處的調(diào)用基括,可以簡單理解為線程內(nèi)部維護(hù)了一個(gè)狀態(tài)列表颜懊,當(dāng)線程內(nèi)的任務(wù)完成之后,會(huì)調(diào)用 dispose 來解除訂閱,釋放線程的占用河爹。

那什么時(shí)候銷毀呢匠璧?可以看到 CachedWorkerPool 構(gòu)造函數(shù)中創(chuàng)建了清理定時(shí)任務(wù):

    static final class CachedWorkerPool implements Runnable {
        CachedWorkerPool(long keepAliveTime, TimeUnit unit, ThreadFactory threadFactory) {
            //...
            // 創(chuàng)建一個(gè)線程,該線程默認(rèn)會(huì)每60s執(zhí)行一次咸这,來清除已到期的線程
                evictor = Executors.newScheduledThreadPool(1, EVICTOR_THREAD_FACTORY);
           // 設(shè)置定時(shí)任務(wù)
                task = evictor.scheduleWithFixedDelay(this, this.keepAliveTime, this.keepAliveTime, TimeUnit.NANOSECONDS);
            //...
        }

        @Override
        public void run() {
            evictExpiredWorkers();
        }
     }
CachedWorkerPool 的 evictExpiredWorkers()
 void evictExpiredWorkers() {
            if (!expiringWorkerQueue.isEmpty()) {
                long currentTimestamp = now();

                for (ThreadWorker threadWorker : expiringWorkerQueue) {
                    if (threadWorker.getExpirationTime() <= currentTimestamp) {
                        if (expiringWorkerQueue.remove(threadWorker)) {
                            allWorkers.remove(threadWorker);
                        }
                    } else {
                        // 隊(duì)列是根據(jù)失效時(shí)間排序的夷恍,所以一旦當(dāng)我們找到未失效的Worker就可以停止清理了
                        break;
                    }
                }
            }
        }

這個(gè)IO調(diào)度器不像計(jì)算調(diào)度器,計(jì)算調(diào)度器用一個(gè)數(shù)組來保存一組線程媳维,然后根據(jù)索引將任務(wù)分配給每個(gè)線程酿雪,多余的任務(wù)放在隊(duì)列中等待執(zhí)行,所以每個(gè)線程后面任務(wù)的執(zhí)行需要等待前面的任務(wù)執(zhí)行完畢侄刽。

image

而IO調(diào)度器里的線程池是一個(gè)可以自增指黎、無上限的線程池,且60s 敝莸ぃ活醋安。也就是說:如果在 60s 內(nèi)密集請求 IO 調(diào)度,超過了復(fù)用閾值墓毒,調(diào)度器不會(huì)約束線程數(shù)且會(huì)不斷開新線程茬故。

image

這樣子就解釋了疑點(diǎn) 1 為什么進(jìn)直播間時(shí)線程暴漲,是因?yàn)闆]有任務(wù)隊(duì)列蚁鳖,直接來一個(gè)任務(wù)磺芭,能復(fù)用就復(fù)用 Worker,不能就新建醉箕。

那疑點(diǎn) 2 呢钾腺?為什么停留超過了 60s 突漲的線程沒有被回收?

我們推測:

  • 清理線程是否在正常工作讥裤?

  • 有沒有可能存在訂閱泄露放棒?有的地方 Observable 沒有及時(shí)結(jié)束,所以一直占用著線程呢己英?

齋看源碼無法模擬真實(shí)生產(chǎn)環(huán)境间螟,那如何在無法改動(dòng)源碼的情況下,做動(dòng)態(tài)觀察损肛?

3種方式:

  1. 動(dòng)態(tài)hook
  2. 靜態(tài)插樁
  3. 非阻塞式斷點(diǎn)厢破,打 log

觀察點(diǎn)整理

  1. 任務(wù)的入口 —— 到底有多頻繁,誰在提交任務(wù)治拿?

  2. 工作的邏輯 —— 這個(gè)任務(wù)被分配了哪個(gè)線程摩泪?

  3. 復(fù)用的邏輯 —— 滿的時(shí)候觸發(fā) new Thread,此時(shí)復(fù)用的情況怎么樣劫谅,為什么會(huì)新建這么多见坑?

  4. 釋放的邏輯 —— 解除訂閱和訂閱數(shù)量的比對嚷掠,是否存在訂閱泄漏?

  5. 過期清除的邏輯 —— 清理線程是否在正常工作荞驴?每個(gè)線程都在做什么不皆,為什么停留在直播間沒有被銷毀掉

好,具體觀察結(jié)果的log就不貼了熊楼,因?yàn)辇S看日志體驗(yàn)很差霹娄,我畫了一張圖總結(jié)下整個(gè)流程:

image

看出什么問題了嗎?

在直播間內(nèi)一直停留孙蒙,超過 keepAliveTime项棠,之所以沒有清理線程悲雳,是因?yàn)榫€程都沒過期挎峦,沒錯(cuò),前面的 46 個(gè) IO 線程都沒有過期合瓢,IoScheduler 使用 ConcurrentLinkedQueue 維護(hù)使用完畢的Worker坦胶,按插入順序(也就是釋放順序)排序,所以會(huì)優(yōu)先使用最早過期的 Worker 提供給新任務(wù)晴楔。

我們來算一下顿苇,算 2s 一個(gè)輪詢,直播間只有 1 個(gè)輪詢協(xié)議(實(shí)際上不止)税弃, 那 60s 已經(jīng)足夠讓 30 個(gè) Work 更新一遍過期時(shí)間了纪岁,n 個(gè)輪詢可以更新 60 / 2 * n 個(gè) worker 的過期時(shí)間。

果然源碼面前则果,了無秘密幔翰。

結(jié)論

  1. 直播間這種業(yè)務(wù)場景的特點(diǎn):進(jìn)房時(shí),短期內(nèi)大量任務(wù)要并行西壮;存在多輪詢遗增;

  2. RxJava 的 IO 調(diào)度策略,并不適合用于并發(fā)多 IO + 輪詢的情況款青,沒有任務(wù)排隊(duì)隊(duì)列做修、線程可自增、無上限抡草、優(yōu)先使用快過期的線程饰及;

  3. 另外,業(yè)務(wù)中存在 Rx 不合理使用(前面我們攔截了入口康震,所以可以直觀看到哪里在使用 IO 調(diào)度)旋炒,如 timer 、打點(diǎn)签杈、jsBridge 都使用了 IO 調(diào)度瘫镇,嵌套調(diào)度(重復(fù) new 了 Worker 任務(wù))鼎兽,沒有跟隨生命周期取消訂閱等等等等。

三铣除、解決

找到問題的根源谚咬,問題便已經(jīng)解決了一半,基本 3 個(gè)解決方向:

  1. 優(yōu)化不合理的調(diào)度器創(chuàng)建釋放

  2. 線程收斂尚粘,不是阻塞就一定要用 IO 調(diào)度

    其實(shí) IO 沒必要使用多線程择卦,改為 IO 多路復(fù)用或者協(xié)程更合理。

  3. 減少并發(fā)IO郎嫁,分塊加載

四秉继、思考

4.1 如何快速分類并定位線程?如何拿到NativeThread泽铛?

用前面的方式去分析尚辑,有幾個(gè)缺點(diǎn):

  1. 因?yàn)榍懊嫖覀兡玫蕉褩J怯昧藬帱c(diǎn) log 的方式,所以我們拿不到?jīng)]有通過指定方法創(chuàng)建的任務(wù)信息盔腔;
  2. 我們沒法約束開發(fā)小伙伴和三方 SDK 為每個(gè)線程起自定義名稱杠茬,無法快速分類線程,例如 thread-1弛随,我們就很難定位到是哪個(gè)類發(fā)起的調(diào)用瓢喉;
  3. 我們只能拿到 Java 層與其對接的 native 層 thread 總數(shù),拿不到?jīng)]有 attach 到 java 層的 native thread舀透,也就是直接在 native 層創(chuàng)建的線程栓票,比如 Futter engine 中的native thread。

有什么騷操作呢愕够?

  1. ASM字節(jié)碼修改

    思路很簡單走贪,你既然要?jiǎng)?chuàng)建線程,就肯定是通過以下幾種方式:

  • Thread 及其子類
  • TheadPoolExecutor 及其子類链烈、Executors厉斟、ThreadFactory 實(shí)現(xiàn)類
  • AsyncTask
  • Timer 及其子類

滴滴團(tuán)隊(duì)開源庫booster就是這么個(gè)思路 —— 利用ASM對字節(jié)碼修改,將所有創(chuàng)建線程的指令在編譯期間替換成自定義的方法調(diào)用强衡,為線程名加上調(diào)用者的類名前綴擦秽,實(shí)現(xiàn)了追蹤線程創(chuàng)建來源。

除了支持線程重命名漩勤,還可以把Executors 的方法調(diào)用替換成 ShadowExecutors 中對應(yīng)的優(yōu)化方法感挥,達(dá)到全局暴力收斂的效果。

備注:如果采用 booster 究飞,盡量多做一些測試和降級方案置谦。例如:ShadowExecutors.newOptimizedFixedThreadPool方法中使用了 LinkedBlockingQueue 隊(duì)列堂鲤,沒有指定隊(duì)列大小,默認(rèn)為 Integer.MAX_VALUE媒峡,無界的LinkedBlockingQueue 作為阻塞隊(duì)列瘟栖,當(dāng)任務(wù)耗時(shí)較長時(shí)可能會(huì)導(dǎo)致大量新任務(wù)在隊(duì)列中堆積, CPU 和內(nèi)存飆升谅阿,最終導(dǎo)致 OOM半哟。

  1. NativeHook

    那問題來了,ASM字節(jié)碼修改签餐,只 hook 到了 Java 層與其對接的 native 層 thread 寓涨,怎么拿到直接在 native 層創(chuàng)建的線程呢?誒氯檐,我們前面不是看了線程創(chuàng)建的 C++ 代碼嗎戒良?基本思路就是找到 pthread_create 相關(guān)的函數(shù),攔截它男摧。

第一步:尋找Hook點(diǎn)

這需要對線程的啟動(dòng)流程有一定的了解,可以參考這篇文章Android線程的創(chuàng)建過程

java_lang_Thread.cc:Thread_nativeCreate

static void Thread_nativeCreate(JNIEnv* env, jclass, jobject java_thread, jlong stack_size, jboolean daemon) {
  Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE);
}

thread.cc 中的CreateNativeThread函數(shù)

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
    ...
    pthread_create_result = pthread_create(&new_pthread,
                                             &attr,
                                             Thread::CreateCallback,
                                             child_thread);
    ...
}
第二步:查找Hook的So

上面Thread_nativeCreate竿刁、CreateNativeThread和pthread_create函數(shù)分別編譯在哪個(gè) library 中呢负甸?

很簡單,我們看看編譯腳本Android.bp就知道了柴淘。

art_cc_library {
   name: "libart",
   defaults: ["libart_defaults"],
}

cc_defaults {
   name: "libart_defaults",
   defaults: ["art_defaults"],
   host_supported: true,
   srcs: [
    thread.cc",
   ]
}

可以看到是在"libart.so"中。

第三步:查找Hook函數(shù)的符號

C++ 的函數(shù)名會(huì) Name Mangling,我們需要看看導(dǎo)出符號辕狰。

readelf -a libart.so

pthread_create函數(shù)的確是在libc.so中偶翅,而且因?yàn)閏編譯的不需要deMangling

001048a0  0007fc16 R_ARM_JUMP_SLOT   00000000   pthread_create@LIBC
第四步:實(shí)現(xiàn)

考慮到性能問題形导,我們只 hook 指定的so。

hook_plt_method("libart.so", "pthread_create", (hook_func) &pthread_create_hook);

如果你想監(jiān)控其他so庫的 pthread_create,可以自己加上。Facebook 的 profilo 中有一種做法是把目前已經(jīng)加載的所有so都統(tǒng)一hook了祷膳。

至于 pthread_create 的參數(shù)直接查看pthread.h就可以了陶衅。

int pthread_create(pthread_t* __pthread_ptr, pthread_attr_t const* __attr, void* (*__start_routine)(void*), void*);

獲取堆棧就是在 native 反射 Java 的方法

jstring java_stack = static_cast<jstring>(jniEnv->CallStaticObjectMethod(kJavaClass, kMethodGetStack));

Profilo :Facebook 的性能分析工具,黑科技很多

epic:該庫已經(jīng)支持?jǐn)r截 Thread 類以及 Thread 類所有子類的 run方法直晨,更進(jìn)一步搀军,我們可以結(jié)合 Systrace 等工具,來生成整個(gè)過程的執(zhí)行流程圖勇皇。

注:對于 app 上的 hook罩句,不要再像以前那樣去依賴反射、動(dòng)態(tài)代理了敛摘,關(guān)注下 lancet门烂、epic,真的是為所欲為兄淫。

4.2 排查痛點(diǎn)

非惩驮叮可惜的是沒能保留一手現(xiàn)場,只能靠猜捕虽,靠復(fù)現(xiàn)慨丐。

卡頓、崩潰都需要“現(xiàn)場信息”薯鳍。因?yàn)?bug 產(chǎn)生也是依賴很多因素咖气,比如用戶的系統(tǒng)版本挨措、CPU 負(fù)載挖滤、網(wǎng)絡(luò)環(huán)境、應(yīng)用數(shù)據(jù)浅役、線程數(shù)斩松、利用率、崩潰發(fā)生時(shí)所有的線程棧觉既,而不只是崩潰的線程椌屙铮……

脫離這個(gè)現(xiàn)場,我們本地難以復(fù)現(xiàn)瞪讼,也就很難去解決問題钧椰。那我們應(yīng)該如何去監(jiān)控線上,并且保留足夠多的現(xiàn)場信息協(xié)助我們排查解決問題呢符欠?

這里要么我們可以自己研發(fā)一套崩潰收集系統(tǒng)嫡霞,要么可以接入現(xiàn)有的方案

4.3 異步的本質(zhì)?協(xié)程希柿、NIO诊沪、fiber养筒、loom 解決了什么?

回到本質(zhì)上端姚,思考下晕粪,為什么我們需要多線程?多線程真的有必要嗎渐裸?

因?yàn)轫樞虼a結(jié)構(gòu)是阻塞式的巫湘,每一行代碼的執(zhí)行都會(huì)使線程阻塞在那里,也就決定了所有的耗時(shí)操作不能在主線程中執(zhí)行昏鹃,所以就需要多線程來執(zhí)行剩膘。

所以目的是非阻塞,方式是異步盆顾。

但許多異步庫被引入怠褐,根本原因是當(dāng)前線程實(shí)現(xiàn)的不足,而并非說明異步的代碼更好您宪。我們不要理所當(dāng)然覺得奈懒,異步就是正常不過的事情。實(shí)際上是 Java 的設(shè)計(jì)問題宪巨,讓我們一直默默忍受到現(xiàn)在磷杏,異步帶來的一系列問題:回調(diào)地獄、不方便調(diào)試分析……

長期以來捏卓,Java 的線程是與操作系統(tǒng)的線程一一對應(yīng)的极祸,這種模式直接限制了 Java 平臺(tái)并發(fā)能力的提升:任務(wù)阻塞意味著線程阻塞,線程狀態(tài)切換又帶來開銷怠晴,阻塞線程對系統(tǒng)資源的浪費(fèi)…… 從 Quasar 項(xiàng)目遥金、Alibaba JDK 的協(xié)程特性,到 Kotlin 協(xié)程和 OpenJDK 的 Project Loom蒜田, Java 社區(qū)已經(jīng)越來越多地認(rèn)識到:目前 Java 的線程模型越來越難以滿足整個(gè)行業(yè)對高并發(fā)應(yīng)用開發(fā)的需求稿械。

解決方式很多,其中一個(gè)流派 —— 語言層:

其中的代表 —— 協(xié)程冲粤,雖然在不同語言中美莫,協(xié)程的實(shí)現(xiàn)方法各有不同,但本質(zhì)是一致的梯捕,是一種任務(wù)封裝的思想:調(diào)度任務(wù)代替了調(diào)度線程厢呵,從而減少線程阻塞,用盡量少的線程執(zhí)行盡量多的任務(wù)傀顾。

線程調(diào)度二級模型

比如 Kotlin 協(xié)程襟铭,因?yàn)?Kotlin 的運(yùn)行依賴于 JVM,因此沒辦法在底層支持協(xié)程。同時(shí)蝌矛,Kotlin 是一門編程語言道批,需要在語言層面支持協(xié)程,而不是像框架那樣在語言層面之上支持入撒。因此隆豹,Kotlin-JVM 協(xié)程最核心的部分是在編譯器中,基于各種 Callback 技術(shù)達(dá)到看起來像同步代碼的效果茅逮,本質(zhì)上還是異步璃赡,調(diào)用各種阻塞 API 還是無解,比如 synchronized献雅、 Native 方法中線程掛起碉考,該阻塞線程還是會(huì)阻塞。

為了使 Java 并發(fā)能力在更大范圍上得到提升挺身,從底層進(jìn)行改進(jìn)便是必然侯谁。這就是 Project Loom 項(xiàng)目發(fā)起的原因,也就是另外一個(gè)流派 —— JVM 層章钾。

[譯]loom項(xiàng)目提案

代表項(xiàng)目是 Project Loom 和 AJDK(Alibaba JDK)墙贱,從 Erlang 和 Go 語言中得到了啟發(fā),從 JVM 層面著手贱傀,把之前阻塞線程的惨撇,統(tǒng)統(tǒng)改為阻塞“纖程(fiber)”、“輕量級線程”或者“虛擬線程”府寒,這么做的優(yōu)點(diǎn)是更能徹底解決問題魁衙,不需要靠 async / await 這種語法糖了,在JVM 和類庫層面做支持株搔,能使整個(gè) JVM 生態(tài)上的其他語言都收益剖淀。

但缺點(diǎn)或者說難點(diǎn)同樣明顯,那就是如何與現(xiàn)有的代碼兼容邪狞,這個(gè)改造祷蝌,意味著很多 native 方法也要改,大概要等到 JDK20 才能出預(yù)覽版本吧帆卓,再看看我們 Android 現(xiàn)在僅支持到 Java8,emmm米丘,還是早日使用 Kotlin 吧剑令。

如果后面 JVM 開發(fā)團(tuán)隊(duì)完成這個(gè)項(xiàng)目,看看 Kotlin coroutine 對此會(huì)有什么反應(yīng)拄查,需要怎么調(diào)整吁津,在最好的情況下,Kotlin coroutine 將來只是簡單映射到 “纖程” 上。其實(shí)蠻有意思的碍脏,社區(qū)上關(guān)于異步的討論梭依,還有對Java、JVM的設(shè)計(jì)反思典尾。感興趣的小伙伴可以去研究研究役拴。

參考:


我是 FeelsChaotic,一個(gè)寫得了代碼 p 得了圖钾埂,剪得了視頻畫得了畫的程序媛河闰,致力于追求代碼優(yōu)雅架構(gòu)設(shè)計(jì)T 型成長褥紫。

歡迎關(guān)注 FeelsChaotic 的簡書掘金姜性,如果我的文章對你哪怕有一點(diǎn)點(diǎn)幫助,歡迎 ??髓考! 你的鼓勵(lì)是我寫作的最大動(dòng)力部念!

最最重要的,請給出你的建議或意見氨菇,有錯(cuò)誤請多多指正印机!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市门驾,隨后出現(xiàn)的幾起案子射赛,更是在濱河造成了極大的恐慌,老刑警劉巖奶是,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件楣责,死亡現(xiàn)場離奇詭異,居然都是意外死亡聂沙,警方通過查閱死者的電腦和手機(jī)秆麸,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來及汉,“玉大人沮趣,你說我怎么就攤上這事】浪妫” “怎么了房铭?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長温眉。 經(jīng)常有香客問我缸匪,道長,這世上最難降的妖魔是什么类溢? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任凌蔬,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘砂心。我一直安慰自己懈词,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布辩诞。 她就那樣靜靜地躺著坎弯,像睡著了一般。 火紅的嫁衣襯著肌膚如雪躁倒。 梳的紋絲不亂的頭發(fā)上荞怒,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天,我揣著相機(jī)與錄音秧秉,去河邊找鬼褐桌。 笑死,一個(gè)胖子當(dāng)著我的面吹牛象迎,可吹牛的內(nèi)容都是我干的荧嵌。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼砾淌,長吁一口氣:“原來是場噩夢啊……” “哼啦撮!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起汪厨,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤赃春,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后劫乱,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體织中,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年衷戈,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了狭吼。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,690評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡殖妇,死狀恐怖刁笙,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情谦趣,我是刑警寧澤疲吸,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站蔚润,受9級特大地震影響磅氨,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜嫡纠,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧除盏,春花似錦叉橱、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至踱侣,卻和暖如春粪小,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背抡句。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工探膊, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人待榔。 一個(gè)月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓逞壁,卻偏偏與公主長得像,于是被迫代替她去往敵國和親锐锣。 傳聞我的和親對象是個(gè)殘疾皇子腌闯,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評論 2 353