Java HashMap

HashMap 是 Map 接口中基于哈希表的非同步实现, 自身也可以自动扩容。使用时可以通过 key 快速定位到对应的 value。key 和 value 同时可以都为 null。

1 HashMap 的结构定义

JDK1.8 对 HashMap 进行了比较大的优化, 底层实现由之前的 “数组 + 链表” 改为 “数组 + 链表 + 红黑树”。
在链表的长度大于等于 8 并且数组的长度大于等于 64 时, 将对应的链表转为红黑树 (本文不涉及红黑树部分的分析, 涉及到时, 只会提一下, 然后跳过)。

首先数组是整个数据的存储真正实体, 数组中的存储的数据是链表或者红黑树, 当链表的长度达到了条件, 就变成了红黑树, 大体的情况如下:
Alt 'HashMap 数据结构'

1.1 数组的定义

public class HashMap<K,V>  {

    transient Node<K,V>[] table;
}

从上面的代码就是 HashMap 中数据存储的地方: 数组。 数组存储的数据类型为 Node, 这个 Node 是链表的定义。
而 红黑树的定义是继承了 Node, 所以通过向上转型的方式, 就能通过一个 Node 的类型表示链表和红黑树。

1.2 链表的定义

public class HashMap<K,V> {

    /**
	 * 链表定义
     * Map.Entry 行为接口, 定义了一堆操作方法, 比如 getValue, setValue 等
	 */
    static class Node<K,V> implements Map.Entry<K,V> {

        // 当前节点的 hash 值
        final int hash;

        // 当前节点的 key
        final K key;

        // 当前节点的 value
        V value;

        // 下一个节点, 这个属性决定了 Node 为链表
        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;
        }

        /**
         * 计算当前节点 hashCode
         */
        public final int hashCode() {
        	// Objects.hashCode 本质就是调用对象的 o.hashCode()
        	// 当前节点的 hashCode 值 等于 key 的 hashCode  异或 value 的 hashCode 值
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        /**
         * 节点比较
         */
        public final boolean equals(Object o) {

            if (o == this)
                return true;

            // 都是 Map.Entry 节点
            if (o instanceof Map.Entry) {

                Map.Entry<?,?> e = (Map.Entry<?,?>)o;

                // Objects.equals 会先比较一下 2 者的内存地址, 一样直接返回 true
                // 不一样, 调用 key.equals(e.getKey) 进行比较
                if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue()))
                    return true;
            }

            return false;
        }

    }
}

1.3 红黑树的定义


public class HashMap<K, V> {

    // 继承了 LinkedHashMap.Entry, Entry 继承了 HashMap.Node  所以 TreeNode 具有 链表的特点

    /** 
     * 红黑树的定义
     * LinkedHashMap.Entry 继承了 HashMap.Node 节点, 所以 TreeNode 是 Node 的子类, 也具备链表的特点
     */    
	static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {

        /**
         * 红黑树的根节点
         */
        TreeNode<K,V> parent;
        
        /**
         * 当前节点的左节点
         */
        TreeNode<K,V> left;

        /**
         * 当前节点的右节点
         */
        TreeNode<K,V> right;

        /**
         * 删除后需要解决连接的节点
         */
        TreeNode<K,V> prev;  

        /**
         * 是否为红色节点
         */
        boolean red;

        // ... 后面 省略 红黑树的操作
    }
}

2 HashMap 中的几个重要属性

public class HashMap<K,V> {

    transient Node<K,V>[] table;

    transient int size;

    transient int modCount;

    final float loadFactor;

    private int threshold;

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

    static final int MAXIMUM_CAPACITY = 1 << 30;

    static final int TREEIFY_THRESHOLD = 8;

    static final int UNTREEIFY_THRESHOLD = 6;

    static final int MIN_TREEIFY_CAPACITY = 64;
}

2.1 table

transient Node<K,V>[] table;

HashMap 中存储数据的数组

2.2 size

transient int size;

HashMap 中已经存储的数据个数

2.3 modCount

transient int modCount;

HashMap 已经被修改了多少次, 用于支撑 fail-fast 机制

2.4 loadFactor

final float loadFactor;

负载因子: 默认等于 DEFAULT_LOAD_FACTOR = 0.75f;
作用: 一般情况都是在容器满了才会进行扩容, 但是在 HashMap 中, 数据量达到了数组的长度 * 负载因子的值, 就会进行扩容了。
原因: HashMap 在数组中插入一个数据, 是先通过一个 hash 方法转换为一个 hash 值, 通过这个 hash 值计算得到存储在数组的位置, 通过 hash 计算, 就可能存在 hash 冲突。
数据越密集, 冲突的可能性越大, 所以 HashMap 中的数组是不会完成存满的, 通过空留一部分, 减少冲突等。

