[Java]-HashMap源码分析

前言

HashMap 底层结构是 数组 + 链表,即使用 链地址法 解决哈希冲突,数组的每个元素是一个链表,链表上存放的就是哈希值相等的一组元素。该结构常用的方法为 put() 和 get()

部分静态常量

//默认初始化的数组的大小,即当用户构造HashMap没有指定数组大小时使用;容量必须为2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大的数组大小,当用户构造HashMap时如果指定的大小超过了这个值,就会以这个值作为数组的大小(必须是2的n次幂,如果1 << 31就是负数了)
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当单个链表上节点达到8个的时候就将链表转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;

为什么负载因子默认是0.75

官方表示 0.75 在空间跟时间之间做到了很好的权衡。如果负载因子太高,比如说 1.0,由于哈希冲突的存在,可能在达到扩容阈值时,某些桶内已经存放了很多元素,使得链表长度或者红黑树高度比较高,这种情况虽然空间利用率升高了,但是由于某些桶内存放了太多元素,导致查询或者插入等操作的时间效率降低了。

而如果负载因子太低,比如说 0.5,相当于有整个空间只占用一半就扩容,虽然当个桶内的元素数量会比较小,查询效率会比较高,但是却浪费了存储空间,降低了空间利用率,同时也会提高扩容的频率。

而取 0.75 时时间效率跟空间效率不会差很多,能达到一个很好的平衡,所以就选 0.75 作为默认的负载因子了。

