解读HashMap-对比JDK7和JDK8

前言

HashMap 算是我们日常学习工作中遇到的比较多的一个类,它用于存储 Key-Value 键值对。HashMap 允许使用 null 键和 null 值,在计算 hash 值时,null 键的 hash 值就是 0,HashMap 并不保证在执行某些操作后键值对的顺序和原来相同,在多线程的环境下,使用 HashMap 需要注意线程安全问题。

在 JDK1.8 之前,HashMap 底层采用数组+链表实现,即用链表处理冲突,同一 hash 值的元素都存储在一个链表里。但是当位于一个桶中的元素较多,即 hash 值相等的元素较多时,通过 key 值依次查找的效率较低。在 JDK1.8 中,HashMap 存储采用数组+链表+红黑树实现,当链表长度超过阈值 8 且数组长度超过 64 时,将链表转换为红黑树,这样大大减少了查找时间。

在本文中,我会通过对 JDK1.7 和 JDk1.8 的比较,为你介绍如下内容:

  • 增删改查方法分析
  • resize 方法分析
  • 树的实现(后续有时间再写,主要是图需要画更多)
  • 问答题(必看)
一些没有提到的细节,我会在最后以问答题的方式呈现。

构造方法

在 JDK1.7 中的构造方法如下:

// 无参构造方法
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

// 参数为容量大小
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 参数为容量大小 + 负载因子
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; // (*)
    init(); // 忽略这个
}

// 参数为一个Map的子类
public HashMap(Map m) {
    this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                  DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
    inflateTable(threshold);

    putAllForCreate(m);
}

在 JDK1.8 中的构造方法如下:

// 无参构造方法
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

// 参数为容量大小
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 参数为容量大小 + 负载因子
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;
    this.threshold = tableSizeFor(initialCapacity); // (*)
}

// 参数为一个Map的子类
public HashMap(Map m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
   
    putMapEntries(m, false);
}

分析:

  1. 无参构造方法的虽然写法不同,但是实际效果是一样的,这个很容易看出来。
  2. 注意到我上面注释(*)的地方

    threshold = initialCapacity;
    this.threshold = tableSizeFor(initialCapacity);

    这两行代码都是把初始容量赋值给了threshold变量。我们知道,threshold指的是 HashMap 存储元素的阈值,超过了这个阈值就会对其进行扩容操作。难道这里和我们想的还不一样?是的,这里的threshlod只是用于暂存 HashMap 的容量,因为在 HashMap 中并不存在 capacity 这个成员变量。

    所不同的是,在 JDK1.7 中,threshold是传入的初始容量,而在 JDK1.8 中,threshold是传入的初始容量经过tableSizeFor方法进行向上取最近的 2 的次幂之后的容量值。举个例子,如果传入的容量是 12,那么在 JDK1.7 中,在构造方法调用后,threshold值为 12,在 JDK1.8 中,threshold值为 16。

    tableSizeFor方法的源码如下:

    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

    经过这一系列的位运算,如果输入值是 2 的冪,则原样返回,如果不是 2 的冪,则向上取就近的冪。至于为什么可以自己列举下,这里我们只需要知道这个方法的作用就够了。

    现在我有一个问题,为什么在 JDK1.7 里threshold就不需要向上取 2 的次幂呢?答案是需要的,不过它不是在构造方法中完成的,而是在inflateTable方法中进行了 HashMap 的初始化

    inflateTable方法的源码如下:

    private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);
    
        // threshold 真正的值
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

    看到上面的roundUpToPowerOf2方法了吗?作用其实和tableSizeFor方法是一样的,就是让容量向上取最近的 2 的次幂。

    在这个方法中threshold才是真正的进行初始化了,threshold = capacity * loadFactor

    同时也把table进行了初始化,我这里特别提到初始化这三个字,上面一处我也特地加粗了。我强调的原因是在 JDK1.8 中,初始化并没有类似inflateTable这样单独的方法,而是在resize方法中完成的,也就是说,在 JDK1.8 中,resize等价于 JDK1.7 中的inflateTable + resize

  3. 我们看传入参数为 Map 子类的构造方法。

    在JDk1.7中,初始化完loadFactor后,就直接调用inflateTable(threshold)方法初始化 HashMap 了。最后把调用putAllForCreate方法把所有 KV 装入新的 HashMap 中,这个方法还是比较简单的。

    putAllForCreate方法源码:

    private void putAllForCreate(Map m) {
        for (Map.Entry e : m.entrySet())
            // 点进去
            putForCreate(e.getKey(), e.getValue());
    }

    putForCreate方法源码:

    private void putForCreate(K key, V value) {
        // 获取当前key的hash值
        int hash = null == key ? 0 : hash(key);
        // 找到hash值对应的bucket(哈希数组的位置)
        int i = indexFor(hash, table.length);
    
        // 如果当前bucket已经有元素占据,则继续向后找,如果找到有key相同的元素,那么覆盖原来的值
        for (Entry e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) {
                e.value = value;
                return;
            }
        }
    
        // 当前bucket首元素没有被占据,或者当前bucket中没有相同元素,那么就在桶的第一个位置添加该元素
        createEntry(hash, key, value, i);
    }

    createEntry方法源码:

    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

    注意到这一行table[bucketIndex] = new Entry<>(hash, key, value, e);说明是把新的节点放入到数组中,也就是链表的头部,JDK1.7 插入元素时头插法

