Android 高階面試-3:Java、同步和併發相關

WngShhng發表於2019-02-21

主要內容:Kotlin, Java, RxJava, 多執行緒/併發, 集合

1、Java 相關

1.1 快取相關

  • LruCache 的原理
  • DiskLruCache 的原理

LruCache 用來實現基於記憶體的快取,LRU 就是最近最少使用的意思,LruCache 基於 LinkedHashMap 實現。LinkedHashMap 是在 HashMap 的基礎之上進行了封裝,除了具有雜湊功能,還將資料插入到雙向連結串列中維護。每次讀取的資料會被移動到連結串列的尾部,當達到了快取的最大的容量的時候就將連結串列的首部移出。使用 LruCache 的時候需要注意的是單位的問題,因為該 API 並不清楚要儲存的資料是如何計算大小的,所以它提供了方法供我們實現大小的計算方式。(《Android 記憶體快取框架 LruCache 的原始碼分析》

DiskLruCache 與 LruCache 類似,也是用來實現快取的,並且也是基於 LinkedHashMap 實現的。不同的是,它是基於磁碟快取的,LruCache 是基於記憶體快取的。所以,LinkedHashMap 能夠儲存的空間更大,但是讀寫的速率也更慢。使用 DiskLruCache 的時候需要到 Github 上面去下載。OkHttp 和 Glide 的磁碟快取都是基於 DiskLruCache 開發的。DiskLruCahce 內部維護了一個日誌檔案,記錄了讀寫的記錄的資訊。其他的基本都是基礎的磁碟 IO 操作。

  • Glide 快取的實現原理

1.2 List 相關

  • ArrayList 與 LinkedList 區別
  1. ArrayList 是基於動態陣列,底層使用 System.arrayCopy() 實現陣列擴容;查詢值的複雜度為 O(1),增刪的時候可能擴容,複雜度也比 LinkedList 高;如果能夠大概估出列表的長度,可以通過在 new 出例項的時候指定一個大小來指定陣列的初始大小,以減少擴容的次數;適合應用到查詢多於增刪的情形,比如作為 Adapter 的資料的容器
  2. LinkedList 是基於雙向連結串列;增刪的複雜度為 O(1),查詢的複雜度為 O(n);適合應用到增刪比較多的情形
  3. 兩種列表都不是執行緒安全的,Vector 是執行緒安全的,但是它的執行緒安全的實現方式是通過對每個方法進行加鎖,所以效能比較低。

如果想執行緒安全地使用這列表類(可以參考下面的問題)

  • 如何實現執行緒間安全地操作 List?

我們有幾種方式可以執行緒間安全地操作 List. 具體使用哪種方式,可以根據具體的業務邏輯進行選擇。通常有以下幾種方式:

  1. 第一是在操作 List 的時候使用 sychronized 進行控制。我們可以在我們自己的業務方法上面進行加鎖來保證執行緒安全。
  2. 第二種方式是使用 Collections.synchronizedList() 進行包裝。這個方法內部使用了私有鎖來實現執行緒安全,就是通過對一個全域性變數進行加鎖。呼叫我們的 List 的方法之前需要先獲取該私有鎖。私有鎖可以降低鎖粒度。
  3. 第三種是使用併發包中的類,比如在讀多寫少的情況下,為了提升效率可以使用 CopyOnWriteArrayList 代替 ArrayList,使用 ConcurrentLinkedQueue 代替 LinkedList. 併發容器中的 CopyOnWriteArrayList 在讀的時候不加鎖,寫的時候使用 Lock 加鎖。ConcurrentLinkedQueue 則是基於 CAS 的思想,在增刪資料之前會先進行比較。

1.3 Map 相關

  • SparseArray 的原理

SparseArray 主要用來替換 Java 中的 HashMap,因為 HashMap 將整數型別的鍵預設裝箱成 Integer (效率比較低). 而 SparseArray 通過內部維護兩個陣列來進行對映,並且使用二分查詢尋找指定的鍵,所以它的鍵對應的陣列無需是包裝型別。SparseArray 用於當 HashMap 的鍵是 Integer 的情況,它會在內部維護一個 int 型別的陣列來儲存鍵。同理,還有 LongSparseArray, BooleanSparseArray 等,都是用來通過減少裝箱操作來節省記憶體空間的。但是,因為它內部使用二分查詢尋找鍵,所以其效率不如 HashMap 高,所以當要儲存的鍵值對的數量比較大的時候,考慮使用 HashMap.

  • HashMap、ConcurrentHashMap 以及 HashTable
  • hashmap 如何 put 資料(從 hashmap 原始碼角度講解)?(掌握 put 元素的邏輯)

HashMap (下稱 HM) 是雜湊表,ConcurrentHashMap (下稱 CHM) 也是雜湊表,它們之間的區別是 HM 不是執行緒安全的,CHM 執行緒安全,並且對鎖進行了優化。對應 HM 的還有 HashTable (下稱 HT),它通過對內部的每個方法加鎖來實現執行緒安全,效率較低。

HashMap 的實現原理:HashMap 使用拉鍊法來解決雜湊衝突,即當兩個元素的雜湊值相等的時候,它們會被方進一個桶當中。當一個桶中的資料量比較多的時候,此時 HashMap 會採取兩個措施,要麼擴容,要麼將桶中元素的資料結構從連結串列轉換成紅黑樹。因此存在幾個常量會決定 HashMap 的表現。在預設的情況下,當 HashMap 中的已經被佔用的桶的數量達到了 3/4 的時候,會對 HashMap 進行擴容。當一個桶中的元素的數量達到了 8 個的時候,如果桶的數量達到了 64 個,那麼會將該桶中的元素的資料結構從連結串列轉換成紅黑樹。如果桶的數量還沒有達到 64 個,那麼此時會對 HashMap 進行擴容,而不是轉換資料結構。

從資料結構上,HashMap 中的桶中的元素的資料結構從連結串列轉換成紅黑樹的時候,仍然可以保留其連結串列關係。因為 HashMap 中的 TreeNode 繼承了 LinkedHashMap 中的 Entry,因此它存在兩種資料結構。

HashMap 在實現的時候對效能進行了很多的優化,比如使用擷取後面幾位而不是取餘的方式計算元素在陣列中的索引。使用雜湊值的高 16 位與低 16 進行異或運算來提升雜湊值的隨機性。

因為每個桶的元素的資料結構有兩種可能,因此,當對 HashMap 進行增刪該查的時候都會根據結點的型別分成兩種情況來進行處理。當資料結構是連結串列的時候處理起來都非常容易,使用一個迴圈對連結串列進行遍歷即可。當資料結構是紅黑樹的時候處理起來比較複雜。紅黑樹的查詢可以沿用二叉樹的查詢的邏輯。

下面是 HashMap 的插入的邏輯,所有的插入操作最終都會呼叫到內部的 putVal() 方法來最終完成。

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    private V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;

        if ((tab = table) == null || (n = tab.length) == 0) { // 原來的陣列不存在
            n = (tab = resize()).length;
        }

        i = (n - 1) & hash; // 取雜湊碼的後 n-1 位,以得到桶的索引
        p = tab[i]; // 找到桶
        if (p == null) { 
            // 如果指定的桶不存在就建立一個新的,直接new 出一個 Node 來完成
            tab[i] = newNode(hash, key, value, null);
        } else { 
            // 指定的桶已經存在
            Node<K,V> e; K k;

            if (p.hash == hash // 雜湊碼相同
                && ((k = p.key) == key || (key != null && key.equals(k))) // 鍵的值相同
            ) {
                // 第一個結點與我們要插入的鍵值對的鍵相等
                e = p;
            } else if (p instanceof TreeNode) {
                // 桶的資料結構是紅黑樹,呼叫紅黑樹的方法繼續插入
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            } else {
                // 桶的資料結構是連結串列,使用連結串列的處理方式繼續插入
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        // 已經遍歷到了連結串列的結尾,還沒有找到,需要新建一個結點
                        p.next = newNode(hash, key, value, null);
                        // 插入新結點之後,如果某個連結串列的長度 >= 8,則要把連結串列轉成紅黑樹
                        if (binCount >= TREEIFY_THRESHOLD - 1) {
                            treeifyBin(tab, hash);
                        }
                        break;
                    }
                    if (e.hash == hash // 雜湊碼相同 
                        && ((k = e.key) == key || (key != null && key.equals(k))) // 鍵的值相同
                    ) {
                        // 說明要插入的鍵值對的鍵是存在的,需要更新之前的結點的資料
                        break;
                    }
                    p = e;
                }
            }

            if (e != null) { 
                // 說明指定的鍵是存在的,需要更新結點的值
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null) {
                    e.value = value;
                }
                return oldValue;
            }
        }

        ++modCount;

        // 如果插入了新的結點之後,雜湊表的容量大於 threshold 就進行擴容
        if (++size > threshold) {
            resize(); // 擴容
        }
        return null;
    }
