爲什麼Map的大小必須是2的冪

環境:jdk1.8

構造函數

首先我們看下HashMap構造函數,以及默認容量DEFAULT_INITIAL_CAPACITY設置,指定初始化容量的構造函數中對初始化容量做了2的冪處理,例如:指定17,處理後會變成32(向上取冪)。默認容量16也是2的冪,並且註釋中寫明瞭必須爲2的冪。

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
...
public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.threshold = tableSizeFor(initialCapacity);
}

很多朋友可能會感覺到奇怪,爲什麼必須要是2的冪呢?我們繼續看它的源碼來挖掘原因

put

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

第一步是取key的hash值,我們知道一個hash算法的好壞主要看其hash後元素分佈的均勻性,越是均勻,hash衝突也就是越少。我們看下hashmap如何實現的hash算法

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  1. hashCode()函數是Object提供的native函數,調用系統函數返回一個內存地址轉換而來的int值
  2. h ^ (h >>> 16),該函數什麼意思呢?首先h無符號右移16位,剛好32位的一半,然後h與移位的結果做異或運算。異或算法也就是同假異真或不進行進位的加法。右移16位後高16位全部補0,與原高位16位異或結果不會改變原高16位。低16位與移位後的高16位異或運算

第二步其實就是將Object提供的hashCode返回值的低16位變成它的低16位與高16位異或運算後的值。這麼做有什麼好處呢?
如果低16位的兩個位爲00,高16位對應位置的兩個位異或運算後兩個位相同的概率是2/4(00或11),也就是有2/4的概率不同,異或後結果不是00或11的概率有2/4(01或10),顯然對於一個兩位的二進制01比00更均勻。所有場景如下表。可以看出異或運算後結果更加傾向均勻分佈。正式我們期待的結果

兩位長度二進制組合 異或運算後兩個位數字相同的概率 異或運算後兩個位數字不相同的概率
00 2/4 2/4
01 1/4 3/4
10 1/4 3/4
11 2/4 2/4

第二步putVal

final 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;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    ...
}

tab就是map的hash表,根據hash判斷表中是否存在數據,也就是根據hash轉換爲hash表的下標,然後判斷下標處是否有數據即可。hash值不能直接作爲下標嗎?爲什麼要轉換?因爲我們的hash表的大小默認是16,通常指定hash表的大小也不會是Integer最大值,因爲我們的hash函數返回的是一個int值,那麼就會存在下標越界問題,如何處理下標越界問題呢?直接使用hash值對hash表的大小取餘數即可。那麼我們看下HashMap如何處理的?顯然不是直接用的取餘算法,而是按位與運算:hash表大小-1 & hash值。

命題:X % (2^n) = [(2^n) - 1] & X

上面我們看到HashMap中的處理:(hash表大小-1) & hash值等同於取餘運算
首先,我們回憶下2進制換算10進制的方法:
12的二進制表示:1100=12^3 + 12^2 + 02^1 + 02^0
取餘數算法:
13%3=1:3+3+3+3+1
13%11=2:11+2
13%8=5:8+5
13%2=1:2+2+2+2+2+2+1

13轉爲2進制的另一種表示方法:12^3 + 12^2 + 02^1 + 12^0
對2取餘,2轉爲2進制的另一種表示方法:12^1 + 020=1*20
對4取餘,4轉爲2進制的另一種表示方法:12^2 + 02^1 + 020=0*21 + 12^0
對8取餘,8轉爲2進制的另一種表示方法:12^3 + 02^2 + 02^1 + 020=1*22 + 02^1 + 12^0

寫程序驗證可以得出結論,即:X∈Integer,2^n∈Integer

結論

任意10進制數字對2的冪求模運算,等於10進制轉爲2進制後第(2^n) - 1右邊的所有位轉成10進制後的數字。
如何獲取10進制轉二進制後,第(2^n) - 1位的右邊的所有位。沒錯就是對(2^n) - 1按位與運算。如下表:

2^n 13的二進制 [(2^n)-1]的二進制 按位與遠算 餘數
n=1 2 1101 0001 0001 1
n=2 4 1101 0011 0001 1
n=3 8 1101 0111 0101 5

性能比較

小於等於1千萬次,模運算性能高於位運算
大於等於1億次,模運算性能低於位運算

位運算耗時 模運算耗時 模運算耗時/位運算耗時 位運算耗時/模運算耗時
100000次位運算耗時 13 100000次模運算耗時 8 0.615384615 1.625
1000000次位運算耗時 86 1000000次模運算耗時 59 0.686046512 1.457627119
10000000次位運算耗時 628 10000000次模運算耗時 559 0.890127389 1.123434705
100000000次位運算耗時 5125 100000000次模運算耗時 5830 1.137560976 0.879073756
1000000000次位運算耗時 60795 1000000000次模運算耗時 70724 1.163319352 0.859609185

性能比較代碼

package com.mytest;

import java.util.Random;

/**
 * @author 會灰翔的灰機
 * @date 2020/3/15
 */
public class BitAndModular {

    public static void main(String[] args) {
        bit();
        modular();
    }

    public static void bit() {
        int power = 10;
        int number = 10000;
        while(true) {
            number *= power;
            if (number <= 1000000000) {
                long start = System.currentTimeMillis();
                int result = 0;
                for (int j = 1; j < number; j++) {
                    result = (16777216 - 1) & new Object().hashCode();
                }
                long end = System.currentTimeMillis();
                System.out.println(String.format("%s次位運算耗時\t%s", number, (end - start)));
            } else {
                break;
            }
        }
    }

    public static void modular() {
        int power = 10;
        int number = 10000;
        for(;;) {
            number *= power;
            if (number <= 1000000000) {
                long start = System.currentTimeMillis();
                int result = 0;
                for (int j = 1; j < number; j++) {
                    result = new Object().hashCode() % 16777216;
                }
                long end = System.currentTimeMillis();
                System.out.println(String.format("%s次模運算耗時\t%s", number, (end - start)));
            } else {
                break;
            }
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章