HashMap

东西都是转自参考
作者:张拭心
https://blog.csdn.net/u011240877/article/details/53351188

https://blog.csdn.net/u011240877/article/details/53358305

why ? when ? what ? how ?

什么是 HashMap?

Hash: 散列

Map:意思是地图(x,y)存储值

HashMap 是一个采用hash表实现键值对集合,继承 AbstractMap,实现了Map接口。

HashMap 先通过哈希运算,得到目标元素在哈希表中的值,然后再进行少量比较就可以得到元素。当哈希冲突时候,HashMap采用拉链法进行解决。HashMap 的底层实现是采用数组+链表,jdk1.8之后当冲突元素达到8个后用红黑树。

HashMap 的特点

  1. key 用 Set 存放,所以想做到 key 不允许重复,key对应的类需要重写 hashCode 和 equals 方法
  2. 元素是无序的,而且顺序会不定时改变
  3. 插入、获取的时间复杂度基本是 O(1)(前提是有适当的哈希函数,让元素分布在均匀的位置)
  4. 两个关键因子:初始容量、加载因子

HashMap 的 13 个成员变量

1.默认初始容量:16,必须是 2 的整数次方

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

2.最大容量: 2^ 30 次方

static final int MAXIMUM_CAPACITY = 1 << 30;

3.默认加载因子的大小:0.75,可不是随便的,结合时间和空间效率考虑得到的

static final float DEFAULT_LOAD_FACTOR = 0.75f;

4.当前 HashMap 修改的次数,这个变量用来保证 fail-fast 机制

transient int modCount;

5.阈值,下次需要扩容时的值,等于 容量*加载因子

int threshold;

6.树形阈值:JDK 1.8 新增的,当使用 树 而不是列表来作为桶时使用。必须必 2 大

static final int TREEIFY_THRESHOLD = 8;

7.非树形阈值:也是 1.8 新增的,扩容时分裂一个树形桶的阈值,要比 TREEIFY_THRESHOLD 小

static final int UNTREEIFY_THRESHOLD = 6;

8.树形最小容量:桶可能是树的哈希表的最小容量。至少是 TREEIFY_THRESHOLD 的 4 倍,这样能避免扩容时的冲突

static final int MIN_TREEIFY_CAPACITY = 64;

9.缓存的 键值对集合(另外两个视图:keySet 和 values 是在 AbstractMap 中声明的)

transient Set

HashMap的初始容量和加载因子

由于 HashMap 扩容开销很大(需要创建新数组、重新哈希、分配等),决定因素:

  1. 容量:数组的数量
  2. 加载因子:决定了 HashMap 中元素占有多少比例时扩容

HashMap 的默认加载因子为 0.75,这是在时间、空间方面均衡考虑

  1. 如果加载因子过大那么冲突的可能就会大,查找效率反而变低
  2. 太小的话频繁 rehash,导致性能降低

如果提前知道需要存储的容量大小,可以设置大点的容量,这样可以少扩容几次,设计合理的加载因子,尽可能避免进行扩容。

HashMap 的关键方法

HashMap 的 4 个构造方法

//创建一个空的哈希表,初始容量为 16,加载因子为 0.75
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);
}

//创建一个内容为参数 m 的内容的哈希表
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

其中第三种构造方法调用了 tableSizeFor(int) 来根据指定的容量设置阈值,这个方法经过若干次无符号右移、求异运算,得出最接近指定参数 cap 的 2 的 N 次方容量。假如你传入的是 5,返回的初始容量为 8 。

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

第四种构造方法调用了 putMapEntries(),这个方法用于向哈希表中添加整个集合:

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {
        //数组还是空,初始化参数
        if (table == null) { // pre-size
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        //数组不为空,超过阈值就扩容
        else if (s > threshold)
            resize();
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            //先经过 hash() 计算位置,然后复制指定 map 的内容
            putVal(hash(key), key, value, false, evict);
        }
    }
}

HashMap 中的链表节点

JDK1.8之前 HashMap 底层数据结构是数组+链表
//实现了 Map.Entry 接口
static class Node

HashMap 中的添加操作

put() 是我们使用 HashMap 最频繁的几个操作之一。

