hashmap 允许key重复吗_你真的懂 HashMap 吗?

159864301f1ced43c61a61855e324382.png

在介绍 HashMap 首先介绍下 Map 接口

此接口位于 java.util 包下,该接口共有四个常用实现类,分别是 HashMap、LinkedHashMap、TreeMap、Hashtable。继承关系如图:

7f784f9fc1ef93f6700f7ff284bf72e1.png
  1. HashMap 它根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections 的 synchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap。
  2. Hashtable:Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。
  3. LinkedHashMap:LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
  4. TreeMap:TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。

HashMap 介绍

jdk 1.8 之前采用的是数组 + 链表的数据结构, 1.8 采用了数组+链表+红黑树,提高查询的效率。

HashMap的使用

翻看 JDK 文档,可以看到 HashMap 实现了 Map 的接口,所以也就实现了 Map 基本的方法操作。

以下是 HashMap 一些常用的方法。

V put(K key, V value); // 保存键值对,如果原来有Key,覆盖,返回原来的值
​
V get(Object Key); //根据键值获取值,没找到,返回null;
​
V remove(Object Key); //根据键值删除键值对,返回 Key 原来的值,如果不存在,返回null
​
int size(); // 查看 Map 中键值的个数
​
boolean isEmpty(); //是否为空
​
boolean containsKey(Object Key); //查看是否包含某个键值
​
void clear(); //清空Map中所有的键值对
​
void putAll(Map<? extends K, ? extends V> m ); //保存m中所有键值对到当前Map
​
boolean containsValue(Obejct value);  // 是否包含某个值
​
Set<K> keySet(); //返回一个 Map 的 set 集合
​
Set<Map.Entry<K, V>> entrySet(); //获取 Map 中所有的键值对
​
Collection< V > values()
//返回一个 Collection 集合包含全部 values 。可用于迭代器使用遍历所有的 value。
//如 hashMap.values().iterator(),也可以转成 List 之类的。
​

HashMap 的定义

查看 JDK 文档可以发现这段定义。

Hash table based implementation of the <tt>Map</tt> interface.  This implementation provides all of the optional map operations, and permits<tt>null</tt> values and the <tt>null</tt> key.  (The <tt>HashMap</tt>class is roughly equivalent to <tt>Hashtable</tt>, except that it is unsynchronized and permits nulls.)  This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.

大概的意思是说

HashMap 是基于哈希表的Map接口的实现, 此实现提供了所有可选的映射操作,并且允许键值都为 null 的情况, (除了不是线程安全的和允许键值为 null 的情况之外,HashMap 与 HashTable 大致上是相同的)。HashMap 不能保证映射的顺序,特别是它不能保证该顺序是永久不变的(ps:当 HashMap 的长度到达指定的阈值 threshold 时,HashMap 会进行扩容 resize, 同时里面的元素会重新 rehash ,修改放置的位置 )。

HashMap 源代码剖析

类的定义

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

该类继承了 AbstractMap 抽象类,实现了 Map 接口, Map 提供了键值的映射。查看 AbstractMap 发现这个抽象类同样继承了 Map 接口。实现了两次 Map 接口,好奇想了解一下这是怎么回事,百度一下, 发现说这是个错误的写法,历史缘由,具体看这篇博客。

成员变量

1、默认初始值:

//初识容量,如果构造函数中未指定除数容量 capacity,那么默认是 16,该值必须是 2 的 n 次方。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
​
// 最大容量上限,可以通过构造函数指定,不过指定的值必须小于等于 2^30(1 << 30)
static final int MAXIMUM_CAPACITY = 1 << 30;
​
//初始装载因子,这里指定的值是 0.75, 可以通过构造函数指定,
static final float DEFAULT_LOAD_FACTOR = 0.75f;
​
//当 hash 冲突添加到同一桶中(hash数组中的)的元素超过 TREEIFY_THRESHOLD 也就是 8,此时链  表将转化为红黑树。
static final int TREEIFY_THRESHOLD = 8;
​
// 与上面的值相反,这个是将红黑树转化为链表,当桶中的数据删除到小于 UNTREEIFY_THRESHOLD ,也就是 6 时转换。
static final int UNTREEIFY_THRESHOLD = 6;
​
//  桶要转换成红黑树时,table的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;

2、字段

