Java容器深度总结:HashMap

向外探寻,向内思考, 向下扎根,向阳生长。

内容

1.HashMap概述

HashMap是根据关键码值(Key-Value)而直接进行访问的数据结构。并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。HashMap线程不安全。

HashMap是JDK1.2时哈希表的实现,JDK1.8底层是使用数组+链表+红黑树实现,JDK1.7使用数组+链表实现,HashMap使用“链地址法”解决哈希冲突。

在这里插入图片描述

使用红黑树主要是因为当链表过长时,对某个桶位元素的查找变成了线性时间O(n),转换为红黑树之后查询时间复杂度从变成O(logN)提高了查找效率。

之所以不直接使用红黑树去处理hash冲突,是因为红黑树节点所占的空间大小是普通链表节点的2倍,其次红黑树在增删时需要维护红黑树的性质,实质上还是时间和空间的一种折衷。

2.HashMap基础

在学习HashMap之前,你需要掌握的基础知识有:

几个概念:

  • 哈希表:存放数据的数组叫做哈希表。
  • 哈希桶:存放数据的数组,每一个存储空间都是一个哈希桶。
3.HashMap的定义
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable 

HashMap类图:
在这里插入图片描述

从HashMap的定义中,可以了解到:

  • 实现了Map接口,存放键值对(key-value)形式的数据,拥有Map接口的通用操作。允许null键和null值,元素无序。
  • 继承 AbstractMap 抽象类,给类提供了Map接口的基本实现。
  • 实现了Cloneable、Serializable 标志性接口,支持克隆、序列化操作。
4.主要的类属性
4.1 transient Node< K, V >[] table

table是底层存储key-value数据的数组,该数组的类型为Node,JDK1.8开始,由于新增了红黑树结构,Node可能表示链表节点,也可能是红黑树节点(TreeNode),稍后会讲到。其次table数组的长度必须是2的整数次方

4.2 transient int size

size表示HashMap中实际存储的数据元素个数。

4.3 final float loadFactor

loadFactor表示哈希表的加载因子。加载因子是哈希表在其容量自动扩容之前可以达到多满的一种度量。加载因子越大,填满的元素越多;加载因子越小,填满的元素越少。 加载因子主要用于决定哈希表达到多满的时候,应该进行扩容。

注意:

哈希表的实时加载因子=size/capacity,其中capacity为数组的长度,因此加载因子是可以大于1的。

4.4 int threshold

threshold表示扩容阈值,threshold = 容量 * 加载因子。当哈希表的大小大于扩容阈值时,哈希表就会扩容。

4.5 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4

DEFAULT_INITIAL_CAPACITY :HashMap默认初始容量为16,并且HashMap的容量必须是2的幂次方。

4.6 static final int MAXIMUM_CAPACITY = 1 << 30

MAXIMUM_CAPACITY :最大容量为1 << 30,即2^30次方。
注意:2^30实际上就是int范围类的最大的2的幂次方值。

4.7 static final float DEFAULT_LOAD_FACTOR = 0.75f

DEFAULT_LOAD_FACTOR :默认加载因子为0.75。HashMap的加载因子可以通过构造函数指定,但一般不建议更改。

原因:

加载因子表示哈希表满的程度。

  • 哈希表越满,空间利用率越高,但发生哈希冲突的概率也会越高;
  • 哈希表越空,空间利用率越低,发生哈希冲突的概率也会越低。

因此,加载因子需要在时间和空间成本上寻求一种折衷。

  • 加载因子过高,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本;
  • 加载因子过低,虽然可以减少查询时间成本,但是空间利用率很低,可能还有很多空间没有使用,就要去进行扩容。

默认加载因子为0.75其实就是在时间和空间成本上寻求的一种折衷。具体来说:在理想情况下,使用随机哈希码,节点出现的频率在hash桶中遵循泊松分布。用0.75作为加载因子,每个碰撞位置的链表长度超过8个的概率为0.00000006,几乎是不可能的事情。这也解释了默认加载因子取0.75,而不去其他值的原因。

4.8 static final int TREEIFY_THRESHOLD = 8

TREEIFY_THRESHOLD :链表树形化阈值,即链表转成红黑树的阈值,在存储数据时,当链表长度 大于8 时,则将链表转换成红黑树。

4.9 static final int UNTREEIFY_THRESHOLD = 6

UNTREEIFY_THRESHOLD :红黑树还原为链表的阈值。在扩容的数据转移时,若红黑树链表内节点数量 小于等于6 时,则将红黑树节点链表转换成普通节点链表。

4.10 static final int MIN_TREEIFY_CAPACITY = 64

MIN_TREEIFY_CAPACITY :链表树形化的最小容量阈值,即当哈希表中的容量 大于等于64 时,才允许树形化链表,否则不进行树形化,而是扩容一次。

5.节点

由于JDK1.8引入了红黑树,所以哈希表的每个桶可能是一个链表,也可能是一个红黑树。

5.1 Node节点(链表节点)
 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; // 存放key的hash值
        final K key;
        V value;
        Node<K,V> next; 

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
        // ... 
}

Node节点实现了Map.Entry(Map体系中的集合的内部节点实现类的超级接口),并且实现了Map.Entry接口的所有方法。

Tip:

  • JDK 1.7 中 Node 被 叫做 Entry,只是名字不一样。
5.2 TreeNode节点(红黑树节点)
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;
    //节点的颜色,默认是红色
    boolean red;

    TreeNode(int hash, K key, V val, Node<K, V> next) {
        super(hash, key, val, next);
    }
}

TreeNode直接继承了LinkedHashMap.Entry节点类,而LinkedHashMap类中的节点类Entry则是继承了Node节点类。

TreeNode类图:
在这里插入图片描述
从中可以发现,链表的节点(Node)和红黑树节点(TreeNode)其实是爷孙的关系。因此底层数组table中的Node的实际类型可能就是链表节点Node,也可能是红黑树节点TreeNode