複製程式碼

上面是 HashMap 的插入的邏輯,可以看出,它也是根據頭結點的型別,分成紅黑樹和連結串列兩種方式來進行處理的。對於連結串列,上面已經給出了具體的插入邏輯。在連結串列的情形中,除了基礎的插入,當連結串列的長度達到了 8 的時候還要將桶的資料結構從連結串列轉型成為紅黑樹。對於紅黑樹型別的資料結構,它呼叫 TreeNode 的 putTreeVal() 方法來完成往紅黑樹中插入結點的邏輯。(程式碼貼出來,慢慢領會吧:))

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v) {
    Class<?> kc = null;
    boolean searched = false;
    // 查詢根節點, 索引位置的頭節點並不一定為紅黑樹的根結點
    TreeNode<K,V> root = (parent != null) ? root() : this;  
    for (TreeNode<K,V> p = root;;) {    // 將根節點賦值給 p, 開始遍歷
        int dir, ph; K pk;
        
        if ((ph = p.hash) > h)  
        // 如果傳入的 hash 值小於 p 節點的 hash 值,則將 dir 賦值為 -1, 代表向 p 的左邊查詢樹
            dir = -1; 
        else if (ph < h)    
        // 如果傳入的 hash 值大於 p 節點的 hash 值,則將 dir 賦值為 1, 代表向 p 的右邊查詢樹
            dir = 1;

        // 如果傳入的 hash 值和 key 值等於 p 節點的 hash 值和 key 值, 則 p 節點即為目標節點, 返回 p 節點
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))  
            return p;
        
        // 如果 k 所屬的類沒有實現 Comparable 介面 或者 k 和 p 節點的 key 相等
        else if ((kc == null &&
                  (kc = comparableClassFor(k)) == null) ||
                 (dir = compareComparables(kc, k, pk)) == 0) { 
            if (!searched) {    
                // 第一次符合條件, 該方法只有第一次才執行
                TreeNode<K,V> q, ch;
                searched = true;
                // 從 p 節點的左節點和右節點分別呼叫 find 方法進行查詢, 如果查詢到目標節點則返回
                if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null) 
                    || ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null))  
                    return q;
            }
            // 使用定義的一套規則來比較 k 和 p 節點的 key 的大小, 用來決定向左還是向右查詢
            dir = tieBreakOrder(k, pk); // dir<0 則代表 k<pk,則向 p 左邊查詢;反之亦然
        }
 
        TreeNode<K,V> xp = p;   // xp 賦值為 x 的父節點,中間變數,用於下面給x的父節點賦值
        // dir<=0 則向 p 左邊查詢,否則向 p 右邊查詢,如果為 null,則代表該位置即為 x 的目標位置
        if ((p = (dir <= 0) ? p.left : p.right) == null) {  
        	// 走進來代表已經找到 x 的位置,只需將 x 放到該位置即可
            Node<K,V> xpn = xp.next;    // xp 的 next 節點      
            // 建立新的節點, 其中 x 的 next 節點為 xpn, 即將 x 節點插入 xp 與 xpn 之間
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);   
            if (dir <= 0)   // 如果時 dir <= 0, 則代表 x 節點為 xp 的左節點
                xp.left = x;
            else        // 如果時 dir> 0, 則代表 x 節點為 xp 的右節點
                xp.right = x;
            xp.next = x;    // 將 xp 的n ext 節點設定為 x
            x.parent = x.prev = xp; // 將 x 的 parent 和 prev 節點設定為xp
            // 如果 xpn 不為空,則將 xpn 的 prev 節點設定為 x 節點,與上文的 x 節點的 next 節點對應
            if (xpn != null)    
                ((TreeNode<K,V>)xpn).prev = x;
            moveRootToFront(tab, balanceInsertion(root, x)); // 進行紅黑樹的插入平衡調整
            return null;
        }
    }
}
複製程式碼
  • 集合 Set 實現 Hash 怎麼防止碰撞

HashSet 內部通過 HashMap 實現,HashMap 解決雜湊衝突使用的是拉鍊法,碰撞的元素會放進連結串列中,連結串列長度超過 8,並且已經使用的桶的數量大於 64 的時候,會將桶的資料結構從連結串列轉換成紅黑樹。HashMap 在求得每個結點在陣列中的索引的時候,會使用物件的雜湊碼的高八位和低八位求異或,來增加雜湊碼的隨機性。

  • HashSet 與 HashMap 怎麼判斷集合元素重複

HashSet 內部使用 HashMap 實現,當我們通過 put() 方法將一個鍵值對新增到雜湊表當中的時候,會根據雜湊值和鍵是否相等兩個條件進行判斷,只有當兩者完全相等的時候才認為元素髮生了重複。

  • HashMap 的實現?與 HashSet 的區別?

