HashMap源代码分析

1、 HashMap的基本属性及数据结构

HashMap的基本数据结构是数组,而数组元素是链表,其元素类型是Entry。HashMap是根据对key的hash运算决定将Entry放在数组的哪个位置上的,而对于hash值相同的元素,就会放在同一个链表中。

HashMap中有一个声明为“transient Entry[] table”的属性,Entry是HashMap存储的基本数据类,其基本属性如下:

        final Kkey;

        V value;

        Entry<K,V> next;

        final int hash;

key和value自然不用说,hash是key的hash值,next的类型是Entry,它存在的价值就是解决hash冲突的!如果put一个key-value对时,经过hash运算,该K-V对对应的EntryA应该放在Entry[] table中第5的位置,但是该位置已经有Entry B存在了,那么就将A.next = B,A放在第5的位置上。如下图所示:

 

HashMap中还有几个属性:

默认容量:static final int DEFAULT_INITIAL_CAPACITY = 16;

最大容量: static final int MAXIMUM_CAPACITY = 1 << 30;

   默认加载因子: static final float DEFAULT_LOAD_FACTOR = 0.75f;

扩容因子:intthreshold;(当容量超过threshold时,扩容,threshold = loadFactor* capacity

加载因子:final float loadFactor;

我们可以通过分析如下代码来了解HashMap的初始化过程:

public HashMap(int initialCapacity,float loadFactor) {

        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);}

 

        // Find a power of 2 >= initialCapacity

        int capacity = 1;

        while (capacity < initialCapacity){

            capacity <<= 1;}

 

        this.loadFactor = loadFactor;

        threshold = (int)(capacity *loadFactor);

        table = new Entry[capacity];

        init();

}

该构造函数的参数是我们期望的初始化容量initialCapacity和装载因子loadFactor。

我们通过

while (capacity <initialCapacity){

capacity <<= 1;}

这段代码可以了解到,capacity是大于initialCapacity的最小2次幂数值。也就是说,如果我们的参数initialCapacity = 10,loadFactor = 0.8,那么实际上capacity = 16,该HashMap的初始容量是16,当元素个数超过10 * 0.8 = 8的时候,map进行扩容。

int hash = hash(key.hashCode());

          int i =indexFor(hash,table.length);

              static int indexFor(int h,int length) {

                    return h & (length-1); }

 

 
要注意的是,HashMap对key进行hash时,不是取的key的key.hashCode()方法,而是对key的hashcode作一些运算得到最后的hash值,在所有涉及到entry的操作中都要计算hash = hash(key.hashCode())。有了对元素key两次hash后的hash值,又如何找到元素位于table中的哪个位置呢?

 

 

从上面代码可以看出,i 的值就是元素处于table中的位置,i 是由hash和length计算出来的。

下面来看一下HashMap中的put/get/remove方法实现。

2、  put/get/remove操作如何实现

先看下put方法的源码:

 //put 操作返回key对应的原来的value;(null:如果原来的不存在,oldvalue:原来的已经存在.)  

public V put(K key, V value) {

       //当key=null,调用putForNullKey方法,该方法默认将key=null的值放在table首位

        if (key ==null)

            return putForNullKey(value);

       //计算hash值

        int hash =hash(key.hashCode());

       //计算存储的位置

        int i =indexFor(hash,table.length);

       //遍历table[i]处已经存在的元素

        for (Entry<K,V> e =table[i]; e !=null; e = e.next) {

            Object k;

       //如果该元素的key已经存在,则替换value值,同时返回原始值

            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {

                V oldValue = e.value;

                e.value = value;

                e.recordAccess(this);

                return oldValue;

            }

        }

    //如果该元素的key不存在,执行插入操作,返回null

        modCount++;

        addEntry(hash, key, value, i);

        return null;

    }

 

从代码中可以看出:当我们进行put操作的时候,先根据key的hashCode重新计算hash值,根据hash值得到这个元素在数组中的位置(即下标),如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。

addEntry(int hash, K key, V value,int bucketIndex)方法执行具体的插入操作,可以看下源码:

void addEntry(int hash, K key, V value,int bucketIndex) {

    Entry<K,V> e = table[bucketIndex];

        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);

        if (size++ >=threshold)

            resize(2 * table.length);

    }

 
 

 

 

 

 

 

 

 


参数hash是key两次hash计算后的hash值,bucketIndex就是该元素在table的索引。


当执行put操作后,size>= threshold后,map会自动扩容为现在的2倍容量,稍后详细分析扩容的细节,先看get操作。

 

public V get(Object key) {

    //如果key=null,则返回table[0]处的元素

        if (key ==null)

            returngetForNullKey();

       //进行hash运算,获取索引位置,遍历该处list,根据key,获取返回值

        int hash =hash(key.hashCode());

        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.equals(k)))

                return e.value;

        }

        return null;

    }

  

从源码看以看出,在执行get操作时,先进行hash运算,获取该元素在table中的位置,然后遍历该位置处得list,直到找到key与参数相同的元素,返回该元素的value,如果找不到,则返回null。

下面我们再来看下remove操作的源码:

 

