【Java基础】HashMap详解

HashMap详解

一、HashMap源码 - 概述

1、继承关系

package java.util;

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    
}
  • 继承 AbstractMap 类:AbstractMap提供了Map接口的实现,以最大限度地减少实现此接口所需的工作。
  • 实现 Cloneable 接口:表示可以克隆HashMap对象,创建并返回HashMap对象的一个副本。
  • 实现 Serializable 接口:HashMap对象可以被序列化和反序列化。

这里已经继承了 AbstractMap,而 AbstractMap实现了Map接口,那为什么HashMap还要在实现Map接口呢?

  • 据Java集合框架的创始人Josh Bloch描述,这样的写法是一个失误。JDK的维护者后来也懒得改了…

2、静态常量

package java.util;

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {

    // 序列化版本号
    private static final long serialVersionUID = 362498820763181265L;

    // Map 默认的容量是:16(必须是2的幂次方)
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

    // 集合 最大的容量是:2的30次幂
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 负载因子(默认0.75)
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    // bucket桶上的结点数大于8时,链表会转成红黑树
    static final int TREEIFY_THRESHOLD = 8;
    
    // bucket桶上的结点数小于6时,红黑树会转回链表
    static final int UNTREEIFY_THRESHOLD = 6;
    
    // 链表转成红黑树时,对应的数组长度最小的值(数组没有达到该值,优先扩容数组)
    static final int MIN_TREEIFY_CAPACITY = 64;

}

3、成员变量

package java.util;

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {

    // 存储元素的数组(容量必须是2的n次幂)
	transient Node<K,V>[] table;

    // 存放具体元素的集合
    transient Set<Map.Entry<K,V>> entrySet;

    // 已经存放的元素个数
    transient int size;

    // 每次扩容和更改map结构的计数器
    transient int modCount;

    // 数组扩容的阈值(size > threshold,数组就要扩容为原来的2倍)
    int threshold;

    // 负载因子,table能够使用的比例,默认为0.75(threshold = capacity * loadFactor)
    final float loadFactor;
}

4、静态内部类 - Node

// java.util.HashMap

// 实现了 Map 中的 Entry接口
static class Node<K,V> implements Map.Entry<K,V> {
    final int 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;
    }

    // 重写了 equals 和 hashCode 方法
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}
// java.util.Map

interface Entry<K,V> {
    K getKey();
    V getValue();
    V setValue(V value);
    
    boolean equals(Object o);
    int hashCode();
    
    // comparingByKey、comparingByValue....
}

二、HashMap源码 - 构造方法

1、空参构造

构造一个空的 HashMap ,默认初始容量(16)和 默认负载因子(0.75)

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

2、指定 初始容量

构造一个 HashMap,指定初始容量(传参指定)和 默认负载因子(0.75)

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

3、指定 初始容量 和 负载因子

构造一个HashMap,指定初始容量(传参指定)和 指定负载因子(传参指定)

public HashMap(int initialCapacity, float loadFactor) {
    // 初始容量initialCapacity < 0
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);

    // 初始容量initialCapacity > 集合的最大容量 MAXIMUM_CAPACITY(2的30次幂)
    if (initialCapacity > MAXIMUM_CAPACITY) 
        initialCapacity = MAXIMUM_CAPACITY;

    // 负载因子loadFactor <= 0 或 是一个非数值
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);

    // 指定负载因子的值
    this.loadFactor = loadFactor;

    // 返回比initialCapacity大的最小的2的幂次方(例如10,返回16)
    this.threshold = tableSizeFor(initialCapacity);
}

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

位运算演示:

10000000 00000000 00000000 00000000		// n
01000000 00000000 00000000 00000000		// n >>> 1
------------------------------------	// n |= n >>> 1
11000000 00000000 00000000 00000000  
00110000 00000000 00000000 00000000		// n >>> 2
------------------------------------	// n |= n >>> 2
11110000 00000000 00000000 00000000
00001111 00000000 00000000 00000000		// n >>> 4
------------------------------------	// n |= n >>> 4
11111111 00000000 00000000 00000000
00000000 11111111 00000000 00000000		// n >>> 8
------------------------------------	// n |= n >>> 8
11111111 11111111 00000000 00000000	    
00000000 00000000 11111111 11111111		// n >>> 16
------------------------------------	// n |= n >>> 16
11111111 11111111 11111111 11111111