HashSet 不允許列表中存在重複的元素,HashSet 內部使用的是 HashMap 實現的。在我們向 HashSet 中新增一個元素的時候,會將該元素作為鍵,一個預設的物件作為值,構成一個鍵值對插入到內部的 HashMap 中。(HashMap 的實現見上文。)

  • TreeMap 具體實現

TreeMap 是基於紅黑樹實現的(後續完善)


1.4 註解相關

  • 對 Java 註解的理解

Java 註解在 Android 中比較常見的使用方式有 3 種:

  1. 第一種方式是基於反射的。因為反射本身的效能問題,所以它通常用來做一些簡單的工作,比如為類、類的欄位和方法等新增額外的資訊,然後通過反射來獲取這些資訊。
  2. 第二種方式是基於 AnnotationProcessor 的,也就是在編譯期間動態生成樣板程式碼,然後通過反射觸發生成的方法。比如 ButterKnife 就使用註解處理,在編譯的時候 find 使用了註解的控制元件,併為其繫結值。然後,當呼叫 bind() 的時候直接反射呼叫生成的方法。Room 也是在編譯期間為使用註解的方法生成資料庫方法的。在開發這種第三方庫的時候還可能使用到 Javapoet 來幫助我們生成 Java 檔案。
  3. 最後一種比較常用的方式是使用註解來取代列舉。因為列舉相比於常量有額外的記憶體開銷,所以開發的時候通常使用常量來取代列舉。但是如果只使用常量我們無法對傳入的常量的範圍進行限制,因此我們可以使用註解來限制取值的範圍。以整型為例,我們會在定義註解的時候使用註解 @IntDef({/*各種列舉值*/}) 來指定整型的取值範圍。然後使用註解修飾我們要方法的引數即可。這樣 IDE 會給出一個提示資訊,提示我們只能使用指定範圍的值。(《Java 註解及其在 Android 中的應用》

關聯:ButterKnife, ARouter


1.5 Object 相關

  • Object 類的 equal() 和 hashcode() 方法重寫?

這兩個方法都具有決定一個物件身份功能,所以兩者的行為必須一致,覆寫這兩個方法需要遵循一定的原則。可以從業務的角度考慮使用物件的唯一特徵,比如 ID 等,或者使用它的全部欄位來進行計算得到一個整數的雜湊值。一般,我不會直接覆寫該方法,除非業務特徵非常明顯。因為一旦修改之後,它的作用範圍將是全域性的。我們還可以通過 IDEA 的 generate 直接生成該方法。

  • Object 都有哪些方法?
  1. wait() & notify(), 用來對執行緒進行控制,以讓當前執行緒等待,直到其他執行緒呼叫了 notify()/notifyAll() 方法。wait() 發生等待的前提是當前執行緒獲取了物件的鎖(監視器)。呼叫該方法之後當前執行緒會釋放獲取到的鎖,然後讓出 CPU,進入等待狀態。notify/notifyAll() 的執行只是喚醒沉睡的執行緒,而不會立即釋放鎖,鎖的釋放要看程式碼塊的具體執行情況。
  2. clone() 與物件克隆相關的方法(深拷貝&淺拷貝?)
  3. finilize()
  4. toString()
  5. equal() & hashCode(),見上

1.6 字串相關

  • StringBuffer 與 StringBuilder 的區別?

前者是執行緒安全的,每個方法上面都使用 synchronized 關鍵字進行了加鎖,後者是非執行緒安全的。一般情況下使用 StringBuilder 即可,因為非多執行緒環境進行加鎖是一種沒有必要的開銷。

  • 對 Java 中 String 的瞭解
  1. String 不是基本資料型別。
  2. String 是不可變的,JVM 使用字串池來儲存所有的字串物件。
  3. 使用 new 建立字串,這種方式建立的字串物件不儲存於字串池。我們可以呼叫intern() 方法將該字串物件儲存在字串池,如果字串池已經有了同樣值的字串,則返回引用。使用雙引號直接建立字串的時候,JVM 先去字串池找有沒有值相等字串,如果有,則返回找到的字串引用;否則建立一個新的字串物件並儲存在字串池。
  • String 為什麼要設計成不可變的?
  1. 執行緒安全:由於 String 是不可變類,所以在多執行緒中使用是安全的,我們不需要做任何其他同步操作。
  2. String 是不可變的,它的值也不能被改變,所以用來儲存資料密碼很安全
  3. 複用/節省堆空間:實際在 Java 的開發當中 String 是使用最為頻繁的類之一,通過 dump 的堆可以看出,它經常佔用很大的堆記憶體。因為 java 字串是不可變的,可以在 java 執行時節省大量 java 空間。不同的字串變數可以引用池中的相同的字串。如果字串是可變得話,任何一個變數的值改變,就會反射到其他變數,那字串池也就沒有任何意義了。
  • 常見編碼方式有哪些? utf-8, unicode, ascii
  • Utf-8 編碼中的中文佔幾個位元組?

UTF-8 編碼把一個 Unicode 字元根據不同的數字大小編碼成 1-6 個位元組,常用的英文字母被編碼成 1 個位元組,漢字通常是 3 個位元組,只有很生僻的字元才會被編碼成 4-6 個位元組。

參考文章,瞭解字串編碼的淵源:字元編碼 ASCII UNICODE UTF-8


1.7 執行緒控制

  • 開啟執行緒的三種方式,run() 和 start() 方法區別
  • 多執行緒:怎麼用、有什麼問題要注意;Android 執行緒有沒有上限,然後提到執行緒池的上限
  • Java 執行緒池、執行緒池的幾個核心引數的意義
  • 執行緒如何關閉,以及如何防止執行緒的記憶體洩漏

如何開啟執行緒,執行緒池引數;注意的問題:執行緒數量,記憶體洩漏

    //  方式 1:Thread 覆寫 run() 方法;
    private class MyThread extends Thread {
        @Override
        public void run() {
            // 業務邏輯
        }
    }

    // 方式 2:Thread + Runnable
    new Thread(new Runnable() {
        public void run() {
            // 業務邏輯
        }
    }).start();

    // 方式 3:ExectorService + Callable
    ExecutorService executor = Executors.newFixedThreadPool(5);
    List<Future<Integer>> results = new ArrayList<Future<Integer>>();
    for (int i=0; i<5; i++) {
        results.add(executor.submit(new CallableTask(i, i)));
    }
複製程式碼

執行緒數量的問題:

Android 中並沒有明確規定可以建立的執行緒的數量,但是每個程式的資源是有限的,執行緒本身會佔有一定的資源,所以受記憶體大小的限制,會有數量的上限。通常,我們在使用執行緒或者執行緒池的時候,不會建立太多的執行緒。執行緒池的大小經驗值應該這樣設定:(其中 N 為 CPU 的核數)

  1. 如果是 CPU 密集型應用,則執行緒池大小設定為 N + 1;(大部分時間在計算)
  2. 如果是 IO 密集型應用,則執行緒池大小設定為 2N + 1;(大部分時間在讀寫,Android)

下面是 Android 中的 AysncTask 中建立執行緒池的程式碼(建立執行緒池的核心引數的說明已經家在了註釋中),

    // CPU 的數量
    private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
    // 核心執行緒的數量:只有提交任務的時候才會建立執行緒,噹噹前執行緒數量小於核心執行緒數量,新新增任務的時候,會建立新執行緒來執行任務
    private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
    // 執行緒池允許建立的最大執行緒數量:當任務佇列滿了,並且當前執行緒數量小於最大執行緒數量,則會建立新執行緒來執行任務
    private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
    // 非核心執行緒的閒置的超市時間:超過這個時間,執行緒將被回收,如果任務多且執行時間短,應設定一個較大的值
    private static final int KEEP_ALIVE_SECONDS = 30;

    // 執行緒工廠:自定義建立執行緒的策略,比如定義一個名字
    private static final ThreadFactory sThreadFactory = new ThreadFactory() {
        private final AtomicInteger mCount = new AtomicInteger(1);

        public Thread newThread(Runnable r) {
            return new Thread(r, "AsyncTask #" + mCount.getAndIncrement());
        }
    };

    // 任務佇列:如果當前執行緒的數量大於核心執行緒數量,就將任務新增到這個佇列中
    private static final BlockingQueue<Runnable> sPoolWorkQueue =
            new LinkedBlockingQueue<Runnable>(128);

    public static final Executor THREAD_POOL_EXECUTOR;

    static {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                /*corePoolSize=*/ CORE_POOL_SIZE,
                /*maximumPoolSize=*/ MAXIMUM_POOL_SIZE, 
                /*keepAliveTime=*/ KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
                /*workQueue=*/ sPoolWorkQueue, 
                /*threadFactory=*/ sThreadFactory
                /*handler*/ defaultHandler); // 飽和策略:AysncTask 沒有這個引數
        threadPoolExecutor.allowCoreThreadTimeOut(true);
        THREAD_POOL_EXECUTOR = threadPoolExecutor;
    }
