欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

java中hashmap的底層數(shù)據(jù)結(jié)構(gòu)與實現(xiàn)原理

 更新時間:2021年08月11日 09:28:57   作者:samniwu  
Hashmap是java面試中經(jīng)常遇到的面試題,大部分都會問其底層原理與實現(xiàn),本人也是被這道題問慘了,為了能夠溫故而知新,特地寫了這篇文章,以便時時學(xué)習(xí)

Hash結(jié)構(gòu)

HashMap根據(jù)名稱可知,其實現(xiàn)方法與Hash表有密切關(guān)系。在討論哈希表之前,我們先大概了解下其他數(shù)據(jù)結(jié)構(gòu)在新增,查找等基礎(chǔ)操作執(zhí)行性能。

數(shù)組:采用一段連續(xù)的存儲單元來存儲數(shù)據(jù)。對于指定下標(biāo)的查找,時間復(fù)雜度為O(1);通過給定值進(jìn)行查找,需要遍歷數(shù)組,逐一比對給定關(guān)鍵字和數(shù)組元素,時間復(fù)雜度為O(n),當(dāng)然,對于有序數(shù)組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復(fù)雜度提高為O(logn);對于一般的插入刪除操作,涉及到數(shù)組元素的移動,其平均復(fù)雜度也為O(n)

線性鏈表:對于鏈表的新增,刪除等操作(在找到指定操作位置后),僅需處理結(jié)點間的引用即可,時間復(fù)雜度為O(1),而查找操作需要遍歷鏈表逐一進(jìn)行比對,復(fù)雜度為O(n)

二叉樹:對一棵相對平衡的有序二叉樹,對其進(jìn)行插入,查找,刪除等操作,平均復(fù)雜度均為O(logn)。

哈希表:相比上述幾種數(shù)據(jù)結(jié)構(gòu),在哈希表中進(jìn)行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復(fù)雜度為O(1),接下來我們就來看看哈希表是如何實現(xiàn)達(dá)到驚艷的常數(shù)階O(1)的。

我們知道,數(shù)據(jù)結(jié)構(gòu)的物理存儲結(jié)構(gòu)只有兩種:順序存儲結(jié)構(gòu)和鏈?zhǔn)酱鎯Y(jié)構(gòu)(像棧,隊列,樹,圖等是從邏輯結(jié)構(gòu)去抽象的,映射到內(nèi)存中,也這兩種物理組織形式),而在上面我們提到過,在數(shù)組中根據(jù)下標(biāo)查找某個元素,一次定位就可以達(dá)到,哈希表利用了這種特性,哈希表的主干就是數(shù)組。

比如我們要新增或查找某個元素,我們通過把當(dāng)前元素的關(guān)鍵字 通過某個函數(shù)映射到數(shù)組中的某個位置,通過數(shù)組下標(biāo)一次定位就可完成操作。

location = hash(關(guān)鍵字)

其中,hash函數(shù)設(shè)計的優(yōu)劣直接影響整體的性能。

哈希沖突

哈希算法存在一個缺點就是哈希沖突。例如,我們進(jìn)行數(shù)據(jù)存儲時,我們通過對關(guān)鍵字進(jìn)行hash時得到的地址已經(jīng)存儲過數(shù)據(jù)了,這時就會出現(xiàn)哈希沖突。因此,哈希函數(shù)的設(shè)計至關(guān)重要,好的哈希函數(shù)會盡可能地保證 計算簡單和散列地址分布均勻。但是,我們需要清楚的是,數(shù)組是一塊連續(xù)的固定長度的內(nèi)存空間,再好的哈希函數(shù)也不能保證得到的存儲地址絕對不發(fā)生沖突。那么哈希沖突如何解決呢?哈希沖突的解決方案有多種:開放定址法(發(fā)生沖突,繼續(xù)尋找下一塊未被占用的存儲地址),再散列函數(shù)法,鏈地址法,而HashMap即是采用了鏈地址法,也就是數(shù)組+鏈表的方式