为什么要对cap做减1操作int n = cap - 1;

  • 如果cap已经是2的幂,又没有执行减1操作,那么执行完 位运算 和 最后的+1操作之后,返回值将是这个cap的2倍。

    例如:1000(8) —> 1111 —> 10000(16)

tableSizeFor 分析:

  • 如果 cap = 0,即 (n = cap-1) == -1

    执行完 位运算 操作后,n依然是-1,此时 n < 0,返回1

  • 如果 cap = 1,即 (n = cap-1) == 0

    执行完 位运算 操作,n依然是0,此时返回 n+1 = 1

  • 如果 cap 不是 2 的整数次幂

    执行完 -1操作、位运算、+1操作,返回 cap 最高位1左移1位(即比cap大的最小的2的幂次方)

  • 如果 cap 是 2 的整数次幂

    执行完 -1操作、位运算、+1操作,和原来的值一样。

4、基于Map传参

构造一个HashMap,映射关系与指定 Map 相同,使用默认负载因子(0.75)

public HashMap(Map<? extends K, ? extends V> m) {
    // 负载因子使用默认值0.75
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    // copy指定Map的元素
    putMapEntries(m, false);
}

putMapEntries 方法

这里看一下 putMapEntries 方法的实现:

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    // 指定Map的size
    int s = m.size();
    if (s > 0) {
        // table还未初始化
        if (table == null) {
            // 存放指定Map的元素后 不需要扩容的阈值
            float ft = ((float)s / loadFactor) + 1.0F;

            // 超过最大容量,则取最大容量的值
            int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);

            // t > 扩容阈值,则初始化阈值(threshold默认是0)
            if (t > threshold)
                threshold = tableSizeFor(t);	// 大于t的最小的2的幂次方
        }
        
        // table已初始化 && 指定Map的元素个数大于阈值
        else if (s > threshold)
            resize();	// 扩容处理

        // 将 指定Map的元素 添加至 当前的HashMap
        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);
        }
    }
}

问题:float ft = ((float)s / loadFactor) + 1.0F; 这一行代码中为什么要加1.0F

  • ((float)s / loadFactor) 的结果是小数。
  • 1.0F 与下面的 (int)ft 相当于是对小数做一个向上取整。
  • 然后 调用 tableSizeFor(t) 方法,获取大于t的最小的2的幂次方。

所以 +1.0F 是为了将 小数ft 向上取整,获取更大的threshold,减少数组扩容的概率。

举个栗子:假设参数指定Map的元素个数是6个,此时 ((float)s / loadFactor) = 8

  • 如果没有加1.0F ,则调用 tableSizeFor(8),得到的阈值是8。
    • 此时阈值是8,已经有6个元素了,再存两个元素,就会导致扩容
  • 如果加了 1.0F ,则调用 tableSizeFor(9),得到的阈值是16(比9大的最小的2的幂次方)
    • 此时阈值是16,有6个元素,相对来说不容易导致扩容。

三、HashMap源码 - 成员方法

1、新增方法_put()

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

1)hash 方法

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • key == null:hash值为0(这里可以看出,HashMap是支持key为null的)
  • key != null:先调用key的hashCode方法赋值给h,然后将 低16位 和 高16位 进行 按位异或 得到最后的hash值。

为什么要 将 低16位 和 高16位 进行 按位异或 的计算呢?

// 在后续的`putVal`方法中,使用 计算出来的hash值 与 数组长度-1 进行按位与操作,确定哈希槽
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    // ...
    if ((p = tab[i = (n - 1) & hash]) == null)	// 这里的n表示数组长度16
    // ...
}

如果数组的长度很小,假设就是默认值16,那么n-1=15,二进制就是1111

  • 此时如果直接和hashCode()进行按位与运算,实际上只用到了hashCode()的后4位。
  • 如果hashCode()高位变化很大,低位变化很小,就很容易造成哈希冲突。