注意:HashMap中的红黑树的节点还通过next和prev维持双向链表的特征。

6.构造函数
6.1 HashMap()

构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

需要注意的是,HashMap采用了类似延迟加载的策略,在创建HashMap对象时,并不会立即初始化用于存放数据的table数组,而是在首次向HashMap中添加元素时触发扩容机制,在扩容机制中为table数组进行初始化。

6.2 HashMap(int initialCapacity, float loadFactor)

构造一个带指定初始容量和加载因子的空 HashMap。初始容量为负或者加载因子为负,则抛出IllegalArgumentException异常。

public HashMap(int initialCapacity, float loadFactor) {
    /*1 参数检测*/
    // 如果指定的初始容量小于0,那么抛出IllegalArgumentException异常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                initialCapacity);

    // 如果指定的初始容量如果大于最大容量,那么初始容量等于最大容量
    // 即HashMap的最大容量只能是MAXIMUM_CAPACITY
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;

    // 如果加载因子小于等于0或者不是一个数值类型,那么抛出IllegalArgumentException异常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                loadFactor);

    // 设置实际加载因子
    this.loadFactor = loadFactor;

    // 设置“扩容阈值”。注意这里设置的值并不是真正的阈值,在put元素时,会重新计算threshold,threshold=容量*加载因子
    //tableSizeFor方法计算一个大于等于initialCapacity的2的整数次幂的数
    // 如: 传入10,返回16
    this.threshold = tableSizeFor(initialCapacity);
}

tableSizeFor()方法会计算一个大于等于initialCapacity的2的整数次幂的数,例如:传入10,则会返回16。

注意,此时的扩容阈值(threshold )暂存的是HashMap计算后的初始容量。

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

Tip:

  • 凡是2的N次方的整数,其二进制码中只有一个1。

tableSizeFor()正是利用这一特点,使用无符号右移和位或操作,将cap的最高位1的所有低位全部都变成1,最后在让结果+1来得到2的整数次幂的值。

注意:一开始的减一是为了使得目标值大于或等于原值。若没有减一操作,计算的目标值只能大于原值,例如cap=8,将会计算出16。

6.3 HashMap(int initialCapacity)

构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
6.4 HashMap(Map<? extends K, ? extends V> m)

构造包含指定Map元素的新HashMap。所创建的HashMap具有默认加载因子(0.75)和足以容纳指定 Map 中键值对的初始容量。

public HashMap(Map<? extends K, ? extends V> m) {
    // 设置加载因子为默认值0.75
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    // 将传入的子Map中的全部元素逐个添加到HashMap中
    putMapEntries(m, false);
}

// putMapEntries 是 Map.putAll 的具体实现
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    //s为m的实际元素个数
    int s = m.size();
    if (s > 0) {
        // 判断table是否已经初始化
        if (table == null) { // pre-size
            // 未初始化,计算初始容量
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                    (int)ft : MAXIMUM_CAPACITY);
            // 计算得到的t大于阈值,则初始化阈值
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        // 已初始化,并且m元素个数大于阈值,进行扩容处理
        else if (s > threshold)
            resize();
        // 然后,将m中的所有元素循环添加至HashMap中
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            //该方法也是put方法内部调用的方法
            putVal(hash(key), key, value, false, evict);
        }
    }
}

注意:

putMapEntries()方法中,table未初始化时需要计算HashMap的初始容量:float ft = ((float)s / loadFactor) + 1.0F

之所以还要加1.0F,是为了(int)ft 时,做到对小数向上取整以尽可能保证更大容量。更大的容量能够在后面的存储过程中减少扩容的次数。

7.hash函数(扰动函数)与索引计算
7.1 hash函数(扰动函数)

HashMap中的hash函数也叫扰动函数,hash(key)是先拿到 key 的hashcode(32位的int值),然后让hashcode的高16位和低16位进行异或操作。

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

Tip:

从hash函数中可以看到,对于key为null的元素hash始终返回0,0对任何数取余都为0,所以key为null的元素被始终固定存储到数组0索引的位置。

7.2 索引计算

HashMap中的hash函数会计算出key的hash值,但这个hash值是一个32位的int值,也就是说hash值的范围是 [-2^31 ~ 2^31-1] ,而HashMap的桶是有限的,因此这个hash值不能直接使用,用之前还要对哈希表的长度取模,得到的余数才能访问数组下标。

HashMap使用位与代替了取模:index = (n - 1) & hash

n即为数组的容量。

HashMap使用 (n - 1) & hash 计算数组下标,相比于取模运算效率更高,另外hash值为负数时取模运算还需考虑取绝对值,而位与则不用。

注意:hash % n 等于 (n - 1) & hash 的前提条件是:数组的容量n必须是2的整数次幂。证明可以参考:https://www.cnblogs.com/ysocean/p/9054804.html
这也解释了,为什么数组的容量必须是2的整数次幂,其根本原因就是为了保证哈希计算的高效。

7.3 为什么要用扰动函数而不直接用key.hashCode()计算

由于数组的长度n一定是一个2的整数次幂的数,(n - 1) 正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,以初始长度16为例:

  10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
  00000000 00000000 00000101    //高位全部归零,只保留末四位

也就是说,(n - 1) & hash 其实就是截取计算出的hash值的低logn位,例如数值长度n=16,则计算索引等于key的hashcode的低(log16 = 4)位。

这就导致了,当不同key的hashcode值 高位不同而低位相同时,计算出的索引相同,就产生了严重的哈希冲突,扰动函数让hashcode的高16位和低16位进行异或操作,使得key的hashcode的高位也参与运算,这样低位掺杂了高位的部分特征,降低了不同key的hashcode值 高位不同而低位相同时哈希冲突发生的概率。

8.HashMap扩容机制