HashMap實現(xiàn)原理

HashMap的主干是一個Entry數(shù)組。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。

//HashMap的主干數(shù)組,可以看到就是一個Entry數(shù)組,初始值為空數(shù)組{},主干數(shù)組的長度一定是2的次冪,至于為什么這么做,后面會有詳細(xì)分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

Entry是HashMap中的一個靜態(tài)內(nèi)部類。代碼如下

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存儲指向下一個Entry的引用,單鏈表結(jié)構(gòu)
        int hash;//對key的hashcode值進(jìn)行hash運(yùn)算后得到的值,存儲在Entry,避免重復(fù)計算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

所以,HashMap的整體結(jié)構(gòu)如下

在這里插入圖片描述

簡單來說,HashMap由數(shù)組+鏈表組成的,數(shù)組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的,如果定位到的數(shù)組位置不含鏈表(當(dāng)前entry的next指向null),那么對于查找,添加等操作很快,僅需一次尋址即可;如果定位到的數(shù)組包含鏈表,對于添加操作,其時間復(fù)雜度為O(n),首先遍歷鏈表,存在即覆蓋,否則新增;對于查找操作來講,仍需遍歷鏈表,然后通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現(xiàn)越少,性能才會越好。

其他幾個重要字段:

//實際存儲的key-value鍵值對的個數(shù)
transient int size;
//閾值,當(dāng)table == {}時,該值為初始容量(初始容量默認(rèn)為16);當(dāng)table被填充了,也就是為table分配內(nèi)存空間后,threshold一般為 capacity*loadFactory。HashMap在進(jìn)行擴(kuò)容時需要參考threshold,后面會詳細(xì)談到
int threshold;
//負(fù)載因子,代表了table的填充度有多少,默認(rèn)是0.75
final float loadFactor;
//用于快速失敗,由于HashMap非線程安全,在對HashMap進(jìn)行迭代時,如果期間其他線程的參與導(dǎo)致HashMap的結(jié)構(gòu)發(fā)生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException
transient int modCount;

HashMap有4個構(gòu)造器,其他構(gòu)造器如果用戶沒有傳入initialCapacity 和loadFactor這兩個參數(shù),會使用默認(rèn)值,initialCapacity默認(rèn)為16,loadFactory默認(rèn)為0.75。下面給出一個構(gòu)造函數(shù)示例:

public HashMap(int initialCapacity, float loadFactor) {
     //此處對傳入的初始容量進(jìn)行校驗,最大不能超過MAXIMUM_CAPACITY = 1<<30(230)
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;
     
        init();//init方法在HashMap中沒有實際實現(xiàn),不過在其子類如 linkedHashMap中就會有對應(yīng)實現(xiàn)
    }

從上面這段代碼我們可以看出,在常規(guī)構(gòu)造器中,沒有為數(shù)組table分配內(nèi)存空間(有一個入?yún)橹付∕ap的構(gòu)造器例外),而是在執(zhí)行put操作的時候才真正構(gòu)建table數(shù)組

HashMap-put方法

public V put(K key, V value) {
        //如果table數(shù)組為空數(shù)組{},進(jìn)行數(shù)組填充(為table分配實際內(nèi)存空間),入?yún)閠hreshold,此時threshold為initialCapacity 默認(rèn)是1<<4(24=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
       //如果key為null,存儲位置為table[0]或table[0]的沖突鏈上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//對key的hashcode進(jìn)一步計算,確保散列均勻
        int i = indexFor(hash, table.length);//獲取在table中的實際位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果該對應(yīng)數(shù)據(jù)已存在,執(zhí)行覆蓋操作。用新value替換舊value,并返回舊value
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;//保證并發(fā)訪問時,若HashMap內(nèi)部結(jié)構(gòu)發(fā)生變化,快速響應(yīng)失敗
        addEntry(hash, key, value, i);//新增一個entry
        return null;
    }