複製程式碼

飽和策略:任務佇列和執行緒池都滿了的時候執行的邏輯,Java 提供了 4 種實現;
其他:

  1. 當呼叫了執行緒池的 prestartAllcoreThread() 方法的時候,執行緒池會提前啟動並建立所有核心執行緒來等待任務;
  2. 當呼叫了執行緒池的 allowCoreThreadTimeOut() 方法的時候,超時時間到了之後,閒置的核心執行緒也會被移除。

run()start() 方法區別start() 會呼叫 native 的 start() 方法,然後 run() 方法會被回撥,此時 run() 非同步執行;如果直接呼叫 run(),它會使用預設的實現(除非覆寫了),並且會在當前執行緒中執行,此時 Thread 如同一個普通的類。

    private Runnable target;
    public void run() {
        if (target != null)  target.run();
    }
複製程式碼

執行緒關閉,有兩種方式可以選擇,一種是使用中斷標誌位進行判斷。當需要停止執行緒的時候,呼叫執行緒的 interupt() 方法即可。這種情況下需要注意的地方是,當執行緒處於阻塞狀態的時候呼叫了中斷方法,此時會丟擲一個異常,並將中斷標誌位復位。此時,我們是無法退出執行緒的。所以,我們需要同時考慮一般情況和執行緒處於阻塞時中斷兩種情況。

另一個方案是使用一個 volatile 型別的布林變數,使用該變數來判斷是否應該結束執行緒。

    // 方式 1:使用中斷標誌位
    @Override
    public void run() {
        try {
            while (!isInterrupted()) {
                // do something
            }
        } catch (InterruptedException ie) {  
            // 執行緒因為阻塞時被中斷而結束了迴圈
        }
    }

    private static class MyRunnable2 implements Runnable {
        // 注意使用 volatile 修飾
        private volatile boolean canceled = false;

        @Override
        public void run() {
            while (!canceled) {
                // do something
            }
        }

        public void cancel() {
            canceled = true;
        }
    }
複製程式碼

防止執行緒記憶體洩漏

  1. 在 Activity 等中使用執行緒的時候,將執行緒定義成靜態的內部類,非靜態內部類會持有外部類的匿名引用;
  2. 當需要線上程中呼叫 Activity 的方法的時候,使用 WeakReference 引用 Activity;
  3. 或者當 Activity 需要結束的時候,在 onDestroy() 方法中終止執行緒。
  • wait()、notify() 與 sleep()

wait()/notify():

  1. wait()、notify() 和 notifyAll() 方法是 Object 的本地 final 方法,無法被重寫。
  2. wait() 使當前執行緒阻塞,直到接到通知或被中斷為止。前提是必須先獲得鎖,一般配合 synchronized 關鍵字使用,在 synchronized 同步程式碼塊裡使用 wait()、notify() 和 notifyAll() 方法。如果呼叫 wait() 或者 notify() 方法時,執行緒並未獲取到鎖的話,則會丟擲 IllegalMonitorStateException 異常。再次獲取到鎖,當前執行緒才能從 wait() 方法處成功返回。
  3. 由於 wait()、notify() 和 notifyAll() 在 synchronized 程式碼塊執行,說明當前執行緒一定是獲取了鎖的。當執行緒執行 wait() 方法時候,會釋放當前的鎖,然後讓出 CPU,進入等待狀態。只有當 notify()/notifyAll() 被執行時候,才會喚醒一個或多個正處於等待狀態的執行緒,然後繼續往下執行,直到執行完 synchronized 程式碼塊或是中途遇到 wait()再次釋放鎖
    也就是說,notify()/notifyAll() 的執行只是喚醒沉睡的執行緒,而不會立即釋放鎖,鎖的釋放要看程式碼塊的具體執行情況。所以在程式設計中,儘量在使用了 notify()/notifyAll() 後立即退出臨界區,以喚醒其他執行緒。
  4. wait() 需要被 try catch 包圍,中斷也可以使 wait 等待的執行緒喚醒。
  5. notify()wait() 的順序不能錯,如果 A 執行緒先執行 notify() 方法,B 執行緒再執行 wait() 方法,那麼 B 執行緒是無法被喚醒的。
  6. notify()notifyAll() 的區別:
    notify() 方法只喚醒一個等待(物件的)執行緒並使該執行緒開始執行。所以如果有多個執行緒等待一個物件,這個方法只會喚醒其中一個執行緒,選擇哪個執行緒取決於作業系統對多執行緒管理的實現。
    notifyAll() 會喚醒所有等待 (物件的) 執行緒,儘管哪一個執行緒將會第一個處理取決於作業系統的實現。如果當前情況下有多個執行緒需要被喚醒,推薦使用 notifyAll() 方法。比如在生產者-消費者裡面的使用,每次都需要喚醒所有的消費者或是生產者,以判斷程式是否可以繼續往下執行。