transient int size; // map 中实际存在的键值对
int threshold;  // 动态扩容的阀值
final float loadFactor; // 装载因子
transient int modCount; // 

thresholdHashMap 中所能容纳键值对的最大阀值,当超过这个阀值时 HashMap 就要重新 resize(扩容),扩容的容量是之前的两倍。

threshold = table.length * loadFactor。阀值 threshold 与哈希桶数组的长度和装载因子有关(默认哈希桶数组长度为16,装载因子为 0.75),在定义好哈希桶数组的长度后,负载因子越大,所能容纳的键值对就越多。

modCount 字段主要用于记录 HashMap 内部结构的发送变化的次数,putremove 操作都会改变modCount,而修改不会触发 modCount 值的改变。

HashMap中,哈希桶数组 table 的长度 length 大小必须为 2 的 n 次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,Hashtable 初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable 扩容后不能保证还是素数)。HashMap 采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap 定位哈希桶索引位置时,也加入了高位参与运算的过程。

3、哈桶数组

HashMap 成员变量中一个重要的字段,Node[] table ,哈希桶数组(内部数组中的每个位置称作“存储桶”(bucket) 所以称为“哈希桶数组”),是一个 Node 节点数组

transient Node<K,V>[] table;

transient 修饰 table 有什么意义吗?

源代码中成员变量 table 加上 transient 关键字修饰意味着,在序列化的时候,table 不被序列化。这么做有什么原因吗?

原因是:HashMap 是基于 HashCode ,而 HashCode 作为 Object 方法是 native 修饰的,意味 HashCode 与底层虚拟机的实现有关,不同的虚拟机可能计算 HashCode 的方法不一样,所以 HashCode 值不一样。这就会导致相同的 Key 在不同的虚拟机的 HashCode 不一样,从而导致在 HashMap 中的位置不一样。 例如:Key 在 A 虚拟机上的位置是 table[5], 当代码在 B 虚拟机上运行的时候,Key 计算 hashCode映射到 table[6], 所以 get(Key) 会到 table[6] 查找,此时会查找不到 Key 值。

为了考虑跨平台问题,Java 采用的是重写 table 的方式, 在序列化的时候,writeObject() 方法将 key 和 value 追加到文件后面。这样在反序列化的时候,重新 hash 将 key, value 加到合适的位置。

private void writeObject(java.io.ObjectOutputStream s)
    throws IOException {
    // 获取table容量
    int buckets = capacity();
    // Write out the threshold, loadfactor, and any hidden stuff
    s.defaultWriteObject();
    // 写入容量
    s.writeInt(buckets);
    // 写入 map 中元素的数量
    s.writeInt(size);
    // 调用下面 的方法,将 key 和 value 加到文件后面 
    internalWriteEntries(s);
}
void internalWriteEntries(java.io.ObjectOutputStream s) throws IOException {
        Node<K,V>[] tab;
        if (size > 0 && (tab = table) != null) {
            for (int i = 0; i < tab.length; ++i) {
                for (Node<K,V> e = tab[i]; e != null; e = e.next) {
                    s.writeObject(e.key);
                    s.writeObject(e.value);
                }
            }
        }
    }

哈希桶 table 中的元素 Node 是什么?

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) {...}
        public final K getKey()        { ... }
        public final V getValue()      { ... }
        public final String toString() { ... }
        public final int hashCode() { ... }
        public final V setValue(V newValue) { ... }
        public final boolean equals(Object o) { ... }
}

Node 是 HashMap 中的内部类,实现了 Map.Entry 接口,本质上是一个映射。

构造函数

方法实现

确定哈希桶数组索引的位置。

hash() :