private void inflateTable(int toSize) {
        int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次冪
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此處為threshold賦值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不會超過MAXIMUM_CAPACITY,除非loadFactor大于1
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

inflateTable這個方法用于為主干數(shù)組table在內(nèi)存中分配存儲空間,通過roundUpToPowerOf2(toSize)可以確保capacity為大于或等于toSize的最接近toSize的二次冪,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。

private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

roundUpToPowerOf2中的這段處理使得數(shù)組長度一定為2的次冪,Integer.highestOneBit是用來獲取最左邊的bit(其他bit位為0)所代表的數(shù)值。

hash函數(shù)

//這是一個神奇的函數(shù),用了很多的異或,移位等運(yùn)算,對key的hashcode進(jìn)一步進(jìn)行計算以及二進(jìn)制位的調(diào)整等來保證最終獲取的存儲位置盡量分布均勻
final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

以上hash函數(shù)計算出的值,通過indexFor進(jìn)一步處理來獲取實際的存儲位置

/**
     * 返回數(shù)組下標(biāo)
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

h&(length-1)保證獲取的index一定在數(shù)組范圍內(nèi),舉個例子,默認(rèn)容量16,length-1=15,h=18,轉(zhuǎn)換成二進(jìn)制計算為

1 0 0 1 0
& 0 1 1 1 1
__________________
0 0 0 1 0 = 2

最終計算出的index=2。有些版本的對于此處的計算會使用 取模運(yùn)算,也能保證index一定在數(shù)組范圍內(nèi),不過位運(yùn)算對計算機(jī)來說,性能更高一些(HashMap中有大量位運(yùn)算),所以最終存儲位置的確定流程是這樣的:

在這里插入圖片描述

添加entry

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//當(dāng)size超過臨界閾值threshold,并且即將發(fā)生哈希沖突時進(jìn)行擴(kuò)容
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

通過以上代碼能夠得知,當(dāng)發(fā)生哈希沖突并且size大于閾值的時候,需要進(jìn)行數(shù)組擴(kuò)容,擴(kuò)容時,需要新建一個長度為之前數(shù)組2倍的新的數(shù)組,然后將當(dāng)前的Entry數(shù)組中的元素全部傳輸過去,擴(kuò)容后的新數(shù)組長度為之前的2倍,所以擴(kuò)容相對來說是個耗資源的操作。

為何HashMap的數(shù)組長度一定是2的次冪?

HashMap擴(kuò)容方法resize:

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

如果數(shù)組進(jìn)行擴(kuò)容,數(shù)組長度發(fā)生變化,而存儲位置 index = h&(length-1),index也可能會發(fā)生變化,需要重新計算index,我們先來看看transfer這個方法

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
     //for循環(huán)中的代碼,逐個遍歷鏈表,重新計算索引位置,將老數(shù)組數(shù)據(jù)復(fù)制到新數(shù)組中去(數(shù)組不存儲實際數(shù)據(jù),所以僅僅是拷貝引用而已)
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
          //將當(dāng)前entry的next鏈指向新的索引位置,newTable[i]有可能為空,有可能也是個entry鏈,如果是entry鏈,直接在鏈表頭部插入。
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

這個方法將老數(shù)組中的數(shù)據(jù)逐個鏈表地遍歷,扔到新的擴(kuò)容后的數(shù)組中,我們的數(shù)組索引位置的計算是通過 對key值的hashcode進(jìn)行hash擾亂運(yùn)算后,再通過和 length-1進(jìn)行位運(yùn)算得到最終數(shù)組索引位置。

hashMap的數(shù)組長度一定保持2的次冪,比如16的二進(jìn)制表示為 10000,那么length-1就是15,二進(jìn)制為01111,同理擴(kuò)容后的數(shù)組長度為32,二進(jìn)制表示為100000,length-1為31,二進(jìn)制表示為011111。從下圖可以我們也能看到這樣會保證低位全為1,而擴(kuò)容后只有一位差異,也就是多出了最左位的1,這樣在通過 h&(length-1)的時候,只要h對應(yīng)的最左邊的那一個差異位為0,就能保證得到的新的數(shù)組索引和老數(shù)組索引一致(大大減少了之前已經(jīng)散列良好的老數(shù)組的數(shù)據(jù)位置重新調(diào)換),個人理解。

在這里插入圖片描述

還有,數(shù)組長度保持2的次冪,length-1的低位都為1,會使得獲得的數(shù)組索引index更加均勻,比如:

在這里插入圖片描述

我們看到,上面的&運(yùn)算,高位是不會對結(jié)果產(chǎn)生影響的(hash函數(shù)采用各種位運(yùn)算可能也是為了使得低位更加散列),我們只關(guān)注低位bit,如果低位全部為1,那么對于h低位部分來說,任何一位的變化都會對結(jié)果產(chǎn)生影響,也就是說,要得到index=21這個存儲位置,h的低位只有這一種組合。這也是數(shù)組長度設(shè)計為必須為2的次冪的原因。

在這里插入圖片描述

如果不是2的次冪,也就是低位不是全為1此時,要使得index=21,h的低位部分不再具有唯一性了,哈希沖突的幾率會變的更大,同時,index對應(yīng)的這個bit位無論如何不會等于1了,而對應(yīng)的那些數(shù)組位置也就被白白浪費了。

HashMap-get

public V get(Object key) {
     //如果key為null,則直接去table[0]處去檢索即可。
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);
        return null == entry ? null : entry.getValue();
 }

get方法通過key值返回對應(yīng)value,如果key為null,直接去table[0]處檢索。我們再看一下getEntry這個方法

final Entry<K,V> getEntry(Object key) {
            
        if (size == 0) {
            return null;
        }
        //通過key的hashcode值計算hash值
        int hash = (key == null) ? 0 : hash(key);
        //indexFor (hash&length-1) 獲取最終數(shù)組索引,然后遍歷鏈表,通過equals方法比對找出對應(yīng)記錄
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && 
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

可以看出,get方法的實現(xiàn)相對簡單,key(hashcode)–>hash–>indexFor–>最終索引位置,找到對應(yīng)位置table[i],再查看是否有鏈表,遍歷鏈表,通過key的equals方法比對查找對應(yīng)的記錄。要注意的是,有人覺得上面在定位到數(shù)組位置之后然后遍歷鏈表的時候,e.hash == hash這個判斷沒必要,僅通過equals判斷就可以。其實不然,試想一下,如果傳入的key對象重寫了equals方法卻沒有重寫hashCode,而恰巧此對象定位到這個數(shù)組位置,如果僅僅用equals判斷可能是相等的,但其hashCode和當(dāng)前對象不一致,這種情況,根據(jù)Object的hashCode的約定,不能返回當(dāng)前對象,而應(yīng)該返回null,后面的例子會做出進(jìn)一步解釋。

重寫equals方法需同時重寫hashCode方法

關(guān)于HashMap的源碼分析就介紹到這兒了,最后我們再聊聊老生常談的一個問題,各種資料上都會提到,“重寫equals時也要同時覆蓋hashcode”,我們舉個小例子來看看,如果重寫了equals而不重寫hashcode會發(fā)生什么樣的問題

/**
 * Created by chengxiao on 2016/11/15.
 */
public class MyTest {
    private static class Person{
        int idCard;
        String name;

        public Person(int idCard, String name) {
            this.idCard = idCard;
            this.name = name;
        }
        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()){
                return false;
            }
            Person person = (Person) o;
            //兩個對象是否等值,通過idCard來確定
            return this.idCard == person.idCard;
        }

    }
    public static void main(String []args){
        HashMap<Person,String> map = new HashMap<Person, String>();
        Person person = new Person(1234,"喬峰");
        //put到hashmap中去
        map.put(person,"天龍八部");
        //get取出,從邏輯上講應(yīng)該能輸出“天龍八部”
        System.out.println("結(jié)果:"+map.get(new Person(1234,"蕭峰")));
    }
}