HashMap 中的变量

JDK1.7中的变量:

// 默认Entry数组的初始化容量,为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// Entry数组的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;

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

// 初始化的Entry空数组
static final Entry[] EMPTY_TABLE = {};

// 哈希数组
transient Entry[] table = (Entry[]) EMPTY_TABLE;

// 默认的阈值,当一个键值对的键是String类型时,且map的容量达到了这个阈值,就启用备用哈希(alternative hashing)。备用哈希可以减少String类型的key计算哈希码(更容易)发生哈希碰撞的发生率。该值可以通过定义系统属性jdk.map.althashing.threshold来指定。如果该值是1,表示强制总是使用备用哈希;如果是-1则表示禁用
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

// HashMap的键值对数量
transient int size;

int threshold;

final float loadFactor;

// 结构性变化计数器
transient int modCount;

// 哈希种子值,默认为0
transient int hashSeed = 0;

JDK1.8 中的变量:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 哈希桶上的元素数量增加到此值后,将链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;

// 哈希桶上的红黑树上的元素数量减少到此值时,将红黑树转换为链表
static final int UNTREEIFY_THRESHOLD = 6;

// 哈希数组的容量至少增加到此值,且满足TREEIFY_THRESHOLD的要求时,将链表转换为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;

transient Node[] table;
transient Set> entrySet;
transient int size;
int threshold;
final float loadFactor;
transient int modCount;

put 方法分析

JDK1.8

put方法源码:

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

putVal方法源码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node[] tab; // 指向当前哈希数组
    Node p; // 指向待插入元素应当插入的位置
    int n, i;

    // 如果哈希数组还未初始化,或者容量无效,则需要初始化一个哈希数组
    if ((tab = table) == null || (n = tab.length) == 0) {
        // 初始化哈希数组,后面会将resize方法
        tab = resize();

        n = tab.length;
    }
    // p指向hash所在的哈希槽上的首个元素。 (length - 1) & hash 返回的是元素存放的索引
    // 如果哈希槽为空,则在该槽上放置首个元素(普通Node)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 如果哈希槽不为空,则需要在哈希槽后面链接更多的元素
    else {
        Node e;
        K k;

        /*
         * 对哈希槽中的首个元素进行判断
         *
         * 只有哈希值一致(还说明不了key是否一致),且key也相同(必要时需要用到equals()方法)时,
         * 这里才认定是存在同位元素(在HashMap中占据相同位置的元素)
         */
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果该哈希槽上链接的是红黑树节点,则需要调用红黑树的插入方法
        else if (p instanceof TreeNode)
            e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);

        // 上面两种情况都是针对首个元素的判断,下面就是其他元素的判断
        // 遍历哈希槽后面元素(binCount统计的是插入新元素之前遍历过的元素数量)
        else {
            for (int binCount = 0; ; ++binCount) {
                // 如果没有找到同位元素,则需要插入新元素
                if ((e = p.next) == null) {
                    // 插入一个普通结点
                    p.next = newNode(hash, key, value, null);
                    // 哈希槽上的元素数量增加到TREEIFY_THRESHOLD后,将从链表转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }

                // 找到相同元素,直接退出
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;

                p = e;
            }
        }

        // 走到这里就说明,存在相同元素,那么问题就是是否需要覆盖原来的元素?
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // 如果onlyIfAbsent为false,或者原来的值为null,那么就覆盖
            if (!onlyIfAbsent || oldValue == null)
                // 更新旧值
                e.value = value;

            // 回调接口,不用管
            afterNodeAccess(e);
            return oldValue;
        }
    }

    // HashMap的更改次数加一,只有新增和删除才会更新,修改是不会的
    ++modCount;
    // 如果哈希数组的容量已超过阈值,则需要对哈希数组扩容
    if (++size > threshold)
        // 后面讲
        resize();
    // 回调接口,不用管
    afterNodeInsertion(evict);
    // 如果插入的是全新的元素,在这里返回null
    return null;
}