节点Node类(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;
    }

    public final K getKey()        {
    
     return key; }
    public final V getValue()      {
    
     return value; }
    public final String toString() {
    
     return key + "=" + value; }

    public final int hashCode() {
    
    
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
    
    
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

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

Node 类重写了 hashCode 方法,计算方法是 key 的哈希码跟 value 的哈希码进行异或运算

成员变量

/*
   存放数据的数组,在第一次被使用的时候被初始化;而且在必要的时候会被重新调整大小
   长度必须是2的N次幂
 */
transient Node<K,V>[] table;
/*
   存放键值对数据的集合
 */
transient Set<Map.Entry<K,V>> entrySet;
/*
   map中存放的键值对的数目
 */
transient int size;
/*
   map结构被修改的次数
 */
transient int modCount;
/*
   下次扩容的阙值,即当size大小达到这个值的时候就进行扩容。值等于数组长度乘以负载因子(capacity * load factor)
*/
int threshold;

/*
   哈希表的负载因子
 */
final float loadFactor;

关于 modCount 字段可以看ArrayList源码分析中的内容

hash方法

首先要区分 hashCode 以及 hash。hashCode 是 Object 类中方法,用于计算对象的一致性哈希码,如果没有覆盖重写,就按照 Object 类中实现的那样计算
HashMap 中对对象哈希值的计算是借助 hashCode 的值,通过 hash 方法进行的,实现如下:

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

计算得到的就是对象在 map 中的哈希值 hash
当计算对象在 table 存储的位置下标 index,即存储在 table 中的哪一个桶时,就是将哈希值与 table容量-1 进行按位与运算得到的,具体看后续的代码

可以看到 hash 方法中当 key 为 null 的时候会返回 0,也就是说 HashMap 允许存储 null 的键,且其 hash 值为 0;当 key 不为null,则将其 hashCode 与 hashCode 无符号右移 16 位后得到的值相异或得到,当 hashCode 为 0~65535 时,其高 16 位全为 0,那么无符号右移 16 位后得到的结果就是 32 位全为 0,那么与原来的 hashCode 相异或后得到的结果还是原来的 hashCode,只有 hashCode 大于 65535,最终得到的结果才会不同

我们知道一个良好的哈希算法需要计算结果够分散够平均,而这里的这种算法就是借助了 key 的哈希码来确保哈希结果能够分散开

构造方法

指定初始容量跟负载因子的构造方法

创建一个空的 HashMap,并指定初始容量跟负载因子:

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

由于扩容阙值必须为 2 的 n 次幂,所以需要根据用户指定的初始容量来得到一个 2 的 n 次幂作为下次扩容的阙值,通过 tableSizeFor方法返回:

static final int tableSizeFor(int cap) {
    
    
    int n = cap - 1;
    n |= n >>> 1;   //1
    n |= n >>> 2;   //2
    n |= n >>> 4;   //3
    n |= n >>> 8;   //4
    n |= n >>> 16;  //5
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

cap 的范围为 [0,MAXIMUM_CAPACITY],下面我们先不考虑 n 为 -1 或 0 的情况
考虑 n > 0 时 1,2,3,4,5 五个语句的作用。n 一定至少会有一位 1,我们只看最高位的 1,假设 n 为 0001xxxx,n >>> 1 就相当于将最高位的 1 右移了一位,得到 00001xxx,然后按位或,会使得到的 n 的最高位以及最高位的右边那一位都为 1,得到 00011xxx;然后是右移 2 位,得到0000011x,然后按位与,得到 0001111x;再继续的话,最终会得到 n 为 00011111…可以看出来,最终经过语句 5 后得到的 n 一定是在原来 n 的基础上,将其最高位的 1 后面所有位都变为 1,最后返回的值会是 n + 1,也就是 大于原来的 n 值的第一个 2 的 n 次幂,所以当 cap 就是 2 的 n 次幂的时候,最终返回的就还是 cap 的值。即该方法最终返回的是大于等于 cap 的第一个 2 的 n 次幂

当 cap 等于 0,n=-1,其补码的最高位就是 1,那么运算完 n 的最高位还是1,也就是说还是一个负数,根据最后的 return 语句,最后 n 为负数,那么方法返回 1

当 cap 为 1,n=0,经过运算后 n 还是 0,最终返回的就还是 1

总结一下就是,用户指定了初始容量,那么构造方法会计算出大于等于初始容量的第一个 2 的 n 次幂,然后将这个数赋值给 threshold,即下次扩容的阙值,而不是直接创建 table 数组,等到第一次 put 的时候才会扩容,才会创建容量为 threshold 的 table 数组,这是一种惰性思想,好处是什么呢,假设用户构造了这个 HashMap,然后构造方法也构建了这个实际存放数据的数组,但是用户又不使用了,即没有引用变量指向这个 HashMap,那后续这个 HashMap 就要被垃圾回收了,相当于空间被分配了,但是却没使用过,就要被回收了,浪费了内存空间,也浪费了分配时的运行资源。所以惰性分配的话,等到用户真正要使用时才分配空间,就可以节约掉无用的开销

指定初始容量的构造方法

用户指定初始容量,而负载因子使用默认的0.75

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

无参构造方法

/**
 * Constructs an empty <tt>HashMap</tt> with the default initial capacity
 * (16) and the default load factor (0.75).
 */
public HashMap() {
    
    
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

get方法

public V get(Object key) {
    
    
    Node<K,V> e;
    //1.调用getNode方法
    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;
    //2.根据哈希值找到key存在数组哪个索引位置上
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
    
    
        //3.检查该索引上的第一个节点(链表即头节点,红黑树即根节点)是不是要找的key,是的话返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
    
    
        	//4.否则看现在数组元素是链表还是红黑树,如果是红黑树,就调用getTreeNode方法继续搜索
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
    
    
            	//5.否则就继续遍历链表查找key
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

put 方法

put 方法加入一个键值对,如果原 HashMap 中已经存在指定的 key 了就会把这个 key 对应的值替换掉返回原先 map 中 key 对应的值,如果原先没有这个 key,就返回 null;当然返回 null 也可能是原先map中有这个 key 且它对应的值就是 null。

HashMap 中允许值为 null 的键值对,这样就会出现 二义性 的问题,如果对一个 key 调用了 get 方法,但是返回了 null,那么是不存在这个 key 还是这个 key 对应的值就是 null,无法判断。解决方法是先用 contains 方法判断是否存在这个 key,存在的话再用 get 方法获取值。但这种方法也有线程不安全问题,有可能在 contains 方法判断出存在这个 key,然后在 get 之前被 delete 掉了,此时再用 get 返回的肯定是 null,那么我们肯定就会认为 key 对应的 value 就是 null,但是有可能本来这个 key 对应的 value 不是 null,只是因为被删了,所以才返回 null

public V put(K key, V value) {
    
    
	//1.调用putVal方法
    return putVal(hash(key), key, value, false, true);
}

/**
 * hash,key的哈希值
 * key,目标key
 * value,key对应的值
 * onlyIfAbsent 该值如果传入true,就不修改原先存在的值
 * evict,该值如果传入false,存数据的table数组就进入creation模式
 * 
 * 方法返回值返回原有key对应的值,若不存在key则返回null
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    
    
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //2.如果table为null或者table数组长度为0,就调用resize方法进行扩容
    //(构造方法没有初始化table数组,推迟到了第一次put的时候才会为table数组分配空间)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //3.如果key哈希值所对应的数组索引位置为null,就直接创建一个新节点
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
    
    
        Node<K,V> e; K k;
        //4.否则就先看索引位置上的首节点的key是不是指定的key
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k)))) //可以看出HashMap是允许null为键的
            e = p;
        //5.如果不是,就判断这个索引上的数组元素是不是红黑树,是的话就调用putTreeVal继续完成插入操作
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //6.如果不是红黑树那就是链表,遍历链表查看有没有存在key的节点,没有的话就创建一个节点在尾部,有的话就返回找到的节点
        else {
    
    
            for (int binCount = 0; ; ++binCount) {
    
    
                if ((e = p.next) == null) {
    
    
                    p.next = newNode(hash, key, value, null);
                    //如果会找到null,此时链表节点个数为binCount + 1,所以判断该链表是否达到树化阙值
                    //就是判断binCount + 1 >= TREEIFY_THRESHOLD(8)
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //7.最后e如果不是null,说明原先map中就存在key的键值对,当onlyIfAbsent或者键值对中原先的值为null,就把e的value属性改为指定的value,然后返回旧值
        if (e != null) {
    
     // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //8.如果没有找到原先有key的键值对,就会新增一个节点,那就要判断新增完键值对数目size是不是大于要扩容的阙值,是就扩容
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

putIfAbsent

@Override
public V putIfAbsent(K key, V value) {
    
    
    return putVal(hash(key), key, value, true, true);
}

可以看到与put方法不同的就是调用 putVal 方法时,把第四个参数调为 true,这表示不会修改原有的值 (如果原来有存这个key的话)

resize方法

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) {
    
    
    	//如果数组的长度已经达到了MAXIMUM_CAPACITY,那么就不应该再扩容了
        if (oldCap >= MAXIMUM_CAPACITY) {
    
    
        	//直接将下一次扩容阙值设为整型最大值,这个值肯定小于oldCap * loadFaactor,意思就是以后不会再经历扩容操作了
            threshold = Integer.MAX_VALUE;
            //无需扩容,直接返回
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //oldCap不大于0,所以是第一次扩容
    else if (oldThr > 0) //当oldThr即threshold大于0时,表示的就是根据用户指定的初始容量计算得到的初始容量
        newCap = oldThr;
    else {
    
      //oldThr为0,说明是使用无参构造函数构造的map,那么要扩容的大小就使用newCap默认的DEFAULT_INITIAL_CAPACITY
        newCap = DEFAULT_INITIAL_CAPACITY;
        //下次扩容的阙值就是当前容量table数组大小乘以负载因子
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //检查下次扩容阙值newThr是否未被修改
    if (newThr == 0) {
    
    
        float ft = (float)newCap * loadFactor;
        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];
    table = newTab;
    //迁移旧的记录
    if (oldTab != null) {
    
    
    	//遍历旧的table数组中每一个桶(即每个table[i])
        for (int j = 0; j < oldCap; ++j) {
    
    
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
    
    
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                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;
                    do {
    
    
                        next = e.next;
                        //计算元素要分配的桶
                        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);
                    if (loTail != null) {
    
    
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
    
    
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

元素的重新分配

如果桶上的元素是以链表的形式组织的,那么在重新分配元素时,会把元素的 hash 值跟旧的数组大小进行相与运算,我们知道数组大小是 2 的整数次幂,也就是只有一位为 1,那么元素的 hash 值如果在数组大小的二进制值为 1 的那一位上也为 1 的话,那么相与结果就是旧的数组大小,否则的话就是 0,所以根据这个特点,如果结果为 0 就把元素留在原本的桶中,否则的话就把元素放到新的桶中,第 i 个桶对应的新桶是第 i 加旧数组大小个桶,这样的话由于数组大小是 2 倍扩增,所以扩容出来的每个新的桶其实就跟旧数组中是每一个桶相对应,从而完成对旧桶中元素的重新分配

table 数组的大小为什么必须是2的整数次幂

源码中计算元素应该放在 table 数组的哪个索引位置上时使用的是 (n - 1) & hash 来计算,当 n 为 2 的整数次幂时,n - 1 就是 0000…111111 的格式,假设是前 k 位为 1,那么根据按位与的计算方法,(n - 1) & hash 计算得到的结果就是 hash 的前 k 位,这样的算法使得 不同元素的hash进行计算时得到的结果分布均匀,也即是 在table数组上的分布均匀,这也是良好的哈希算法的要求之一,不同元素经过哈希算法后得到的结果应该要均匀。

而且我们知道,位运算是计算机进行运算时速度最快的运算,一般在哈希表中选择元素放置的位置时是使用 hash % n,其中 n 为哈希表 (中数组) 的大小,这样是为了计算出的索引位置一定在数组大小 n 的范围内。当 n 为 2 的整数次幂时,有 hash % n = hash & (n - 1),既能保证计算出的索引位置一定在n的范围内,而且计算速度快,而且计算结果分布均匀

证明:n为2的n次幂时,hash % n = hash & (n - 1)

找来找去没找到为什么 n 为 2 的 n 次幂时有 hash % n = hash & (n - 1),所以自己尝试证明了一下:

在二进制中,假设 hash 为 m 位二进制数,那么有

hash = a020 + a121 + a222 + … + ak2k + … + am-12m-1

其中 a0,a1,a2,…等于 0 或 1。由于 n 为 2 的整数次幂,假设 n = 2k (k 为非负整数且 m - 1 > k)。对 hash 再进一步可以得到:

hash = a020 + a121 + a222 + ak-12k-1 + 2k(ak + ak+12 + … + am-12m-k-1)

就算 a0 到 ak-1 都为 1,前 k 项的和也是 2k - 1 < 2k,所以 hash % 2k 即 hash % n 的结果就是第 0 到第 k - 1 项的和,也即 hash 二进制下的前 k 位

再看 hash & (n - 1),由于 n = 2k ,说明 n 为 1000…00000,其中 1 为第 k + 1 位 (从地位往高位算),前 k 位全为 0,那么 n - 1 即为 0111…11111,前 k 位全为 1,除此之外其它位全为 0,那么根据按位与操作的特点,hash & (n - 1) 的结果就是 hash 的前 k 位

综上,hash % n = hash & (n - 1),结果都是 hash 的前 k 位

所以对于为什么数组大小 n 必须为 2 的 n 次幂也可以这么理解:通常计算元素存放位置是通过 hash % n 来计算得到,由于 n 为 2 的 n 次幂时,存在 hash % n = hash & (n - 1) 这个关系,为了使用按位与 & 操作来提高效率,就规定了 n 必须为 2 的 n 次幂,然后计算元素存放位置的时候就可以使用 hash & (n - 1) 来进行了

关于hashCode()以及equals()重写

  1. equals 方法在 Object 中的实现是直接使用 “==” 符号比较两个对象,即比较他们的引用地址是否相等,也就是两个对象是不是同一个对象,只要两者不是同一个对象,那返回结果永远是 false。
    在 String 类中,对于两个对象是否 equal 相等的比较规则是两个对象中的字符串内容是不是相同,而两个不同地址的对象其字符串内容完全是有可能相同的,直接使用未重写的equals方法比较这样两个对象的话永远返回 false,这样就达不到 equals 在这个类下的作用了,所以要对其进行重写
  2. 所以对于这两个方法是否需要重写,简单一点说就是需要用到的时候,且使用目的跟其原来在 Object 方法中的实现不一样时就重写,如需要用到 hashCode 方法了,就根据自己的意图进行重写,需要用到 equals 方法,就重写 equals 方法
  3. 由于 hashCode 方法返回的是对象的哈希码,一般来说,如果两个对象相同,即 equals 方法的返回值为 true,那么同样希望他们的 hashCode 也是相等的
    而在 HashMap 这些哈希表结构,是先通过 hashCode (其实是hash值,对 hashCode 加工后得到的值) 计算其在哈希表上的索引值,再对索引位置上的冲突元素调用 equals 方法进一步去重,如果只重写了 equals 方法而没有重写 hashCode 方法,可能会出现两个对象调用 equals 方法时返回值为 true,但其 hashCode 却是不相同的,即可能由于没有发生本应发生的哈希冲突,导致两个相等的对象出现了集合中
  4. 对于这两个方法的配合使用场景一般都离不开集合,所以才会有建议重写 equals 方法就要一起重写 hashCode 方法的说法

为什么既要 hashCode 又要 equals

诚然,如果都使用 equals 判断两个元素是否相同,是可以的,都是适用于 Map,Set 这类需要判重的集合

但是,如果只使用 equals,判断 n 个元素是否在集合中,就需要调用 n 次 equals 方法;而如果有 hashCode 方法,先使用 hashCode 定位出元素所在的桶,把可能冲突的元素缩小到这个桶的范围内,再对桶内的元素进行 equals 比较,就可以大大减少调用 equals 的次数,而且 equals 的开销往往是比 hashCode 大的,所以减少 equals 的次数,也能显著地提高整个集合判重的效率

为什么当链表长度大于 8 之后要转变为红黑树

根据源码中开发人员的注释,由于红黑树的节点的大小是普通节点的大概两倍,因此只有当一个哈希桶中达到一个阈值 (即树化阈值TREEIFY_THRESHOLD) 才将其转化为红黑树结构

而在具有良好的分布特点的哈希方法下,需要使用树的情况是非常少的:理想情况下,借助于每个节点自身随机的哈希码,一个 hash 桶中节点数的概率分布符合泊松分布,单个桶中节点数为 8 的概率只为 0.00000006,已经是非常小了。所以就选择了 8 作为树化阈值

在节点数不够多的时候,使用链表已经足够快可以完成查询了,没有必要使用红黑树,而且红黑树的节点内存空间又比普通节点多那么多。优先是选择链表

不能直接使用红黑树吗

为什么在单个桶内 Entry 数较少时使用链表,而不是一开始就使用红黑树,或二叉搜索树,AVL 这些能提高搜索效率的搜索树。这应该是出于时间和空间两者间的权衡

在哈希冲突比较少的时候,或者单个桶内 Entry 比较少的时候,使用红黑树在节约耗时上产生的效果其实并没有特别大,而且在 put 的时候效率可能会降低,可能每次 put 都要进行复杂的变色,旋转等操作;另外,树的每个结点的空间占用也会更大,这个空间占用与节约的时间相比,显得有点得不偿失了

线程不安全问题

在 JDK 1.7 及以前,HashMap 的线程不安全体现在 扩容时会出现链表死循环 以及 数据覆盖 两个方面上;在 JDK 1.8 开始,死循环的问题已被修复,但数据覆盖的问题还是存在

死循环

死循环的问题源于 JDK 1.7 及之前的 tranfer() 方法中,这个方法是用于扩容时对某一个桶上的链表进行数据迁移:

void transfer(Entry[] newTable, boolean rehash) {
    
    
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
    
    
        while(null != e) {
    
    
            Entry<K,V> next = e.next;
            if (rehash) {
    
    
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i]; //1
            newTable[i] = e;      //2
            e = next;
        }
    }
}

当两个线程同时执行到语句 1,其中一个线程因为某些原因被挂起,另一个线程继续正常执行直到语句 2 执行完毕,然后也被挂起;此时,当前被移动的节点的 next 域已经指向新的桶上的头节点,新的桶的头节点也已经是被移动的节点了,而第一个线程恢复执行,会把被移动节点的 next 域指向新的桶的头节点,也就是自己,所以就造成了这个节点死循环,当后续线程需要遍历该节点所在的链表时,就会陷入死循环状态,造成一定程度上的 死锁。

而 1.8 后,迁移某个桶上的节点时改为了尾插入法,从而解决了头节点死循环的问题

数据覆盖

由于 put 方法是线程不安全的,所以可能出现两个用户线程同时对同一个 key 进行 put,这样就会有一个线程的操作会被覆盖掉

还有一个是先 containsKey 判断没有 key 然后才进行 put,但是可能在 containsKey 判断出确实没有 key 后,在 put 之前被另一个线程 put 了,这也会造成逻辑上的错误

线程安全的 ConcurrentHashMap

HashMap 在设计的时候就是针对单线程环境来实现的,所以其在多线程并发环境下是不安全的。如果有多线程并发安全的需求,我们应该选用 ConcurrentHashMap

对于 ConcurrentHashMap,要分两种情况来讨论,一是 JDK 1.7 及之前的版本,二是 JDK 1.8 及之后的版本

在 JDK 1.7 及之前,ConcurrentHashMap 的底层是基于数组 + 链表的结构,使用分段锁来保证线程安全,将 数组分成了 16 段,给每一个 segment 配一把锁,所以最多也就允许 16 个线程并发操作同一个 ConcurrentHashMap 对象

在 JDK 1.8 及之后,ConcurrentHashMap 也引入了红黑树这种结构。同时不再使用分段锁的方式,而是采用 CAS + synchronized 关键字这种方式来实现更加细粒度的锁,粒度为哈希桶级别。
具体体现为,在 put 新的 kv 时,计算出对应的桶,如果桶是空的,就用 CAS 修改值;如果桶不是空的,就对头节点进行加锁,然后再完成后续的操作。而且在存值成功前方法会一直循环整个逻辑,所以如果 CAS 失败的话最终也会走到加锁的这一步。总而言之就是桶为空则用 CAS 进行修改,否则对头节点进行加锁,再进行修改,实现桶级别的并发度

猜你喜欢

转载自blog.csdn.net/Pacifica_/article/details/123450370
今日推荐