哈希表容量是有限的,数据不断插入,到达一定的数量就会进行扩容,并且新容量是旧容量的2倍,因为哈希表的容量必须是2的整数次幂。HashMap的初始化和扩容机制都由resize()方法实现。

8.1 扩容的时机

当HashMap满足以下条件时,就会触发扩容机制:

  • 第一次 put 元素时(即table未初始化时,进行初始化扩容)。
  • 元素个数size > 扩容阈值 threshold 时(threshold = 数组容量 * loadFactor)。
  • HashMap其中一条链表长度 > 8 并且 数组容量 < 64 时。

注意:HashMap其中一条链表长度 > 8 不一定就会转成红黑树,还必须满足数组容量 >= 64,否则会扩容一次。

8.2 HashMap扩容的执行流程

HashMap的resize()方法扩容时的过程可分为:

  1. 如果旧容量大于0,即已经初始化了哈希表,那么可能需要扩容:

    a. 如果旧容量oldCap大于等于最大容量,则不再扩容,直接返回旧数组,resize方法结束。

    b. 如果旧容量小于最大容量,则需要扩容,计算扩容新容量newCap和新阈值newThr;

  2. 如果旧容量等于0,即没有初始化哈希表,计算初始化新容量newCap和新阈值newThr;

  3. 建立新数组newTab,容量为新容量newCap,将table引用指向新数组。

  4. 如果旧数组oldTab有数据,那么转移旧数组的数据到新数组newTab;

  5. 返回新数组,resize方法结束。

**
 * resize方法可用于:
 * 1 初始化哈希表;
 * 2 扩容;
 */