hashCode()1111 1111 1111 1111 1111 0000 1110 1010
n-116-1=15   0000 0000 0000 0000 0000 0000 0000 1111
------------------------------------------------------
直接进行按位与   0000 0000 0000 0000 0000 0000 0000 1010   // 如果只有高位变化,不管怎么变,哈希槽都是一样的

因此,将低16位与高16位进行 按位异或 运算

  • 这样获取的hash值的低位其实是hashCode()值高位与低位的结合,
  • 将高低位都利用起来,增加了随机性,减少了哈希碰撞发生的几率。

2)putVal 方法

/**
 * put方法的核心逻辑,putVal方法
 *
 * @param hash				key的hash值
 * @param key				key
 * @param value				value
 * @param onlyIfAbsent      如果为true,代表不更改现有的值
 * @param evict				如果为false,表示table为创建状态
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // 如果是第一次新增,进行数组初始化
    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 {
        Node<K,V> e; K k;
        
        // 通过hash值和equals方法比较哈希冲突的两个key是否相同
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            // 相等 -> 用e来记录旧元素节点
            e = p;
        
        // 不相等 -> 判断p是否是红黑树节点
        else if (p instanceof TreeNode)
            // 放入红黑树中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        
        // p是链表节点
        else {
            // 循环遍历链表,遍历到最后节点插入(JDK1.8之后采用尾插法)
            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;
                }
                // 不是最后一个节点,比较链表节点的key和新增元素的key是否相同
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    // key相同,跳出循环,在if (e != null)中进行值的替换
                    break;
                // 和e = p.next配合,遍历链表
                p = e;
            }
        }
        
        // 处理key相同的元素,用新值替换旧值,并返回旧值
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 访问后回调(LinkedHashMap会用到)
            afterNodeAccess(e);
            return oldValue;
        }
    }
    
    // 记录修改次数
    ++modCount;

    // 判断实际大小是否大于threshold阈值,如果超过则扩容
    if (++size > threshold)
        resize();
    
    // 插入后回调(LinkedHashMap会用到)
    afterNodeInsertion(evict);
    return null;
}

3)put 小结

put方法的大致流程如下:

  1. 计算key的hash值 (h = key.hashCode()) ^ (h >>> 16)(低16位 与 高16位 进行 按位异或)
  2. 计算桶下标 hash & (length - 1)
  3. 如果数组table为空,调用 resize() 进行数组初始化。
  4. 如果没有发生哈希碰撞,直接添加元素到散列表中。
  5. 如果发生了哈希碰撞(hash值相同),进行三种判断
    • key地址相同 或 equals比较相同,则替换旧值,返回旧值
    • 如果是红黑树结构,调用树的插入方法
    • 如果是链表结构,采用尾插法进行插入(JDK1.8之后采用尾插法)
  6. 如果容量size大于threshold阈值,调用 resize() 进行扩容

2、链表转红黑树_treeifyBin()

1)源码分析

/**
 * 替换指定哈希表的索引处桶中的所有链接节点,除非表太小,否则将修改大小。
 *
 * @param tab   数组名
 * @param hash  哈希值
 */
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();

    // 满足树形化的条件 -> 将数组指定位置桶中的链表节点赋值给e(从第一个开始)
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // hd:红黑树的头结点   tl:红黑树的尾结点
        TreeNode<K,V> hd = null, tl = null;
        // 遍历链表,将链表的每个节点放入红黑树中
        do {
            // 新创建一个树节点p,内容和当前链表节点e一致
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                // 将链表的头节点e 转换为红黑树节点p 并赋值给红黑树的头节点
                hd = p;
            else {
                // 不断向下遍历,将链表节点 转为 红黑树节点
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        
        // 让桶中的第一个元素指向新创建的树根节点,替换桶的链表内容为树形化内容
        if ((tab[index] = hd) != null)
            // 左旋、右旋、自平衡等一系列操作
            hd.treeify(tab);
    }
}

2)treeifyBin 小结

