近日,據報道,甲骨文已經與TikTok的中國所有者字節跳動進行了初步談判,并認真考慮購買該應用在美國、加拿大、澳大利亞和新西蘭的業務。知情人士還補充稱,甲骨文正在與一群已經持有字節跳動股票的美國投資者合作,包括美國泛大西洋投資集團和紅杉資本。
/ 前言 /
當大家打開AndroidStudio的Profiler工具時,是否遇到過這種情況:
哇塞好幾百個線程??名字咋都是12345?怎么都在sleep或wait但就不銷毀?
其實,當一個項目規模越來越大時,隨著開發人員變更、老代碼不規范、三方sdk引入越來越多,很難避免線程數量暴漲的問題。當線程過多時,不僅有oom風險,更會帶來很多內存泄漏的隱患。但通過Profiler工具也只是知道線程數量,用Thread.getAllStackTraces()方法獲取到的也只有線程的運行時堆棧,即到run()方法就結束了,并不知道start()是被誰調用的。所以也就無法得知當前線程所對應的業務邏輯,以及它在當前時刻是否本應該被銷毀。
/ 思路 /
那如何才能得知一個線程start()方法的調用棧呢?如果我們有個BaseThread,然后所有的線程都使用或繼承于它,就好辦了,我們可以在start()方法中獲取堆棧信息:
@Synchronized
override fun start() {
val stackElements = Throwable().stackTrace
super.start()
}
想要做到這一點,可以使用asm編譯期間改字節碼來完成。這里簡單介紹下asm,它可以通過自定義gradle插件,在代碼被編譯成class后、打包成dex前,把項目中的所有class文件(包括你自己寫的,和三方jar包中的)遍歷一遍,遍歷期間你就可以通過asm任意修改class字節碼,以達到各種不可告人的目的。那么我們就可以通過asm來把項目中的Thread都替換掉:
new Thread() -> new BaseThread()
extends Thread -> extends BaseThread
/ 操作 /
現在有了調用棧,如何將調用棧和線程建立聯系呢?其實Thread初始化時已經產生了線程id,我們可以在start()中把線程id和對應棧信息放到一個map中。另外我們還可以復寫run()方法,當super.run()執行完后,線程即將銷毀,我們可以在此時把map中對應信息移除掉。
好了!就是這么簡單,接下來看線程池。線程池中的線程按剛剛的方法是替換不掉的,因為這些代碼在framework層,asm管不著。并且對于線程池來說,我們需要的不是它的線程在哪被啟動,而是線程正在執行的task是從哪里被添加的,這樣我們才知道是哪個業務添加了task使得線程一直在運行。
線程池一般有兩種創建方法,直接new和調用Executors.xxx,我們先看new出來的,按照上述套路,搞個BaseThreadPool,使用asm全部替換掉,然后在構造函數中獲取線程池創建堆棧,再復寫execute、submit、invokeAny等提交task的方法,在其中獲取task添加堆棧。但獲取到的堆棧如何跟線程對應上呢?我們知道提交的task都是Runnable或Callable形式,如果我們寫個PoolRunnable把它包一層,把線程池名字和task添加棧傳進去,然后在run()中調用Thread.currentThread()獲取當前線程,就把線程池名、線程池創建棧、線程id、task添加棧都關聯起來了。
class PoolRunnable constructor(
private val any: Any,
private val callStack: String,
private val poolName: String? = null
) : Runnable, Callable<Any>, Comparable<Any> {
override fun run() {
val threadId = Thread.currentThread().id
// 至此callStack、poolName、thread就可以全部關聯上
// poolName和poolCreateStack可在外面事先創建關聯
(any as Runnable).run()
// 任務已執行結束,callStack表示task添加棧,此時應為空代表線程當前無任務在運行
info.callStack = ""
}
override fun call(): Any {
// 類似run()方法
}
override fun compareTo(other: Any): Int {
// 省略代碼
}
}
這里有一點要說明,有些task可能同時繼承于Runnable、Callable、甚至Comparable,如果只包裝成Runnable,當調用其他接口方法時會crash,所以這里要把已知的可能會繼承的接口都實現。(當然如果后面發現沒覆蓋全,可以把新的接口繼續加進來。至于用戶自定義Runnable實現很多接口的情況不用擔心。因為替換為PoolRunnable后都是系統代碼在運行,主要看系統代碼是否會調用call()、compareTo()等方法就好,上層隨意調用被包裝的runnable中各種自定義方法是沒問題的)。
但是對于建立“線程-線程池”關系來說,這樣要等到run()方法被執行時才能建立關系,感覺有點晚。這里可以更進一步,替換掉線程池中threadFactory,同樣是自己包一層,在newThread()方法中即可及時拿到線程池創建的線程,這樣就可以先和線程池建立關聯,然后run()時再和堆棧建立關聯。
好了!new方式創建的線程池搞定,接下來看看Executors.xxx怎么玩,我們可不可以自己寫個ProxyExecutors,使用asm把所有Executors.xxx都替換成ProxyExecutors.xxx,然后把其中new ThreadPool都換成new BaseThreadPool呢?
object ProxyExecutors {
@JvmStatic
fun newFixedThreadPool(nThreads: Int): ExecutorService {
return BaseThreadPoolExecutor(
nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
LinkedBlockingQueue()
)
}
@JvmStatic
fun newCachedThreadPool(): ExecutorService {
return BaseThreadPoolExecutor(
0, Int.MAX_VALUE,
60L, TimeUnit.SECONDS,
SynchronousQueue()
)
}
@JvmStatic
fun newScheduledThreadPool(
corePoolSize: Int,
threadFactory: ThreadFactory?
): ScheduledExecutorService {
return BaseScheduledThreadPoolExecutor(corePoolSize, threadFactory)
}
}
對于某些線程池確實沒啥問題,但當你繼續寫下去時,會出問題,比如:
@JvmStatic
fun newSingleThreadScheduledExecutor(): ScheduledExecutorService {
return BaseDelegatedScheduledExecutorService(
ScheduledThreadPoolExecutor(1)
)
}
DelegatedScheduledExecutorService是Executors的私有內部類,沒辦法方便的寫出BaseDelegatedScheduledExecutorService。所以繼續觀察下,發現所有創建線程池方法都返回的都是ExecutorService或ScheduledExecutorService,既然這么統一,并且這兩個都是接口,那我們就使用動態代理吧!通過代理,就可以拿到接口的各種方法以及方法參數,然后為所欲為。以ExecutorService接口為例:
object ProxyExecutors {
@JvmStatic
fun newFixedThreadPool(nThreads: Int): ExecutorService {
return proxy(Executors.newFixedThreadPool(nThreads))
}
}
private fun proxy(executorService: ExecutorService): ExecutorService {
if (executorService is ThreadPoolExecutor) {
// 這里和BaseThreadPoolExecutor一樣,設置ThreadFactory為了盡早獲取線程信息和線程池建立聯系,而不用等到run時
executorService.threadFactory = BaseThreadFactory(
executorService.threadFactory,
toObjectString(executorService)
)
}
val handler = ProxyExecutorService(executorService)
return Proxy.newProxyInstance(
executorService.JAVAClass.classLoader,
AbstractExecutorService::class.java.interfaces,
handler
) as ExecutorService
}
// 這里使用java是因為kotlin在調用java變長參數方法時有坑
public class ProxyExecutorService implements InvocationHandler {
private ExecutorService executor;
private String poolName = null;
ProxyExecutorService(ExecutorService executor) {
this.executor = executor;
poolName = TrackerUtils.toObjectString(executor);
// 初始化時獲取線程池信息
String createStack = TrackerUtils.getStackString(false);
// 省略部分代碼
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 因方法數眾多,并且被代理的各類中方法也不一致
// 所以被調用方法中只要含有Runnable、Callable類型的參數,都替換成PoolRunnable代理
if (args != null) {
String callStack = TrackerUtils.getStackString(true);
for (int i = 0; i < args.length; i++) {
Object arg = args[i];
if ((arg instanceof Runnable || arg instanceof Callable) && !(arg instanceof PoolRunnable)) {
// execute submit 等情況
PoolRunnable any = new PoolRunnable(arg, callStack, poolName);
// 替換方法參數
args[i] = any;
} else if (arg instanceof Collection && !((Collection) arg).isEmpty()) {
// invokeAny invokeAll 等情況
Iterator iter = ((Collection) arg).iterator();
ArrayList<PoolRunnable> taskList = new ArrayList<>();
boolean allOk = iter.hasNext();
while (iter.hasNext()) {
Object it = iter.next();
if (it instanceof Runnable || it instanceof Callable) {
if (it instanceof PoolRunnable) {
taskList.add((PoolRunnable) it);
} else {
taskList.add(new PoolRunnable(it, callStack, poolName));
}
} else {
allOk = false;
break;
}
}
if (allOk) {
// 替換方法參數
args[i] = taskList;
}
}
}
}
if (method.getName().equals("shutdown") || method.getName().equals("shutdownNow")) {
ThreadInfoManager.getINSTANCE().shutDownPool(poolName);
}
return method.invoke(executor, args);
}
}
在invoke方法中,我們把所有參數為Runnable或Callable的都替換成自己的PoolRunnable,后面和new創建線程池的套路一樣,在PoolRunnable的run()或call()方法中進行線程與堆棧的關聯。完美!
但是考慮一個問題,如果有人不幸寫出這樣的代碼,會發生什么呢?
val pool = Executors.newFixedThreadPool(3) as ThreadPoolExecutor
對了,會crash,因為我們動態代理后代理對象變成了ExecutorService,沒辦法向下轉型成ThreadPoolExecutor,這也是動態代理的一個缺點,只能代理接口,如果一個類A除了實現接口還有很多自己的方法,動態代理對這些方法是無能為力的,代理后對象只是接口的實例,無法轉成類A。這個問題可以使用cglib/javassist庫解決,這里就不展開了。為了保險起見,我們可以把能用Base替換的都替換掉,只有類似newSingleThreadScheduledExecutor()這種不能用Base替換的才用動態代理:
object ProxyExecutors {
@JvmStatic
fun newFixedThreadPool(nThreads: Int): ExecutorService {
// 這里使用BaseThreadPoolExecutor主要是為了避免上層代碼把ExecutorService轉型成ThreadPoolExecutor的問題,如果使用proxy方法動態代理,上層這么做會crash
return BaseThreadPoolExecutor(
nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
LinkedBlockingQueue()
)
// return proxy(Executors.newFixedThreadPool(nThreads))
}
ok,線程池搞定了。但除了線程和線程池,Java和Android中還有很多封裝了線程線程池的類。比如HandlerThread,Timer,ASyncTask等,我們先處理這幾個常用類吧。
HandlerThread本質上就是Thread,采用和Thread類似的處理方式就好。
Timer內部有個私有的TimerThread成員,本質也是個Thread,看源碼可知Timer在構造方法中便啟動了這個Thread,所以調用棧也就是Timer被初始化的棧。我們可以新建BaseTimer,使用asm將代碼中的Timer替換掉,然后構造方法中獲取調用棧,再通過反射拿到內部Thread成員,獲取其id等信息,與調用棧關聯即可。
ASyncTask這個相對來說難辦一些,這里面主要有兩個Executor:THREAD_POOL_EXECUTOR和SERIAL_EXECUTOR,其中主要干活的是THREAD_POOL_EXECUTOR,而SERIAL_EXECUTOR并不是真正意義上的線程池,只是實現了Executor接口而已。SERIAL_EXECUTOR的execute()方法在向一個隊列中添加任務,然后依次取出送入THREAD_POOL_EXECUTOR中執行,所以我們需要THREAD_POOL_EXECUTOR的線程池信息,關聯SERIAL_EXECUTOR的execute()任務添加棧信息。
而外界又可以直接用THREAD_POOL_EXECUTOR添加任務,這種情況下就又需要THREAD_POOL_EXECUTOR的任務添加棧信息了,所以這里需要一些特殊處理。總體思路仍然是動態代理SERIAL_EXECUTOR和THREAD_POOL_EXECUTOR,然后把代理設置回ASyncTask,時機為App啟動時。由于ASyncTask中這兩個線程池對象是final的,所以需要通過反射修改modifiers去掉final位。這在5.0及以上系統沒什么問題,但4.x的源碼中modifiers是通過native獲取的:
/**
* Returns the modifiers for this field. The {@link Modifier} class should
* be used to decode the result.
*
* @return the modifiers for this field
* @see Modifier
*/
public int getModifiers() {
return getFieldModifiers(declaringClass, slot);
}
private native int getFieldModifiers(Class<?> declaringClass, int slot);
對此暫未找到修改方式,所以如果要追蹤ASyncTask,請使用Android 5.0及以上手機。
至此,線程/線程池溯源基本完成了,后續會繼續完善IntentService、ForkJoinPool和Java/Android源碼中封裝的其他不太常用的線程/線程池封裝類。
初版效果長這樣:
可以看到界面中對用戶代碼做了高亮,幫助用戶在迷亂的調用棧中一眼看到問題根源。其原理大致是在asm掃描class過程中,根據獲取到的項目信息來記錄哪些是用戶寫的代碼(不能直接根據是否為jar包來判斷,如果項目存在多個module,可能這些module最后也是以jar包形式被asm掃描),然后把相關class包名記下來,過濾掉包含有“已有短包名”的“較長包名”,把這些包名list通過asm寫入到指定Java類的指定ArrayList成員中。在獲取到調用棧后,可以和這些包名list對比,進行高亮。
/ 總結 /
最后,此文作為拋磚引玉,提供一種線程溯源的思路。其實可以看出asm還是很強大的,能做什么就取決于大家的想象力了。后面會逐漸完善新功能,比如記錄線程運行時間,根據線程各維度狀態篩選/排序等,統計線程所屬包名或jar文件名,看看那些三方庫在為非作歹(特別是某些廣告sdk,簡直。。),甚至對線程/線程池運行狀態可疑的給出警告和優化建議等等。
最最后,作為asm初學者,很多用法還比較初級,如果有更優雅的實現方式,歡迎瘋狂pull requests;另外,該項目雖然目前在咕咚這個比較龐大復雜的app上可正常運行,但難免還有考慮不周全的地方,畢竟替換字節碼是比較危險的操作。
以下是我整理的資料
Android核心知識點筆記github:https://github.com/AndroidCot/Android