static final int hash(Object key) {
    int h;
    // 如果 key 为 null, 存储在哈希桶的 0 号位置,
    // h = key.hashCode,取出 key 的 hashCode 值
    // h ^ h >>> 16, 高位异或 
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在计算得到 hash 值后,HashMap 会将 hash 值与哈希桶的长度减一进行与运算,这一步操作相当于取模运算。

i = (n - 1) & hash   // putVal()中确定键值对位置的计算公式
Java 使用位运算(&)来进行取余操作,比取模运算(%)高效:
位运算直接对内存数据进行操作,数据在内存中以二进制的形式存储。而取模运算需要转换成十进制再进行操作。

为什么可以使用位运算(&)来实现取模运算(%)呢?实现原理:

x % 2^n ==  x & (2^n - 1)

假设 n 为 3, 则 2^n = 8, 表示成二进制为:1000。 2^3 - 1 = 7, 二进制位 0111。

此时 x & (2^n - 1) 相当于取 x 的 二进制位的后三位。

从二进制的角度看, X / 8 相当于 x >> 3,即位运算将 x 向右移动 3 位, 此时就得到了 x 的商, 而被移动的部分(后三位), 就是余数,也就是 x % 8。

所以使用 x & (2^n - 1), 即例子 x & 0111, 就可以获取到 x 对 2^n 取模的余数。

摘抄于这篇博客

这也是为什么 HashMap 中容量必须是 2 的 n 次幂。

总结:确定键值对的计算分为三步:

  1. 获取 key 中 hashCode 的值。
  2. 将获取到的 hashCode 进行高位运算。
  3. 高位运算后得到的 hash 再进行取模运算。

以下图为例,n 是 table 的长度,长度为 16。

98f182f24cc9dad1498e83245146b9e6.png

为什么要通过 hash & (table.length -1) 来获取到存储的索引位置的原因?

HashMap底层数组的长度总是 2 的 n 次方,这是 HashMap在速度上的优化。当 length 总是 2 的 n 次方时,hash & (length-1) 运算等价于对 length 取模,也就是 hash % length,但是 & 比 % 具有更高的效率。

例如hash = 21, length = 16
hash & (length - 1) 
21     : 0000 0000 0000 0000 0000 0000 0000 10101
15     : 0000 0000 0000 0000 0000 0000 0000 01111
21 & 15: 0000 0000 0000 0000 0000 0000 0000 00101
hash % length
21 % 16 = 5

hash 的计算为什么要这样实现?

在 JDK1.8 的实现中,优化了高位运算的算法,通过 hashCode的高 16 位与低 16 位进行异或实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组 tablelength比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

Java 中是大端模式(数值高位存储在内存低位地址,数值低位存储在内存高位地址)

put 方法的实现

put 源代码如下:

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        // tab,table 的一个数组引用,n table 的长度
        Node<K,V>[] tab; Node<K,V> p; int n, i;
    	// 1. 将 table 赋值给 tab,并判断 tab 是否为空或者为 null,
        // 是就通过 resize() 创建默认大小的哈希桶数组,
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 2. 计算 key 在 table 中的位置并获取该位置的值,
        //    如果该位置为空则,直接创建新节点并放置到 table 中
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 该位置不为空的情况
    	else {
            Node<K,V> e; K k;
            // 3. 判断 key 是否与 table[i] 相等,是则覆盖
            // hash 相同,
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 4. 判断该哈希桶中的链是否为红黑树
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 5. 该链为链表
            else {
                // 循环遍历该链表, binCount 计算链表的长度 
                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 存在于链表,直接结束循环,在下面代码中覆盖其 value
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
    	// 记录内部结构变化
        ++modCount;
    	// 6. 判断键值对的数量是否大于阈值 threshold,进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

总结:HashMap 的方法 put 的执行不走共分为六步:

① :判断哈希桶数组 table 是否为空,为空则通过 resize() 进行扩容初始化。

② :根据 key 的 hash 值,计算 key 在 table 中的索引位置 i,如果 table[i] == null, 直接新建节点,跳转到步骤 ⑥, 如果 table[i] != null,进入步骤 ③。

③ : 判断 key 是否与 table[i] 相等,相等则覆盖,跳转到步骤⑥。不相等,进入步骤④。

④ :判断 table[i] 是否为 treeNode,即判断哈希桶中的数据结构是否为红黑树,如果是直接在树中插入节点,否则进入步骤⑤。

⑤ :哈希桶中的数据结构为链表的情况,循环遍历 table,将节点加入到链表的结尾,并计算链表的长度,如果链表的长度大于8,则将链表转化为红黑树。遍历的过程如果发现 key 已经存在,则直接覆盖。

⑥ :插入成功,判断 size 是否 大于阈值 threshold,大于则进行扩容 resize。

具体流程如图所示。

a8a10c37eb74d95c01996d6b6f8a1e72.png

扩容的实现

使用 putHashMap 添加元素的时候,当数组中的键值对 size 大于阈值 threshold 的时候,HashMap 就自动调用 resize() 重新进行扩容, 扩容到原来的两倍,扩容的方式是通过新建一个数组,将旧数据的数据搬移到新数组中。

在 JDK 7 的 resize() 实现中,首先创建一个新的数组,然后遍历旧数组,逐个将数组的 Entry 节点重新 rehash 计算新的索引,插入到指定索引位置,在插入新索引位置的过程中如果发生 hash 冲突,采用单链表插入的方式,同一位置上新的元素放置在链表的头部。

JDK 8 则对搬移数据的过程做了优化,搬移的过程不需要重新 rehash 计算索引。 在下面 resize() 源代码中, table 的扩容是以 2 次幂的方式(长度扩展到原来的2倍),所以扩展后元素位置要么在原位置,要么在原位置再移动2次幂的位置(原位置+旧数组的长度)。示例如下图所示。

n 为 table 的长度,hash & (n -1) 相当于 hash % n

bcc26895437a867ac7a54aab181325cd.png

原先 n 等于 16,n-1 等于 15, 当 n 扩大了两倍等于 32, n-1 等于 31, 二进制位比扩容前多了 1 位。 在上图中扩容后 00101(5) & 11111(31) 等于 00101(5),索引不变,而 10101(21) & 11111(31) 等于 10101(21),索引从扩容前的 5 变为 21, 即原索引加上旧数组的长度。

因此 HashMap 在扩容时不需要重新 rehash 计算新索引,只需要看看key 的 hash 与扩容后 n -1 ,&运算后新增 bit 是 1 还是 0 就可以了。是 0 就原索引不变, 1 的话索引变成 “原索引 + 旧的 table 长度”。

如果看新增 bit 是1还是0呢? resize() 源码中采用的是 (e.hash & oldCap) == 0, 利用 key 的 hash 与旧数组的长度进行 & 运算。 以上图 hash1 等于 5,hash = 21,n = 16 为例,5 的二进制为 00101,n 的二进制为 10000, 00101 & 10000 = 0,即索引不变, 21 的二进制为 10101, 10101 & 10000 = 10000,不等于0,即索引变为为原索引加上旧数组长度。

而且在搬移的过程中,链表的元素的位置不会发生倒置,JDK 7中会发生, 具体过程如下图。

755848040499d1769ac9ad6b54a2591a.png

resize() 源码如下:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
    	// 判断 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;
            }
            // 判断扩容两倍后是否会超过最大值,不超过则进行扩容,扩容到原来的两倍。
            // 阈值 threshold 也扩容到原来的两倍。
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            // HashMap 未初始化的情况,用默认值初始化
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
    	// 创建新的哈希桶数组。
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
    	// 将旧哈希桶数组的数据搬移到新哈希桶数组中
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    // oldTab[j] 中只有一个元素的情况。直接 rehash 到 newTab
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    // oldTab[j] 是否为红黑树的情况
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // 元素的位置要么在原位,要么在 原位+oldCap 的位置
                      
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            // 原索引的位置
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            // 原索引+oldCap的位置
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // loTail 记录原索引位置的链表
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // hiTail 记录原索引位置+oldCap的链表
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

指定容量初始化

当通过 HashMap(int initialCapacity) 指定初始化容量是,HashMap 并不会直接采用我们指定的值,而是会通过运算得到一个大于等于我们指定的值的一个 2 的幂次方值。

线程不安全

HashMap 不是线程安全的类,高并发场景下,多个 put 操作可能会引起 CPU 飙升到 100%。

原因:多个 put 操作会引起扩容,而高并发场景下,扩容可能会导致链表成环,从而造成死循环。

具体看这篇博客疫苗:JAVA HASHMAP的死循环。

如果想要使用线程安全的 Map 实现类,可以使用 HashTable 或者通过Collections.synchronizedMap()转为线程安全的 Map。

Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());

HashTable 和通过 Collections.synchronizedMap() 转为线程安全的 Map 本质上相同,都是通过 synchronized 关键字实现的同步容器。加锁的对象都是 this,在并发环境中,Hashtable 同步容器的性能会比较差,当有一个线程对这个容器进行操作时例如 put 操作,其他线程就不能对其进行操作。高比发场景下推荐使用 ConcurrentHashMap 并发容器。

参考链接:

Java 8系列之重新认识HashMap

疫苗:JAVA HASHMAP的死循环

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值