集合框架--HashMap

65 阅读15分钟

1. 概述

image-20210201215016198

HashMap 基于键的 HashCode 值唯一标识一条数据,同时基于键的 HashCode 值进行数据的存取,因此可以快速地更新和查询数据,但其每次遍历的顺序无法保证相同。HashMap 的key和 value允许为 null。

HashMap 是非线程安全的,即在同一时刻有多个线程同时写 HashMap 时将可能导致数据的不一致。如果需要满足线程安全的条件,则可以使用 Collections 的 synchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap。

JDK1.7 版本中,其底层数据结构为“数组+链表”,数组中每个元素都是一个单向链表,链表中的每个元素都是嵌套类 Entry 的实例,Entry 实例包含 4 个属性值:key、value、hash 值和用于指向单向链表下一个元素的 next。

JDK1.7 数据结构图如下:

image-20230208195537468

HashMap 常见参数如下:

  • capacity:当前数组的容量,默认为 16,可以扩容,扩容后数组的大小为当前的两倍,因此该值始终为 2 的 n 次方。
  • loadFactor:负载因子,默认为 0.75
  • threshold:扩容的阈值,其值等于 capacity * loadFactor
  • UNTREEIFY_THRESHOLD

HashMap 在查找数据时,根据HashMap的Hash 值可以快速定位到数组的具体下标,但是在找到数组下标后需要对链表进行顺序遍历直到找到需要的数据,时间复杂度为 O(n)。

为了减少链表遍历的开销,Java8 对 HashMap 进行了优化,将数据结构修改为“数组+链表+红黑树”。在链表中的元素超过 8 个以后,HashMap 会将链表结构转换为红黑树结构以提高查询效率,因此其时间复杂度为 O(logN)。Java 8 HashMap 的数据结构图如下:

image-20230208202626975

小总结:

  1. 底层基于散列算法实现,是一个 key-value 结构的容器,key 不能够重复
  2. JDK8 HashMap 基于 数组+链表+红黑树实现
  3. 不保证键值的顺序
  4. 可以存放 null
  5. 非线程安全,多线程环境下可能出现问题

类定义:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {}
  • 继承 AbstractMap 类,实现 Map 接口,提供 key-value 结构格式访问的方法、
  • 实现了 Cloneable 接口,支持克隆
  • 实现了 Serializable 接口,支持序列化和反序列化

2. 成员变量

// 主数组初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// map 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 负载因子,默认的加载因子,一般 HashMap 的扩容临界点是当前 HashMap的大小 > DEFAULT_LOAD_FACTOR * 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当 hash 桶中的某个 bucket 上的节点数大于该数值的时候,会由链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当 hash 桶中的某个 bucket 上的节点数小于该值的时候,红黑树转变为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的 table 的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
​
// hash 桶,存储数据的主数据
transient Node<K,V>[] table;
// 保存缓存的 entrySet
transient Set<Map.Entry<K,V>> entrySet;
// 桶的实际元素个数 != table.length
transient int size;
// 扩容或者更改了 map 的计数器。标识这个 HashMap 结构被修改的次数,结构修改是那些改变 HashMap 中的映射数量或者
// 修改其内部结构(重新散列 rehash)的修改。该字段用于在 HashMap 失败快速(fast-fail)的Collection-views 上创建迭代器。
transient int modCount;
// 临界值,当实际大小(cap * loadFactor)大于该值的时候会进行扩冲
int threshold;
// 加载因子
final float loadFactor;

为什么加载因子是 0.75?

分析:

  1. 如果设置为 1 的话,那么空间利用率得到了很大的满足,很容易发生碰撞,碰撞多了就会变成链表,变成链表查询效率低下
  2. 如果设置为 0.5 的话,碰撞的概率机会降低,但是会产生空间的浪费,空间利用率低。

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs.作为一个非常普遍的规则,默认的加载因此提供了一个很好的折中,这个折中在时间和空间中得到满足

3. 构造方法

HashMap 中提供了 4 个构造方法。