對於 sleep()wait() 方法之間的區別,總結如下,

  1. 所屬類不同:sleep() 方法是 Thread 的靜態方法,而 wait() 是 Object 例項方法。
  2. 作用域不同:wait() 方法必須要在同步方法或者同步塊中呼叫,也就是必須已經獲得物件鎖。而 sleep() 方法沒有這個限制可以在任何地方種使用。
  3. 鎖佔用不同:wait() 方法會釋放佔有的物件鎖,使得該執行緒進入等待池中,等待下一次獲取資源。而 sleep() 方法只是會讓出 CPU 並不會釋放掉物件鎖;
  4. 鎖釋放不同:sleep() 方法在休眠時間達到後如果再次獲得 CPU 時間片就會繼續執行,而 wait() 方法必須等待 Object.notift()/Object.notifyAll() 通知後,才會離開等待池,並且再次獲得 CPU 時間片才會繼續執行。
  • 執行緒的狀態

Android 高階面試-3:Java、同步和併發相關

  1. 新建 (NEW):新建立了一個執行緒物件。
  2. 可執行 (RUNNABLE):執行緒物件建立後,其他執行緒(比如 main 執行緒)呼叫了該物件的 start() 方法。該狀態的執行緒位於可執行執行緒池中,等待被執行緒排程選中,獲取 CPU 的使用權 。
  3. 執行 (RUNNING):RUNNABLE 狀態的執行緒獲得了 CPU 時間片(timeslice) ,執行程式程式碼。
  4. 阻塞 (BLOCKED):阻塞狀態是指執行緒因為某種原因放棄了 CPU 使用權,也即讓出了 CPU timeslice,暫時停止執行。直到執行緒進入 RUNNABLE 狀態,才有機會再次獲得 CPU timeslice 轉到 RUNNING 狀態。阻塞的情況分三種:
    1. 等待阻塞:RUNNING 的執行緒執行 o.wait() 方法,JVM 會把該執行緒放入等待佇列 (waitting queue) 中。
    2. 同步阻塞:RUNNING 的執行緒在獲取物件的同步鎖時,若該同步鎖被別的執行緒佔用,則 JVM 會把該執行緒放入鎖池 (lock pool) 中。
    3. 其他阻塞:RUNNING 的執行緒執行 Thread.sleep(long)t.join() 方法,或者發出了 I/O 請求時,JVM 會把該執行緒置為阻塞狀態。當 sleep() 狀態超時、join() 等待執行緒終止或者超時、或者 I/O 處理完畢時,執行緒重新轉入 RUNNABLE 狀態。
  5. 死亡 (DEAD):執行緒 run()main() 方法執行結束,或者因異常退出了 run() 方法,則該執行緒結束生命週期。死亡的執行緒不可再次復生。
  • 死鎖,執行緒死鎖的 4 個條件?
  • 死鎖的概念,怎麼避免死鎖?

當兩個執行緒彼此佔有對方需要的資源,同時彼此又無法釋放自己佔有的資源的時候就發生了死鎖。發生死鎖需要滿足下面四個條件,

  1. 互斥:某種資源一次只允許一個程式訪問,即該資源一旦分配給某個程式,其他程式就不能再訪問,直到該程式訪問結束。(一個筷子只能被一個人拿)
  2. 佔有且等待:一個程式本身佔有資源(一種或多種),同時還有資源未得到滿足,正在等待其他程式釋放該資源。(每個人拿了一個筷子還要等其他人放棄筷子)
  3. 不可搶佔:別人已經佔有了某項資源,你不能因為自己也需要該資源,就去把別人的資源搶過來。(別人手裡的筷子你不能去搶)
  4. 迴圈等待:存在一個程式鏈,使得每個程式都佔有下一個程式所需的至少一種資源。(每個人都在等相鄰的下一個人放棄自己的筷子)

產生死鎖需要四個條件,那麼,只要這四個條件中至少有一個條件得不到滿足,就不可能發生死鎖了。由於互斥條件是非共享資源所必須的,不僅不能改變,還應加以保證,所以,主要是破壞產生死鎖的其他三個條件。

破壞佔有且等待的問題:允許程式只獲得執行初期需要的資源,便開始執行,在執行過程中逐步釋放掉分配到的已經使用完畢的資源,然後再去請求新的資源。

破壞不可搶佔條件:當一個已經持有了一些資源的程式在提出新的資源請求沒有得到滿足時,它必須釋放已經保持的所有資源,待以後需要使用的時候再重新申請。釋放已經保持的資源很有可能會導致程式之前的工作實效等,反覆的申請和釋放資源會導致程式的執行被無限的推遲,這不僅會延長程式的週轉週期,還會影響系統的吞吐量。

破壞迴圈等待條件:可以通過定義資源型別的線性順序來預防,可將每個資源編號,當一個程式佔有編號為i的資源時,那麼它下一次申請資源只能申請編號大於 i 的資源。

《死鎖的四個必要條件和解決辦法》

  • synchronized 與 Lock 的區別
  • Lock 的實現原理
  • synchronized 的實現原理
  • ReentrantLock 的內部實現
  • CAS 介紹
  • 如何實現執行緒同步?synchronized, lock, 無鎖同步, voliate, 併發集合,同步集合

sychronized 原理(表面的):

Java 虛擬機器中的同步 (Synchronization) 基於進入和退出管程 (Monitor) 物件實現,無論是顯式同步 (有明確的 monitorenter 和 monitorexit 指令,即同步程式碼塊),還是隱式同步都是如此。進入 monitorenter 時 monitor 中的計數器 count 加 1,釋放當前持有的 monitor,count 自減 1. 反編譯程式碼之後經常看到兩個 monitorexit 指令對應一個 monitorenter,這是用來防止程式執行過程中出現異常的。虛擬機器需要保證即使程式允許中途出了異常,鎖也一樣可以被釋放(執行第二個 monitorexit)。

對同步方法,JVM 可以從方法常量池中的方法表結構(method_info Structure) 中的 ACC_SYNCHRONIZED 訪問標誌區分一個方法是否同步方法。當呼叫方法時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒將先持有 monitor,然後再執行方法,最後再方法完成 (無論是正常完成還是非正常完成) 時釋放monitor. 在方法執行期間,其他任何執行緒都無法再獲得同一個 monitor. 如果一個同步方法執行期間丟擲了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的 monitor 將在異常拋到同步方法之外時自動釋放。

sychronized 原理(底層的):