JDK1.7

put方法源码:

public V put(K key, V value) {
    // 如果哈希数组还未初始化,则调用inflateTable初始化
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    
    // 如果是key是null,那么单独调用putForNullKey添加
    if (key == null)
        return putForNullKey(value);
                              
    int hash = hash(key);
    // 获取桶的位置
    int i = indexFor(hash, table.length);
    // 如果当前桶已经有元素占据,则继续向后找,如果找到有key相同的元素,那么覆盖原来的值
    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++;
    // 当前桶首元素没有被占据,或者当前桶中没有相同元素,那么就在桶的第一个位置添加该元素
    addEntry(hash, key, value, i);
    return null;
}

addEntry方法源码:

void addEntry(int hash, K key, V value, int bucketIndex) {
    // 如果HashMap的大小超过阈值,并且当前桶不为空,那么进行扩容操作
    if ((size >= threshold) && (null != table[bucketIndex])) {
        // 扩容到原来的两倍
        resize(2 * table.length);
        // 不为null进行hash
        hash = (null != key) ? hash(key) : 0;
        // 获取桶的位置
        bucketIndex = indexFor(hash, table.length);
    }

    // 头插法创建新的节点
    createEntry(hash, key, value, bucketIndex);
}

resize 方法分析

JDK1.7