负载因子默认值为 0.75 的原因: 太大冲突可能性变大, 太小浪费了空间, 同时会导致数组扩容等耗时操作。
所以 0.75 应该是一个经验值的估算, 或者是因为 HashMap的数组长度为 2^n, 乘以 0.75, 能获得一个整数。

2.5 threshold

private int threshold;

当前数组的阈值, 即数组实际应该放多少数据

2.6 几个常量

// 1 左移多少位, 就是相当于 2 的 多少次方, 这里就是 2^4 = 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

声明 HashMap, 不指定容量时, 默认为 16。

// 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

HashMap 最大容量, 容量必须是 2 的 n 次方, 涉及到通过 key 的 hash 值定位到数组的位置中的一个配合。
HashMap 中数组的容量设定为 2 的 n 次方具体有 2 个作用:

第一, 得到了 key 对应的 hash 后, 如何通过 hash 定位到数组中的哪一个位置呢?

最简单的方式就是取模了吧, 通过 hash 值模于当前数组的长度, 得到的就是当前 hash 对应的数组位置。
但是取模的效率不太好, 那么有什么好的优化方式吗?

在数学上, a, b 都是正整数的情况下, a % b, 在 b 是 2 的 n 次方下, a % b = a & (b - 1)
位运算比正常的算术运算快, 那么将 HashMap 的容量设置为 2 的 n 次方, 就可以通过与运算达到取模的效果。
所以 HashMap 中的数组容量必须为 2 的 n 次方。

第二个作用在后面会分析。

static final int TREEIFY_THRESHOLD = 8;

HashMap 中链表变为红黑树的长度配置, 链表长度达到了 8 满足了链表变为红黑树的条件之一。

static final int UNTREEIFY_THRESHOLD = 6;

HashMap 中红黑树重新变为链表的长度配置, 红黑树的节点个数达到了 6, 满足了红黑树变为链表的条件之一。

static final int MIN_TREEIFY_CAPACITY = 64;

HashMap 中链表变为红黑树的的另外一个条件, 当前数组的长度达到了 64。

3 HashMap 的构造方法

3.1 无参的构造函数


public HashMap() {
    // 设置负载因子为 0.75
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

3.2 指定初始容量 (和负载因子) 的构造函数

public HashMap(int initialCapacity) {

    // 调用到自身的指定容量 和负载因子的构造函数, 传入的负载因子为默认值 0.75
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/** 
 * 指定 初始容量 和 负载因子  
 */
public HashMap(int initialCapacity, float loadFactor) {

    // 指定的容量小于 0, 格式不正确
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);

    // 指定的容量超过了最大容量, 设置为支持的最大容量值
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;

    // 格式不正确 NaN: Not-a-Number,  isNaN(arg) => arg 不是数字, 返回true
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);    

    // 负载因子直接等于输入的值
    this.loadFactor = loadFactor;
    // tableSizeFor 这个方法会对输入的值, 转换为第一个大于或等于输入值的 2 的 n 次方的数, 比如: 输入 3 得到 4, 输入 4 得到 4, 输入 5 得到 8
    // 因为 HashMap 的容量需要是 2 的 n 次方, 通过 tableSizeFor 将用户输入的不是 2 的 n 次方的数, 进行修正
    
    // threshold 这个属性上面说过是用来存储 HashMap 的实际容量 (声明的数组容量 * 负载因子), 但是这里直接赋值的是 2 的 n 次方, 错了吗?
    // 实际上没错的, threshold 就是用来存储实际的容量的, 这里只是把计算出来的容量临时存在这个变量, 在第一次进行存放数据时, 进行修正为实际容量的
    this.threshold = tableSizeFor(initialCapacity);
        
}

/**
 * 计算数组容量
 */
static final int tableSizeFor(int cap) {
    // 此次减 1 的原因: 让后面的处理结果变为 n 是第一个大于等于 cap 的 2 的 n 次方的数
    // 如果这里不减 1 的话, 刚好传进来的数是 2 的 n 次方, 经过下面几步的处理会变成 2 的 n+1 次方的数
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    // 做一个容错 如果 cap 是一个小于等于 0 的数, 经过上面的处理, n 将会变成一个负数
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

3.3 给定一个 Map 的构造函数

public HashMap(Map<? extends K, ? extends V> m) {
    // 负载因子 = 0.75
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

/**
 * 放入数据
 * @param m 数据
 * @param evict 这个参数主用是在插入节点后, 做调整用的, 在 HashMap 中不具备任何作用, 构造参数中直接使用 false, 其他的情况使用 true 即可, 基于 HashMap 实现的 LinkedHashMap 才需要使用到这个参数
 */
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {

    // 获取需要传入的 Map 的数据个数
    int s = m.size();
    // 大于 0 的话, 才出来
    if (s > 0) {
        // 当前存放数据的 table 为空, 表示为初始化
        if (table == null) {
            // 计算出来需要的容量 =  长度 / 赋值因子 + 1
            float ft = ((float)s / loadFactor) + 1.0F;
            // 限制最大的容量 为  2^ 30
            int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);

            // 计算出来的当前需要的容量 > 实际的容量, 进行新容量的计算
            if (t > threshold)
                // 计算存储数据的数组的容量
                threshold = tableSizeFor(t);

        } else if (s > threshold)
            // 当前存入的数据量直接大于当前阈值, 进行重新扩容
            // 扩容方法在下面的添加数据讲解
            resize();

        // 遍历 Map, 将里面的数据逐个迁移到当前的 Map 中
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            // 添加数据的在下面的添加数据讲解
            putVal(hash(key), key, value, false, evict);
        }
    }
}