treeifyBin方法的大致流程如下:

  1. 根据数组长度确定是扩容还是树形化。
  2. 如果是树形化,遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系。
  3. 然后让桶中的第一个元素指向新创建的树根节点,替换桶的链表内容为树形化内容。

3、扩容方法_resize()

扩容会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。因此在编写程序中,要尽量避免resize。

1)什么时候会扩容?

  • 当 HashMap 中的数组还没有初始化时,会调用 resize 方法进行数组的初始化。

  • 当 size (HashMap中的元素个数) > capicity (数组长度) * loadFactor (负载因子) 时,会进行数组的扩容。

  • 当 HashMap 中一个链表的长度超过阈值8时

    • 如果数组长度没有达到64,HashMap会优先扩容解决

    • 如果数组长度达到了64,会将链表变成红黑树,节点类型由Node变成TreeNode类型。

      如果映射关系被移除后,下次执行resize方法时,判断树的节点个数低于6,也会再把树转换为链表。

2)扩容的机制

HashMap在进行扩容时,使用的rehash方式非常巧妙。

  • 因为每次扩容都是翻倍,与原来计算的 (n-1)&hash 的结果相比,只是多了一个bit位,
  • 所以节点要么就在原来的位置,要么就被分配到 “ 原位置+旧容量 " 这个位置。

怎么理解呢?假设我们从16扩容为32,具体的变化如下所示:

在这里插入图片描述

元素在重新计算hash之后,n变为2倍,那么n-1的标记范围在高位多1bit,因此新的index就会发生如下变化:

在这里插入图片描述

这样就验证了上述所描述的:扩容后的节点要么就在原来的位置,要么就被分配到 “ 原位置+旧容量 " 这个位置。

因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就可以了

  • 如果是0,索引不变;如果是1,索引变成 “oldIndex+oldCap (原位置+旧容量)”

下图为16扩充为32的resize示意图:

在这里插入图片描述

正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,同时由于新增的1bit是0还是1是随机的,又在resize的过程中保证了rehash之后 每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。

3)源码分析

final Node<K,V>[] resize() {
    // 老数组
    Node<K,V>[] oldTab = table;
    // 老数组的长度(这里>=0)
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 老的阈值
    // 1. 空参构造 --> threshold = 0
    // 2. 有参构造 --> threshold = tableSizeFor(initialCapacity)
    int oldThr = threshold;

    int newCap, newThr = 0;

    // 老数组长度大于0,开始计算扩容后的大小
    if (oldCap > 0) {
        // 超过最大值就不再扩充了,就只好随你碰撞去吧
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;	// 2^31 - 1
            return oldTab;
        }

        // 没超过最大值,就扩充为原来的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 // 原数组长度 >= 数组初始长度16
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // 阈值也扩大一倍
    }

    // 老数组的长度=0 且 老的阈值>0(空参构造) -->  新的数组长度 = 老的阈值(大于容量的最小的2的整次幂)
    else if (oldThr > 0)
        newCap = oldThr;

    // 老数组的长度=0 且 老的阈值=0(有参构造)--> 直接使用默认值
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;	// 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);	// 0.75 * 16
    }

    // 计算新的resize最大上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }

    // 新的阈值 默认原来是12 乘以2之后变为24
    threshold = newThr;
    
    // newCap是新的数组长度 -> 32
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    
    // 判断旧数组是否等于空
    if (oldTab != null) {
        // 把每个bucket都移动到新的buckets中
        // 遍历旧的哈希表的每个桶,重新计算桶里元素的新位置
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // help gc
                oldTab[j] = null;
                
                // 没有next,说明没有hash冲突,即只有一个元素,直接插入
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                
                // 有hash冲突
                
                // 红黑树处理冲突
                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;
                    // rehash(参考上面讲的扩容的机制)
                    do {
                        // 原索引
                        next = e.next;
                        // 如果等于true,e这个节点在resize之后不需要移动位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引 + oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    
                    // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    
                    // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    
    return newTab;
}

4)resize 小结