resize方法源码:

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    // 原来大小已经达到最大值,就不扩容了
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    // 扩容后新的Entry数组
    Entry[] newTable = new Entry[newCapacity];
    // 将原来的元素转移到新的Entry数组,initHashSeedAsNeeded方法决定是否重新计算String类型的hash值
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // 更新table
    table = newTable;
    // 更新threshold
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer方法源码:

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry e : table) {
        while(null != e) {
            Entry next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 获取新的桶位置
            int i = indexFor(e.hash, newCapacity);
            // 如果i位置原来没有值,则直接插入;有值,采用头插法
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

initHashSeedAsNeeded方法源码:

final boolean initHashSeedAsNeeded(int capacity) {
    // 如果hashSeed != 0,表示当前正在使用备用哈希
    boolean currentAltHashing = hashSeed != 0;
    // 如果vm启动了且map的容量大于阈值,使用备用哈希
    boolean useAltHashing = sun.misc.VM.isBooted() &&
        (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    // 异或操作,如果两值同时为false,或同时为true,都算是false
    boolean switching = currentAltHashing ^ useAltHashing;
    if (switching) {    
        // 改变hashSeed的值,使hashSeed!=0,rehash时String类型会使用新hash算法
        hashSeed = useAltHashing
            ? sun.misc.Hashing.randomHashSeed(this)
            : 0;
    }
    return switching;
}

Holder中维护的ALTERNATIVE_HASHING_THRESHOLD是触发启用备用哈希的阈值,该值表示,如果 HashMap 的容量(Entry 数组大小)达到了该值,启用备用哈希。

Holder会尝试读取 JVM 启动时传入的参数-Djdk.map.althashing.threshold并赋值给ALTERNATIVE_HASHING_THRESHOLD。它的值有如下含义:

  • ALTERNATIVE_HASHING_THRESHOLD = 1,总是使用备用哈希
  • ALTERNATIVE_HASHING_THRESHOLD = -1,禁用备用哈希

initHashSeedAsNeeded(int capacity)方法中,会判断如果 HashMap 的容量(Entry 数组大小)是否大于等于ALTERNATIVE_HASHING_THRESHOLD,是的话就会生成一个随机的哈希种子hashSeed,该种子会在hash方法中使用到。

上述操作实际上就是为了防止哈希碰撞攻击,只对 String 有效,因为 String 的hashcode方法是公开的。我们自己定义的类的hashcode方法就不需要这种操作了。

在JDK1.7里,经过 resize 后的链表元素会倒置。

JDK1.8

resize方法源码:

final Node[] resize() {

    Node[] oldTab = table;
    // 旧容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 旧阈值
    int oldThr = threshold;
    // 新容量,新阈值
    int newCap, newThr = 0;

    // 如果哈希数组已经初始化(非首次进来)
    if (oldCap > 0) {
        // 如果哈希表数组容量已经超过最大容量
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 将HashMap的阈值更新为允许的最大值
            threshold = Integer.MAX_VALUE;
            // 不需要更改哈希数组(容量未发生变化),直接返回
            return oldTab;
        }
        // newCap = oldCap << 1 尝试将哈希表数组容量加倍,如果容量成功加倍(没有达到上限),则将阈值也加倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 如果哈希数组还未初始化(首次进来)
    // 如果实例化HashMap时已经指定了初始容量,则将哈希数组当前容量初始化为与旧阈值一样大 this.threshold = tableSizeFor(initialCapacity);
    else if (oldThr > 0) // initial capacity was placed in threshold
        // oldThr在这里实际上就是原始capacity,因为capacity暂存在threshold里
        newCap = oldThr;

    // 如果实例化HashMap时没有指定初始容量,则使用默认的容量与阈值
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    /*
     * 至此,如果newThr==0,则可能有以下两种情形:
     * 1.哈希数组已经初始化,且哈希数组的容量还未超出最大容量,
     *   但是,在执行了加倍操作后,哈希数组的容量达到了上限
     * 2.哈希数组还未初始化,但在实例化HashMap时指定了初始容量
     */
    if (newThr == 0) {
        //
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft              // 针对第二种情况,将阈值更新为初始容量*装载因子
                : Integer.MAX_VALUE);  // 针对第一种情况,将阈值更新为最大值
    }

    // 更新阈值
    threshold = newThr;

    // 至此,说明哈希数组需要初始化,或者需要扩容,即创建新的哈希数组
    @SuppressWarnings({"rawtypes","unchecked"})
    Node[] newTab = (Node[])new Node[newCap];

    // 初始化数组,不需要扩容的话直接返回
    table = newTab;

    // 如果是扩容,则需要将旧元素复制到新容器
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node e;

            // 如果当前哈希槽上存在元素
            if ((e = oldTab[j]) != null) {
                // 置空该哈希槽
                oldTab[j] = null;

                // 如果该哈希槽上只有一个元素
                if (e.next == null)
                    // 由于总容量变了,所以需要重新哈希
                    newTab[e.hash & (newCap - 1)] = e;

                // 如果该哈希槽上链接了不止一个元素,且该元素是TreeNode类型
                else if (e instanceof TreeNode)
                    // 拆分红黑树以适应新的容量要求
                    ((TreeNode)e).split(this, newTab, j, oldCap);

                // 如果该哈希槽上链接了不止一个元素,且该元素是普通Node类型
                else { // preserve order

                    // 低位链表:存放扩容之后数组下标 = 当前数组下标
                    Node loHead = null, loTail = null;
                    // 高位链表:存放扩容之后数组下标 = 当前数组下标 + 扩容前数组大小
                    Node hiHead = null, hiTail = null;
                    Node next;

                    do {
                        next = e.next;
                        // 扩容前16,扩容后32,比如在最后一个哈希桶索引为15的元素进行如下操作:
                        // hash             =    0 1111
                        // oldCap           =    1 0000
                        // e.hash & oldCap  =    0 0000
                        if ((e.hash & oldCap) == 0) {
                            // 如果没有尾,说明链表为空
                            if (loTail == null)
                                // 链表为空时,头节点指向该元素
                                loHead = e;
                            else
                                // 如果有尾,那么链表不为空,把该元素挂到链表的最后
                                loTail.next = e;
                            // 把尾节点设置为当前元素
                            loTail = e;
                        }
                        // hash             =    1 1111
                        // oldCap           =    1 0000
                        // e.hash & oldCap  =    1 0000
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);

                    // 低位的元素组成的链表还是放置在原来的位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }

                    // 高位的元素组成的链表放置的位置只是在原有位置上偏移了老数组的长度个位置
                    // 例:hash为17在老数组放置在0下标,在新数组放置在16下标
                    //    hash为18在老数组放置在1下标,在新数组放置在17下标
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

分析:

  1. threshold = newThr;看到了吗?threshold在这里才更新为真正的阈值,之前都是暂存容量的。
  2. 对于链表结构节点的重新分配,不同于 JDK1.7 中需要重新进行 index 的计算,在 JDK1.8 中,是通过分组的方式存储在低位和高位链表中。

    举个例子:

    有一个哈希表的容量为16,其中一个元素的 hash 值为:1001 1111,那么经过计算,最后这个元素在哈希表中的位置是 15

    ​ n - 1: 0000 1111

    ​ hash:1001 1111

    ​ index:0000 1111 = 15

    另有一个元素的 hash 值为:1000 1111,那么经过计算,最后这个元素在哈希表中的位置也是 15

    ​ n - 1: 0000 1111

    ​ hash:1000 1111

    ​ index:0000 1111 = 15


    可以发现,在扩容前这两个元素都是存放在了索引为 15 的哈希桶中。但是扩容后就不一样了,由于容量变成了原来的两倍 32,那么哈希表的索引也就会发生改变

    ​ n - 1: 0001 1111

    ​ hash:1001 1111

    ​ index:0001 1111 = 31 = 15 + 16

    ​ n - 1: 0001 1111

    ​ hash:1000 1111

    ​ index:0000 1111 = 15

    注意到我加粗的数字,扩容后的索引位置貌似和 hash 值的第 5 位有关,也就是说,我们只需要考虑第 5 位是 0 还是 1,如果是 1 就放在高位,如果是 0 就放在低位,没错,事实就是如此,那该如何判断呢?我们发现,哈希表原来的容量是16,转换成二进制刚好是 0001 0000,这样不就可以通过让元素的 hash 值和原来的数组容量进行 & 运算来判断第 5 位了。如果第 5 位是 1,说明存放在高位,数组索引为原位置+原数组大小,否则是 0,说明存在在低位,也就是原位置

    在 JDK1.8 中确实就是这么做的,见如下代码:

    // 让元素的哈希值与扩容前的数组大小进行&运算,为0存放在低位链表loHead loTail
    if ((e.hash & oldCap) == 0) {
        // 如果没有尾,说明链表为空
        if (loTail == null)
            // 链表为空时,头节点指向该元素
            loHead = e;
        else
            // 如果有尾,那么链表不为空,把该元素挂到链表的最后
            loTail.next = e;
        // 把尾节点设置为当前元素
        loTail = e;
    }
    // 为1存放在高位链表hiHead hiTail
    else {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
    // 低位的元素组成的链表还是放置在 原来的位置
    if (loTail != null) {
        loTail.next = null;
        newTab[j] = loHead;
    }
    
    // 高位的元素组成的链表放置的位置是在 原有位置上偏移了原来数组的长度个位置
    if (hiTail != null) {
        hiTail.next = null;
        newTab[j + oldCap] = hiHead;
    }

实际效果如下图:

JDK1.8扩容重新分配

remove 方法分析

JDK1.7

remove方法源码:

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

removeEntryForKey方法源码:

final Entry removeEntryForKey(Object key) {
    if (size == 0) {
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);
    Entry prev = table[i];
    Entry e = prev;

    while (e != null) {
        Entry next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {
            // 找到可以删除的元素,删除需要标志结构性变化
            modCount++;
            size--;
            // 需要删除的元素刚好是桶中第一个元素,那么让table[i]指向后一个元素
            if (prev == e)
                table[i] = next;
            else
                prev.next = next;
            e.recordRemoval(this);
            return e;
        }
        prev = e;
        e = next;
    }

    return e;
}

JDK1.8

remove方法源码:

public boolean remove(Object key, Object value) {
    return removeNode(hash(key), key, value, true, true) != null;
}

removeNode方法源码:

final Node removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node[] tab;
    Node p;
    int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node node = null, e;
        K k;
        V v;

        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    // p指向的是node的前一个节点
                    p = e;
                } while ((e = e.next) != null);
            }
        }

        /*
         * 从HashMap中移除匹配的元素
         * 可能只需要匹配hash和key就行,也可能还要匹配value,这取决于matchValue参数
         */
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode)node).removeTreeNode(this, tab, movable);
            // 删除的是第一个节点
            else if (node == p)
                tab[index] = node.next;
            // 删除中间的节点,node表示待删元素,即让node的前一个节点p的下一个节点指向node的下一个节点
            else
                p.next = node.next;
            ++modCount;
            --size;
            // 回调接口
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

removeNode方法参数说明:

  • matchValue:移除元素时是否需要考虑 value 的匹配问题
  • movable:移除元素后如果红黑树根结点发生了变化,那么是否需要改变结点在链表上的顺序

get 方法分析

JDK1.7

get方法源码:

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry entry = getEntry(key);

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

getEntry方法源码:

final Entry getEntry(Object key) {
    if (size == 0) {
        return null;
    }

    int hash = (key == null) ? 0 : hash(key);
    for (Entry 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;
}

根据给定的 keyhash查找对应的(同位)元素,如果找不到,则返回 null

JDK1.8

get方法源码:

public V get(Object key) {
    Node e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode方法源码:

final Node getNode(int hash, Object key) {
    Node[] tab;
    Node first, e;
    int n;
    K k;

    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

根据给定的 keyhash查找对应的(同位)元素,如果找不到,则返回 null

删除和获取我都不在详细分析了,基本和添加差不多。

问答

为什么使用数组+链表或红黑树?

数组是用来确定哈希桶的位置,利用元素的 key 的 hash 值对数组长度取模得到。链表或红黑树是用来解决 hash 冲突问题,当出现 hash 值一样的情形,就在数组上的对应位置形成一条链表或一棵树。

PS:这里的 hash 值并不是指 hashcode,而是将 hashcode 高低十六位异或过的(JDK1.8)。

HashMap 的 get 过程(JDK1.8)

对 key 的 hashCode 进行 hash 运算,计算在哈希数组中的下标获取 bucket 位置,如果在桶的首位上就可以找到就直接返回,否则在树中找或者链表中遍历寻找。

HashMap 的 put 过程(JDK1.8)

putVal添加元素的过程:

  1. 如果哈希数组没有初始化,那么调用resize方法初始化哈希数组
  2. 获取添加元素在哈希数组中的索引,判断该位置是否有元素,如果没有,那么直接添加即可
  3. 如果已经有元素占用,那么判断该位置存放的是链表还是红黑树。如果是链表,判断当前位置的第一个元素的 hashcode 和 key 是否和自己的相同,相同则由 onlyIfAbsent 确定是否需要覆盖(或者本身是null直接覆盖);如果是红黑树,则直接调用 putTreeVal 方法存放。
  4. 首元素判断完后,如果不满足条件,那么开始遍历后面的节点,如果到了链表末尾还是没有找到相同的元素,那么直接在尾部添加当前元素。如果在这期间遍历的元素数量达到树化的条件,那么需要将原来的链表转换为红黑树。
  5. 如果遍历期间找到和自己 hashcode 和 key 相同的元素,那么由 onlyIfAbsent 确定是否需要覆盖(或者本身是null直接覆盖)
  6. 如果添加了新元素而不是覆盖原有值,需要 modCount 加1,表示发生了一次结构性变化。如果 size大于 threshold,则需要扩容resize

为什么用 (n-1)&hash 而不是 hash%n

这个问题也就是 为什么 HashMap 扩容需要是2的次幂

这里的 n 代表哈希表的长度,哈希表习惯将长度设置为 2 的 n 次方,这样恰好可以保证 (n - 1) & hash 的计算得到的索引值总是位于 table 数组的索引之内。例如:hash=15,n=16 时,结果为 15;hash=17,n=16 时,结果为 1。

但如果用 hash%n,那么如果 hash 是负数就会出现结果也是负数,并且%运算的效率低。

为什么 JDK1.8 不直接使用红黑树,而是保留了链表?

HashMap 在 JDK1.8 及以后的版本中引入了红黑树结构,若桶中链表元素个数大于等于 8 时, 链表转换成树结构;若桶中链表元素个数小于等于 6 时, 树结构还原成链表。因为红黑树的平均查找长度是 log(n),长度为 8 的时候,平均查找长度为 3,如果继续使用链表,平均查找长度为 8/2=4,这才有转换为树的必要。链表长度如果是小于等于 6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

选择 6 和 8,中间有个差值 7 可以有效防止链表和树频繁转换(类似于复杂度震荡)。假设一下,如果设计成链表个数超过 8 则链表转换成树结构,链表个数小于 8 则树结构转换成链表,如果一个 HashMap 不停的插入、删除元素,链表个数在 8 左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

第二种回答:

因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。 当元素小于8个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。所有才选取 8 这个数字作为链表转为红黑树的阈值,因为发生哈希冲突的概率满足泊松分布,当发生8次哈希碰撞的概率几乎为千万分之六,即以后很少会有元素再次添加到这个桶中,这样即使红黑树的新增元素效率低,也不会有多大影响了,因为几乎没有哈希桶中元素会超过8个。

当然这都得益于哈希函数设计的好,如果自己设计的哈希函数分布不均匀,比如我们把对象的hashcode都统一返回一个常量,最终的结果就是 HashMap 会退化为一个链表,get 方法的性能降为 O(n),使用红黑树可以将性能提升到 O(log(n)),所以应该避免这种情况的发生。

谈一下 HashMap 中 hash 函数是怎么实现的

用高16位与低16位进行异或

1、至于为什么要这样呢?

hashcode是一个32位的值,用高16位与低16位进行异或,原因在于求index是是用 (n-1) & hash ,如果hashmap的capcity很小的话,那么对于两个高位不同,低位相同的hashcode,可能最终会装入同一个桶中。那么会造成hash冲突,好的散列函数,应该尽量在计算hash时,把所有的位的信息都用上,这样才能尽可能避免冲突。

2、为什么使用异或运算?

通过写出真值表可以看出:异或运算为 50%的0和 50%的1,因此对于合并均匀的概率分布非常有用。

a | b | a AND b

0 | 0 | 0

0 | 1 | 0

1 | 0 | 0

1 | 1 | 1

a | b | a OR b

0 | 0 | 0

0 | 1 | 1

1 | 0 | 1

1 | 1 | 1

a | b | a XOR b

0 | 0 | 0

0 | 1 | 1

1 | 0 | 1

1 | 1 | 0

hash 冲突有哪些解决办法?

链地址法

开放地址法

  • 线性探测。遇到哈希冲突 +1 到下一个判断
  • 平方探测。遇到哈希冲突 +1 +4 +9 +16
  • 二次哈希。遇到哈希冲突 + hash2(key)

再哈希法

公共溢出区域法

HashMap 在什么条件下扩容?

JDK1.7

存放新值的时候当前已有元素的个数必须大于等于阈值,且当前加入的数据发生了 hash 冲突

JDK1.8

1、初始化哈希数组时会调用 resize 方法

2、put 时如果哈希数组的容量已超过阈值,则需要对哈希数组扩容

3、在树化前,会先检查哈希数组长度,如果哈希数组的长度小于64,则进行扩容,而不是进行树化

HashMap 扩容优化

在 JDK1.7 中,HashMap 整个扩容过程就是分别取出数组元素,一般该元素是最后一个放入链表中的元素,然后遍历以 该元素为头(头插法)的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将 原来哈希冲突的单向链表尾部变成扩容后单向链表的头部

而在 JDK 1.8 中,HashMap 对扩容操作做了优化。由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值和左移动的一位(newtable 的值)按位与操作是 0 或 1 就行,0 的话索引不变,1 的话索引变成原索引加上扩容前数组。

之所以能通过这种“与运算“来重新分配索引,是因为 hash 值本来就是随机的,而 hash 按位与上 newTable 得到的 0(扩容前的索引位置)和 1(扩容前索引位置加上扩容前数组长度的数值索引处)就是随机的,所以扩容的过程就能把之前哈希冲突的元素再随机分布到不同的索引中去。

一般使用什么作为 HashMap 的键?

一般用 Integer、String 这种不可变类作为 HashMap 的 key。

String 最为常用,因为:

  • 因为字符串是不可变的,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算。这就使得字符串很适合作为 Map 中的键,字符串的处理速度要快过其它的键对象。这就是 HashMap中 的键往往都使用字符串。
  • 因为获取对象的时候要用到 equals() 和 hashCode() 方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了 hashCode() 以及 equals() 方法。

LoadFactor 负载因子的设计

默认 LoadFactor 值为 0.75。 为什么是 0.75 这个值呢?

这是因为对于使用链表法的哈希表来说,查找一个元素的平均时间是 O(n),这里的 n 指的是遍历链表的长度,因此加载因子越大,对空间的利用就越充分,这就意味着链表的长度越长,查找效率也就越低。如果设置的加载因子太小,那么哈希表的数据将过于稀疏,对空间造成严重浪费。

HashMap 与 HashTable 区别

Hashtable 可以看做是线程安全版的 HashMap,两者几乎“等价”(当然还是有很多不同)。

Hashtable 几乎在每个方法上都加上 synchronized(同步锁),实现线程安全。

HashMap 可以通过 Collections.synchronizeMap(hashMap) 进行同步。

区别:

  • HashMap 继承于 AbstractMap,而 Hashtable 继承于 Dictionary;
  • 线程安全不同。Hashtable 的几乎所有函数都是同步的,即它是线程安全的,支持多线程。而HashMap 的函数则是非同步的,它不是线程安全的。若要在多线程中使用 HashMap,需要我们额外的进行同步处理;
  • null 值。HashMap 的 key、value 都可以为 null。Hashtable 的 key、value 都不可以为 null;
  • 迭代器 (Iterator)。HashMap 的迭代器 (Iterator) 是 fail-fast 迭代器,而 Hashtable 的 enumerator 迭代器不是 fail-fast 的。所以当有其它线程改变了 HashMap 的结构(增加或者移除元素),将会抛出ConcurrentModificationException。
  • 容量的初始值和增加方式都不一样:HashMap 默认的容量大小是 16;增加容量时,每次将容量变为“原始容量x2”。Hashtable 默认的容量大小是 11;增加容量时,每次将容量变为“原始容量x2 + 1”;
  • 添加 key-value 时的 hash 值算法不同:HashMap 添加元素时,是使用自定义的哈希算法。Hashtable 没有自定义哈希算法,而直接采用的 key 的 hashCode()。
  • 速度。由于 Hashtable 是线程安全的也是 synchronized,所以在单线程环境下它比 HashMap 要慢。如果你不需要同步,只需要单一线程,那么使用 HashMap 性能要好过 Hashtable。

红黑树中为什么新加入的节点总是红色的?

因为被插入前的树结构是构建好的,一旦我们进行添加黑色的节点,无论添加在哪里都会破坏原有路径上的黑色节点的数量平等关系,所以插入红色节点是正确的选择。

你可能感兴趣的:(java,hashmap的工作原理,面试)