// 参数为:初始化容量,加载因子
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
// 参数为:初始化容量,加载因子为默认 0.75
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 无参构造方法,这里的初始化容量是没有指定的,后面第哦啊用 put 或者 get 方法的时候才会 resize
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 将 m 集合中的值调用 putMapEntries 加入到新的 map 集合中,默认的加载因子
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

4. 较多使用方法

// hash 算法,计算传入的 key 的 hash 值
static final int hash(Object key) {
    int h;
    // 如果 key == null 返回 0
    // key != null
    //  1. 得到 key 的 hashCode
    //  2. 将 h 无符号右移 16 位
    //  3. 异或运算:h ^ h>>>16
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
 * Returns a power of two size for the given target capacity.
 * 返回大于 cap 的最小的二次幂数值
 */
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;
}

5. put 方法

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
/**
 * Implements Map.put and related methods.
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value 如果为真,则不要更改现有值
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // table == null 或者 table 的长度为 0,调用 resize() 方法进行扩容
    // 因此说明 table 被延迟插入到新数据时再进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // i = (n - 1) & hash 数组中计算出下标位置的元素值
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 如果计算得到的桶下标值中的 Node 为 null,就新建一个 Node 加入该位置
        tab[i] = newNode(hash, key, value, null);
    // 如果 put 的元素用自己的 key 的hash 值计算得到的下标和桶中的第一个位置产生了冲突,即:
    // key 相同,value 不同
    // 只是通过 hash 值计算得到的下标相同,但是 key 和 value 都不同,这里处理的方法就是链表和红黑树
    else {
        Node<K,V> e; K k;
        // 上面已经计算得到了该 hash 对应的下标 i,p = tab[i]。这里比较的有:
        // 1. tab[i].hash 是否等于传入的 hash,这里的 tab[i] 就是桶中的第一个元素
        // 2. 比较传入的 key 和该位置的 key 是否相同
        // 3. 如果都相同,说明是同一个 key,那么直接替换对应的 value 值
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 将桶的第一个元素赋值给 e,用来记录第一个位置的值
            e = p;
        // 判断为红黑树,hash 值不相等,key 不相等,为红黑树结点
        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);
                    // binCount 记录的链表的长度,如果链表长度大于 8,就会转变为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果在遍历链表的时候,判断得出要插入的节点的 key 和链表中间的某个结点的 key 相同
                // 退出循环,更换旧的 value 值
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // e = p.next  遍历链表所用 
                p = e;
            }
        }
        // 判断插入的是否在 HashMap 中,e 被赋值不为空则说明存在,更换旧值
        // hash 判断数组位置一样的时候返回旧的值,设置新的值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 判断容量超出就进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

下标计算方法:

i = (n - 1) & hash

红黑树转换方法:

/**
 * Replaces all linked nodes in bin at index for given hash unless
 * table is too small, in which case resizes instead.
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            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);
    }
}

小总结:

当我们调用 HashMap 的 put(key,value) 方法的时候,实际上是调用了 putVal 方法,通过 hash(key) 方法得到 key 的 hash 值。

  1. 首先会判断 hash 桶是否为空: (tab = table) == null || (n = tab.length) == 0,如果为 null 就会调用 tab = resize() 方法进行扩容。

  2. 通过 key 的 hash 值和 hash 桶的长度(n)计算得到下标 i,(n - 1) & hash(key),如果该位置没有元素即 null,那么新建结点然后添加到该位置。

  3. 如果 tab[i] 处不为 null,已经有元素了,表明发生了 hash 冲突,可能是 3 中情况:

    ① 判断 key 是否一样,如果一样则把新增值替换旧值,记录旧的值用于最后的返回

    ② 如果 key 不一样,判断当前该桶是否已经转换为红黑树,如果是构造一个 TreeNode 结点插入红黑树

    ③ 如果不是红黑树,使用链地址法处理冲突问题。主要就是遍历链表,如果在遍历过程中找到了 key 一样的元素,用新值替换旧值。负责遍历到链表尾部,新添加一个 Node 结点插入链表,插入之后需判断是否已经超过了转换红黑树的阈值 8,如果超过就会调用 treeifyBin 方法转换为红黑树。

  4. 判断插入后的 size 大小是不是超过了 threshhold,如果超过调用 resize 方法扩容。

6. resize 方法

resize 就是重新计算容量,当 map 内部的 size 大于 DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY 就需要扩大数组的长度,以便能装入更多的元素。resize 方法实现是使用一个新的数组代替已有的容量小的数组。

/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 * 初始化或加倍表大小。 如果为空,则根据字段阈值中的初始容量目标进行分配。
   否则,因为我们使用的是二次幂扩展,每个容器中的元素必须保持在相同的索引处,或者在新表中以二次方偏移量移动。
 * @return the table
 */