在 Java 物件的物件頭中,有一塊區域叫做 MarkWord,其中儲存了重量級鎖 sychronized 的標誌位,其指標指向的是 monitor 物件。每個物件都存在著一個 monitor 與之關聯。在 monitor 的資料結構中定義了兩個佇列,_WaitSet 和 _EntryList. 當多個執行緒同時訪問一段同步程式碼時,首先會進入 _EntryList 集合,當執行緒獲取到物件的monitor 後進入 _Owner 區域並把 monitor 中的 owner 變數設定為當前執行緒同時 monitor 中的計數器 count 加 1,若執行緒呼叫 wait() 方法,將釋放當前持有的 monitor,owner 變數恢復為 null,count 自減 1,同時該執行緒進入 _WaitSet 集合中等待被喚醒。若當前執行緒執行完畢也將釋放 monitor (鎖)並復位變數的值,以便其他執行緒進入獲取 monitor (鎖)。

由此看來,monitor 物件存在於每個 Java 物件的物件頭中(儲存的指標的指向),synchronized 鎖便是通過這種方式獲取鎖的,也是為什麼 Java 中任意物件可以作為鎖的原因,同時也是 notify()/notifyAll()/wait() 等方法存在於頂級物件 Object 中的原因。

當然,從 MarkWord 的結構中也可以看出 Java 對 sychronized 的優化:Java 6 之後,為了減少獲得鎖和釋放鎖所帶來的效能消耗,引入了輕量級鎖和偏向鎖,鎖效率也得到了優化。

(關於 sychronized 的底層實現原理可以參考筆者的文章:併發程式設計專題 3:synchronized)

sychronized 與 lock 的區別體現在下面四個方面:

  1. 等待可中斷:當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待;(兩種方式獲取鎖的時候都會使計數+1,但是方式不同,所以重入鎖可以終端)
  2. 公平鎖:當多個執行緒等待同一個鎖時,公平鎖會按照申請鎖的時間順序來依次獲得鎖;而非公平鎖,當鎖被釋放時任何在等待的執行緒都可以獲得鎖(不論時間嘗試獲取的時間先後)。sychronized 只支援非公平鎖,Lock 可以通過構造方法指定使用公平鎖還是非公平鎖。
  3. 鎖可以繫結多個條件:ReentrantLock 可以繫結多個 Condition 物件,而 sychronized 要與多個條件關聯就不得不加一個鎖,ReentrantLock 只要多次呼叫newCondition 即可。

ReentrantLock 的實現原理

ReentrantLock 的實現是基於 AQS(同步器),同步器設計的思想是 CAS. 同步器中維護了一個連結串列,藉助 CAS 的思想向連結串列中增刪資料。其底層使用的是 sun.misc.Unsafe 類中的方法來完成 CAS 操作的。在 ReentrantLock 中實現兩個 AQS 的子類,分別是 NonfairSyncFairSync. 也就是用來實現公平鎖和非公平鎖的關鍵。當我們使用構造方法獲取 ReentrantLock 例項的時候,可以通過一個布林型別的引數指定使用公平鎖還是非公平鎖。在實現上, NonfairSyncFairSync 的區別僅僅是,在當前執行緒獲取到鎖之前,是否會從上述佇列中判斷是否存在比自己更早申請鎖的執行緒。對於公平鎖,當存在這麼一個執行緒的話,那麼當前執行緒獲取鎖失敗。噹噹前執行緒獲取到鎖的時候,也會使用一個 CAS 操作將鎖獲取次數 +1. 當執行緒再次獲取鎖的時候,會根據執行緒來進行判斷,如果當前持有鎖的執行緒是申請鎖的執行緒,那麼允許它再次獲取鎖,以此來實現鎖的可重入。

所謂 CAS 就是 Compare-And-Swape,類似於樂觀加鎖。但與我們熟知的樂觀鎖不同的是,它在判斷的時候會涉及到 3 個值:“新值”、“舊值” 和 “記憶體中的值”,在實現的時候會使用一個無限迴圈,每次拿 “舊值” 與 “記憶體中的值” 進行比較,如果兩個值一樣就說明 “記憶體中的值” 沒有被其他執行緒修改過;否則就被修改過,需要重新讀取記憶體中的值為 “舊值”,再拿 “舊值” 與 “記憶體中的值” 進行判斷。直到 “舊值” 與 “記憶體中的值” 一樣,就把 “新值” 更新到記憶體當中。

這裡要注意上面的 CAS 操作是分 3 個步驟的,但是這 3 個步驟必須一次性完成,因為不然的話,當判斷 “記憶體中的值” 與 “舊值” 相等之後,向記憶體寫入 “新值” 之間被其他執行緒修改就可能會得到錯誤的結果。JDK 中的 sun.misc.Unsafe 中的 compareAndSwapInt 等一系列方法 Native 就是用來完成這種操作的。另外還要注意,上面的 CAS 操作存在一些問題:

  1. 一個典型的 ABA 的問題,也就是說當記憶體中的值被一個執行緒修改了,又改了回去,此時當前執行緒看到的值與期望的一樣,但實際上已經被其他執行緒修改過了。想要解決 ABA 的問題,則可以使用傳統的互斥同步策略。
  2. CAS 還有一個問題就是可能會自旋時間過長。因為 CAS 是非阻塞同步的,雖然不會將執行緒掛起,但會自旋(無非就是一個死迴圈)進行下一次嘗試,如果這裡自旋時間過長對效能是很大的消耗。 根據上面的描述也可以看出,CAS 只能保證一個共享變數的原子性,當存在多個變數的時候就無法保證。一種解決的方案是將多個共享變數打包成一個,也就是將它們整體定義成一個物件,並用 CAS 保證這個整體的原子性,比如 AtomicReference
  • volatile 原理和用法

voliate 關鍵字的兩個作用

  1. 保證變數的可見性:當一個被 voliate 關鍵字修飾的變數被一個執行緒修改的時候,其他執行緒可以立刻得到修改之後的結果。當寫一個 volatile 變數時,JMM 會把該執行緒對應的工作記憶體中的共享變數值重新整理到主記憶體中,當讀取一個 volatile 變數時,JMM 會把該執行緒對應的工作記憶體置為無效,那麼該執行緒將只能從主記憶體中重新讀取共享變數。
  2. 遮蔽指令重排序:指令重排序是編譯器和處理器為了高效對程式進行優化的手段,它只能保證程式執行的結果時正確的,但是無法保證程式的操作順序與程式碼順序一致。這在單執行緒中不會構成問題,但是在多執行緒中就會出現問題。非常經典的例子是在單例方法中同時對欄位加入 voliate,就是為了防止指令重排序。