final Node<K, V>[] resize() {
    // 获取旧数组
    Node<K, V>[] oldTab = table;
    //获取数组的容量(长度),如果旧的数组oldTab为空,旧的数组容量oldCap设为0
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //获取旧的扩容阈值
    int oldThr = threshold;
    int newCap, newThr = 0;

    /*1 如果旧容量大于0,即已经初始化了哈希表 那么可能需要扩容*/
    if (oldCap > 0) {
        /*1.1 如果旧容量大于等于最大容量,则不再扩容*/
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 设置容量阈值为int类型最大值,
            threshold = Integer.MAX_VALUE;
            //直接返回旧的数组,即不进行扩容。
            return oldTab;
        }
        /*1.2 如果旧容量小于最大容量,则需要扩容*/
        // 首先计算新容量、新阈值
        // 新容量尝试扩容为旧容量的2倍: newCap = oldCap << 1
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY) {
            // 如果新容量小于最大容量,并且旧容量大于等于默认容量,那么新阈值也尝试直接扩容为旧阈值的2倍:newThr = oldThr << 1
            newThr = oldThr << 1;
        }
    }
    /*2 否则,如果旧阈值大于0,即没有初始化哈希表 那么需要初始化*/
    /*即使用HashMap(int initialCapacity)、HashMap( initialCapacity, loadFactor) 
    这两个构造函数创建的对象,并且还没有添加元素的情况*/
    else if (oldThr > 0) {
        // 第一次初始化哈希表,新容量newCap就设置为旧的阈值
        /* 
         * 注意此时的oldThr 暂存的是计算出的大于initialCapacity的最小2点的整数次幂的容量
         */
        newCap = oldThr;
    }
    /*
     * 3 oldThr等于0 & oldCap等于0。即采用 无参构造器 HashMap() 创建Map对象,但是还没有添加数据时的情况。
    else {
        //容量和阈值都直接初始化为默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    /*4 如果新阈值newThr等于0,计算新的阈值newThr*/
    if (newThr == 0) {
        float ft = (float) newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
                (int) ft : Integer.MAX_VALUE);
    }
    // 将阈值设置为新阈值
    threshold = newThr;
    
    /*5 创建新数组*/
    /*新建一个Node数组newTab,容量为计算出的newCap*/
    Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
    //newTab赋值给table,然后进行数据的转移
    table = newTab;
    
    /*6 转移旧数组的数据 */
    if (oldTab != null) {
        // 循环整个数组将非空元素进行复制;
        for (int j = 0; j < oldCap; ++j) {
            Node<K, V> e;
            /*获取数组的第j个元素,使用变量e来保存*/
            /*如果e不为null,说明这个桶位存在至少一个元素,然后开始处理*/
            if ((e = oldTab[j]) != null) {
                //将旧数组该位置置空,方便GC
                oldTab[j] = null;
                /*6.1 如果e.next为null,说明e是这个桶位的唯一一个元素*/
                if (e.next == null) {
                    //再次使用哈希算法计算出该元素在新数组的桶位,然后插入
                    newTab[e.hash & (newCap - 1)] = e;
                }
                /*6.2 如果e属于TreeNode,调用split方法,将红黑树节点也转移到新数组中*/
                else if (e instanceof TreeNode) {
                    ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
                }
                /*6.3 此处桶位为链表,并且有哈希冲突。开始进行链表节点的转移*/
                else {
                    //存放不需要移动索引位置的链表的头、尾节点
                    Node<K, V> loHead = null, loTail = null;
                    //存放需要移动索引位置的链表的头、尾节点
                    Node<K, V> hiHead = null, hiTail = null;
                    Node<K, V> next;
                    /*do while循环旧链表*/
                    do {
                        //获取下一个节点
                        next = e.next;
                        /*e.hash & oldCap用于比较元素是否需要移动,即比较高一位是否是1还是0,1就需要移动,0则不需要*/
						// 不需要移动,尾插法建立链表
                        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);
                    /*6.3.2 将上面找到的两张旧链表迁移到新数组的对应索引位置中 */
                    if (loTail != null) {
                        loTail.next = null;
                        //直接将不需要移动索引位置的节点放到新数组的原索引位置处
                        newTab[j] = loHead;
                    }

                    if (hiTail != null) {
                        hiTail.next = null;
                        //直接将需要移动索引位置的节点放到新数组的(原索引+oldCap)索引位置处
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    /*7 返回新数组*/
    return newTab;
}

Tip:

在上述的2过程中,是对table数组未初始化时的处理:

  • 使用无参构造时,计算的newCap为默认值16。
  • 使用指定初始容量的构造方法时,计算的newCap为大于等于指定初始容量的最小2的幂次方数。
8.3 数据转移
8.3.1 rehash

数据转移时需要重新计算节点的桶位(rehash),这是因为 index = (Length - 1)& hash ,数组的长度变化会导致hash规则的改变,JDK1.8优化了rehash的过程。

数组在扩容时,newCap 为 oldCap 的 2 倍,并且 oldCap 本身也是2的整数次幂,因此 newCap 的二进制码相当于 oldCap 的二进制码左移一位。因此在重新计算数组索引时,(oldCap - 1) & hash 与 (newCap - 1) & hash 相比, (newCap - 1) & hash 新增了一个高位参与运算,因此最终重新计算的数组索引只取决于这个高位。

在这里插入图片描述

从上面的例子中可以发现:

  • 新增高位对应的原始hash值为0时,扩容计算出的新索引与旧索引相同。
  • 新增高位对应的原始hash值为1时,扩容后计算出的新索引为旧索引+oldCap。

注意:数组旧容量oldCap的二进制码中惟一的一个1正好对应节点e的hash值新增的高位,因此通过 e.hash & oldCap 就可判断该节点的位置了,具体来说:

  • e.hash & oldCap = 0,那么说明e.hash的对应新增高位为0,该数据就存储在新数组的原索引处;
  • e.hash & oldCap != 0,说明e.hash的对应高位为1,该数据就存储在新数组的原索引+oldCap的索引位置处。

JDK1.8 在 rehash时并没有通过 (n - 1) & hash 去重新计算节点所在的桶位,而是通过e.hash & oldCap 是否为 0 来判断节点所在的桶位,更加高效。

8.3.2 链表的数据转移

在扩容方法resize()中,数据转移时,当桶中的节点不是红黑树节点(TreeNode)时,进行链表的数据转移,其转移的过程为:

  • 将老索引位置k的全部节点,拆分成不需要移动索引位置和需要移动索引位置的两条链表;
  • 将这两条链表头节点分别赋值给新数组的k和k+oldCap索引位置处。
		//存放不需要移动索引位置的链表的头、尾节点
        Node<K, V> loHead = null, loTail = null;
        //存放需要移动索引位置的链表的头、尾节点
        Node<K, V> hiHead = null, hiTail = null;
        Node<K, V> next;
        /*do while循环旧链表*/
        do {
            //获取下一个节点
            next = e.next;
            /*e.hash & oldCap用于比较元素是否需要移动,即比较高一位是否是1还是0,1就需要移动,0则不需要*/
            // 不需要移动,尾插法建立链表
            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);
        /*6.3.2 将上面找到的两张旧链表迁移到新数组的对应索引位置中 */
        if (loTail != null) {
            loTail.next = null;
            //直接将不需要移动索引位置的节点放到新数组的原索引位置处
            newTab[j] = loHead;
        }

        if (hiTail != null) {
            hiTail.next = null;
            //直接将需要移动索引位置的节点放到新数组的(原索引+oldCap)索引位置处
            newTab[j + oldCap] = hiHead;
        }

需要注意的是:JDK1.8中,在构建需要移动和不需要移动这两条链表时,使用的是尾插法;JDK1.7时,在构建需要移动和不需要移动这两条链表时,使用的是头插法,头插法在并发扩容时,可能会形成循环链表而导致死循环。

8.3.3 JDK1.7链表头插法导致的死循环

假设HashMap的初始容量为2,并且已经插入了元素A:

A线程在插入节点B,B线程也在插入,假设A线程刚开始扩容就挂起了,B线程成功插入元素B并且扩容完成,B线程扩容时数据转移采用头插法,因此A、B元素顺序倒置,A线程执行完之后,就可能出现环。

  1. A线程、B线程同时执行扩容操作;
  2. A线程保存了 e 和 next 的值后被挂起;
  3. B线程完成扩容操作;
  4. A线程继续执行,头插法转移数据,链表成环。

在这里插入图片描述
注意:

JDK1.8 扩容时的数据转移采用尾插法,因此不会出现链表成环的问题,但这并不意味着JDK1.8后HashMap就能在多线程环境下使用了。

8.3.4 红黑树的数据转移

红黑树的数据节点转移通过split()方法实现,其步骤为:

  • 将老索引位置k的全部节点,拆分成不需要移动索引位置和需要移动索引位置的两条链表,并记录两条链表的长度;

  • 将这两条链表分别转移到新数组的k和k+oldCap索引位置处,在这个过程中需要判断两条链表的长度小于等于6就调用“树链表还原为普通链表”的方法untreeify存储到新位置,否则选择“树形化”的方法treeify形成新的红黑树存储到新位置。

/**
 * 红黑树节点类型的数据转移
 * @param map   当前hashMap对象
 * @param tab   新数组
 * @param index 需要转移的红黑树的位置,旧数组索引
 * @param bit   旧数组容量
 */
final void split(HashMap<K, V> map, Node<K, V>[] tab, int index, int bit) {
    TreeNode<K, V> b = this;
    /*1 类似于链表的转移的第一步,将老索引位置的全部节点,拆分成不需要移动索引位置和需要移动索引位置的两条链表;
    * 为什么红黑树也能采用这种方法?因为TreeNode间接的继承了Node,自然具有Node的所有属性和方法
    * 并且在转换为红黑树或者插入红黑树节点时,实际上TreeNode之间还维护了插入的先后关系的next字段
    * */
    //存放不需要移动索引位置的链表的头、尾节点
    TreeNode<K, V> loHead = null, loTail = null;
    //存放需要移动索引位置的链表的头、尾节点
    TreeNode<K, V> hiHead = null, hiTail = null;
    //存放不需要\需要移动索引位置的链表的长度
    int lc = 0, hc = 0;
    //由于TreeNode之间通过next维护了先后顺序,因此同样循环遍历就可以了
    for (TreeNode<K, V> e = b, next; e != null; e = next) {
        next = (TreeNode<K, V>) e.next;
        e.next = null;
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        } else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }
    /*2 将上面找到的两张旧链表迁移到新数组的对应索引位置中,相比于链表的迁移更加复杂*/
    //判断不需要移动索引位置的链表有数据
    if (loHead != null) {
        //如果不需要移动索引位置的链表长度小于等于UNTREEIFY_THRESHOLD,即小于等于6
        if (lc <= UNTREEIFY_THRESHOLD)
            //那么将loHead链表的红黑树节点转换为链表节点存储
            //树还原为链表,untreeify将返回普通链表头节点
            tab[index] = loHead.untreeify(map);
        else {
            //否则,那么将loHead链表转换为红黑树存储
            tab[index] = loHead;
            if (hiHead != null) // (else is already treeified)
                //树形化。该方法在“树形化treeifyBin方法”部分有源码讲解。
                loHead.treeify(tab);
        }
    }
    //判断需要移动索引位置的链表有数据
    if (hiHead != null) {
        //如果需要移动索引位置的链表长度小于等于UNTREEIFY_THRESHOLD,即小于等于6
        //那么将loHead链表的红黑树节点转换为链表节点存储
        if (hc <= UNTREEIFY_THRESHOLD)
            //树还原为链表,untreeify将返回普通链表头节点
            tab[index + bit] = hiHead.untreeify(map);
        else {
            //否则,那么将loHead链表转换为红黑树存储
            tab[index + bit] = hiHead;
            if (loHead != null)
                //树形化。该方法在“树形化treeifyBin方法”部分有源码讲解。
                hiHead.treeify(tab);
        }
    }
}

注意:在红黑树节点的数据转移过程中,若生成的红黑树节点链表长度 <= 6,则红黑树节点链表转换为普通节点链表。

8.3.5 树形化treeifyBin()

当链表长度 > 8 并且 哈希表的容量 >= 64时,链表将转化为红黑树。

注意:链表长度 > 8 并且 哈希表的容量 < 64时,链表不会转化为红黑树,而是对哈希表进行一次扩容。

树形化treeifyBin()执行流程:

  1. 如果旧数组为空,或者容量小于MIN_TREEIFY_CAPACITY,即小于64,那么进行数组扩容,方法结束。
  2. 否则,可以开始树形化:
    a. 循环普通链表,将普通节点链表,转换为红黑树节点链表,顺序还是原来的顺序;
    b. 红黑树链表头节点调用treeify方法,由红黑树节点链表构建成为红黑树,方法结束。
/**
 * 当添加新节点之后的链表长度大于8,那么将该链表转换为红黑树,使用的就是treeifyBin方法。
 * JDK1.8的HashMap的红黑树最初的由来就是通过该方法构造的!
 *
 * @param tab  旧数组
 * @param hash key的hash值
 */
final void treeifyBin(Node<K, V>[] tab, int hash) {
    int n, index;
    Node<K, V> e;
    /*1 如果旧数组为空,或者容量小于MIN_TREEIFY_CAPACITY,即小于64,那么进行数组扩容*/
    //从这里可以看出来,想要进行树形化,那么需要 某个桶位的链表长度大于8,同时需要数组容量大于等于64
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) {
        //扩容一次,方法返回
        resize();
    }
    /*2 否则,可以开始树形化*/
    //计算链表索引位置
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        //hd保存红黑树链表的头部,tl保存红黑树链表的尾部
        TreeNode<K, V> hd = null, tl = null;
        /*2.1 循环普通链表,将普通节点链表,转换为红黑树节点链表,顺序还是原来的顺序*/
        do {
            //新建红黑树节点,注意这里没有保存了节点之间的next的关系
            TreeNode<K, V> p = replacementTreeNode(e, null);
            //第一次进入do代码块时,tl为null
            if (tl == null)
                //hd赋值为p
                hd = p;
                //后续进入do代码块时,tl不为null
            else {
                //p的前驱设置为tl,这里保存了节点之间的前驱关系
                p.prev = tl;
                //tl的后继设置为p,这里保存了节点之间的后继关系
                tl.next = p;
            }
            //每次,tl赋值为p
            tl = p;
        } while ((e = e.next) != null);
        //数组的槽位暂时存入红黑树链表的头节点,后面还会调整为红黑树的根节点(moveRootToFront方法中)
        if ((tab[index] = hd) != null)
            /*2.2 红黑树链表头节点调用treeify方法,由红黑树节点链表构建成为红黑树*/
            hd.treeify(tab);
    }
}


/**
 * 新建红黑树节点,注意这里没有保存了节点之间的next的关系
 *
 * @param p    链表节点,从头节点开始
 * @param next 下一个节点引用,为null
 * @return 红黑树节点,颜色属性red默认是false,即黑色。
 */
TreeNode<K, V> replacementTreeNode(Node<K, V> p, Node<K, V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

在红黑树节点链表构建成为红黑树时调用了treeify方法,这里不再详细说明了…但是要注意下面的提示。

Tip:红黑树是二叉排序树的一种,因此红黑树节点之间必然具有大小关系。

  • 当key实现了Comparable接口,则调用CompareTo()方法进行比较;
  • 当key未实现Comparable接口,则通过key的类名字符串来比较(String类实现了Comparable接口)。
8.3.6 树还原untreeify()

树还原方法untreeify用于将红黑树链表转换为普通节点链表。

/**
 * 树还原为普通链表的方法,由红黑树链表头节点调用
 *
 * @param map 当前Map集合
 * @return 普通节点链表头节点
 */
final Node<K, V> untreeify(HashMap<K, V> map) {

    Node<K, V> hd = null, tl = null;
    //循环该红黑树链表,转换为普通节点链表,节点顺序还是原来的顺序
    for (Node<K, V> q = this; q != null; q = q.next) {
        //将每一个树节点转换为普通节点
        Node<K, V> p = map.replacementNode(q, null);
        /*类似于转移节点时的链表构造原理*/
        //首次for循环时,tl为null,将会进入该代码块
        if (tl == null)
            //红黑树链表头节点作为普通链表头节点
            hd = p;
        //后续循环时,tl不为null,将会进入该代码块
        else
            //这里重新关联next关系
            tl.next = p;
        //tl指向该新节点
        tl = p;
    }
    //普通节点链表头节点
    return hd;
}


/**
 * 用于从树节点转换为普通节点
 *
 * @param p    树节点
 * @param next next引用
 * @return 普通节点
 */
Node<K, V> replacementNode(Node<K, V> p, Node<K, V> next) {
    //返回新建的普通节点,存入树节点的内容
    return new Node<>(p.hash, p.key, p.value, next);
}

Tip:红黑树节点TreeNode继承自链表节点Node,之所以要在链表节点和红黑树节点之间相互转换,就是为了节省空间,TreeNode所占用的内存空间是Node节点的2倍。

9.removeNode()

removeNode()方法:用于移除相应节点,可以是根据key移除节点,也可以是根据key和value移除节点。执行流程:

  • 在哈希表中尝试查找与key相同的节点;
  • 如果找到了节点则尝试移除节点,返回被移除的节点,否则返回null。
**
 * 用于移除节点,可以是根据key移除,也可以是根据key和value移除
 *
 * @param hash       key的hash
 * @param key        要匹配的key
 * @param value      要匹配的value
 * @param matchValue 如果为 true,则需要在键和值 都比较并相等时才删除;否则只比较key
 * @param movable    如果为 false,则在删除时不移动其他节点,用在红黑树中
 * @return 返回被删除的节点,没有删除则返回null
 */
final Node<K, V> removeNode(int hash, Object key, Object value,
                            boolean matchValue, boolean movable) {
    Node<K, V>[] tab;
    Node<K, V> p;
    int n, index;
   
    //如果table非空,key对应桶位节点p不为null,那么才进一步处理,否则直接返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
        //node保存要返回的节点
        Node<K, V> node = null, e;
        K k;
        V v;
        /*1 在哈希表中查找与key相同的节点*/
        //如果key和数组桶为的第一个节点就像等了,那么node赋值为p
        if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
            
        else if ((e = p.next) != null) {
            /*如果p是红黑树节点类型,那么p调用getTreeNode方法查找,
            if (p instanceof TreeNode)
                node = ((TreeNode<K, V>) p).getTreeNode(hash, key);
                /*否则,就是普通节点类型,那么遍历链表查找*/
            else {             
                do {
                    if (e.hash == hash &&
                            ((k = e.key) == key ||
                                    (key != null && key.equals(k)))) {
                        //如果key相等,那么node赋值为e,结束循环
                        node = e;
                        //这里break之后,下面的p = e;不执行,e=p.next;
                        break;
                    }
                    p = e;            
                } while ((e = e.next) != null);
            }
        }
        /*2 尝试移除节点 */
        // node不为空时移除节点,若要求key、value都匹配,这同时比较key、value,否则职匹配key
        if (node != null && (!matchValue || (v = node.value) == value ||
                (value != null && value.equals(v)))) {
            /*2.1 如果找到的节点node属于红黑树节点,那么node节点调用removeTreeNode方法移除节点*/
            if (node instanceof TreeNode)
                ((TreeNode<K, V>) node).removeTreeNode(this, tab, movable);
                /*2.2 否则,node 为桶的第一个元素*/
            else if (node == p)
                tab[index] = node.next;
                /*2.3 否则,node = p.next;
                 * 那么p.next指向node.next,即将node去除
                 * */
            else
                p.next = node.next;
            //数据结构改变次数自增1
            ++modCount;
            //节点数量自减1
            --size;
            //元素被删除之后的回调方法,该方法在HashMap中的实现为空
            //是留给子类LinkedHashMap实现的,用于删除LinkedHashMap中维护的双链表
            afterNodeRemoval(node);
            //返回node
            return node;
        }
    }
    //返回null
    return null;
}

当node属于红黑树节点,就会调用removeTreeNode()来移除该节点,该方法也比较复杂,因为需要对移除节点的红黑树进行再平衡操作。

这里只强调该方法在删除红黑树节点之前,如果满足 root == null || root.right == null || (rl = root.left) == null || rl.left == null,那么将红黑树转换为普通链表。这是红黑树转链表的第二种情况。

//removeTreeNode方法的部分代码
//TreeNode<K, V> first = (TreeNode<K, V>) tab[index], root = first, rl;
 if (root == null || root.right == null ||
            (rl = root.left) == null || rl.left == null) {
        //untreeify方法将红黑树转换为链表,转换为链表时实际上是使用到了的next和prev引用
        tab[index] = first.untreeify(map);  // too small
        return;
}

即:如果root为null,或者root的右子树为null,或者root的左子树rl为null,或者左子树rl的左子树为null,将会使用untreeify方法将红黑树转换为链表。

10.HashMap迭代器

HashMap本身是没有迭代器的。实现Collection接口的都会实现迭代器(Collection继承了Iterable接口),HashMap没有实现Collection接口,因此自身没有迭代器,它的迭代是借助Collection类来实现的。

  • keySet()方法获取包含key的set对象,调用该对象的迭代器对key值遍历。
  • entrySet()方法获取包含Map.Entry的set对象,调用该对象的迭代器对Entry实例遍历。
  • values()方法获取包含value的Collection对象,调用该对象的迭代器对value遍历。
import java.util.*;

public class HashMapTest {

    public static void main(String[] args) {
        
        Map map = new HashMap();
        map.put("key1", "value1");
        map.put("key2", "value2");

        Set set = map.keySet();

        for (Iterator iter = set.iterator(); iter.hasNext(); ) {
            String key = (String) iter.next();
            String value = (String) map.get(key);
            System.out.println(key + "--" + value);
        }

        System.out.println("=========");

        Set set2 = map.entrySet();

        for (Iterator iter = set2.iterator(); iter.hasNext(); ) {
            Map.Entry entry = (Map.Entry) iter.next();
            System.out.println(entry.getKey() + "--" + entry.getValue());
        }

        System.out.println("=========");

        Collection values = map.values();
        for (Iterator iter = values.iterator(); iter.hasNext();) {
            String value = (String)iter.next();
            System.out.println(value);
        }
    }
}
输出:
key1--value1
key2--value2
=========
key1--value1
key2--value2
=========
value1
value2
10.1 KeyIterator

keySet()方法实质上会得到一个KeySet类的一个对象,KeySet是HashMap的内部类:

public Set<K> keySet() {
        Set<K> ks = keySet;
        if (ks == null) {
            ks = new KeySet();
            keySet = ks;
        }
        return ks;
}

final class KeySet extends AbstractSet<K> {
        //...
        public final Iterator<K> iterator() { 
        	return new KeyIterator(); 
        }
        //...
}

因此通过keySet().iterator()方法会得到KeyIterator迭代器的对象。

10.2 EntryIterator

与keySet()类似,entrySet()方法会得到EntrySet类的对象:

public Set<Map.Entry<K,V>> entrySet() {
        Set<Map.Entry<K,V>> es;
        return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
        //...
        public final Iterator<Map.Entry<K,V>> iterator() {
            return new EntryIterator();
        }
        //...
}

因此通过entrySet().iterator()方法会得到EntryIterator迭代器的对象。

10.3 ValueIterator

values()方法返回一个Values类对象,values().iterator()返回一个ValueIterator迭代器的对象。

public Collection<V> values() {
        Collection<V> vs = values;
        if (vs == null) {
            vs = new Values();
            values = vs;
        }
        return vs;
}

final class Values extends AbstractCollection<V> {
        //...
        public final Iterator<V> iterator() { 
        	return new ValueIterator(); 
		}
		//...
}
10.4 HashIterator抽象类

KeyIterator 、EntryIterator 、ValueIterator 都实现了Iterator 接口,并且继承了HashIterator抽象类,HashIterator为他们提供了Iterator 接口的基本实现,不同的迭代器对next()方法的实现不同而已。

final class KeyIterator extends HashIterator
    implements Iterator<K> {
    public final K next() { return nextNode().key; }
}

final class ValueIterator extends HashIterator
    implements Iterator<V> {
    public final V next() { return nextNode().value; }
}

final class EntryIterator extends HashIterator
    implements Iterator<Map.Entry<K,V>> {
    public final Map.Entry<K,V> next() { return nextNode(); }
}

HashIterator:

abstract class HashIterator {
        Node<K,V> next;        // next entry to return
        Node<K,V> current;     // current entry
        int expectedModCount;  // for fast-fail
        int index;             // current slot

        HashIterator() {
            expectedModCount = modCount;
            Node<K,V>[] t = table;
            current = next = null;
            index = 0;
            if (t != null && size > 0) { // advance to first entry
                do {} while (index < t.length && (next = t[index++]) == null);
            }
        }

        public final boolean hasNext() {
            return next != null;
        }

        final Node<K,V> nextNode() {
            Node<K,V>[] t;
            Node<K,V> e = next;
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            if (e == null)
                throw new NoSuchElementException();
            if ((next = (current = e).next) == null && (t = table) != null) {
                do {} while (index < t.length && (next = t[index++]) == null);
            }
            return e;
        }

        public final void remove() {
            Node<K,V> p = current;
            if (p == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            current = null;
            K key = p.key;
            removeNode(hash(key), key, null, false, false);
            expectedModCount = modCount;
        }
    }
11.快速失败(fail-fast)

HashMap内的迭代器是快速失败的(fail-fast)。

快速失败(fail-fast)是Java容器中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合的结构进行了修改(增加、删除、修改),则会抛出ConcurrentModificationException

Java容器快速失败(fail-fast)实现原理:

Java容器使用一个modCount 成员变量来记录其结构改变的次数,迭代器一被创建出来,就会把modCount 保存到 expectedmodCount 变量中,一旦集合在遍历期间结构发生了变化,modCount 必然也会变化,每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,如果不相等就会抛出ConcurrentModificationException异常

// HashIterator 迭代器抛出并发修改异常的代码
if (modCount != expectedModCount)
        throw new ConcurrentModificationException();

Tip:java.util.concrrent 包下的集合类都是 安全失败(fail-safe)的。

12.序列化

HashMap实现了Serializable接口,并且提供了writeObject()和readObject()方法,ObjectOutputStream和ObjectInputStream通过反射机制调用HashMap中的这两个方法来实现序列化。

	private void writeObject(java.io.ObjectOutputStream s)
        throws IOException {
        int buckets = capacity();
        // Write out the threshold, loadfactor, and any hidden stuff
        s.defaultWriteObject();
        s.writeInt(buckets);
        s.writeInt(size);
        internalWriteEntries(s);
    }

    private void readObject(java.io.ObjectInputStream s)
        throws IOException, ClassNotFoundException {
        // Read in the threshold (ignored), loadfactor, and any hidden stuff
        s.defaultReadObject();
        reinitialize();
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new InvalidObjectException("Illegal load factor: " +
                                             loadFactor);
        s.readInt();                // Read and ignore number of buckets
        int mappings = s.readInt(); // Read number of mappings (size)
        if (mappings < 0)
            throw new InvalidObjectException("Illegal mappings count: " +
                                             mappings);
        else if (mappings > 0) { // (if zero, use defaults)
            // Size the table using given load factor only if within
            // range of 0.25...4.0
            float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
            float fc = (float)mappings / lf + 1.0f;
            int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
                       DEFAULT_INITIAL_CAPACITY :
                       (fc >= MAXIMUM_CAPACITY) ?
                       MAXIMUM_CAPACITY :
                       tableSizeFor((int)fc));
            float ft = (float)cap * lf;
            threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                         (int)ft : Integer.MAX_VALUE);

            // Check Map.Entry[].class since it's the nearest public type to
            // what we're actually creating.
            SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].class, cap);
            @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
            table = tab;

            // Read the keys and values, and put the mappings in the HashMap
            for (int i = 0; i < mappings; i++) {
                @SuppressWarnings("unchecked")
                    K key = (K) s.readObject();
                @SuppressWarnings("unchecked")
                    V value = (V) s.readObject();
                putVal(hash(key), key, value, false, false);
            }
        }
    }

