今天繼續上一篇:想實現Android隊列功能?Handler內功心法,你值得擁有!——Handler源碼和常見問題的解答,今天來給大家講講Handler深層次問題的解答。
主要內容:
- ThreadLocal
- epoll機制
- Handle同步屏障機制
- Handler的鎖相關問題
- Handler中的同步方法
ThreadLocal
ThreadLocal爲每個線程都提供了變量的副本,使得每個線程在某一時間訪問到的並非同一個對象,這樣就隔離了多個線程對數據的數據共享。如果讓你設計一個ThreadLocal,ThreadLocal 的目標是讓不同的線程有不同的變量 V,那最直接的方法就是創建一個 Map,它的 Key 是線程,Value 是每個線程擁有的變量 V,ThreadLocal 內部持有這樣的一個 Map 就可以了。你可能會設計成這樣。
實際上Java的實現是下面這樣,Java 的實現裏面也有一個 Map,叫做 ThreadLocalMap,不過持有 ThreadLocalMap 的不是 ThreadLocal,而是 Thread。Thread 這個類內部有一個私有屬性 threadLocals,其類型就是 ThreadLocalMap,ThreadLocalMap 的 Key 是 ThreadLocal。
精簡之後的代碼如下:
class Thread {
//內部持有ThreadLocalMap
ThreadLocal.ThreadLocalMap
threadLocals;
}
class ThreadLocal<T>{
public T get() {
//首先獲取線程持有的
//ThreadLocalMap
ThreadLocalMap map =
Thread.currentThread()
.threadLocals;
//在ThreadLocalMap中
//查找變量
Entry e =
map.getEntry(this);
return e.value;
}
static class ThreadLocalMap{
//內部是數組而不是Map
Entry[] table;
//根據ThreadLocal查找Entry
Entry getEntry(ThreadLocal key){
//省略查找邏輯
}
//Entry定義
static class Entry extends
WeakReference<ThreadLocal>{
Object value;
}
}
}
在Java的實現方案中,ThreadLocal僅僅只是一個代理工具類,內部並不持有任何線程相關的數據,所有和線程相關的數據都存儲在Thread裏面,這樣的設計從數據的親緣性上來講,ThreadLocalMap屬於Thread也更加合理。所以ThreadLocal的get方法,其實就是拿到每個線程獨有的ThreadLocalMap。
還有一個原因,就是不容易產生內存泄漏,如果用我們的設計方案,ThreadLocal持有的Map會持有Thread對象的引用,這就意味着只要ThreadLocal對象存在,那麼Map中的Thread對象就永遠不會被回收。ThreadLocal的生命週期往往都比線程要長,所以這種設計方案很容易導致內存泄漏。
而Java的實現中Thread持有ThreadLocalMap,而且ThreadLocalMap裏對ThreadLocal的引用還是弱引用,所以只要Thread對象可以被回收,那麼ThreadLocalMap就能被回收。Java的實現方案雖然看上去複雜一些,但是更安全。
ThreadLocal與內存泄漏
但是一切並不總是那麼完美,如果在線程池中使用ThreadLocal可能會導致內存泄漏,原因是線程池中線程的存活時間太長,往往和程序都是同生共死的,這就意味着Thread持有的ThreadLocalMap一直都不會被回收,再加上ThreadLocalMap中的Entry對ThreadLocal是弱引用,所以只要ThreadLocal結束了自己的生命週期是可以被回收掉的。但是Entry中的Value卻是被Entry強引用的,所以即便Value的生命週期結束了,Value也是無法被回收的,從而導致內存泄漏。
所以我們可以通過try{}finally{}方案來手動釋放資源
ExecutorService es;
ThreadLocal tl;
es.execute(()->{
//ThreadLocal增加變量
tl.set(obj);
try {
// 省略業務邏輯代碼
}finally {
//手動清理ThreadLocal
tl.remove();
}
});
epoll機制
epoll機制在Handler中的應用,在主線程的 MessageQueue 沒有消息時,便阻塞在 loop 的 queue.next() 中的 nativePollOnce() 方法裏,最終調用到epoll_wait()進行阻塞等待。此時主線程會釋放 CPU 資源進入休眠狀態,直到下個消息到達或者有事務發生,通過往 pipe 管道寫端寫入數據來喚醒主線程工作。這裏採用的 epoll 機制,是一種IO多路複用機制,可以同時監控多個描述符,當某個描述符就緒(讀或寫就緒),則立刻通知相應程序進行讀或寫操作,本質同步I/O,即讀寫是阻塞的。所以說,主線程大多數時候都是處於休眠狀態,並不會消耗大量CPU資源。
表面上看epoll的性能最好,但是在連接數少並且連接都十分活躍的情況下,select和poll的性能可能比epoll好,畢竟epoll的通知機制需要很多函數回調。
select低效是因爲每次它都需要輪詢。但低效也是相對的,視情況而定,也可通過良好的設計改善。
之所以選擇Handler底層選擇epoll機制,我感覺是epoll在效率上更高。在select/poll中,進程只有在調用一定的方法後,內核纔對所有監視的文件描述符進行掃描,而epoll事先通過epoll_ctl()來註冊一個文件描述符,一旦基於某個文件描述符就緒時,內核會採用類似callback的回調機制,迅速激活這個文件描述符,當進程調用epoll_wait()時便得到通知。(此處去掉了遍歷文件描述符,而是通過監聽回調的的機制。這正是epoll的魅力所在。)
Handler的同步屏障機制
如果有一個緊急的Message需要優先處理,該怎麼做?這其實涉及到架構方面的設計了,通用場景和特殊場景的設計。你可能會想到sendMessageAtFrontOfQueue()這個方法,實際也遠遠不只是如此,Handler中加入了同步屏障這種機制,來實現[異步消息優先]執行的功能。
postSyncBarrier()發送同步屏障,removeSyncBarrier()移除同步屏障。
同步屏障的作用可以理解成攔截同步消息的執行,主線程的 Looper 會一直循環調用 MessageQueue 的 next() 來取出隊頭的 Message 執行,當 Message 執行完後再去取下一個。當 next() 方法在取 Message 時發現隊頭是一個同步屏障的消息時,就會去遍歷整個隊列,只尋找設置了異步標誌的消息,如果有找到異步消息,那麼就取出這個異步消息來執行,否則就讓 next() 方法陷入阻塞狀態。如果 next() 方法陷入阻塞狀態,那麼主線程此時就是處於空閒狀態的,也就是沒在幹任何事。所以,如果隊頭是一個同步屏障的消息的話,那麼在它後面的所有同步消息就都被攔截住了,直到這個同步屏障消息被移除出隊列,否則主線程就一直不會去處理同步屏幕後面的同步消息。
而所有消息默認都是同步消息,只有手動設置了異步標誌,這個消息纔會是異步消息。另外,同步屏障消息只能由內部來發送,這個接口並沒有公開給我們使用。
Choreographer 裏所有跟 message 有關的代碼,你會發現,都手動設置了異步消息的標誌,所以這些操作是不受到同步屏障影響的。這樣做的原因可能就是爲了儘可能保證上層 app 在接收到屏幕刷新信號時,可以在第一時間執行遍歷繪製 View 樹的工作。
Choreographer 過程中的動作也都是異步消息,這樣可以確保 Choreographer 的順利運轉,也確保了第一時間執行 doTraversal(doTraversal → performTraversals 就是執行 view 的 layout、measure、draw),這個過程中如果有其他同步消息,也無法得到處理,都要等到 doTraversal 之後。
因爲主線程中如果有太多消息要執行,而這些消息又是根據時間戳進行排序,如果不加一個同步屏障的話,那麼遍歷繪製 View 樹的工作就可能被迫延遲執行,因爲它也需要排隊,那麼就有可能出現當一幀都快結束的時候纔開始計算屏幕數據,那即使這次的計算少於 16.6ms,也同樣會造成丟幀現象。
那麼,有了同步屏障消息的控制就能保證每次一接收到屏幕刷新信號就第一時間處理遍歷繪製 View 樹的工作麼?
只能說,同步屏障是儘可能去做到,但並不能保證一定可以第一時間處理。因爲,同步屏障是在 scheduleTraversals() 被調用時才發送到消息隊列裏的,也就是說,只有當某個 View 發起了刷新請求時,在這個時刻後面的同步消息纔會被攔截掉。如果在 scheduleTraversals() 之前就發送到消息隊列裏的工作仍然會按順序依次被取出來執行。
下面是部分詳細的分析:
WindowManager維護着所有的Activity的DecorView和ViewRootImpl。在前面我們講過,WindowManagerGlobal的addView方法中中初始化了ViewRootImpl,然後調用它的setView方法,將DecorView作爲參數傳遞了進去。所以我們看看ViewRootImpl做了什麼。
//ViewRootImpl.java
//view是DecorView
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
synchronized (this) {
if (mView == null) {
mView = view;
···
// Schedule the first layout -before- adding to the window
// manager, to make sure we do the relayout before receiving
// any other events from the system.
requestLayout(); //發起佈局請求
···
view.assignParent(this); //將當前ViewRootImpl對象this作爲參數調用了DecorView的 assignParent
···
}
}
}
在setView()方法裏調用了DecorView的assignParent。
//View.java
/*
* Caller is responsible for calling requestLayout if necessary.
* (This allows addViewInLayout to not request a new layout.)
*/
@UnsupportedAppUsage
void assignParent(ViewParent parent) {
if (mParent == null) {
mParent = parent;
} else if (parent == null) {
mParent = null;
} else {
throw new RuntimeException("view " + this + " being added, but"
+ " it already has a parent");
}
}
參數是ViewParent,而ViewRootImpl是實現了ViewParent接口的,所以在這裏就將DecorView和ViewRootImpl綁定起來了。每個Activity的根佈局都是DecorView,而DecorView的parent又是ViewRootImpl,所以在子View裏執行invalidate()之類的工作,循環找parent,最後都會找到ViewRootImpl裏來。所以實際上View的刷新都是由ViewRootImpl來控制的。
即使是界面上一個小小的 View 發起了重繪請求時,都要層層走到 ViewRootImpl,由它來發起重繪請求,然後再由它來開始遍歷 View 樹,一直遍歷到這個需要重繪的 View 再調用它的 onDraw() 方法進行繪製。
View.invalidate()請求重繪的操作最後調用到的是ViewRootImpl.scheduleTraversals(),而ViewRootImpl.setView()方法中調用了requestLayout方法。
@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
scheduleTraversals();
}
}
最終也調用到了scheduleTraversals()方法,其實這個方法是屏幕刷新的關鍵。
其實打開一個 Activity,當它的 onCreate---onResume 生命週期都走完後,纔將它的 DecoView 與新建的一個 ViewRootImpl 對象綁定起來,同時開始安排一次遍歷 View 任務也就是繪製 View 樹的操作等待執行,然後將 DecoView 的 parent 設置成 ViewRootImpl 對象。所以我們在onCreate~onResume中獲取不到View寬高,界面的繪製也是在onResume之後纔開始執行的。
ViewRootImpl.scheduleTraversals()的一系列分析以及屏幕刷新機制可以參考這篇文章,這裏的內容也是大部分參考它的,同步屏障相關的分析內容也在裏面。Choreographer主要作用是協調動畫,輸入和繪製的時間,它從顯示子系統接收定時脈衝(例如垂直同步),然後安排渲染下一個frame的一部分工作。可通過Choreographer.getInstance().postFrameCallback()來監聽幀率情況。
public class FPSFrameCallback implements Choreographer.FrameCallback {
private static final String TAG = "FPS_TEST";
private long mLastFrameTimeNanos;
private long mFrameIntervalNanos;
public FPSFrameCallback(long lastFrameTimeNanos) {
mLastFrameTimeNanos = lastFrameTimeNanos;
//每一幀渲染時間 多少納秒
mFrameIntervalNanos = (long) (1000000000 / 60.0);
}
@Override
public void doFrame(long frameTimeNanos) { //Vsync信號到來的時間frameTimeNanos
//初始化時間
if (mLastFrameTimeNanos == 0) {
//上一幀的渲染時間
mLastFrameTimeNanos = frameTimeNanos;
}
final long jitterNanos = frameTimeNanos - mLastFrameTimeNanos;
if (jitterNanos >= mFrameIntervalNanos) {
final long skippedFrames = jitterNanos / mFrameIntervalNanos;
if (skippedFrames > 5) {
Log.d(TAG, "Skipped " + skippedFrames + " frames! "
+ "The application may be doing too much work on its main thread.");
}
}
mLastFrameTimeNanos = frameTimeNanos;
//註冊下一幀回調
Choreographer.getInstance().postFrameCallback(this);
}
}
調用方式在Application中註冊。
Choreographer.getInstance().postFrameCallback(FPSFrameCallback(System.nanoTime()))
丟幀的原因:造成丟幀大體上有兩類原因,一是遍歷繪製 View 樹計算屏幕數據的時間超過了 16.6ms;二是,主線程一直在處理其他耗時的消息,導致遍歷繪製 View 樹的工作遲遲不能開始,從而超過了 16.6 ms 底層切換下一幀畫面的時機。
Handler鎖相關問題
既然可以存在多個Handler往MessageQueue中添加數據(發送消息時各個Handler可能處於不同線程),那它內部是如何確保線程安全的?
Handler.sendXXX,Handler.postXXX最終會會調到MessageQueue的enqueueMessage方法。
源碼如下:
boolean enqueueMessage(Message msg, long when) {
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
if (msg.isInUse()) {
throw new IllegalStateException(msg + " This message is already in use.");
}
//加鎖保證安全
synchronized (this) {
···
}
}
其內部通過synchronized關鍵字保證線程安全。同時messagequeue.next()內部也會通過synchronized加鎖,確保取的時候線程安全,同時插入也會加鎖。這個問題其實不難,只是看你有沒有了解源碼。
Handler中的同步方法
如何讓handler.post消息執行之後然後再繼續往下執行,同步方法runWithScissors。
public final boolean runWithScissors(@NonNull Runnable r, long timeout) {
if (r == null) {
throw new IllegalArgumentException("runnable must not be null");
}
if (timeout < 0) {
throw new IllegalArgumentException("timeout must be non-negative");
}
if (Looper.myLooper() == mLooper) {
r.run();
return true;
}
BlockingRunnable br = new BlockingRunnable(r);
return br.postAndWait(this, timeout);
}
最後
只要是程序員,不管是Java還是Android,如果不去閱讀源碼,只看API文檔,那就只是浮於表象,這對我們的知識體系的建立和完備以及實戰技術的提升都是不利的。
真正最能鍛鍊能力的便是直接去閱讀源碼,不僅限於閱讀Android系統源碼,還包括各種優秀的開源庫。
最後爲了幫助大家深刻理解Handler相關知識點的原理以及面試相關知識,這裏還爲大家整理了Android開發相關源碼精編解析:
深入解析 Handler 源碼解析
- 發送消息
- 消息入隊
- 消息循環
- 消息遍歷
- 消息的處理
- 同步屏障機制
- 阻塞喚醒機制
還有Handler相關面試題解析幫助熟練掌握Handler知識:
最後爲了幫助大家深刻理解Android相關知識點的原理以及面試相關知識,這裏放上我搜集整理的2019-2020BAT 面試真題解析,我把大廠面試中常被問到的技術點整理成了PDF,包知識脈絡 + 諸多細節。
節省大家在網上搜索資料的時間來學習,也可以分享給身邊好友一起學習。
《379頁Android開發面試寶典》
包含了騰訊、百度、小米、阿里、樂視、美團、58、獵豹、360、新浪、搜狐等一線互聯網公司面試被問到的題目。熟悉本文中列出的知識點會大大增加通過前兩輪技術面試的機率。
《507頁Android開發相關源碼解析》
只要是程序員,不管是Java還是Android,如果不去閱讀源碼,只看API文檔,那就只是停留於皮毛,這對我們知識體系的建立和完備以及實戰技術的提升都是不利的。
真正最能鍛鍊能力的便是直接去閱讀源碼,不僅限於閱讀各大系統源碼,還包括各種優秀的開源庫。
資料太多,全部展示會影響篇幅,暫時就先列舉這些部分截圖,以上資源均免費分享,以上內容均放在了開源項目:【github】 中已收錄,大家可以自行獲取(或者關注主頁掃描加微信獲取)。