HashMap(jdk1.6)梳理

HashMap是我们平时开发中接触得比较多的数据容器之一。jdk1.8之后还对HashMap进行了优化。本篇将首先介绍jdk1.6版本的HashMap相关知识,涉及到jdk1.8的后续章节补充。我们将围绕如下几点展开:HashMap数据结构、存储和resize过程、fail-fast机制。

1. HashMap的基本数据结构

HashMap是K-V型容器,对于每一个K-V值,保存在内部HashMap.Entry对象中。对于不同的Entry对象,依据哈希值,保存在数组字段table中。针对Hash冲突的情况,采用链式地址法,即Entry对象可以形成一个链表结构,指向相同哈希的下一个值。

public class HashMap
    extends AbstractMap
    implements Map, Cloneable, Serializable
{
     /**
     * The table, resized as necessary. Length MUST Always be a power of two.
     */
    transient Entry[] table;
    //......
    static class Entry implements Map.Entry {
        final K key;
        V value;
        Entry next;
        final int hash;
    }
}

2. put的基本流程

2.1 大致流程:

(0). 根据key的hash值,获取对应的位置索引;
(1). 判断k-v值是否已存在,存在则更新,结束;
(2). k-v值不存在,插入节点,采用头插法(插入到已有链表元素的前面);
(3). 判断当前是否需要扩容,否,结束;
(4). 如果需要扩容,创建新表,把旧表数据依次按照头插法,插入对应的bucket及链表。
注意: 如果是putAll,则会事先判断是否需要resize,然后再循环执行put方法。

2.2 步骤详解

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        // 对应步骤2.1-(0),根据key哈希,获取索引位置。注意之所以要再hash一次,是为了增加扰动,提高哈希值的随机性和均匀分布性
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);

        //对应步骤2.1-(1),判断对应的k值是否存在,存在则更新
        for (Entry e = table[i]; e != null; e = e.next) {
            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++;
        //对应步骤2.1-(2)(3)(4),插入节点,并判断新添加元素后,是否需要扩容
        addEntry(hash, key, value, i);
        return null;
    }

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry e = table[bucketIndex];
        // 获取当前索引的头部元素,并且把新值的next指针指向头部元素,然后新插入的元素就做为新的头部元素-头插入法
        table[bucketIndex] = new Entry(hash, key, value, e);
        // 判断是否需要扩容,后续介绍
        if (size++ >= threshold)
            resize(2 * table.length);
    }

3. resize过程

resize的基本思想还是比较简单的。就是把旧的table[]中的元素,一个一个地根据原先的哈希规则,保存到新的扩容好的table[]对象中。然后使用新的table[]对象即可。当然,这些都是基于单线程的,在多线程的条件下,会出问题,此处先不分析。我们直接从代码入手,看下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);
    }

    /**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        // 外层for循环,按顺序读取每一个bucket的Entry对象
        for (int j = 0; j < src.length; j++) {
            Entry e = src[j];
            if (e != null) {
                // 旧数组的bucket指针置null
                src[j] = null;
                // 遍历当前bucket的所有链表值,并根据最新的容量,计算新的索引值,然后采用头插法,插入新值
                do {
                    Entry next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

4. fail-fast机制

fail-fast机制,就是指在使用迭代器遍历元素的过程当中,如果在其它线程中对HashMap进行元素增加或删除时,迭代器会抛ConcurrentModificationException。需要注意的是,fail-fast机制是一种尽最大努力抛异常的机制,业务开发中,不能依赖这种机制。
fail-fast机制的实现,直观上理解还是比较简单的,其主要依赖如下俩字段,modCount和expectedModCount,并且判断两者是否相等:

public class HashMap
    extends AbstractMap
    implements Map, Cloneable, Serializable
{
    transient volatile int modCount;
    private abstract class HashIterator implements Iterator {
        // ......
        int expectedModCount;   // For fast-fail
       
        HashIterator() {
            // 在新建一个迭代器的时候,会保存新建时的修改次数
            expectedModCount = modCount;
            // ......
        }

        final Entry nextEntry() {
            // 因为modeCount是跟随HashMap的,所以如果有别的线程对HashMap进行了元素的增删(注意,不包括修改),那么modeCount就会计数加一;而expectedModCount是初始化时就定下来的,那么通过比较修改次数,就可以发现HashMap被修改了。
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            // ......
        }
}

6. 总结

6.1 需关注的点

(1) capacity一定是2的n次方。在初始化的时候指定了capacity大小,则会挑选一个比指定capacity大的最小的2的n次方的数。

int capacity = 1;
while (capacity < initialCapacity)
    capacity <<= 1;

6.2 可能产生的问题

(1) 并发put后,在get时可能产生死循环,消耗CPU:
这个主要是因为resize的原因,resize完后,新的链表相对于原来的链表,是倒置过的,即,原来某个bucket的链表是:bucket[i]=A->B->C,因为HashMap扩容新元素采用的是头插法,这里我们假设A\B\C三个元素,扩容后还是属于同一个bucket。那么新的链表中就会变成:newBucket[i]=C->B->A。
接下来,我们加入并发操作的情况。假设另一个线程也引起了resize操作,线程1顺利地修改了链表,线程2之前已经读取了A元素,现在准备插入A元素到newBucket[i],此时的newBucket[i]=C->B->A,因为线程2的操作,又变成了newBucket[i]=A->C->B->A,出现是循环了。
(2) 并发put时,可能导致元素丢失:
我们知道,要插入新元素,会调addEntry方法,该方法会在某个bucket的头部插入新值,具体是插入新值后,再修改bucket的指向指针,如果有两个线程都同时执行完了new Entry()方法,那么就会出现两次table[bucketIndex]的赋值,显然有一次的插入就会丢失了。

void addEntry(int hash, K key, V value, int bucketIndex) {
        Entry e = table[bucketIndex];
        table[bucketIndex] = new Entry(hash, key, value, e);
        // ......
    }

(3)put非null元素后get出来的却是null:
transfer方法中,会先把bucket置null,这时候如果刚好有线程去读这个bucktet,那拿出来的就是null了。

 void transfer(Entry[] newTable) {
        // ......
        // 外层for循环,按顺序读取每一个bucket的Entry对象
        for (int j = 0; j < src.length; j++) {
            Entry e = src[j];
            if (e != null) {
                // 旧数组的bucket指针置null
                src[j] = null;
               // ......
            }
        }
    }

你可能感兴趣的:(HashMap(jdk1.6)梳理)