下文用“桶”来指代要数组,每个桶都对应着一条链表:
//添加指定的键值对到 Map 中,如果已经存在,就替换
public V put(K key, V value) {
//先调用 hash() 方法计算位置
return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //如果当前 哈希表内容为空,新建,n 指向最后一个桶的位置,tab 为哈希表另一个引用
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //如果要插入的位置没有元素,新建个节点并放进去
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        //如果要插入的桶已经有元素,替换
        // e 指向被替换的元素
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //p 指向要插入的桶第一个 元素的位置,如果 p 的哈希值、键、值和要添加的一样,就停止找,e 指向 p
            e = p;
        else if (p instanceof TreeNode)
            //如果不一样,而且当前采用的还是 JDK 8 以后的树形节点,调用 putTreeVal 插入
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //否则还是从传统的链表数组查找、替换

        //遍历这个桶所有的元素
            for (int binCount = 0; ; ++binCount) {
                //没有更多了,就把要添加的元素插到后面得了
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //当这个桶内链表个数大于等于 8,就要树形化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果找到要替换的节点,就停止,此时 e 已经指向要被替换的节点
                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;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //如果超出阈值,就得扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

根据代码可以总结插入逻辑如下:

  1. 先调用 hash()方法进行计算哈希值
  2. 然后调用 putVal()方法根据哈希值进行相关操作
  3. 如果当前哈希表内容为空,新建一个哈希表
  4. 如果要插入的桶中没有元素,新建个节点放进去
  5. 否则从桶中第一个元素开始查找哈希值对应位置

    1.如果桶中第一个元素的哈希值要和添加的一样,替换,结束查找

    2.如果第一个元素不一样,而且当前采用的还是 JDK8以后的树形节点,调用 putTreeVal()进行插入

    3.否则还是从传统的链表数据中查找、替换、结束查找

    4.当这个桶内链表个数大于等于 8 ,就要调用 treeifyBin方法进行树形化

  6. 最后检查是否需要扩容

插入过程中涉及到几个其他关键的方法 :

hash():计算对应的位置

resize():扩容

putTreeVal():树形节点的插入

treeifyBin():树形化容器

HashMap 中的哈希函数 hash()

HashMap 中通过将传入键的 hashCode 进行无符号右移 16 位,然后进行按位异或,得到这个键的哈希值。

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

由于哈希表的容量都是 2 的 N 次方,在当前,元素的 hashCode() 在很多时候下低位是相同的,这将导致冲突(碰撞),因此 1.8 以后做了个移位操作:将元素的 hashCode() 和自己右移 16 位后的结果求异或。

这样可以避免只靠低位数据来计算哈希时导致的冲突,计算结果由高低位结合决定,可以避免哈希值分布不均匀。

而且,采用位运算效率更高。

HashMap 中的初始化/扩容方法 resize

每次添加时会比较当前元素个数和阈值:

//如果超出阈值,就得扩容
if (++size > threshold)
    resize();

扩容:

final Node<K,V>[] resize() {
    //复制一份当前的数据
    Node<K,V>[] oldTab = table;
    //保存旧的元素个数、阀值
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //新的容量为旧的两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //如果旧容量小于等于 16,新的阈值就是旧阈值的两倍
            newThr = oldThr << 1; // double threshold
    }
    //如果旧容量为 0 ,并且旧阈值>0,说明之前创建了哈希表但没有添加元素,初始化容量等于阈值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        //旧容量、旧阈值都是0,说明还没创建哈希表,容量为默认容量,阈值为 容量*加载因子
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //如果新的阈值为 0 ,就得用 新容量*加载因子 重计算一次
    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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //接下来就得遍历复制了
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                //旧的桶置为空
                oldTab[j] = null;
                //当前 桶只有一个元素,直接赋值给对应位置
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    //如果旧哈希表中这个位置的桶是树形结构,就要把新哈希表里当前桶也变成树形结构
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { //保留旧哈希表桶中链表的顺序
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        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;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

扩容过程中几个关键的点:

  1. 新初始化哈希表时,容量为默认容量,阈值为 容量*加载因子
  2. 已有哈希表扩容时,容量、阈值均翻倍
  3. 如果之前这个桶的节点类型是树,需要把新哈希表里当前桶也变成树形结构
  4. 复制给新哈希表中需要重新索引(rehash),这里采用的计算方法是
    e.hash & (newCap - 1),等价于 e.hash % newCap

结合扩容源码可以发现扩容的确开销很大,需要迭代所有的元素,rehash、赋值,还得保留原来的数据结构。

所以在使用的时候,最好在初始化的时候就指定好 HashMap 的长度,尽量避免频繁 resize()。

HashMap 的获取方法 get()

HashMap 另外一个经常使用的方法就是 get(key),返回键对应的值:

如果 HashMap 中包含一个键值对 k-v 满足:

(key == null ? k == null : key.equals(k))

就返回值 v,否则返回 null;

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

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //tab 指向哈希表,n 为哈希表的长度,first 为 (n - 1) & hash 位置处的桶中的头一个节点
    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)
                //如果是树形节点,就调用树形节点的 get 方法
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                //do-while 遍历链表的所有节点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

查找 方法比较简单:

  1. 先计算哈希值;
  2. 然后再用 (n - 1) & hash 计算出桶的位置;
  3. 在桶里的链表进行遍历查找。

时间复杂度一般跟链表长度有关,因此哈希算法越好,元素分布越均匀,get() 方法就越快,不然遍历一条长链表,太慢了。

不过在 JDK 1.8 以后 HashMap 新增了红黑树节点,优化这种极端情况下的性能问题。

红黑树操作

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;  // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;    // needed to unlink next upon deletion
boolean red;
}

可以看到就是个红黑树节点,有父亲、左右孩子、前一个元素的节点,还有个颜色值。

另外由于它继承自 LinkedHashMap.Entry ,而 LinkedHashMap.Entry 继承自 HashMap.Node ,因此还有额外的 6 个属性:

//继承 LinkedHashMap.Entry 的
Entry<K,V> before, after;

//HashMap.Node 的
final int hash;
final K key;
V value;
Node<K,V> next;

HashMap 中关于红黑树的三个关键参数

HashMap 中有三个关于红黑树的关键参数:

  1. TREEIFY_THRESHOLD
  2. UNTREEIFY_THRESHOLD
  3. MIN_TREEIFY_CAPACITY

值及作用如下:

//一个桶的树化阈值
//当桶中元素个数超过这个值时,需要使用红黑树节点替换链表节点
//这个值必须为 8,要不然频繁转换效率也不高
static final int TREEIFY_THRESHOLD = 8;

//一个树的链表还原阈值
//当扩容时,桶中元素个数小于这个值,就会把树形的桶元素 还原(切分)为链表结构
//这个值应该比上面那个小,至少为 6,避免频繁转换
static final int UNTREEIFY_THRESHOLD = 6;

//哈希表的最小树形化容量
//当哈希表中的容量大于这个值时,表中的桶才能进行树形化
//否则桶内元素太多时会扩容,而不是树形化
//为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;

桶的树形化 treeifyBin()

在Java 8 中,如果一个桶中的元素个数超过 TREEIFY_THRESHOLD(默认是 8 ),就使用红黑树来替换链表,从而提高速度。

这个替换的方法叫 treeifyBin() 即树形化。

//将桶内所有的 链表节点 替换成 红黑树节点
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //如果当前哈希表为空,或者哈希表中元素的个数小于 进行树形化的阈值(默认为 64),就去新建/扩容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        //如果哈希表中的元素个数超过了 树形化阈值,进行树形化
    // e 是哈希表中指定位置桶里的链表节点,从第一个开始
        TreeNode<K,V> hd = null, tl = null;//红黑树的头、尾节点
        do {
            //新建一个树形节点,内容和当前链表节点 e 一致
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)//确定树头节点
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        //让桶的第一个元素指向新建的红黑树头结点,以后这个桶里的元素就是红黑树而不是链表了
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

上述操作做了这些事:

  1. 根据哈希表中元素个数确定是扩容还是树形化
  2. 如果是树形化

    1.遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系

    2.然后让桶第一个元素指向新建的树头结点,替换桶的链表内容为树形内容

但是我们发现,之前的操作并没有设置红黑树的颜色值,现在得到的只能算是个二叉树。在 最后调用树形节点 hd.treeify(tab) 方法进行塑造红黑树,来看看代码:

final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        if (root == null) { //头回进入循环,确定头结点,为黑色
            x.parent = null;
            x.red = false;
            root = x;
        }
        else {  //后面进入循环走的逻辑,x 指向树中的某个节点
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            //又一个循环,从根节点开始,遍历所有节点跟当前节点 x 比较,调整位置,有点像冒泡排序
            for (TreeNode<K,V> p = root;;) {
                int dir, ph;        //这个 dir 
                K pk = p.key;
                if ((ph = p.hash) > h)  //当比较节点的哈希值比 x 大时, dir 为 -1
                    dir = -1;
                else if (ph < h)  //哈希值比 x 小时 dir 为 1
                    dir = 1;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    // 如果比较节点的哈希值、 x 
                    dir = tieBreakOrder(k, pk);

                    //把 当前节点变成 x 的父亲
                    //如果当前比较节点的哈希值比 x 大,x 就是左孩子,否则 x 是右孩子 
                TreeNode<K,V> xp = p;
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    moveRootToFront(tab, root);
}

可以看到,将二叉树变为红黑树时,需要保证有序。这里有个双重循环,拿树中的所有节点和当前节点的哈希值进行对比(如果哈希值相等,就对比键,这里不用完全有序),然后根据比较结果确定在树种的位置。

红黑树中添加元素 putTreeVal()

在添加时,如果一个桶中已经是红黑树结构,就要调用红黑树的添加元素方法 putTreeVal()。

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                               int h, K k, V v) {
    Class<?> kc = null;
    boolean searched = false;
    TreeNode<K,V> root = (parent != null) ? root() : this;
    //每次添加元素时,从根节点遍历,对比哈希值
    for (TreeNode<K,V> p = root;;) {
        int dir, ph; K pk;
        if ((ph = p.hash) > h)
            dir = -1;
        else if (ph < h)
            dir = 1;
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))  
        //如果当前节点的哈希值、键和要添加的都一致,就返回当前节点(奇怪,不对比值吗?)
            return p;
        else if ((kc == null &&
                  (kc = comparableClassFor(k)) == null) ||
                 (dir = compareComparables(kc, k, pk)) == 0) {
            //如果当前节点和要添加的节点哈希值相等,但是两个节点的键不是一个类,只好去挨个对比左右孩子 
            if (!searched) {
                TreeNode<K,V> q, ch;
                searched = true;
                if (((ch = p.left) != null &&
                     (q = ch.find(h, k, kc)) != null) ||
                    ((ch = p.right) != null &&
                     (q = ch.find(h, k, kc)) != null))
                    //如果从 ch 所在子树中可以找到要添加的节点,就直接返回
                    return q;
            }
            //哈希值相等,但键无法比较,只好通过特殊的方法给个结果
            dir = tieBreakOrder(k, pk);
        }

        //经过前面的计算,得到了当前节点和要插入节点的一个大小关系
        //要插入的节点比当前节点小就插到左子树,大就插到右子树
        TreeNode<K,V> xp = p;
     //这里有个判断,如果当前节点还没有左孩子或者右孩子时才能插入,否则就进入下一轮循环 
        if ((p = (dir <= 0) ? p.left : p.right) == null) {
            Node<K,V> xpn = xp.next;
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
            if (dir <= 0)
                xp.left = x;
            else
                xp.right = x;
            xp.next = x;
            x.parent = x.prev = xp;
            if (xpn != null)
                ((TreeNode<K,V>)xpn).prev = x;
            //红黑树中,插入元素后必要的平衡调整操作
            moveRootToFront(tab, balanceInsertion(root, x));
            return null;
        }
    }
}

