(5)HashMap原理解析——为啥线程不安全?

目录

一、 HashMap的数据结构

二、HashMap的功能实现源码解析

1. hash方法

2. 由链表改为红黑树

3.扩容

4. 扩容后的新位置

5. 搬家

三、 怎样将HashMap升级为线程安全的

1. HashMap为啥线程不安全呢?

2.  HashMap应该怎样实现线程安全呢?


一、 HashMap的数据结构

1.7版本与1.8版本数据结构的区别

    1.7版本使用的数据结构是数组 + 链表的形式。对于新增的节点使用的是头插法,新增的节点增加在离桶最近的地方。

    

1.8版本使用的是 数组 + 链表/红黑树的形式。新增节点使用的是尾插法,新增的节点在链表的尾部。当链表的长度>=8时,会转换为红黑树结构。

二、HashMap的功能实现源码解析

1. hash方法

    如果没有指明HashMap的初始化大小值,则其默认初始化大小是16。

    当我们有一个新的值被put方法放入HashMap时,它应该在0~15之间有一个具体的位置。那么应该用什么方法确定它的位置呢?

    我们常想到的就是用随机取模的方法来做,Random(16).nextInt(),简单粗暴。但是如果我们对同一个key比如"hello",反复地放入同一个HashMap,则其每次的位置都是随机的且位置不同,这样对于查找并不方便,最好用一种与key本身带有某种关系的算法,同一个key往往放在同一个位置。我们看下HashMap的源码是怎样确定key的位置的。

    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 {
            ....
        }
    }

     源码的算法是 tab[i = (n - 1) & hash]

     tab是HashMap内部的Node数组,tab[i]就是第i个的位置。 i 的取值是(n-1) & hash。

     n -1 是数组的长度 - 1, 那么hash是什么呢?

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

    它的计算原理是: hashCode  ^  (hashCode   >>> 16)

    每一个对象都有一个hashCode,是一个32位的int值。  算式的意思是将它的高16位与低16位相异或

    为什么要用异或

    我们用了hashCode的高16位与低16位来进行运算,我们当然可以取与(&)运算或者或( | )运算,但是这样的结果与异或(^)相比较一下,就可以发现任意两个数X和Y进行(&)或( | )运算之后,每一位取0和1 的概率都是不一样的,也就是说某个操作数(即高16位或者低16位)被赋予的权重是不一样的,这就会使hash计算后分布不够均匀。而异或(^)运算则没有这个问题。

    HashMap数组的长度n必须为2的整数次方。这个n值为什么要这么规定?

    tab[i] 中位置 i 的位置是 (n-1) & hash。 我们明白了hash的计算结果,是一个16位的值。

    n为2的整数次方,比如说16,则n-1 = 15,换算成二进制就是 00001111。

    长度必须为2的整数次方的原因就是

    (1) n-1 与 hash相与,最大值为15,最小值为0,其结果值分布在0 ~ 15之间,完美契合座标范围

    

    (2)n-1的二进制,其值全部为1,可以采样到hashCode后面所有位的值。如果n-1中间有某几位为0,则该位与(&)的结果一定是0,取不到值,则tab[] 数组某些位置就会为空,永远也不会被存放值,造成内存浪费。

2. 由链表改为红黑树

    当链表的长度 >= TREEIFY_THRESHOLD (=8)时,就会将链表改为红黑树。因为链表查找的时间复杂度为O(n),而红黑树的查找时间复杂度为O(logn)。 (红黑树的内容在数据结构篇中查看)

    当红黑树的节点 <= UNTREEIFY_THRESHOLD (=6)时,又会从红黑树转换为链表。

3.扩容

    随着Node数组存放的数据越来越多,达到 0.75 *f (f为Node数组的长度)时,就会对HashMap进行扩容。

    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;  //如果到了 1 << 30,则扩容到Integer.MAX_VALUE
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // 扩容一倍
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // 初始时用默认值16
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        ...
    }

     如果是首次初始化则初始化为16,如果 >= 2^30则扩容为Integer.MAX_VALUE,其他情况则扩容为原来的2倍。

4. 扩容后的新位置

    扩容的过程中,容量变为原来的2倍,原Node数组中的节点位置,就需要计算新的 hash & (n-1)来确定。

    新的位置只可能在两个位置: 

  •     原来的位置
  •     [原下标 + 原容量] 的位置

    这个很容易理解:

    

    下标就是 hash & (n-1), hash没有变, 只有 n 扩大为原来的2倍,则 n-1的二进制就是比原来的最左侧多了一个 1 。那么计算 hash & (n-1),如果最左侧的1上hash码为0,则为原来的数;如果最左侧1上hash码为1,则为原来的数 + 原来的容量。

5. 搬家

    当HashMap扩容之后,hashMap的各个Node节点都要移动到新的位置上去。这个过程再去增删改查一定是不安全的,因此就需要先禁止这些操作,等到各Node节点存放到新的位置之后才能操作。

 

三、 怎样将HashMap升级为线程安全的

    与HashTable相比较,HashTable为啥是线程安全的?

    因为HashTable的几乎所有方法都加上了synchronized 关键字。

    public synchronized V put(K key, V value) { 
        ...
    }

    这样是实现了安全性,但是性能也肯定被降低了,算是牺牲了性能来保证了安全性。

 

1. HashMap为啥线程不安全呢?

      我们来看一下HashMap中有哪些操作。

     (1) 对于一个普通的put操作,步骤有:

     hash(key)

     数组初始化

     将该key/value值存放入某个位置

    (2)扩容

      数组扩容

      移动数据

      这些步骤除了hash(key) 之外,其他都是线程不安全的。这就是HashMap线程不安全的原因。

 

2.  HashMap应该怎样实现线程安全呢?

    除了像HashTable那样low地为每个方法用synchronized修改,我们可以根据每个步骤来对其进行优化。

   (1) 对于一个普通的put/remove 操作,步骤有:

     hash(key)   --------- 线程安全

     数组初始化  -------- 线程不安全,只能有一个线程在处理,可以用CAS来解决。

     将该key/value值存放入某个位置   -------- 线程不安全,插入时如果为null则用CAS 解决, 如果不为null,可以使用synchronized(i  i为数组下标)的方式,尽量减小锁的粒度。

    (2)扩容

      数组扩容  -------  线程不安全,只能由一个线程操作,用CAS解决。

      移动数据  ------- 线程不安全,必须禁止其他的增删改查操作,之后借鉴ConcurrentHash中的方式,将每一个桶将由不同的线程去负责搬运它们的位置,将锁的粒度减小到单个桶的范围。

      此外其他的方法都要仿照这种形式进行改造。所以对多线程来说,还是要用 ConcurrentHash 作为更好的选择。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章