HashMap并没有序列化整个table数组,而是只序列化了table数组中保存的key-value数据,因为table多数情况下是无法被存满的,反序列化时只需要通过key-value数据重建HashMap。

13.clone

HashMap实现了Cloneable接口,并且重写了clone()方法。

	@SuppressWarnings("unchecked")
    @Override
    public Object clone() {
        HashMap<K,V> result;
        try {
            result = (HashMap<K,V>)super.clone();
        } catch (CloneNotSupportedException e) {
            // this shouldn't happen, since we are Cloneable
            throw new InternalError(e);
        }
        result.reinitialize();
        result.putMapEntries(this, false);
        return result;
    }

可以发现,HashMap的clone方法创建了新的HashMap对象,并且通过putMapEntries()方法将数据添加到新HashMap中,所以对于HashMap中存储的对象来说,依然是浅克隆的。

14.线程安全性

HashMap线程不安全,HashMap在并发时可能出现的问题主要是:

  • 如果多个线程同时使用 put 方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的桶一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程 put 的数据被覆盖。

  • 如果多个线程同时检测到元素个数超过数组大小 * loadFactor,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失。

14.1 HashMap线程不安全的解决方案
  • HashTable
  • Collections.synchronizedMap()
  • ConcurrentHashMap
15.HashMap总结