//这个方法用于 a 和 b 哈希值相同但是无法比较时,直接根据两个引用的地址进行比较
//这里源码注释也说了,这个树里不要求完全有序,只要插入时使用相同的规则保持平衡即可
 static int tieBreakOrder(Object a, Object b) {
    int d;
    if (a == null || b == null ||
        (d = a.getClass().getName().
         compareTo(b.getClass().getName())) == 0)
        d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
             -1 : 1);
    return d;
}

通过上面的代码可以知道,HashMap 中往红黑树中添加一个新节点 n 时,有以下操作:

  1. 从根节点开始遍历当前红黑树中的元素 p,对比 n 和 p 的哈希值;
  2. 如果哈希值相等并且键也相等,就判断为已经有这个元素(这里不清楚为什么不对比值);
  3. 如果哈希值就通过其他信息,比如引用地址来给个大概比较结果,这里可以看到红黑树的比较并不是很准确,注释里也说了,只是保证个相对平衡即可;
  4. 最后得到哈希值比较结果后,如果当前节点 p 还没有左孩子或者右孩子时才能插入,否则就进入下一轮循环;
  5. 插入元素后还需要进行红黑树例行的平衡调整,还有确保根节点的领先地位。

红黑树中查找元素 getTreeNode()

HashMap 的查找方法是 get():

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