// 1. 初始化哈希表(table == null)
// 2. 容量过小,需要扩容
final Node<K,V>[] resize() {
    // 旧数组
    Node<K,V>[] oldTab = table;
    // 旧数组的大小,数组为空则大小为0 否则为就数组的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 阈值
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // oldCap != 0     oldCap = 旧table.length();
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 如果大于最大容量了,赋值为整数的最大阈值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 如果数组的长度在扩容后小于最大容量并且 oldCap 大于默认值 16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // newCap 在原来的长度上扩展两倍
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        // oldThr = tabSizeFor(initialCapacity),从构造方法看,如果不是调用的无参构造,那 threshhold 肯定会经过 tabSizeFor 运算得到 2 的整数次幂的,所以将其作为 Node 数组的长度
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 调用无参构造函数的时候(table == null, threshhold = 0),新的容量等于默认的容量,并且 threshhold 也等于默认加载因子 * 默认初始化容量
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 计算新的 resize 上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    // 以新的容量作为长度,创建一个新的 Node 数组存放结点元素,完成桶的初始化
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 旧 table 不为空
    if (oldTab != null) {
        // 把每个 bucket 都移动到新的 bucket 中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // 原table 中下标位置不为 null
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    // 如果结点的 next 为null,则表示不是链表,是数组中的一个元素
                    // 通过新的容量计算在新的 talbe 数组中的下标
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 如果是红黑树结点,重新映射,需要对红黑树进行拆分
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 链表优化重hash
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 上面判断不是红黑树那就是链表,遍历链表进行重新映射
                    do {
                        next = e.next;
                        // 原位置
                        if ((e.hash & oldCap) == 0) {
                            // loTail 为 null,那么直接加到该位置
                            if (loTail == null)
                                loHead = e;
                            else
                                // loTail 为链表尾节点,添加到尾部
                                loTail.next = e;
                            // 添加后,将 loTail 指向链表尾部,以便下次从尾部添加
                            loTail = e;
                        }
                        // 原位置 + 旧容量
                        else {
                            // hiTail 处为 null,就直接添加到该位置
                            if (hiTail == null)
                                hiHead = e;
                            // hiTail 为链表尾部节点,尾部法插入
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 将分组后的链表映射到新桶中
                    // 原索引放到 bucket 里
                    if (loTail != null) {
                        // 旧链表迁移新链表,链表元素相对位置没有变化
                        // 实际是对对象的内存地址进行操作
                        loTail.next = null; // 链表尾元素设置为null
                        newTab[j] = loHead; // 数组中对应的位置存放链表的 head 节点
                    }
                    // 原索引 + oldCap 放到bucket 里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

小总结:

判断当前 oldTab 长度是否为空,如果为空,则进行初始化桶数组;如果不为空,则进行位运算,左移一位,2倍运算扩容。

扩容,创建一个新容量的数组,遍历旧的数组:

  1. 如果节点为空,直接赋值插入
  2. 如果节点为红黑树,则需要进行分析拆分操作
  3. 如果为链表,根据 hash 算法进行重新计算下标,将链表进行拆分分组

7. get 方法

public V get(Object key) {
    Node<K,V> e;
    // hash(key)  根据 key 计算 hash 值
    
    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;
    // 计算存放在数组 table 中的位置
    // 判断表不为空,表数组长度 > 0,计算数组位置的元素不为null,否则返回 null
    // 1. 判断是否为空,为空则返回 null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 查看是否是数组中的元素,hash 相同 key 相同返回
        // 2. 不为空,判断第一个位置的 hash 和 key 值是否相等
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            // 3. 如果一样就返回第一个节点的信息
            return first;
        // 判断如果 next 节点不为空则可能是红黑树或者链表
        // 红黑树根节点或者链表头节点
        // 4. 如果第一个节点的 hash 和 key 不一样就是发生了 hash 碰撞,开始遍历链表
        if ((e = first.next) != null) {
            // 从红黑树中遍历查询
            if (first instanceof TreeNode)
                // 5. 如果第一个节点的 hash 和 key 不一样,并且第一个节点的 Node 的 next 属性不为空
                	// 通过 instanceof 判断是否为树结构,如果为树结构就调用 getTreeNode 方法去树结构里面进行判断
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 从链表中遍历查找
            // 6. 如果不是树结构,使用 do while{} 循环遍历链表判断是否有相同的 hash 和 key 的元素,如果有即找到返回,否则循环结束返回 null
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

小总结:

  1. 判断数组是否为null 和相应下标的元素是否为null,如果是返回 null
  2. 查看数组中的元素,相同 hash 相同 key 返回
  3. 判断 first.next 节点是否为null,如果不为空则可能是红黑树根节点或者链表头节点
  4. 如果是红黑树根节点从红黑树遍历返回
  5. 如果是链表头节点从链表遍历返回

8. 常用 api 方法

  1. 添加元素,put(key,value) 方法

    public class Test {
        public static void main(String[] args) {
            //创建 HashMap 对象
            HashMap<Integer, String> list = new HashMap<>();
            //向对象中添加键值对
            list.put(1,"aaaa");
            list.put(2,"bbbb");
            System.out.println(list);
        }
    }
    
    运行结果:
        {1=aaaa, 2=bbbb}
    
  2. 访问元素,get(key) 方法获取 key 对应的 value 值

    public class Test {
        public static void main(String[] args) {
            //创建 HashMap 对象
            HashMap<Integer, String> list = new HashMap<>();
            //向对象中添加键值对
            list.put(1,"aaaa");
            list.put(2,"bbbb");
            System.out.println(list.get(1));
            System.out.println(list.get(2));
        }
    }
    
    运行结果:
        aaaa
    	bbbb
    
  3. 删除元素,remove(key),删除 key 对应的键值对(key - value)

    public class Test {
        public static void main(String[] args) {
            //创建 HashMap 对象
            HashMap<Integer, String> list = new HashMap<>();
            //向对象中添加键值对
            list.put(1,"aaaa");
            list.put(2,"bbbb");
            list.remove(2);
            System.out.println(list);
        }
    }
    
    运行结果:
        {1=aaaa}
    
  4. 删除所有键值对,clear() 方法

    public class Test {
        public static void main(String[] args) {
            //创建 HashMap 对象
            HashMap<Integer, String> list = new HashMap<>();
            //向对象中添加键值对
            list.put(1,"aaaa");
            list.put(2,"bbbb");
            list.remove(2);
            list.clear();
            System.out.println(list);
        }
    }
    
    运行结果:
        {}
    
  5. 计算大小,size() 方法计算 HashMap 中的元素数量

    public class Test {
        public static void main(String[] args) {
            //创建 HashMap 对象
            HashMap<Integer, String> list = new HashMap<>();
            //向对象中添加键值对
            list.put(1,"aaaa");
            list.put(2,"bbbb");
            System.out.println(list.size());
        }
    }
    
    运行结果:
        2
    
  6. 迭代集合,for-each

    通过 keySet() 方法获取key,然后通过 get(key) 获取对应的 value

    public class Test {
        public static void main(String[] args) {
            //创建 HashMap 对象
            HashMap<Integer, String> list = new HashMap<>();
            //向对象中添加键值对
            list.put(1,"aaaa");
            list.put(2,"bbbb");
            //通过 keySet 获取元素的 key
            for (Integer i : list.keySet()) {
                //然后通过 get(key) 得到值输出
                System.out.println(list.get(i));
            }
        }
    }
    
    运行结果:
        aaaa
    	bbbb
    

更多常用 API 方法可以参考文档:www.matools.com/api/java8

内容参考文献:

juejin.cn/post/684490…