天天看點

Android 進階面試-3:語言相關

主要内容: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:語言相關
  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 的子類,分别是

NonfairSync

FairSync

. 也就是用來實作公平鎖和非公平鎖的關鍵。當我們使用構造方法擷取 ReentrantLock 執行個體的時候,可以通過一個布爾類型的參數指定使用公平鎖還是非公平鎖。在實作上,

NonfairSync

FairSync

的差別僅僅是,在目前線程擷取到鎖之前,是否會從上述隊列中判斷是否存在比自己更早申請鎖的線程。對于公平鎖,當存在這麼一個線程的話,那麼目前線程擷取鎖失敗。當目前線程擷取到鎖的時候,也會使用一個 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? Typ

    e 為例,當 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 進階面試系列文章,關注作者及時擷取更多面試資料,

  • Android 進階面試-1:Handler 相關
  • Android 進階面試-2:IPC 相關
  • Android 進階面試-3:語言相關

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