JDK原始碼中的一些“小技巧”

vvsuperman發表於2018-03-24

均摘選自JDK原始碼

1 i++ vs i--

String原始碼的第985行,equals方法中

while (n--!= 0) { if (v1[i] != v2[i]) return false; i++;
} 這段程式碼是用於判斷字串是否相等,但有個奇怪地方是用了i--!=0來做判斷,我們通常不是用i++麼?為什麼用i--呢?而且迴圈次數相同。原因在於編譯後會多一條指令:

i-- 操作本身會影響CPSR(當前程式狀態暫存器),CPSR常見的標誌有N(結果為負), Z(結果為0),C(有進位),O(有溢位)。i > 0,可以直接通過Z標誌判斷出來。 i++操作也會影響CPSR(當前程式狀態暫存器),但隻影響O(有溢位)標誌,這對於i < n的判斷沒有任何幫助。所以還需要一條額外的比較指令,也就是說每個迴圈要多執行一條指令。

簡單來說,跟0比較會少一條指令。所以,迴圈使用i--,高階大氣上檔次。

2 成員變數 vs 區域性變數

JDK原始碼在任何方法中幾乎都會用一個區域性變數來接受成員變數,比如

public int compareTo(String anotherString) {
    int len1 = value.length;
    int len2 = anotherString.value.length;
複製程式碼

因為區域性變數初始化後是在該方法執行緒棧中,而成員變數初始化是在堆記憶體中,顯然前者更快,所以,我們在方法中儘量避免直接使用成員變數,而是使用區域性變數。

3 刻意載入到暫存器 && 將耗時操作放到鎖外部

在ConcurrentHashMap中,鎖segment的操作很有意思,它不是直接鎖,而是類似於自旋鎖,反覆嘗試獲取鎖,並且在獲取鎖的過程中,會遍歷連結串列,從而將資料先載入到暫存器中快取中,避免在鎖的過程中在便利,同時,生成新物件的操作也是放到鎖的外部來做,避免在鎖中的耗時操作

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    /** 在往該 segment 寫入前,需要先獲取該 segment 的獨佔鎖
       不是強制lock(),而是進行嘗試 */
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
複製程式碼

scanAndLockForPut()原始碼

private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    HashEntry<K,V> first = entryForHash(this, hash);
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    int retries = -1; // negative while locating node

    // 迴圈獲取鎖
     while (!tryLock()) {
        HashEntry<K,V> f; // to recheck first below
        if (retries < 0) {
            if (e == null) {
                if (node == null) // speculatively create node
                    //該hash位無值,新建物件,而不用再到put()方法的鎖中再新建
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            //該hash位置key也相同,退化成自旋鎖
            else if (key.equals(e.key))
                retries = 0;
            else
                // 迴圈連結串列,cpu能自動將連結串列讀入快取
                e = e.next;
        }
        // retries>0時就變成自旋鎖。當然,如果重試次數如果超過 MAX_SCAN_RETRIES(單核1多核64),那麼不搶了,進入到阻塞佇列等待鎖
        //    lock() 是阻塞方法,直到獲取鎖後返回,否則掛起
        else if (++retries > MAX_SCAN_RETRIES) {
            lock();
            break;
        }
        else if ((retries & 1) == 0 &&
                 // 這個時候是有大問題了,那就是有新的元素進到了連結串列,成為了新的表頭
                 //     所以這邊的策略是,相當於重新走一遍這個 scanAndLockForPut 方法
                 (f = entryForHash(this, hash)) != first) {
            e = first = f; // re-traverse if entry changed
            retries = -1;
        }
}
return node;
}
複製程式碼

4 判斷物件相等可先用==

在判斷物件是否相等時,可先用==,因為==直接比較地址,非常快,而equals的話會最物件值的比較,相對較慢,所以有可能的話,可以用a==b || a.equals(b)來比較物件是否相等

5 關於transient

transient是用來阻止序列化的,但HashMap原始碼中內部陣列是定義為transient的

/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 */
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
複製程式碼

那豈不裡面的鍵值對都無法序列化了麼,網路中用hashmap來傳輸豈不是無法傳輸,其實不然。

Effective Java 2nd, Item75, Joshua大神提到:

For example, consider the case of a hash table. The physical representation is a sequence of hash buckets containing key-value entries. The bucket that an entry resides in is a function of the hash code of its key, which is not, in general, guaranteed to be the same from JVM implementation to JVM implementation. In fact, it isn't even guaranteed to be the same from run to run. Therefore, accepting the default serialized form for a hash table would constitute a serious bug. Serializing and deserializing the hash table could yield an object whose invariants were seriously corrupt.

怎麼理解? 看一下HashMap.get()/put()知道, 讀寫Map是根據Object.hashcode()來確定從哪個bucket讀/寫. 而Object.hashcode()是native方法, 不同的JVM裡可能是不一樣的.

打個比方說, 向HashMap存一個entry, key為 字串"STRING", 在第一個java程式裡, "STRING"的hashcode()為1, 存入第1號bucket; 在第二個java程式裡, "STRING"的hashcode()有可能就是2, 存入第2號bucket. 如果用預設的序列化(Entry[] table不用transient), 那麼這個HashMap從第一個java程式裡通過序列化匯入第二個java程式後, 其記憶體分佈是一樣的, 這就不對了.

舉個例子,比如向HashMap存一個鍵值對entry, key="方老司", 在第一個java程式裡, "方老司"的hashcode()為1, 存入table[1],好,現在傳到另一個在JVM程式裡, "方老司" 的hashcode()有可能就是2, 於是到table[2]去取,結果值不存在。

HashMap現在的readObject和writeObject是把內容 輸出/輸入, 把HashMap重新生成出來.

6 不要用char

char在Java中utf-16編碼,是2個位元組,而2個位元組是無法表示全部字元的。2個位元組表示的稱為 BMP,另外的作為high surrogate和 low surrogate 拼接組成由4位元組表示的字元。比如String原始碼中的indexOf:

 //這裡用int來接受一個char,方便判斷範圍
 public int indexOf(int ch, int fromIndex) {
        final int max = value.length;
        if (fromIndex < 0) {
            fromIndex = 0;
        } else if (fromIndex >= max) {
            // Note: fromIndex might be near -1>>>1.
            return -1;
        }
        //在Bmp範圍
        if (ch < Character.MIN_SUPPLEMENTARY_CODE_POINT) {
            // handle most cases here (ch is a BMP code point or a
            // negative value (invalid code point))
            final char[] value = this.value;
            for (int i = fromIndex; i < max; i++) {
                if (value[i] == ch) {
                    return i;
                }
            }
            return -1;
        } else {
            //否則轉到四個位元組的判斷方式
            return indexOfSupplementary(ch, fromIndex);
        }
    }
複製程式碼

所以Java的char只能表示utf­16中的bmp部分字元。對於CJK(中日韓統一表意文字)部分擴充套件字符集則無法表示。

例如,下圖中除Ext-A部分,char均無法表示。

JDK原始碼中的一些“小技巧”

此外還有一種說法是要用char,密碼別用String,String是常量(即建立之後就無法更改),會儲存到常量池中,如果有其他程式可以dump這個程式的記憶體,那麼密碼就會隨著常量池被dump出去從而洩露,而char[]可以寫入其他的資訊從而改變,即是被dump了也會減少洩露密碼的風險。

但個人認為你都能dump記憶體了難道是一個char能夠防範的住的?除非是String在常量池中未被回收,而被其它執行緒直接從常量池中讀取,但恐怕也是非常罕見的吧。

俺的一個課程:《Java基礎教程: 手寫JDK》大家不妨圍觀下:)

相關文章