resize方法的大致流程如下:

  • 判断数组是否初始化过?
    • 否,进行数组的初始化(默认大小16,加载因子0.75,扩容阈值12)
    • 是,将数组扩容为原来的两倍,然后将元素进行rehash,复制到新的散列表中。

4、删除方法_remove()

1)源码分析

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
}

/**
 * remove方法的具体实现在removeNode方法中
 */
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;
    
    // 判断要删除的key映射的桶是否为空
    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;
        
        // 如果桶上的节点就是要找的key,则将node指向该节点
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;

        // 继续找下一节点
        else if ((e = p.next) != null) {
            // p是红黑树节点 -> 说明是以红黑树来处理的hash冲突,则获取红黑树要删除的节点
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            
            // p是链表节点 -> 说明是以链表方式处理的hash冲突,则遍历链表来寻找要删除的节点
            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);
            }
        }
        
        // 比较找到的key的value和要删除的是否匹配
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            // node是红黑树节点 -> 调用红黑树的方法来删除节点
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            
            else if (node == p)
                // 链表删除
                tab[index] = node.next;
            else
                p.next = node.next;
            
            // 记录修改次数
            ++modCount;
            
            // 变动的数量
            --size;
            
            // 删除回调(LinkedHashMap会用)
            afterNodeRemoval(node);
            
            return node;
        }
    }
    return null;
}

2)remove 小结

remove方法的大致流程如下:

  • 首先先找到元素的位置
  • 如果是链表就遍历链表,找到元素之后删除
  • 如果是红黑树就遍历树,找到元素之后删除(树小于6的时候要转链表)

5、查找方法_get()

1)源码分析 - get

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;
    
    // 如果哈希表不为空 且 key对应的桶上不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        
        // 判断第一个元素是不是就是要找的,是就直接返回
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;

        // 如果不是第一个元素,判断是否有后续节点
        if ((e = first.next) != null) {
            // 红黑树 -> 调用红黑树中的getTreeNode方法获取节点
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            
            // 链表 -> 循环判断链表中是否存在该key
            do {
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    
    return null;
}

2)源码分析 - getTreeNode

final TreeNode<K,V> getTreeNode(int h, Object k) {
    return ((parent != null) ? root() : this).find(h, k, null);
}

final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
    TreeNode<K,V> p = this;
    do {
        int ph, dir; K pk;
        TreeNode<K,V> pl = p.left, pr = p.right, q;
        if ((ph = p.hash) > h)
            p = pl;
        else if (ph < h)
            p = pr;
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            return p; // 找到之后直接返回
        else if (pl == null)
            p = pr;
        else if (pr == null)
            p = pl;
        else if ((kc != null ||
                  (kc = comparableClassFor(k)) != null) &&
                 (dir = compareComparables(kc, k, pk)) != 0)
            p = (dir < 0) ? pl : pr;
        // 递归查找
        else if ((q = pr.find(h, k, kc)) != null)
            return q;
        else
            p = pl;
    } while (p != null);
    return null;
}
  • 查找红黑树,由于之前添加时已经保证这个树是有序的了,因此查找时基本就是折半查找,效率更高。

  • 这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断key是否相等,相等直接返回。不相等就从子树中递归查找。

  • 若为树,则在树中通过key.equals(k)查找,O(logn)

    若为链表,则在链表中通过key.equals(k)查找,O(n)

3)get 小结

get方法的实现:

  • 通过key计算获取bucket中的下标位置
  • 如果在桶的首位,则直接返回
  • 如果不在桶的首位,则在树中(getTreeNode)或链表中(getNode)遍历找
  • 如果有哈希冲突,则利用equals方法去遍历链表查找节点。

四、HashMap相关问题

哈希冲突

什么是哈希冲突?何时发生哈希冲突?如何解决哈希冲突?

1. 什么是哈希冲突?何时发生哈希冲突?
		哈希冲突是指,两个元素通过hash计算后,放到同一个哈希槽,发生了冲突。
		只要两个元素的key计算的hash值相同,就会发生哈希冲突。
2. 如何解决哈希冲突?
		将哈希碰撞/哈希冲突的元素放到哈希桶中	
            jdk8之前使用 链表 解决哈希碰撞。
            jdk8之后使用 链表+红黑树 解决哈希碰撞。