volatile 是通過記憶體屏障(Memory Barrier) 來實現其在 JMM 中的語義的。記憶體屏障,又稱記憶體柵欄,是一個 CPU 指令,它的作用有兩個,一是保證特定操作的執行順序,二是保證某些變數的記憶體可見性。如果在指令間插入一條記憶體屏障則會告訴編譯器和 CPU,不管什麼指令都不能和這條 Memory Barrier 指令重排序。Memory Barrier 的另外一個作用是強制刷出各種 CPU 的快取資料,因此任何 CPU 上的執行緒都能讀取到這些資料的最新版本。

  • 手寫生產者/消費者模式

參考 《併發程式設計專題-5:生產者和消費者模式》 中的三種寫法。


1.8 併發包

  • ThreadLocal 的實現原理?

ThreadLocal 通過將每個執行緒自己的區域性變數存在自己的內部來實現執行緒安全。使用它的時候會定義它的靜態變數,每個執行緒看似是從 TL 中獲取資料,而實際上 TL 只起到了鍵值對的鍵的作用,實際的資料會以雜湊表的形式儲存在 Thread 例項的 Map 型別區域性變數中。當呼叫 TL 的 get() 方法的時候會使用 Thread.currentThread() 獲取當前 Thread 例項,然後從該例項的 Map 區域性變數中,使用 TL 作為鍵來獲取儲存的值。Thread 內部的 Map 使用線性陣列解決雜湊衝突。(《ThreadLocal的使用及其原始碼實現》)

  • 併發類:併發集合瞭解哪些?
  1. ConcurrentHashMap:執行緒安全的 HashMap,對桶進行加鎖,降低鎖粒度提升效能。
  2. ConcurrentSkipListMap:跳錶,自行了解,給跪了……
  3. ConCurrentSkipListSet:藉助 ConcurrentSkipListMap 實現
  4. CopyOnWriteArrayList:讀多寫少的 ArrayList,寫的時候加鎖
  5. CopyOnWriteArraySet:藉助 CopyOnWriteArrayList 實現的……
  6. ConcurrentLinkedQueue:無界且執行緒安全的 Queue,其 poll()add() 等方法藉助 CAS 思想實現。鎖比較輕量。

1.9 輸入輸出

  • NIO

  • 多執行緒斷點續傳原理

斷點續傳和斷點下載都是用的用的都是 RandomAccessFile,它可以從指定的位置開始讀取資料。斷點續傳是由伺服器給客戶端一個已經上傳的位置標記position,然後客戶端再將檔案指標移動到相應的 position,通過輸入流將檔案剩餘部分讀出來傳輸給伺服器。

如果要使用多執行緒來實現斷點續傳,那麼可以給每個執行緒分配固定的位元組的檔案,分別去讀,然後分別上傳到伺服器。

2、Kotlin 相關

  • 對 Kotlin 協程的瞭解

協程實際上就是極大程度的複用執行緒,通過讓執行緒滿載執行,達到最大程度的利用 CPU,進而提升應用效能。相比於執行緒,協程不需要進行執行緒切換,和多執行緒比,執行緒數量越多,協程的效能優勢就越明顯。第二大優勢就是不需要多執行緒的鎖機制,因為只有一個執行緒,也不存在同時寫變數衝突,在協程中控制共享資源不加鎖,只需要判斷狀態就好了,所以執行效率比多執行緒高很多。

協程和執行緒,都能用來實現非同步呼叫,但是這兩者之間是有本質區別的:

  1. 協程是編譯器級別的,執行緒是系統級別的。協程的切換是由程式來控制的,執行緒的切換是由作業系統來控制的。
  2. 協程是協作式的,執行緒是搶佔式的。協程是由程式來控制什麼時候進行切換的,而執行緒是有作業系統來決定執行緒之間的切換的。
  3. 一個執行緒可以包含多個協程。Java 中,多執行緒可以充分利用多核 cpu,協程是在一個執行緒中執行。4. 協程適合 IO 密集型 的程式,多執行緒適合 計算密集型 的程式(適用於多核 CPU 的情況)。當你的程式大部分是檔案讀寫操作或者網路請求操作的時候,這時你應該首選協程而不是多執行緒,首先這些操作大部分不是利用 CPU 進行計算而是等待資料的讀寫,其次因為協程執行效率較高,子程式切換不是執行緒切換,是由程式自身控制,因此,沒有執行緒切換的開銷,和多執行緒比,執行緒數量越多,協程的效能優勢就越明顯。
  4. 使用協程可以順序呼叫非同步程式碼,避免回撥地獄

參考:是繼續Rxjava,還是應該試試Kotlin的協程 - Android架構的文章 - 知乎

  • Kotlin 跟 Java 比,kotlin 具有哪些優勢?

Kotlin 是一門基於 JVM 的語言,它提供了非常多便利的語法特性。如果說 Kotlin 為什麼那麼優秀的話,那隻能說是因為它站在了 Java 的肩膀上。學習了一段時間之後,你會發現它的許多語法的設計非常符合我們實際開發中的使用習慣。

比如,對於一個類,通常我們不會去覆寫它。尤其是 Java Web 方向,很多的類用來作為 Java Bean,它們沒有特別多的繼承關係。而 Kotlin 中的類預設就是不允許繼承的,想允許自己的類被繼承,你還必須顯式地使用 open 關鍵字指定。

對於 Java Bean,作為一個業務物件,它會有許多的欄位。按照 Java 中的處理方式,我們要為它們宣告一系列的 setter 和 getter 方法。然後,獲取屬性的時候必須使用 setter 和 getter 方法。導致我們的程式碼中出現非常多的括號。而使用 Kotlin 則可以直接對屬性進行賦值,顯得優雅地多。

再比如 Java 中使用 switch 的時候,我們通常會在每個 case 後面加上 break,而 kotlin 預設幫助我們 break,這樣就節省了很多的程式碼量。

另外 Kotlin 非常優秀的地方在於對 NPE 的控制。在 Android 開發中,我們可以使用 @NoneNull 和 @Nullable 註解來標明某個欄位是否可能為空。在 Java 中預設欄位是空的,並且沒有任何提示。你一個不留神可能就導致了 NPE,但 Kotlin 中就預設變數是非空的,你想讓它為空必須單獨宣告。這樣,對於可能為空的變數就給了我們提示的作用,我們知道它可能為空,就會去特意對其進行處理。對於可能為空的類,Kotlin 定義瞭如下的規則,使得我們處理起來 NPE 也變得非常簡單:

  1. 使用 ? 在型別的後面則說明這個變數是可空的;
  2. 安全呼叫運算子 ?.,以 a?.method() 為例,當 a 不為 null 則整個表示式的結果是 a.method() 否則是 null;
  3. Elvis 運算子 ?:,以 a ?: "A" 為例,當 a 不為 null 則整個表示式的結果是 a,否則是 “A”;
  4. 安全轉換運算子 as?,以 foo as? Type 為例,當 foo 是 Type 型別則將 foo 轉換成 Type 型別的例項,否則返回 null;
  5. 非空斷言 !!,用在某個變數後面表示斷言其非空,如 a!!
  6. let 表示對呼叫 let 的例項進行某種運算,如 val b = "AA".let { it + "A" } 返回 “AAA”;

