深入理解 hashcode() 和 HashMap 中的hash 算法

前言

Java中的HashMap非常常用也非常重要, 提到HashMap是離不開hashcode()方法的, 整天嘴邊掛着HashMap、Hashtable、TreeMap、LinkedHashMap、IdentityHashMap、ConcurrentHashMap和WeakHashMap等詞, 也許用起來簡單, hash的原理也很簡單, 經常不以爲然, 但是細想下來總覺得有哪裏有疑惑, 這裏細緻分析一下hashcode與hashmap中用到的hash算法

hash是什麼

hash是一個函數,該函數中的實現就是一種算法,就是通過一系列的算法來得到一個hash值,這個時候,我們就需要知道另一個東西,hash表,通過hash算法得到的hash值就在這張hash表中,也就是說,hash表就是所有的hash值組成的,有很多種hash函數,也就代表着有很多種算法得到hash值, 編寫散列函數是老生常談的研究課題,是數學家和理論方面的計算機科學家的研究任務, 我們只需要知道那些比較好用, 大概爲啥好用就可以了

hashcode是啥

hashcode就是通過hash函數得來的,通俗的說,就是通過某一種算法得到的,hashcode就是在hash表中有對應的位置

每個對象都有hashcode,對象的hashcode怎麼得來的呢

首先一個對象肯定有物理地址,網上有人把對象的hashcode說成是對象的地址,事實上這種看法是不全面的,確實有些JVM在實現時是直接返回對象的存儲地址,但是大多時候並不是這樣,只能說可能存儲地址有一定關聯,

那麼對象如何得到hashcode呢?通過對象的內部地址(也就是物理地址)轉換成一個整數,然後該整數通過hash函數的算法就得到了hashcode(不同jvm的實現不同, hotspot的實現貼在了最後),所以,hashcode是什麼呢?就是在hash表中對應的位置。這裏如果還不是很清楚的話,舉個例子,hash表中有 hashcode爲1、hashcode爲2、(...)3、4、5、6、7、8這樣八個位置,有一個對象A,A的物理地址轉換爲一個整數17(這是假如),就通過直接取餘算法,17%8=1,那麼A的hashcode就爲1,且A就在hash表中1的位置。

爲什麼使用 HashCode