哈希值相同就是同一个对象?

哈希值相同 就 一定是同一个对象吗?

// 两个不同的对象,hashCode相同
System.out.println("重地".hashCode()); // 1179395
System.out.println("通话".hashCode()); // 1179395
  • hashCode不同,两对象一定不同。
  • hashCode相同,两对象也不一定相同。(需要通过equals方法进一步判断)

HashMap数据结构的变化

JDK1.8前后,HashMap的数据结构是不同的:

  • JDK 1.8 之前 : 数组+链表
  • JDK 1.8 之后 : 数组+链表 +红黑树

在这里插入图片描述

在这里插入图片描述

为什么要引入红黑树?

哈希函数取得再好,也很难达到百分百均匀分布

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

此时引入红黑树,遍历的时间复杂度就是O(logn)解决了链表过长时的问题,提高了查询效率。

红黑树 是否可以取代 链表?

  • 当链表长度很小的时候,即使遍历,速度也非常快,没有必要引入结构复杂的红黑树。
  • 但是当链表长度不断变长,肯定会对查询性能有一定的影响,此时就需要引入红黑树来提升效率了。

因此,HashMap并非是用红黑树替换链表,而是两者结合使用,应对不同的场景。

链表与红黑树的转换

  • 链表长度 > 阈值8
    • 数组长度 < 64:优先进行数组的扩容(2倍扩容,rehash)
    • 数组长度 > 64:链表 转为 红黑树,减少了查找时间,提高了查找效率。
  • 链表长度 < 6
    • 红黑树 转回 链表

为什么链表转红黑树的阈值是8?

理想情况下,随机hashCode算法下所有bin中节点的分布频率会遵循 泊松分布(离散概率分布)

0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million

我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。

所以,之所以选择8,不是随便决定的,而是根据概率统计决定的。

总的来说:选择8因为符合泊松分布,超过8的时候,概率已经非常小了,所以我们选择8这个数字。

HashMap中的关键变量?