public V remove(Object key) {

    //根据key进行remove元素操作

        Entry<K,V> e = removeEntryForKey(key);

        return (e ==null ?null : e.value);

    }

final Entry<K,V> removeEntryForKey(Object key) {

        //计算hash值,获取位置索引

        int hash = (key ==null) ? 0 :hash(key.hashCode());

        int i =indexFor(hash,table.length);

        //获取该索引位置链表的头元素

        Entry<K,V> prev = table[i];

        Entry<K,V> e = prev;

       //从头开始遍历链表

        while (e !=null) {

            Entry<K,V> next = e.next;

            Object k;

           //如果成功找到

            if (e.hash == hash &&

                ((k = e.key) == key || (key !=null && key.equals(k)))) {

                modCount++;

                size--;

                         //如果是表头,则将表头置为next

                if (prev == e)

                    table[i] = next;

                else

                 // 否则将原本指向该元素的元素,指向该元素指向的元素(好绕)  

prev.next = next;

                e.recordRemoval(this);

                return e;

            }

            prev = e;

            e = next;

        }

        return e;

    }

 
 

归纳起来简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry。(此段引自网络)

3、  HashMap的扩容机制

当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小(而不是map的size噢,size是所有元素的个数,capacity是数组的大小)*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

 

来看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);

        table = newTable;

        threshold = (int)(newCapacity *loadFactor);

    }

 

 
 

 


resize方法实际上执行的操作是以newCapacity参数值新建一个Entry数组,将table中的元素转移到新Entry中去,并且将table指向新数组。

下面来看下transfer()方法的源码:

   void transfer(Entry[] newTable) {

        Entry[] src = table;

        int newCapacity = newTable.length;

        for (int j = 0; j < src.length; j++) {

            Entry<K,V> e = src[j];

            if (e !=null) {

                src[j] = null;

                do {

  // 首先备份当前元素的下一个元素,放到next entry中 

                    Entry<K,V> next = e.next;

 // 计算数组下标

                    int i =indexFor(e.hash, newCapacity);

  // 把原来的表头置成但前循环元素的next

                    e.next = newTable[i];

// 帮当前循环元素防止在表头

                    newTable[i] = e;

//开始下一次的循环。

                    e = next;

                } while (e !=null);

            }

        }

    }

在整个transfer的 过程中,链表被倒置理了,并且链表在数组中的位置也重新排序了。

 
 

 


但是为什么要扩容为两倍呢?

我们知道,在初始化HashMap的时候,有下面的语句

        int capacity = 1;

        while (capacity < initialCapacity)

            capacity <<= 1;

 
 

 


该语句保证了table的初始大小是2的n次方,在resize的时候,也是将容量扩充为原来的两倍,这保证了table的大小一直都是2的n次方,而这,是很有玄机的。

我们知道indexFor操作执行的是hash&(length-1)的操作(该操作等价于hash%lengh,但是&操作比%要快),对与操作有了解的同学应该都明白,当length为2的n次幂时,length-1的二进制表示是0111…111,它能够保证与hash值进行&操作后,使元素分配的更均匀,更合理。

从上面可以看到,HashMap有一个不断扩容的过程,如果map中元素很多,将不断进行size的扩充和元素的拷贝,对于性能肯定会有很大的影响,所以我们在开发的过程中,可以根据预估的数据量对HashMap进行合理的初始化操作。

4、 Fail-Fast机制

我们知道java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException(如果是单线程遍历时,对map进行了修改,也会抛出ConcurrentModificationException,这个问题施嘉佳4月份邮件分享过),这就是所谓fail-fast策略。

这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。

 

 

  private abstract class HashIterator<E>implements Iterator<E> {

        Entry<K,V> next;   // next entry to return

        int expectedModCount; // For fast-fail

        int index;      // current slot

        Entry<K,V> current;   // current entry

 

        HashIterator() {

            expectedModCount =modCount;

            if (size > 0) {// advance to first entry

                Entry[] t = table;

                while (index < t.length && (next = t[index++]) == null)

                    ;

            }

        }

        public final boolean hasNext() {

            returnnext !=null;

        }

        final Entry<K,V>nextEntry() {

            if (modCount !=expectedModCount)

                throw new ConcurrentModificationException();

            Entry<K,V> e = next;

            if (e ==null)

                throw new NoSuchElementException();

 

            if ((next = e.next) ==null) {

                Entry[] t = table;

                while (index < t.length && (next = t[index++]) == null)

                    ;

            }

        current = e;

            return e;

        }

        public void remove() {

            if (current ==null)

                throw new IllegalStateException();

            if (modCount !=expectedModCount)

                throw new ConcurrentModificationException();

            Object k = current.key;

            current = null;

            HashMap.this.removeEntryForKey(k);

            expectedModCount =modCount;

        }

    }

 
 

 

modCount是修改的次数,在对map进行put/remove操作的时候,都会增加这个值。通过HashIterator源码我们可以看到,遍历时会判断当前的modCount和遍历开始时的modCount是否相等,如果不相等,则表示在遍历期间,map被修改了,直接抛出ConcurrentModificationException。

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值