4 HashMap 的操作方法

4.1 添加数据

public class HashMap<K, V> {

    public V put(K key, V value) {
        // 1. 计算 key 的 hash 值
        // 2. 调用 putVal 进行数据的新增
        return putVal(hash(key), key, value, false, true);
    }

    /**
     * key 计算出 hash 值
     */
    static final int hash(Object key) {
        int h;
        // 对象的 hashCode ^ 对象的 hashCode >>> 16
        // 将 hashCode 的高 16 位与 hashCode 进行异或运算, 主要是为了在 table 的 length 较小的时候, 让高位也参与运算, 减少 hash 冲突, 并且不会有太大的开销

        // 这里做了一个容错, 如果 key 为 0, 那么计算出来的 hash 值为 0, 也就是 HashMap 直接 key 为 0 的情况, 但是只能存一个 key 为 null 的数据, 
        // 后面的 key 为 null, 计算出来的 hash 都是 0, 会把旧值覆盖(默认情况)
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    /**
     * 存值
     * @ hash 存的 key 的hash值
     * @ key  对象的 key
     * @ value 存的值
     * @ onlyIfAbsent 存入的数据已在 HashMap 中存在, 是否有新的 value 替代旧的 value, false 进行修改, true 不修改
     * @ evict 这个值用于 LinkedHashMap, 在HashMap 中没有作用
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {

        Node<K,V>[] tab; Node<K,V> p; int n, i;

        // 数组为空 或者 数组的长度为 0, 进行扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        
        // HashMap 对象的位置是通过 (数组的长度 - 1) & hash, 也就是 hash % (2 ^ n - 1) 的形式
        // 存储的位置为空, 直接将 hash, key, value 封装为新的 Node 节点, 放到指定的位置
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);    
        else {

            // 存储的位置已经有数据了
            Node<K,V> e; K k;
            // 需要放入的位置的第一个节点 的 hash 和 key 一样, 取到这个节点
			if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
				e = p;
            else if (p instanceof TreeNode)
            	// 调用红黑树 进行处理
                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个, 转为红黑树, binCount 从 0 开始遍历的
						if (binCount >= TREEIFY_THRESHOLD - 1)
                            // 这个方法后面
                            treeifyBin(tab, hash);
                        break;    
                    }

                    // 链表中有一个节点的 hash 和 key 和要插入的一样, 取到这个节点, 停止遍历链表
					if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) 
                        break;
                    // p = p 的下一个节点, 判断下一个节点
                    p = e                        
                }
            }

            // e 不为空, 表示在数组当前的位置的链表或红黑树中存在一个 key 值和 hash 值和当前要存入的数据一样。
            if (e != null) {
                // 获取旧值
                V oldValue = e.value;
				//  旧值为 null 或者入参的 onlyIfAbsent 为 false, 新值替换旧值
				if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 在 HashMap 中这个是空方法                    
                afterNodeAccess(e);
                return oldValue;
            }

            // 修改次数 + 1, 用于支持 fast-fail 机制
            ++modCount;
            // 当前存储的数据个数达到了阈值, 进行扩容
            if (++size > threshold)
            	resize();
        } 

    	// HashMap 的这个方法没有实现
     	afterNodeInsertion(evict);
       	return null;
    }


    /**
     * 重新扩容
     */
    final Node<K,V>[] resize() {

        // 获取当前数组的引用
        Node<K,V>[] oldTab = table;
        // 当前的容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;

        // 当前的阈值
        int oldThr = threshold;
        // 新的容量, 新的阈值
        int newCap, newThr = 0;

        // 当前的容量不为0, 也就是有数据存在了
        if (oldCap > 0) {
            // 当前的容量已经达到最大了, 直接不进行操作
            if (oldCap >= MAXIMUM_CAPACITY) {
                // 直接将阈值设为 int 的最大值
            	// 实际的数组长度依旧为 2^30, 一般情况阈值 threshold = 实际数组的长度 * 负载因子, 负载因子一般都是小于 1 大于 0 的数, 所以 threshold < 数组的实际长度
                // 决定数组是否扩容的则是由 threshold 是否小于当前的数据个数 size, 所以数组的容量还未完全用完了, 就扩容了,
                // 现在的情况是, 旧的数组的容量已经达到了设定的最大容量了, 无法继续扩了, 所以将 threshold 直接设置为 int 的最大值 > 数组的长度, 这样可以继续利用原本因负载因子而无法使用到的空间
                threshold = Integer.MAX_VALUE;
                return oldTab;
            
            // 新容量的值 = 旧数组的容量 * 2
            // 新的容量 < 最大容量 && 旧的容量 >= 初始默认的容量(16),  这种情况下设置新的负载 = 旧的 * 2
            // 那么就存在旧的容量为 2, 4, 8 三种情况, 没有走到下面的 newThr = old << 1, 那么新的阈值为 0 

            // oldCap >= DEFAULT_INITIAL_CAPACITY, 新的阈值什么不是无条件的等于旧的阈值的 2 倍
            // 而是只有在 oldCap >= DEFAULT_INITIAL_CAPACITY, 新的阈值才会是旧阈值的 2 倍
            // 而如果 oldCap 为 2, 4, 8, 则新的阈值为 0, 需要到下面的新的阈值 = 0 的判断进行处理, 具体的分析看下面的备注 1
            } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1;

        } else if (oldThr > 0)
            // 当旧的阈值大于 0, 设置新的容量 = 阈值
            // 能够走到这一步的情况有: 
            // 1. 我们初始时, 只指定了容量, 然后第一次往里面加数据
            // 2. 初始时, 只传递了一个 Map, 然后第一次把 Map 里面的数据放到当前 HashMap 时

            // 新的容量 = 当前的阈值 = threshold, 因为在声明 HashMap 时, 会临时将容量存储到 threshold, 直接赋值过来就是需要的容量了
            newCap = oldThr;
        else { 

            // 当我们创建时, 没有指定容量时, 在第一次放数据, 会走这一步
            // 这里将需要的容量的设置为默认值 16, 新的阈值为 16 * 0.75
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }

        // 新的阈值为 0 
        if (newThr == 0) {
            // 一般情况下阈值 = 新容量 * 0.75, 如果数组的容量达到了最大容量, 则直接设置为 int 的最大值
            // 这里新的阈值为 0, 能够走到这一步的情况有:
            // 1. 我们初始时, 只指定了容量, 然后第一次往里面加数据
            // 2. 初始时, 只传递了一个 Map, 然后第一次把 Map 里面的数据放到当前 HashMap 时
            // 3. 扩容时, 旧的容量为 2/4/8 中的一个, 那么 newThr 也是 0, 需要结合上面的扩容的 oldCap >= DEFAULT_INITIAL_CAPACITY 进行分析

            // 计算理论的阈值 = 新的容量 * 负载因子
            float ft = (float)newCap * loadFactor;
            // 新的容量 < 最大值 && 理论的阈值 < 最大值 ? 新的阈值 = 计算出来的阈值 : 新的阈值 = int 的最大值
            // 这里个人感觉有点问题, 具体看下面的 备注 2
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
        }

        // 设置新的阈值
        threshold = newThr;


        // 声明新的数组, 用于存储数据
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        // HashMap 中的数组引用修改为新声明的数组
        table = newTab;

        // 数组 重新赋值
        if (oldTab != null) {

            // 遍历旧数组的每一项
            for (int j = 0; j < oldCap; ++j) {

                Node<K,V> e;

                // 获取到当前数组的第 j 个元素
                if ((e = oldTab[j]) != null) {
                    第 j 个元素不为 null, 表示有数据, 需要进行迁移

                    // 旧数组的 j 位置的值设置为空, 方便垃圾回收
                    oldTab[j] = null;

                    if (e.next == null)
                        // 如果 e.next 为空, 则代表旧数组的该位置只有 1 个节点, 那么把这个节点直接放到新数组里面, 就行了
                        // 通过 (节点 的 hash 值 & 新容量 -1 ) 取到新的位置
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        // 原本的这个节点为树节点, 调用 TreeNode 的 split 进行处理
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else {
                        
                        // 到了这一步, 说明当前位置存储的是一个 链表, 同时链表的数据存在 2 个以上

                        // 声明了2 个链表, lo 链表, hi 链表, 这 4 个指针, 分别指向了 2 个链表的头和尾 
                        // 其中的 lo 可以理解为 low, 地位链表, hi 为 hight, 高维链表, 
                        // 新的数组的容量是是原来的2倍, 那么 原来的一倍 可以理解为 low, 扩充出来的为 hight
                        // 原本在同一个链表上的节点, 转移到扩容后的数组式, 只可能会被分配到 2 个位置,  和原来一样的位置 或者原来的位置 + oldCap 的位置, 看下面的备注 3 
                        // lo 链表存放的是节点位置不需要修改的节点, hi 就是存储位置变为 原来位置 + oldCap 的节点

                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;

                        Node<K,V> next;
                        do {
                            // 遍历 j 位置上的链表
                            // 临时保存下一个节点
                            next = e.next;

                            // 当前节点的 hash 值 与上旧的容量值等于 0, 那么可以确定这个节点在新数组的位置和原来的一样
                            // 先将其放到低维的链表, 后面在把低维的链表放到新数组的对应位置
                            if ((e.hash & oldCap) == 0) {
                                
                                if (loTail == null)
                                    // 如果loTail为空, 代表该节点为第一个节点
                                    loHead = e;
                                else
                                    // 否则将节点添加在链表的尾部
                                    loTail.next = e;    
                                // 重新设置尾结点
                                loTail = e;

                            } else { 
                                // 不等于 0, 表示这个节点需要存放到新的位置, 先放到高维链表
                                if (hiTail == null)
                                	// 作用同上面
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);


                        if (loTail != null) {
                        	// 设置数组的 j 位置为 lo 链表
                            loTail.next = null;
                            newTab[j] = loHead;
                        }

                        if (hiTail != null) {
                        	// 设置数组的 j + oldCap 为 hi链表
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }    
                }
            }
        }
        // 返回新数组
        return newTab;
    }

    /**
     * 将指定的位置转为树
     * 在 putVal 时, 如果发现链表的长度大于 8 了, 就会调用这个方法, 将链表变为链表
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {

        int n, index; Node<K,V> e;
        // 注意这里: 数组为空, 或者长度还不够 64, 进行扩容, 不进行转换操作
        // 所以链表转为红黑树的还有一个大前提: 当前 HashMap 中存储数据的数组的长度要大于 64, 而不是链表的长度大于 8 就树化
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        // 需要处理的位置的链表不为 null
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
            	// 因为 TreeNode 继承了 Node, 所以 TreeNode 也可以当做链表使用
            	// 将 Node 转为 TreeNode
            	// 然后将下面拼接成一个以 TreeNode 为类型的链表
                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);
        }
    }
}
4.1.1 备注 1

在 resize 方法中, 旧数组已有数据了, 但是在进行扩容时, 容量默认是 2 倍扩展, 但是阈值只有在 oldCap >= DEFAULT_INITIAL_CAPACITY 时才会进行 2 倍扩展, 否则是为新的容量 * 负载因子
原因: 为了在容量少的情况下, 尽可能的利用数组的空间, 不造成浪费。

假设我们在初始时, 指定了容量为 2, 那么在初始后容量值为 2, 阈值为 2 * 0.75 = 1。

第一次扩容:

旧的容量新的容量旧的阈值经过运算新的阈值
旧阈值 << 12412
新容量 × 0.752413

第一次扩容:

旧的容量新的容量旧的阈值经过运算新的阈值
旧阈值 << 14824
新容量 × 0.754836

第三次扩容

旧的容量新的容量旧的阈值经过运算新的阈值
旧阈值 << 181648
新容量 × 0.75816612

第四次扩容

旧的容量新的容量旧的阈值经过运算新的阈值
旧阈值 << 11632816
新容量 × 0.7516321224

通过上面可以发现: 在旧容量 < 16 之间变化时, 通过新容量 × 负载因子, 阈值会大一些, 可以更充分的利用数组的空间, 在第四次扩容时, 新容量为 32 时, 旧的阈值为 12, 这时 12 << 1 等于 32 * 0.75, 所以 2 者后续的计算是一样的, 通过位运算比较快。

可以简单理解为: 阈值一直都是等于当前的容量 * 负载因子,
在旧容量为 2, 4, 8 按照上面的方式计算。
到了就容量 >= 16 时, 可以通过位运算达到同样的计算效果, 使用位运算更快

4.1.2 备注 2

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;
        // 步骤 1    
        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;

    } else if (oldThr > 0)
        // 省略
    else { 
        // 省略
    }

    if (newThr == 0) {
        // 步骤 2
        float ft = (float)newCap * loadFactor;
        // 步骤 3
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
    }
    // 设置新的阈值
    threshold = newThr;
}

上面的阈值的计算一般情况下是没问题的, 但是在容量从 2^29 到最大值 2^30 扩容时, 按照上面的步骤

  1. 走到步骤 1 时, 容量变为原来的 2 倍, newCap = MAXIMUM_CAPACITY, 这时 newCap < MAXIMUM_CAPACITY, 不满足条件, 所以 newThr 还是 0
  2. 到了步骤 2 时, 计算出来的 ft = newCap * loadFactor, loadFactory 一般都是大于 0, 小于 1 的数, 所以 ft < newCap = MAXIMUM_CAPACITY, 既 ft < MAXIMUM_CAPACITY
  3. 到了步骤 3 时, new = MAXIMUM_CAPACITY < MAXIMUM_CAPACITY 为 false, false && true = false
  4. 经过步骤 3 后, newThr 直接变为 int.max_value, 正常应该是 newCap * loadFactor, 下次扩容时, 到达上面的旧容量 >= MAXIMUM_CAPACITY, 再把阈值修改为 int.max_value

上面的分析都是基于负载因子大于 0, 小于 1 的情况的分析, 如果用户将负载因子设置为大于 1 的情况, 可能导致步骤 2 计算出来的 ft 为 负数。
所以可以将上面的步骤 1 修改为 <= MAXIMUM_CAPACITY 或者步骤 3 修改为 <= MAXIMUM_CAPACITY

4.1.3 备注 3

(e.hash & oldCap) == 0 可以判断节点在新数组的位置和旧数组的是否一样, 很巧妙的利用了数组的长度为 2 的 n 次方这个特性。 也就是 HashMap 中数组的容量是 2 的 n 次方的第二个作用。

能达到这样的效果的必须知道:

  1. oldCap 是 2 ^ n, newCap 是 在 oldCap 的基础 * 2, 也就是 newCap 是 2 ^ (n + 1)
  2. 2 ^ n 在二进制的表示为 1 + n 个 0 , 2 ^ (n + 1) 二进制表示 1 + ( n + 1 ) 个 0
  3. 2 ^ n - 1 在二进制的表示为 n 个 1, 那么 2 ^ (n + 1) 就是 n + 1 个 1
  4. (2 ^ n - 1) & hash, 我们只需要取 hash 的从右到左的 1 到 n 位就行了, 因为 2 ^ n - 1 前面都是 0, & 上都是 0

我们假设有个节点 A, 其 现在在数组中的 index 位置, 现在 oldCap 是 16, 既 2^4, 16 - 1 = 15, 二进制表示为 00000000 00000000 00000000 00001111

这时候 index = hash & (16 - 1), index 的值自然就是只和 hash 值的低 4 位有关, 我们假设它为 abcd

oldCap 扩大了一倍, 当前节点的的位置 index 的计算公式 = (32-1) & hash, 和 hash 值的低 5 位有关, hash 的低 5 位的值无外乎下面两种情况: 0abcd 或者 1abcd

0abcd = index, 而 1abcd = 0abcd + 10000 = 0abcd + oldCap = index + oldCap, 从这里可以知道容量扩大了一倍, 那么新的 index 是有规律的, 要么不变, 要么就是 index + oldCap

新旧 index 是否一致就体现在 hash 值的第 5 位, 那么第 5 位怎么知道呢? 32 的 2^n 的二进制形式 1 + 5 个 0, 那么 hash & oldCap 就能知道 hash 的第 5 位是 0 或者 1 了, 既 hash & oldCap = 0, hash 的第 5 位为 0,
hash & oldCap 不等于 0, hash 的第 5 位为 1。

最终可以通过 hash & oldCap 得到

  1. hash & oldCap = 0 => 当前节点在数组的位置不用变
  2. hash & oldCap != 0 => 当前节点在新数组的 index + oldCap 的位置

4.2 其他添加数据的方式

  1. 直接添加一个 Map 的 putAll(Map<K, V> map);
  2. 添加一个元素, 如果对应的位置已经有数据了, 则不添加 putIfAbsent(K key, V value)

4.3 获取数据

public class HashMap<K, V> {

    /** 
     * 通过 key 获取 value, 如果节点为 null, 返回 nulL
     */
    public V get(Object key) {
        Node<K,V> e;

        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    /**
     * 通过 key 的 hash 值和 key 值获取 value 值
     */
    final Node<K,V> getNode(int hash, Object key) {

        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

        // 数组不为空, 长度大于 0, 链表的第一个节点 不为 null
        if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {

        	// 第一个节点的 hash 值和传入的 hash 值一样, key 值也一样,  第一个节点符合条件了, 直接返回
            if (first.hash == hash &&  ((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;
    }
}

4.4 删除数据

public class HashMap<K, V> {

    /**
	 * 直接通过 key删除
	 */
	public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
    }

    /**
     * 删除节点
     * matchValue : 为 true, 找到了节点, 还会比较他们的值, 值相同才会删除
     * movable : 针对红黑树起作用, 为 false, 节点删除了, 不改变其他节点的位置
     */
    final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {

        // 数组不为 null, 长度大于 0, 同时定位到的位置不为 null
        if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) {

            Node<K,V> node = null, e; K k; V v;
            // 链表的第一个节点符合了条件
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            // 链表的下一个节点不为null
            else if ((e = p.next) != null) {    

                // 节点为 树节点
                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)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }    
            }

            // 找到了需要删除的节点, 如果设置了需要检查值, 后面会对其值的内存地址和 equals 进行比较 
            if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)
                	// 调用红黑树的删除节点的方法
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
                	// 把链表的头部直接指向了下一个节点
                    tab[index] = node.next;
                else
                	// p 在上面的链表的遍历中, 变成需要删除的节点的前一个节点了
                    // p 的下一个节点指向需要删除节点的下一个节点
                    p.next = node.next;

                ++modCount;
                --size;
                // 空方法
                afterNodeRemoval(node);
                return node;    
            }
        }
    }
}

上面只是通过 key 进行删除数据的, 同样还有一个通过 key 和 value 值都相同的情况进行删除的方法。

4.5 修改数据

public class HashMap<K, V> {

    public boolean replace(K key, V oldValue, V newValue) {
        Node<K,V> e; V v;

        // 通过 getNode 获取到对应的节点, 节点的值等于入参的 oldValue
        if ((e = getNode(hash(key), key)) != null && ((v = e.value) == oldValue || (v != null && v.equals(oldValue)))) {
            // 替换节点的 value 值等于入参的新 value
            e.value = newValue;
            afterNodeAccess(e);
            return true;
        }
        return false;
    }
}

5 HashMap 的补充

  1. HashMap 同样是线程不安全的, 如果需要线程安全, 同样可以通过 Collections.synchronizedMap() 获取到一个线程安全的实现类。
  2. HashMap 支持 fail-fast 机制
  3. HashMap 的序列化也是自定义的
  4. HashMap 内部提供了许多迭代器: KeyIterator, ValueIterator, EntryIterator, 方便各种遍历需求
  5. HashMap 的线程不安全, 猜测时, 同时有 2 个线程判断到需要扩容了, 然后同时进行扩容, 期间就可能造成闭合的链路。

6 红黑树相关的内容

  1. 清晰理解红黑树的演变—红黑的含义 红黑树的前身
  2. 教你初步了解红黑树 红黑树的节点插入
  3. 红黑树之删除节点 红黑树的节点删除
  4. 红黑树原理以及插入、删除算法 附图例说明 里面的增删图片不错

7 参考

Java集合: HashMap详解 (JDK 1.8)
深入理解HashMap(四): 关键源码逐行分析之resize扩容

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/195952.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

怎么解决 申请获取你的手机号,但该功能使用次数已达当前小程序上限,暂时无法使用。

微信出新规了&#xff0c; 获取手机号数据需要收费&#xff0c;1分钱一条。 在以前的开发中&#xff0c;获取手机号是默认不需要收费的&#xff0c;现在收费等于微信现在作为运营商一样&#xff0c;验证一个手机短信&#xff0c;需要收费 几分钱。 如果你的程序遇到了问题&am…

面试题:说一下你对 OAuth2 协议原理的理解?

文章目录 OAuth2简介角色流程客服端注册Client Type四种授权模式授权码模式隐藏式密码式凭证式RefreshToken OAuth2简介 OAuth 是一个开放授权协议标准&#xff0c;允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息&#xff0c;而不需要将用户名和密码提供给第三…

Python爬虫之代理IP与访问控制

目录 前言 一、代理IP 1.1.使用代理IP的步骤 1.2.寻找可用的代理IP 1.3.设置代理IP 1.4.验证代理IP的可用性 二、访问控制 2.1.遵守Robots协议 2.2.设置访问时间间隔 2.3.多线程爬取 总结 前言 在进行Python爬虫过程中&#xff0c;代理IP与访问控制是我们经常需要处…

11.盛最多的水的容器

一、题目 给定一个长度为 n 的整数数组 height 。有 n 条垂线&#xff0c;第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。 找出其中的两条线&#xff0c;使得它们与 x 轴共同构成的容器可以容纳最多的水。 返回容器可以储存的最大水量。 题目难度&#xff1a;中等 示例&a…

为什么API管理工具对开发人员有益?

应用程序编程接口 &#xff08;API&#xff09; 用于在应用程序之间创建连接&#xff0c;以允许它们相互通信。这种连接是当今数字世界运作方式不可或缺的一部分。实际上&#xff0c;API 使企业能够集成系统&#xff0c;通过创新提供更好的服务和产品。 这就是为什么在 IT 内部…

C语言常见算法

算法&#xff08;Algorithm&#xff09;&#xff1a;计算机解题的基本思想方法和步骤。算法的描述&#xff1a;是对要解决一个问题或要完成一项任务所采取的方法和步骤的描述&#xff0c;包括需要什么数据&#xff08;输入什么数据、输出什么结果&#xff09;、采用什么结构、使…

低代码部署方式大揭秘:满足你的多种选择

本文由葡萄城技术团队原创并首发。转载请注明出处&#xff1a;葡萄城官网&#xff0c;葡萄城为开发者提供专业的开发工具、解决方案和服务&#xff0c;赋能开发者。 前言 低代码开发平台为企业提供创新的应用程序开发和部署方法&#xff0c;让非技术人员也能够轻松创建和发布应…

C++ :静态成员

静态成员 静态成员就是在成员变量和成员函数前加上关键字 static &#xff0c;称为静态成员 静态成员分为&#xff1a; 静态成员变量 1.所有对象共享同一份数据 2.在编译阶段分配内存 3.类内声明&#xff0c;类外初始化 静态成员函数 1.所有对象共享同一个函数 2.静态成…

【华为OD题库-040】计算最接近的数-java

题目 给定一个数组X和正整数K&#xff0c;请找出使表达式X[i]-x[i1]…-X[ik-1]&#xff0c;结果最接近于数组中位数的下标i&#xff0c;如果有多个满足条件&#xff0c;请返回最大的i。 其中&#xff0c;数组中位数:长度为N的数组&#xff0c;按照元素的值大小升序排列后&#…

每日一练 | 华为认证真题练习Day138

1、IPv6地址FE80::2EO:FCFF:FE6F:4F36属于哪一类&#xff1f; A. 组播地址 B. 任播地址 C. 链路本地地址 D. 全球单播地址 2、如果IPv6的主机希望发出的报文最多经过10台路由器转发&#xff0c;则应该修改IPv6报文头中的哪个参数&#xff1f; A. Next Header B. Version …

基于单片机的大棚温湿度检测系统(论文+源码)

1. 系统设计 本课题主要开发一个大棚温湿度检测系统、其功能要求如下&#xff1a; 1.实现大棚温室环境的空气中的温湿度检测&#xff1b; 2.当检测到的土壤湿度低于阈值时&#xff0c;模拟水泵进行浇水&#xff0c;湿度太高则进行干燥&#xff1b; 4. 当检测到环境的温度太…

Git开发实用技巧

文章目录 一图胜千言&#xff1a;

【JMeter】配置元件

1. 元件的分类 HTTP Request Default 作用&#xff1a; 可以配置成通用的信息&#xff0c;可复用 ​​​​​​​ JDBC Connection Configuration 作用&#xff1a;连接数据库 前提&#xff1a; 下载好对应数据类型的jar包 ​​​​​​​ HTTP Header Manager信息头管理…

触控板窗口管理软件Swish mac中文版

Swish mac是一款触控板窗口管理工具&#xff0c;它允许用户通过简单的手势来控制窗口。Swish利用MacBook的触控板&#xff0c;使得用户可以更加便捷地管理窗口。它支持多种手势&#xff0c;例如捏合、拖动、放大和缩小等&#xff0c;使得用户可以轻松地实现窗口的切换、最小化、…

【项目实战】SpringBoot连接openGauss

一&#xff1a;Docker安装openGauss 1.下载openGauss 安装好Docker好以后&#xff0c;执行如下命令下载openGauss3.0镜像。docker pull enmotech/opengauss:3.0.0 2.运行openGauss 执行如下命令docker run -itd --name opengauss \ --restartalways \ --privilegedtrue \ …

老师怎样预防校园欺凌的发生

作为老师&#xff0c;面对校园欺凌这个问题&#xff0c;我觉得有必要为各位老师提供一些实用的建议和策略。因为大家都知道&#xff0c;校园欺凌的存在不仅会对学生造成身心伤害&#xff0c;还会对整个教育环境产生负面影响。 关注学生的心理健康 校园欺凌往往与学生的心理问题…

第二证券:北证50飙升引发跷跷板效应

沪指周一低开震动&#xff0c;盘中一度杀跌进入3000点整数关口&#xff0c;尽管午后跌幅有所收窄&#xff0c;但毕竟收盘仍在30日均线下方。深成指相同低开低走&#xff0c;表现稍弱于沪指。到收盘&#xff0c;沪指报收3031.7点&#xff0c;跌落0.3%&#xff1b;深成指报收9785…

【JavaScript】实现页面中填写文档、电子签名,填写完后 转为pdf并自动下载;附带psd转图片预览效果

效果图&#xff1a; 需求&#xff1a; 用户可以在线进行文档编辑&#xff0c;在线电子签名&#xff0c;然后点击可以另存为pdf文档 实现&#xff1a; 首先实现布局 让填写文档 随着页面的变化 一直保持居中 <!DOCTYPE html> <html lang"en"><head…

十八数藏的文化数字革新:传统之美的数字转变

在数字时代的冲击下&#xff0c;十八数藏以其独特的文化数字革新&#xff0c;将传统之美注入数字的脉络中&#xff0c;实现了非遗之珍的数字转变。这种数字化的创新不仅为传统工艺赋予了新的生命&#xff0c;也使得传承变得更为生动与全面。 十八数藏通过数字技术&#xff0c;将…

腾讯云轻量服务器通过Docker搭建外网可访问连接的redis5.x集群

原创/朱季谦 最近买了一台4核16的腾讯云轻量应用服务器,花了我快四百的大洋&#xff0c;打算搭建一堆docker组件集群&#xff0c;最先开始是通过docker搭建redis集群&#xff0c;计划使用三个端口&#xff0c;分别是7001,7002,7003。 腾讯云服务器有防火墙限制&#xff0c;故…