如果你能从头看完这篇博客,那我只能用两个字来形容:NB!/笑哭

HashMap知识点又难又多,我觉得几乎没有人能这么耐心把这篇博客都看完,写这篇博客也花了我不少时间(从收集资料,看博客写笔记,再到写出这篇博客,零零散散将近花了我一周时间,真难 /笑哭 /笑哭 /笑哭 ),而且网上关于HashMap的博客有很多,说实话质量也参差不齐,很少有人能把HashMap说的很细,总结的很全面,所以我才打算在文章末尾总结一下,只写结论,适合给懒人看。 /笑哭

15.1 HashMap的容量
  • HashMap默认的容量是16,默认的加载因子是0.75。
  • HshMap的容量必须是2的整数次幂,这是为了保证hash函数的高效。
  • 创建HashMap时可以指定初始容量,但HashMap实际的容量为大于等于指定初始容量的最小2的整数次幂,例如 指定初始容量为10,实际容量为16。
15.2 hash函数
  • HashMap的hash函数也叫扰动函数,是先拿到 key 的hashcode,然后让hashcode的高16位和低16位进行异或操作。
  • HashMap桶的计算式:index = hash & ( Length - 1 )
  • HashMap桶的计算用位与代替了取模,效率更高,但 hash % n 等于 (n - 1) & hash 的前提条件是:数组的容量n必须是2的整数次幂。