實際輸出結(jié)果:

null

如果我們已經(jīng)對HashMap的原理有了一定了解,這個結(jié)果就不難理解了。盡管我們在進(jìn)行g(shù)et和put操作的時候,使用的key從邏輯上講是等值的(通過equals比較是相等的),但由于沒有重寫hashCode方法,所以put操作時,key(hashcode1)–>hash–>indexFor–>最終索引位置 ,而通過key取出value的時候 key(hashcode1)–>hash–>indexFor–>最終索引位置,由于hashcode1不等于hashcode2,導(dǎo)致沒有定位到一個數(shù)組位置而返回邏輯上錯誤的值null(也有可能碰巧定位到一個數(shù)組位置,但是也會判斷其entry的hash值是否相等,上面get方法中有提到。)

所以,在重寫equals的方法的時候,必須注意重寫hashCode方法,同時還要保證通過equals判斷相等的兩個對象,調(diào)用hashCode方法要返回同樣的整數(shù)值。而如果equals判斷不相等的兩個對象,其hashCode可以相同(只不過會發(fā)生哈希沖突,應(yīng)盡量避免)。

總結(jié)

本篇文章就到這里了,希望更給您帶來幫助,也希望您能夠多多關(guān)注腳本之家的更多內(nèi)容!

相關(guān)文章

  • 詳解Spring集成Redis的兩種方式

    詳解Spring集成Redis的兩種方式

    在工作中,我們用到分布式緩存的時候,第一選擇就是Redis,今天介紹一下SpringBoot如何集成Redis的,具有一定的參考價值,感興趣的可以了解一下
    2021-09-09
  • JPA之多對多查詢死循環(huán)嵌套問題及解決方案

    JPA之多對多查詢死循環(huán)嵌套問題及解決方案

    這篇文章主要介紹了JPA之多對多查詢死循環(huán)嵌套問題及解決方案,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教
    2024-05-05
  • Java遞歸 遍歷目錄的小例子

    Java遞歸 遍歷目錄的小例子

    Java遞歸 遍歷目錄的小例子,需要的朋友可以參考一下
    2013-03-03
  • java socket 詳細(xì)介紹

    java socket 詳細(xì)介紹

    本篇文章小編為大家介紹,java socket 詳細(xì)介紹。需要的朋友參考下
    2013-04-04
  • 使用Spring多數(shù)據(jù)源配置

    使用Spring多數(shù)據(jù)源配置

    這篇文章主要介紹了使用Spring多數(shù)據(jù)源配置方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教
    2024-07-07
  • springboot整合logback實現(xiàn)日志管理操作

    springboot整合logback實現(xiàn)日志管理操作

    本章節(jié)是記錄logback在springboot項目中的簡單使用,本文將會演示如何通過logback將日志記錄到日志文件或輸出到控制臺等管理操作,感興趣的朋友跟隨小編一起看看吧
    2024-02-02
  • java實現(xiàn)商品信息管理系統(tǒng)

    java實現(xiàn)商品信息管理系統(tǒng)

    這篇文章主要為大家詳細(xì)介紹了java實現(xiàn)商品信息管理系統(tǒng),文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下
    2019-11-11
  • Java后端接入微信小程序登錄功能(登錄流程)

    Java后端接入微信小程序登錄功能(登錄流程)

    這篇文章主要介紹了Java后端接入微信小程序登錄功能,本文通過實例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下
    2023-06-06
  • Java數(shù)據(jù)庫連接PreparedStatement的使用詳解

    Java數(shù)據(jù)庫連接PreparedStatement的使用詳解

    這篇文章主要介紹了Java數(shù)據(jù)庫連接PreparedStatement的使用詳解,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧
    2017-08-08
  • 通過實踐了解如何處理Java異常

    通過實踐了解如何處理Java異常

    Java中的異常處理不是一個簡單的主題。初學(xué)者發(fā)現(xiàn)它很難理解,甚至有經(jīng)驗的開發(fā)者也可以花幾個小時討論如何以及應(yīng)該拋出或處理哪些異常。下面我們通過實踐來了解如何解決異常
    2019-05-05

最新評論