它通过计算指定 key 的哈希值后,调用内部方法 getNode();

final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> 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<K,V>)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;
}

树形结构修剪 split()

HashMap 中, resize() 方法的作用就是初始化或者扩容哈希表。当扩容时,如果当前桶中元素结构是红黑树,并且元素个数小于链表还原阈值 UNTREEIFY_THRESHOLD (默认为 6),就会把桶中的树形结构缩小或者直接还原(切分)为链表结构,调用的就是 split():

//参数介绍
//tab 表示保存桶头结点的哈希表
//index 表示从哪个位置开始修剪
//bit 要修剪的位数(哈希值)
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        //如果当前节点哈希值的最后一位等于要修剪的 bit 值
        if ((e.hash & bit) == 0) {
                //就把当前节点放到 lXXX 树中
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            //然后 loTail 记录 e
            loTail = e;
            //记录 lXXX 树的节点数量
            ++lc;
        }
        else {  //如果当前节点哈希值最后一位不是要修剪的
                //就把当前节点放到 hXXX 树中
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            //记录 hXXX 树的节点数量
            ++hc;
        }
    }


    if (loHead != null) {
        //如果 lXXX 树的数量小于 6,就把 lXXX 树的枝枝叶叶都置为空,变成一个单节点
        //然后让这个桶中,要还原索引位置开始往后的结点都变成还原成链表的 lXXX 节点
        //这一段元素以后就是一个链表结构
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
        //否则让索引位置的结点指向 lXXX 树,这个树被修剪过,元素少了
            tab[index] = loHead;
            if (hiHead != null) // (else is already treeified)
                loHead.treeify(tab);
        }
    }
    if (hiHead != null) {
        //同理,让 指定位置 index + bit 之后的元素
        //指向 hXXX 还原成链表或者修剪过的树
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

从上述代码可以看到,HashMap 扩容时对红黑树节点的修剪主要分两部分,先分类、再根据元素个数决定是还原成链表还是精简一下元素仍保留红黑树结构。

1.分类

指定位置、指定范围,让指定位置中的元素 (hash & bit) == 0 的,放到 lXXX 树中,不相等的放到 hXXX 树中。

2.根据元素个数决定处理情况

符合要求的元素(即 lXXX 树),在元素个数小于 6 时还原成链表,最后让哈希表中修剪的痛 tab[index] 指向 lXXX 树;在元素个数大于 6 时,还是用红黑树,只不过是修剪了下枝叶;

不符合要求的元素(即 hXXX 树)也是一样的操作,只不过最后它是放在了修剪范围外 tab[index + bit]。


总结

JDK 1.8 以后哈希表的 添加、删除、查找、扩容方法都增加了一种 节点为 TreeNode 的情况:

  1. 添加时,当桶中链表个数超过 8 时会转换成红黑树;
  2. 删除、扩容时,如果桶中结构为红黑树,并且树中元素个数太少的话,会进行修剪或者直接还原成链表结构;
  3. 查找时即使哈希函数不优,大量元素集中在一个桶中,由于有红黑树结构,性能也不会差。

1.HashMap 的缺点:不同步

当多线程并发访问一个 哈希表时,需要在外部进行同步操作,否则会引发数据不同步问题。

你可以选择加锁,也可以考虑用 Collections.synchronizedMap 包一层,变成个线程安全的 Map:

Map m = Collections.synchronizedMap(new HashMap(...));

最好在初始化时就这么做。

2.HashMap 三个视图返回的迭代器都是 fail-fast 的:如果在迭代时使用非迭代器方法修改了 map 的内容、结构,迭代器就会报 ConcurrentModificationException 的错。

3.当 HashMap 中有大量的元素都存放到同一个桶中时,这时候哈希表里只有一个桶,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。

针对这种情况,JDK 1.8 中引用了 红黑树(时间复杂度为 O(logn)) 优化这个问题。

4.HashMap 允许 key, value 为 null,同时他们都保存在第一个桶中。

5.HashMap 中 equals() 和 hashCode() 有什么作用?

HashMap 的添加、获取时需要通过 key 的 hashCode() 进行 hash(),然后计算下标 ( n-1 & hash),从而获得要找的同的位置。

当发生冲突(碰撞)时,利用 key.equals() 方法去链表或树中去查找对应的节点。

展开阅读全文

没有更多推荐了,返回首页