JDK1.7 HashMap源码分析

JDK1.7中HashMap采用的是数组+链表结构保存所有数据,其结构如下图:
《JDK1.7 HashMap源码分析》
JDK1.8 HashMap源码分析中已经分析了JDk1.8中HashMap的结构更改,但是和JDk1.7之前的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);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;//初始阈值为初始容量,与JDK1.8中不同,JDK1.8中调用了tableSizeFor(initialCapacity)得到大于等于初始容量的一个最小的2的指数级别数,比如初始容量为12,那么threshold为16,;如果初始容量为5,那么初始容量为8
        init();//空实现
    }


 public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

 /** * Constructs an empty <tt>HashMap</tt> with the default initial capacity * (16) and the default load factor (0.75). */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }


  public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);

        putAllForCreate(m);
    }

从上面可以看到,使用无参构造方法时,和JDK1.8的相同,默认的初始容量为16,默认的加载因子为0.75。但是在两个参数的构造方法时,实现稍有不同,关键是初始阈值的赋值。上面注释中已经说明了。

hash方法

JDK1.8中的hash方法如下:

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

而JDK1.7中的hash方法如下:

final int hash(Object k) {
        int h = hashSeed;//默认为0
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

从上面可以看到JDk1.8中的hash方法实现简单得多。

基本操作

put方法

HashMap中的put(K k,V v)方法用于将一对键值对插入到哈希表中,返回的键对应的旧的值。实现如下:


static final Entry<?,?>[] EMPTY_TABLE = {};

    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

 public V put(K key, V value) {
        //哈希表还未被创建时,相同
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);//创建哈希表
        }

        //如果键是null,调用putForNullKey方法
        if (key == null)
            return putForNullKey(value);
        //计算hash值
        int hash = hash(key);
        //得到哈希表中桶的索引
        int i = indexFor(hash, table.length);
        //遍历桶中的链表
        for (Entry<K,V> 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++;
        //桶中还不存在链表或者没有键值相同的,直接添加一个链表节点
        addEntry(hash, key, value, i);
        return null;
    }

    //插入键为null的值
    private V putForNullKey(V value) {
        //可以看到键为null的值永远被放在哈希表的第一个桶中
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            //一旦找到键为null,替换旧值
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        //如果第一个桶中为null或没有节点的键为null的,插入新节点
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

从上面的代码可以看到put(K k,V v)有几步操作:
1. 如果哈希表还未创建,那么创建哈希表
2. 如果键为null,那么调用putForNullKey插入键为null的值
3. 如果键不为null,计算hash值并得到桶中的索引数,然后遍历桶中链表,一旦找到匹配的,那么替换旧值
4. 如果桶中链表为null或链表不为null但是没有找到匹配的,那么调用addEntry方法插入新节点

下面先分析当哈希表还未创建时调用的inflateTable()方法,其实现如下:

  private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);//初始化hashSeed变量
    }

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

从上面可以看到,初始化表是一个方法,其中也是根据初始容量得到不小于自己的最小的2的指数倍数的数,这个方法与1.8中tableSizeFor功能是一样的,所以初始化表时是相同的。
下面再分析addEntry()方法,其中第一个参数是hash值,第二个是键,第三个是值,第四个是哈希表中桶的索引。

void addEntry(int hash, K key, V value, int bucketIndex) {
        //如果尺寸已将超过了阈值并且桶中索引处不为null
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //扩容2倍
            resize(2 * table.length);
            //重新计算哈希值
            hash = (null != key) ? hash(key) : 0;
            //重新得到桶索引
            bucketIndex = indexFor(hash, table.length);
        }

        //创建节点
        createEntry(hash, key, value, bucketIndex);
    }

 void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        //将该节点作为头节点
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        //尺寸+1
        size++;
    }

从上面可以看到新加的节点将是作为头节点加入到链表中的,这点是与JDk1.8中的区别。另外,1.7的扩容是插入之前之前判断,而1.8是插入之后再判断是否需要扩容,不过都是扩容2倍。下面再来看resize()方法的实现:

//扩容到新容量
 void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        //如果旧容量已经达到了最大,将阈值设置为最大值,与1.8相同
        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);
    }