参数含义
capacity数组table的容量大小,初始容量为16。(必须保证是2n
sizeHashMap中键值对的数量,每存入一组数据,就++。(不等于数组的长度)
thresholdsize的临界值,当size > threshold时,数组就要进行扩容操作。(扩容为原来的两倍
loadFactor加载因子,table能够使用的比例,默认为0.75(threshold = capacity * loadFactor
  • 扩容通过 resize() 方法实现,扩容后,table数组的容量 capacity 为原来的两倍
  • 扩容操作 会把 oldTable 的所有键值对重新插入 newTable 中,并重新计算 hash值 / 桶下标

为什么数组容量必须是2n

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   

可以看到,注释中明确指出,数组的容量必须是 2n,为什么?

向HashMap中添加一个元素时,需要根据key的hash值,去确定其在数组中的具体位置。 HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据在数组中分配均匀,使得每个链表长度大致相同。

  • 通过取模运算:hash%length,可以使数据在数组中均匀分布。
  • 计算机中,取模运算的效率不如位运算,所以源码中使用位运算进行了优化: hash&(length-1)
  • hash&(length-1) 等价于 hash%length 的前提,就是 length 是 2n

2n-1 的 二进制,实际上就是 n 个 1,这样和 hash 值进行按位与运算,可以保证数据的均匀分布,减少哈希碰撞。

总的来说,就是为了通过位运算达到取模的效果,使数据在数组中分配均匀的同时,提高效率。

而且,在数组扩容的时候,也可以巧妙的进行rehash。

如果数组容量不是2n

  • 空参构造:初始化Map的时候,threshold = 0
  • 有参构造:初始化Map的时候,threshold = tableSizeFor(initialCapacity)
    • tableSizeFor 方法会返回 大于等于参数的最小的2的幂次方

然后,在第一次resize的时候,会进行数组的初始化:

  • threshold = 0:数组容量 = 默认值16
  • threshold > 0:数组容量 = threshold = 2的幂次方

由此可见,及时数组容量不是2n ,也会通过计算,找到最接近的2的幂次方作为数组容量。

HashMap的初始化问题

如果知道有多少键值对需要存储,那么在初始化HashMap时就应该指定它的容量,以防止HashMap自动扩容,影响使用效率。

  • 如果没有设置初始容量大小,随着元素的不断增加,HashMap就有可能会发生多次扩容,非常影响性能。

那么当我们已知HashMap中即将存放的 K-V 的个数时,容量具体设置成多少比较好呢?

HashMap的构造方法是支持指定初始容量的,但是HashMap不会拿我们传入的initialCapacity作为容量

  • 通过之前的内容,我们知道,HashMap会根据initialCapacity计算找到最接近的一个2的幂当做初始容量。
  • 如果我们设置的默认值是7,经过计算处理之后,会被设置成8,这扩容的几率是很高的,显然这么设置是有问题的。

关于这个值的设置,在《阿里巴巴Java开发手册》有以下建议:

initialCapacity = (expectedSize / 0.75F) + 1.0F

通过以上方式计算,7/0.75 + 1 = 10 ,10经过计算处理之后,会被设置成16,这就大大的减少了扩容的几率。

什么是负载因子?

负载因子是用来衡量 HashMap 满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率。

  • 默认是0.75,当HashMap存储的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容。

负载因子为什么默认0.75?

  • loadFactor越大,也就是趋近于1,数组中存放的数据(entry)也就越多,也就越密集,会让链表的长度增加。

  • loadFactor越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏,浪费空间,浪费性能。

如果希望链表尽可能少些。要提前扩容,有的数组空间可能一直没有存储数据。加载因子尽可能小一些。

举例:

例如:加载因子是0.4。 那么16*0.4 ---> 6  如果数组中满6个空间就扩容,会造成数组利用率太低了。
	 加载因子是0.9。 那么16*0.9 ---> 14 那么这样就会导致链表有点多了,导致查找元素效率低。

所以既要保证数组利用率,又考虑链表不要太多,经过大量测试0.75是最佳方案。

哈希值的计算?

哈希值的计算:(h = key.hashCode()) ^ (h >>> 16)

  • 将 hashCode 的 低16位 与 高16位 进行 按位异或 计算,得到哈希值。

这样hash值的低16位是hashCode值中高位与低位的结合,增加了随机性,减少了哈希碰撞发生的几率。(只有高16位变化时)

哈希槽的计算

哈希槽的计算:hash & (length - 1)

  • length 设定为 2n 后,hash&(length-1) 等价于 hash%length 取模运算。

取模保证了数据在数组中分配均匀,同时位运算具有更高的效率。

数组什么时候会扩容?

  • 当 HashMap 中的数组还没有初始化时,会调用 resize 方法进行数组的初始化。
  • 当 size (HashMap中的元素个数) > capicity (数组长度) * loadFactor (负载因子) 时,会进行数组的扩容。
  • 当 HashMap 中一个链表的长度超过阈值8 且 数组长度没有达到64 时,HashMap会优先扩容解决。

扩容rehash的机制?

扩容操作 会把 oldTable 的所有键值对重新插入 newTable 中,并重新计算 hash值 / 桶下标

HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash 的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到 “ 原位置+旧容量 " 这个位置。

HashMap的存储流程?

  1. 先通过hash值计算出key映射到哪个桶;
    • 计算key的hash值 (h = key.hashCode()) ^ (h >>> 16)(低16位 与 高16位 进行 按位异或)
    • 计算桶下标 hash & (length - 1)
  2. 如果数组table为空,调用 resize() 进行数组初始化。
  3. 如果没有发生哈希碰撞,直接添加元素到散列表中。
  4. 如果发生了哈希碰撞(hash值相同),进行三种判断
    • key地址相同 或 equals比较相同,则替换旧值,返回旧值
    • 如果是红黑树结构,调用树的插入方法。
    • 如果是链表结构,采用尾插法进行插入(JDK1.8之后采用尾插法)
  5. 如果容量size大于threshold阈值,调用 resize() 进行扩容

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

scj1022

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值