HashCode的存在主要是爲了查找的快捷性, HashCode是用來在散列存儲結構中確定對象的存儲地址的 ( 用hashcode來代表對象在hash表中的位置 ) ,  hashCode 存在的重要的原因之一就是在 HashMap(HashSet 其實就是HashMap) 中使用(其實Object 類的 hashCode 方法註釋已經說明了 ),HashMap 之所以速度快,因爲他使用的是散列表,根據 key 的 hashcode 值生成數組下標(通過內存地址直接查找,不需要判斷, 但是需要多出很多內存,相當於以空間換時間

 比如:我們有一個能存放1000個數這樣大的內存中,在其中要存放1000個不一樣的數字,用最笨的方法,就是存一個數字,就遍歷一遍,看有沒有相同得數,當存了900個數字,開始存901個數字的時候,就需要跟900個數字進行對比,這樣就很麻煩,很是消耗時間,用hashcode來記錄對象的位置,來看一下。hash表中有1、2、3、4、5、6、7、8個位置,存第一個數,hashcode爲1,該數就放在hash表中1的位置,存到100個數字,hash表中8個位置會有很多數字了,1中可能有20個數字,存101個數字時,他先查hashcode值對應的位置,假設爲1,那麼就有20個數字和他的hashcode相同,他只需要跟這20個數字相比較(equals),如果每一個相同,那麼就放在1這個位置,這樣比較的次數就少了很多,實際上hash表中有很多位置,這裏只是舉例只有8個,所以比較的次數會讓你覺得也挺多的,實際上,如果hash表很大,那麼比較的次數就很少很少了。  通過對原始方法和使用hashcode方法進行對比,我們就知道了hashcode的作用,並且爲什麼要使用hashcode了

equals方法和hashcode的關係?

關於這點又是一大篇, 直接上結論, 想細緻瞭解的看我轉的一位大佬的博客:

https://blog.csdn.net/q5706503/article/details/84076261

歸納總結:

1.若重寫了equals(Object obj)方法,則有必要重寫hashCode()方法。

2.若兩個對象equals(Object obj)返回true,則hashCode()有必要也返回相同的int數。

3.若兩個對象equals(Object obj)返回false,則hashCode()不一定返回不同的int數。

4.若兩個對象hashCode()返回相同int數,則equals(Object obj)不一定返回true。

5.若兩個對象hashCode()返回不同int數,則equals(Object obj)一定返回false。

6.同一對象在執行期間若已經存儲在集合中,則不能修改影響hashCode值的相關信息,否則會導致內存泄露問題。

String 類型的 hashcode 方法

我們看一個例子:

class Test{

  String name;

  public Test1(String name) {
    this.name = name;
  }

  public static void main(String[] args) {
    Map<Test1, String> map = new HashMap<>(4);
    map.put(new Test1("hello"), "hello");
    String hello = map.get(new Test1("hello"));
    System.out.println(hello);
  }
}

這段代碼打印出來的會是什麼呢?

null

從某個角度說,這兩個對象是一樣的,因爲名稱一樣,name 屬性都是 hello,當我們使用這個 key 時,按照邏輯,應該返回 hello 給我們。但是,由於沒有重寫 hashcode 方法,JDK 默認使用 Objective 類的 hashcode 方法,返回的是一個虛擬內存地址,而每個對象的虛擬地址都是不同的,所以,這個肯定不會返回 hello 。

如果我們重寫 hashcode 和 equals 方法:

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Test1 test1 = (Test1) o;
        return Objects.equals(name, test1.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }

再次運行:得到的結果就不是 null 了,而是 hello。

這纔是比較符合邏輯,符合直覺的。

JDK 中,我們經常把 String 類型作爲 key,那麼 String 類型是如何重寫 hashCode 方法的呢?

我們看看代碼:

    /*返回哈希碼,String的哈希碼計算方式爲s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]*/
    public int hashCode() {
        //私有實例字段hash表示該串的哈希值,在第一次調用hashCode方法時,字符串的哈希值
        //被計算並且賦值給hash字段,之後再調用hashCode方法便可以直接取hash字段返回。
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

代碼非常簡單,就是以31爲權,每一位爲字符的ASCII值進行運算,用自然溢出來等效取模,因此,每個不同的字符串,返回的 hashCode 肯定不一樣。那麼爲什麼使用 31 呢?

爲什麼大部分 hashcode 方法使用 31

如果有使用 eclipse 的同學肯定知道,該工具默認生成的 hashCode 方法實現也和 String 類型差不多。都是使用的 31 ,那麼有沒有想過:爲什麼要使用 31 呢?

在名著 《Effective Java》第 42 頁就有對 hashCode 爲什麼採用 31 做了說明:

之所以使用 31, 是因爲他是一個奇素數。如果乘數是偶數,並且乘法溢出的話,信息就會丟失,因爲與2相乘等價於移位運算(低位補0)。使用素數的好處並不很明顯,但是習慣上使用素數來計算散列結果。 31 有個很好的性能,即用移位和減法來代替乘法,可以得到更好的性能: 31 * i == (i << 5) - i, 現代的 VM 可以自動完成這種優化。這個公式可以很簡單的推導出來。

這個問題在 SO 上也有討論: https://stackoverflow.com/questions/299304/why-does-javas-hashcode-in-string-use-31-as-a-multiplier)

可以看到,使用 31 最主要的還是爲了性能。當然用 63 也可以。但是 63 的溢出風險就更大了。那麼15 呢?仔細想想也可以。

在《Effective Java》也說道:編寫這種散列函數是個研究課題,最好留給數學家和理論方面的計算機科學家來完成。我們此次最重要的是知道了爲什麼使用31。

HashMap 的 hash 算法的實現原理(爲什麼右移 16 位,爲什麼要使用 ^ 位異或)

好了,知道了 hashCode 的生成原理了,我們要看看今天的主角,hash 算法。

其實,這個也是數學的範疇,從我們的角度來講,只要知道這是爲了更好的均勻散列表的下標就好了,我們來看看 HashMap 的 hash 算法(JDK 8).

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

乍看一下就是簡單的異或運算和右移運算,但是爲什麼要異或呢?爲什麼要移位呢?而且移位16?

在分析這個問題之前,我們需要先看看另一個事情, HashMap 如何根據 hash 值找到數組中的對象,我們看看 get 方法的代碼:

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            // 我們需要關注下面這一行
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

 我們看看代碼中註釋下方的一行代碼:first = tab[(n - 1) & hash])。

使用數組長度減一 與運算 hash 值。這行代碼就是爲什麼要讓前面的 hash 方法移位並異或。

我們分析一下:

首先,假設有一種情況,對象 A 的 hashCode 爲 1000010001110001000001111000000,對象 B 的 hashCode 爲 0111011100111000101000010100000。

如果數組長度是16,也就是 15 與運算這兩個數, 你會發現結果都是0。這樣的散列結果太讓人失望了。很明顯不是一個好的散列算法。

但是如果我們將 hashCode 值右移 16 位,也就是取 int 類型的一半,剛好將該二進制數對半切開。並且使用位異或運算(如果兩個數對應的位置相反,則結果爲1,反之爲0),這樣的話,就能避免我們上面的情況的發生。

總的來說,使用位移 16 位和 異或 就是防止這種極端情況。但是,該方法在一些極端情況下還是有問題,比如:10000000000000000000000000 和 10000000001000000000000000 這兩個數,如果數組長度是16,那麼即使右移16位,在異或,hash 值還是會重複。但是爲了性能,對這種極端情況,JDK 的作者選擇了性能。畢竟這是少數情況,爲了這種情況去增加 hash 時間,性價比不高。

HashMap 爲什麼使用 & 與運算代替模運算?

我們再看看剛剛說的那個根據hash計算下標的方法:

tab[(n - 1) & hash];

其中 n 是數組的長度。其實該算法的結果和模運算的結果是相同的。但是,對於現代的處理器來說,除法和求餘數(模運算)是最慢的動作。

上面情況下和模運算相同

a % b == (b-1) & a ,當b是2的指數時,等式成立。

我們說 & 與運算的定義:與運算 第一個操作數的的第n位於第二個操作數的第n位如果都是1,那麼結果的第n爲也爲1,否則爲0;

當 n 爲 16 時, 與運算 101010100101001001101 時,也就是
1111 & 101010100101001001000 結果:1000 = 8
1111 & 101000101101001001001 結果:1001 = 9
1111 & 101010101101101001010 結果: 1010 = 10
1111 & 101100100111001101100 結果: 1100 = 12

可以看到,當 n 爲 2 的冪次方的時候,減一之後就會得到 1111* 的數字,這個數字正好可以掩碼。並且得到的結果取決於 hash 值。因爲 hash 值是1,那麼最終的結果也是1 ,hash 值是0,最終的結果也是0。

HashMap 的容量爲什麼建議是 2的冪次方?

到這裏,我們提了一個關鍵的問題: HashMap 的容量爲什麼建議是 2的冪次方?正好可以和上面的話題接上。樓主就是這麼設計的。

爲什麼要 2 的冪次方呢?

我們說,hash 算法的目的是爲了讓hash值均勻的分佈在桶中(數組),那麼,如何做到呢?試想一下,如果不使用 2 的冪次方作爲數組的長度會怎麼樣?

假設我們的數組長度是10,還是上面的公式:
1010 & 101010100101001001000 結果:1000 = 8
1010 & 101000101101001001001 結果:1000 = 8
1010 & 101010101101101001010 結果: 1010 = 10
1010 & 101100100111001101100 結果: 1000 = 8

看到結果我們驚呆了,這種散列結果,會導致這些不同的key值全部進入到相同的插槽中,形成鏈表,性能急劇下降。

所以說,我們一定要保證 & 中的二進制位全爲 1,才能最大限度的利用 hash 值,並更好的散列,只有全是1 ,纔能有更多的散列結果。如果是 1010,有的散列結果是永遠都不會出現的,比如 0111,0101,1111,1110…,只要 & 之前的數有 0, 對應的 1 肯定就不會出現(因爲只有都是1纔會爲1)。大大限制了散列的範圍。

我們自定義 HashMap 容量最好是多少?

那我們如何自定義呢?如果我們預計我們的散列表中有2個數據,那麼我就初始化容量爲2嘛?

絕對不行,如果大家看過源碼就會發現,如果Map中已有數據的容量達到了初始容量的 75%,那麼散列表就會擴容,而擴容將會重新將所有的數據重新散列,性能損失嚴重,所以,我們可以必須要大於我們預計數據量的 1.34 倍,如果是2個數據的話,就需要初始化 2.68 個容量。當然這是開玩笑的,2.68 不可以,3 可不可以呢?肯定也是不可以的,我前面說了,如果不是2的冪次方,散列結果將會大大下降。導致出現大量鏈表。那麼我可以將初始化容量設置爲4。 當然了,如果你預計大概會插入 12 條數據的話,那麼初始容量爲16簡直是完美,一點不浪費,而且也不會擴容。

如果某個map很大,注意,肯定是事先沒有定義好初始化長度,假設,某個Map存儲了10000個數據,那麼他會擴容到 20000,實際上,根本不用 20000,只需要 10000* 1.34= 13400 個,然後向上找到一個2 的冪次方,也就是 16384 初始容量足夠。

這是JDK8以前需要注意的事, JDK8之後的構造函數會自動把容量設置爲 >=初始化容量的 2的n次方的值,也就是自動設置爲2的次方冪

貼個Hotpot虛擬機生成hash散列值的實現:

static inline intptr_t get_next_hash(Thread * Self, oop obj) {
  intptr_t value = 0 ;
  if (hashCode == 0) {
     // This form uses an unguarded global Park-Miller RNG,
     // so it's possible for two threads to race and generate the same RNG.
     // On MP system we'll have lots of RW access to a global, so the
     // mechanism induces lots of coherency traffic.
     value = os::random() ;
  } else
  if (hashCode == 1) {
     // This variation has the property of being stable (idempotent)
     // between STW operations.  This can be useful in some of the 1-0
     // synchronization schemes.
     intptr_t addrBits = intptr_t(obj) >> 3 ;
     value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom ;
  } else
  if (hashCode == 2) {
     value = 1 ;            // for sensitivity testing
  } else
  if (hashCode == 3) {
     value = ++GVars.hcSequence ;
  } else
  if (hashCode == 4) {
     value = intptr_t(obj) ;
  } else {
     // Marsaglia's xor-shift scheme with thread-specific state
     // This is probably the best overall implementation -- we'll
     // likely make this the default in future releases.
     unsigned t = Self->_hashStateX ;
     t ^= (t << 11) ;
     Self->_hashStateX = Self->_hashStateY ;
     Self->_hashStateY = Self->_hashStateZ ;
     Self->_hashStateZ = Self->_hashStateW ;
     unsigned v = Self->_hashStateW ;
     v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ;
     Self->_hashStateW = v ;
     value = v ;
  }
 
  value &= markOopDesc::hash_mask;
  if (value == 0) value = 0xBAD ;
  assert (value != markOopDesc::no_hash, "invariant") ;
  TEVENT (hashCode: GENERATE) ;
  return value;
}

 

參考以下博客:

https://www.cnblogs.com/whgk/p/6071617.html

https://blog.csdn.net/qq_38182963/article/details/78940047

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章