//如果hashSeed变了,那么rehash为true,否则为false
void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        //遍历旧表
        for (Entry<K,V> e : table) {
            //当桶不为空
            while(null != e) {
                Entry<K,V> next = e.next;
                //如果hashSeed变了,需要重新计算hash值
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //得到新表中的索引
                int i = indexFor(e.hash, newCapacity);
                //将新节点作为头节点添加到桶中
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

从上面的代码可以看到resize()中需要完成对hashSeed变量的更新,一旦更新成功,那么就需要rehash,否则不需要。而将链表转换时,新表中的链表与原链表中的顺序将会颠倒。这儿可以看到与1.8中的区别,1.7中是通过控制hashSeed的变化导致hash()方法得到的hash值,而JDK1.8中一旦得到了一个键的hash值后,就不会再改变了,而是通过hash&cap==0为区分,将链表分散,而1.7是通过更新hashSeed将旧表中的链表分散。
至此,我们可以发现JDK1.7中很多与1.8的实现区别,如下:
1. JDK1.8中resize()方法在表为空时,创建表,在表不为空时,扩容;而JDK1.7中resize()方法负责扩容,inflateTable()负责创建表
2. 1.8中没有区分键为null的情况,而1.7版本中对于键为null的情况调用putForNullKey()方法。但是两个版本中如果键为null,那么调用hash()方法得到的都将是0,所以键为null的元素都始终位于哈希表中第一个桶中,这一点两个版本是相同的。
3. 当1.8中的桶中元素处于链表的情况,遍历的同时最后如果没有匹配的,直接将节点添加到链表了尾部,而1.7在遍历的同时没有添加数据,而是另外调用了addEntry()方法。
4. addEntry中默认将新加的节点作为链表的头节点,而1.8中会将新加的结点添加到链表末尾
5. 1.7中是通过更改hashSeed值修改节点的hash值从而达到rehash时的分散,而1.8中键的hash值不会改变,rehash时根据hash&cap==0将链表分散
6. 1.8rehash时保证原链表的顺序,而1.7中rehash时将改变链表的顺序

get方法

HashMap的get方法如下:

public V get(Object key) {
        //如果键为null,调用getForNullKey方法
        if (key == null)
            return getForNullKey();
        //键不为null,调用getEntry方法
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }

private V getForNullKey() {
        if (size == 0) {
            return null;
        }
        //键为null的插入在第一个桶中
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

 final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        //计算hash值
        int hash = (key == null) ? 0 : hash(key);
        //遍历桶中的链表
        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()方法与1.8中差别不大,只是区分出了键是否为null的情况,而1.8中则不区分这种情况。

remove方法

remove()方法如下:

 public V remove(Object key) {
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value);
    }


    final Entry<K,V> removeEntryForKey(Object key) {
        if (size == 0) {
            return null;
        }
        //计算hash值
        int hash = (key == null) ? 0 : hash(key);
        //得到桶索引
        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--;
                if (prev == e)
                    table[i] = next;
                else
                    prev.next = next;
                e.recordRemoval(this);
                return e;
            }
            prev = e;
            e = next;
        }

        return e;
    }

从remove()方法可以看到与1.8中的实现也差距不大。

总结

下面对JDK1.7和JDk1.8中HashMap的相同与不同点做出总结。
首先是相同点:
1. 默认初始容量都是16,默认加载因子都是0.75。容量必须是2的指数倍数
2. 扩容时都将容量增加1倍
3. 根据hash值得到桶的索引方法一样,都是i=hash&(cap-1)
4. 初始时表为空,都是懒加载,在插入第一个键值对时初始化
5. 键为null的hash值为0,都会放在哈希表的第一个桶中

接下来是不同点,主要是思想上的不同,不再纠结与实现的不同:
1. 最为重要的一点是,底层结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构
2. 主要区别是插入键值对的put方法的区别。1.8中会将节点插入到链表尾部,而1.7中会将节点作为链表的新的头节点
3. JDk1.8中一个键的hash是保持不变的,JDK1.7时resize()时有可能改变键的hahs值
4. rehash时1.8会保持原链表的顺序,而1.7会颠倒链表的顺序
5. JDK1.8是通过hash&cap==0将链表分散,而JDK1.7是通过更新hashSeed来修改hash值达到分散的目的

    原文作者:xingfeng_coder
    原文地址: https://blog.csdn.net/qq_19431333/article/details/61614414
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