15.3 扩容机制
  • HashMap扩容时,新容量为旧容量的2倍。
  • HashMap扩容的时机:HashMap未初始化、元素个数大于扩容阈值、链表长度大于 8 并且 数组容量小于64。
  • HashMap数据转移时,e.hash & oldCap = 0 则数据节点的索引位置不变;否则数据节点的索引位置=原索引位置 + oldCap(旧容量)。
  • JDK1.7 数据转移使用头插法建立链表,在并发扩容下可能会形成循环链表而导致死循环。
15.4 树形化条件
  • 当链表长度 > 8 并且 哈希表的容量 >= 64时,链表将转化为红黑树。
  • 链表长度 > 8 但是 哈希表的容量 < 64 时,链表不会转化为红黑树,而是对哈希表进行一次扩容。
15.5 树还原条件
  • 在红黑树节点的数据转移过程中,若红黑树节点 <= 6,则红黑树转换为链表。
  • 在删除红黑树节点前,如果满足 root == null || root.right == null || (rl = root.left) == null || rl.left == null,那么将红黑树转换为普通链表。
15.6 HashMap在JDK1.7与JDK1.8中的一些区别
  • 数据结构:JDK1.7 使用 数组 + 链表 ; JDK 1.8 使用 数组 + 链表 + 红黑树。
  • 数据转移:JDK1.7在数据转移时使用头插法;JDK1.8采用尾插法。头插法在并发扩容下可能会形成循环链表而导致死循环。
  • 扩容:JDK1.7是先判断是否需要扩容,再插入新数据;JDK1.8是先插入数据,再判断是否需要扩容。
  • rehash:JDK1.7通过hash&(length-1)重新计算桶位;JDK1.8通过(e.hash & oldCap) == 0来判断新位置处于原始位置还是原始位置+老容量的位置。
  • 扰动函数:JDK1.7的扰动算法是四次>>>运算、四次^运算;JDK1.8则只有一次>>>运算和一次 ^ 运算。
15.7 使用HashMap需要注意什么?
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值