諸如此類,很多時候,我覺得 Java 設計的一些規則對人們產生了誤導,實際開發中並不符合我們的使用習慣。而 Kotlin 則是根據多年來人們使用 Java 的經驗,簡化了許多的呼叫,更加符合我們使用習慣。所以說,Kotlin 之所以強大是因為站在 Java 的肩膀上。

3、設計模式

  • 談談你對 Android 設計模式的理解
  • 專案中常用的設計模式有哪些?
  1. 工廠+策略:用來建立各種例項,比如,美國一個實現,中國一個實現的情形;
  2. 觀察者:一個頁面對事件進行監聽,註冊,取消註冊,通知;
  3. 單例:太多,為了延遲初始化;
  4. 構建者:類的引數太多,為了方便呼叫;
  5. 介面卡:RecyclerView 的介面卡;
  6. 模板:設計一個頂層的模板類,比如抽象的 Fragment 或者 Activity 等,但是注意組合優於繼承,不要過度設計;
  7. 外觀:相機模組,Camera1 和 Camera2,封裝其內部實現,統一使用 CameraManager 的形式對外提供方法。
  • 手寫觀察者模式?

觀察者設計模式類似於我們經常使用的介面回撥,下面的程式碼中在觀察者的構造方法中訂閱了主題,其實這個倒不怎麼重要,什麼時候訂閱都可以。核心的地方就是主題中維護的這個佇列,需要通知的時候調一下通知的方法即可。另外,如果在多執行緒環境中還要考慮如何進行執行緒安全控制,比如使用執行緒安全的集合等等。下面只是一個非常基礎的示例程式,瞭解設計思想,用的時候可以靈活一些,不必循規蹈矩。

    public class ConcreteSubject implements Subject {
        private List<Observer> observers = new LinkedList<>(); // 維護觀察者列表

        @Override
        public void registerObserver(Observer o) { // 註冊一個觀察者
            observers.add(o);
        }

        @Override
        public void removeObserver(Observer o) { // 移除一個觀察者
            int i = observers.indexOf(o);
            if (i >= 0) {
                observers.remove(o);
            } 
        }

        @Override
        public void notifyObservers() { // 通知所有觀察者主題的更新
            for (Observer o : observers) {
                o.method();
            }
        }
    }

    public class ConcreteObserver implements Observer {
        private Subject subject; // 該觀察者訂閱的主題

        public ConcreteObserver(Subject subject) {
            this.subject = subject;
            subject.registerObserver(this); // 將當前觀察者新增到主題訂閱列表中
        }
        
        // 當主題發生變化的時候,主題會遍歷觀察者列表並通過呼叫該方法來通知觀察者
        @Override
        public void method() {
            // ...  
        }
    }
複製程式碼

(瞭解更多關於觀察者設計模式的內容,請參考文章:設計模式解析:觀察者模式

  • 手寫單例模式,懶漢和飽漢
    // 飽漢:就是在呼叫單例方法的時候,例項已經初始化過了
    public class Singleton {
        private static Singleton singleton = new Singleton();

        private Singleton() {}

        public static Singleton getInstance() {
            return singleton;
        }
    }

    // 懶漢:在呼叫方法的時候才進行初始化
    public class Singleton {
        private volatile static Singleton singleton;

        private Singleton() {}

        public static Singleton getInstance() {
            if (singleton == null) {
                sychronized(Singleton.class) {
                    if (singleton == null) {
                        singleton = new Singleton();
                    }
                }
            }
            return singleton;
        }
    } 
複製程式碼

另外,單例需要注意的問題是:1.如果使用者使用反射進行初始化怎麼辦?可以在建立第二個例項的時候丟擲異常;2.如果使用者使用 Java 的序列化機制反覆建立單例呢?將所有的例項域設定成 transient 的,然後覆寫 readResolve() 方法並返回單例。

另外,單例項太多的時候可以想辦法使用一個 Map 將它們儲存起來,然後通過一種規則從雜湊表中取出,這樣就沒必要宣告一大堆的單例變數了。

(瞭解更多關於單例設計模式的內容,請參考文章:設計模式-4:單例模式

  • 介面卡模式、裝飾者模式、外觀模式、代理模式的異同?(這個幾個設計模式比較容易混)

四個設計模式相同的地方是,它們都需要你傳入一個類,然後內部使用你傳入的這個類來完成業務邏輯。

我們以字母 A,B,C 來表示 3 種不同的類(某種東西)。

外觀模式要隱藏內部的差異,提供一個一致的對外的介面 X,那麼讓定義 3 個類 AX, BX, CX 並且都實現 X 介面,其中分別引用 A, B, C 按照各自的方式實現 X 介面的方法即可。以相機開發為例,Camera1 和 Camera2 各有自己的實現方式,定義一個統一的介面和兩個實現類。

假如現在有一個類 X,其中引用到了介面 A 的實現 AX. AX 的邏輯存在點問題,我們想把它完善一下。我們提供了 3 種方案,分別是 A1, A2 和 A3. 那麼此時,我們讓 A1, A2 和 A3 都實現 A 介面,然後其中引用 AX 完成業務,在實現的 A 介面的方法中分別使用各自的方案進行優化即可。這種方式,我們對 AX 進行了修飾,使其 A1, A2 和 A3 可以直接應用到 X 中。

對於介面卡模式,假如現在有一個類 X,其中引用到了介面 A. 現在我們不得不使用 B 來完成 A 的邏輯。因為 A 和 B 屬於兩個不同的類,所以此時我們需要一個介面卡模式,讓 A 的實現 AX 引用 B 的實現 BX 完成 A 介面的各個方法。

外觀模式的目的是隱藏各類間的差異性,提供一致的對外介面。裝飾者模式對外的介面是一致的,但是內部引用的例項是同一個,其目的是對該例項進行擴充,使其具有多種功能。所以,前者是多對一,後者是一對多的關係。而介面卡模式適用的是兩個不同的類,它使用一種類來實現另一個類的功能,是一對一的。相比之下,代理模式也是用一類來完成某種功能,並且一對一,但它是在同類之間,目的是為了增強類的功能,而介面卡是在不同的類之間。裝飾者和代理都用來增強類的功能,但是裝飾者裝飾之後仍然是同類,可以無縫替換之前的類的功能。而代理類被修飾之後已經是代理類了,是另一個類,無法替換原始類的位置。


Android 高階面試系列文章,關注作者及時獲取更多面試資料

本系列以及其他系列的文章均維護在 Github 上面:Github / Android-notes,歡迎 Star & Fork. 如果你喜歡這篇文章,願意支援作者的工作,請為這篇文章點個贊?!

相關文章