并发之HashMap模拟多线程下死循环场景

知识点

hash

    把任意长度的输入通过一种算法(散列),变成固定长度的输出,这个输出值就是散列值。这种转换是一种压缩映射,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值,容易产生哈希冲突。

处理冲突方法:

  1. 开放寻址法
  2. 再散列法
  3. 链地址法

常用hash算法的介绍:

  1. MD4
  2. MD5:它对输入仍以512位分组,其输出是4个32位字的级联
  3. SHA-1及其他

位运算

位与 & (1&1=1 0&0=0 1&0=0)
位或 | (1|1=1 0|0=0 1|0=1)
位非 ~ (~1=0 ~0=1)
位异或 ^ (1^1=0 1^0=1 0^0=0)
有符号右移 >>(若正数,高位补0;负数,高位补1)
有符号左移<<
无符号右移>>>(不论正负,高位均补0)

取模 a % (2n) 等价于 a & (2n - 1),所以在map里的数组个数一定是2的乘方数,计算key值在哪个元素中的时候,就用位运算来快速定位。
以16为例,16-1=15 = …0000,1111(低位数后四位),任何数与1111进行与运算后,都是该数0~15范围以内的值。

hash扩容

    在多线程环境下,使用HashMap进行put操作时调用扩容方法引起死循环,导致CPU利用率接近100%。是因为多线程会导致HashMap的Entry链表在扩容时形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。

 public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        //检查是否需要扩容
        addEntry(hash, key, value, i);
        return null;
    }
    
    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //Entry[]数组扩容两倍
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        //扩容
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    
    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];
                newTable[i] = e;
                e = next;
            }
        }
    }

综合来说,HashMap一次扩容的过程:

  1. 取当前table的2倍作为新table的大小
  2. 根据算出的新table的大小new出一个新的Entry数组来,名为newTable
  3. 轮询原table的每一个位置,将每个位置上连接的Entry,算出在新table上的位置,并以链表形式连接
  4. 原table上的所有Entry全部轮询完毕之后,意味着原table上面的所有Entry已经移到了新的table上,HashMap中的table指向newTable

模拟死循环

往table[0] put 两个元素 3和7,模拟多线程下扩容场景,线程1执行到 Entry<K,V> next = e.next; 挂起,等线程2扩容完继续执行。

public class 模拟HashMap头插法死循环<K,V> {

    static int capacity = 1;
    private CountDownLatch latch = new CountDownLatch(2);

    Entry<?,?>[] EMPTY_TABLE = {};
    //仅使用table[0]的链表用于测试
    Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

    public static void main(String[] args) throws InterruptedException {
        模拟HashMap头插法死循环<String,String> test = new 模拟HashMap头插法死循环<String,String>();
//        模拟20次多线程扩容场景
//        for(int i=0;i<20;i++){
//            System.out.println("第"+(i+1)+"次扩容结果:");
            test.latch = new CountDownLatch(2);
            test.table = (Entry<String, String>[]) test.EMPTY_TABLE;
            test.put(0,"7","7");
            test.put(0,"3","3");
            Thread 线程1 = new Thread(() -> {
                test.resize(capacity);
            }, "线程1");
            Thread 线程2 = new Thread(() -> {
                test.resize(capacity);
            }, "线程2");
            线程1.start();
            线程2.start();

            test.latch.await();
            Entry entity = test.table[0];
            //输出5次就跳出循环
            int count=0;
            while(entity!=null && count<6){
                System.out.println(entity.key + " , next:"+ ((entity.next!=null)?entity.next.key:"null"));
                entity = entity.next;
                count++;
            }
//        }
    }

    //扩容方法
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        Entry[] newTable = new Entry[capacity];
        transfer(newTable);
        table = newTable;
        
        latch.countDown();
    }

    void transfer(Entry[] newTable) {
        int newCapacity = newTable.length;
        int count =0;
        // 1. 遍历老table
        for (Entry<K,V> e : table) {
            // 2. 如果元素不为空,遍历Entry元素
            while(null != e) {
                Entry<K,V> next = e.next;
                if(Thread.currentThread().getName().equals("线程1") && count++==0){
                    //等待线程2创建完成,当线程1挂起后,保证线程2大概率能拿到CPU使用权
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException ex) {
                        ex.printStackTrace();
                    }
                    Thread.yield();
                }

                e.next = newTable[0];
                // 3. 元素放入新table
                newTable[0] = e;
                // 4. 继续遍历Entry子节点
                e = next;
            }
        }
    }

    public V put(int hash,K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(capacity);
        }
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }

        createEntry(hash, key, value, 0);
        return null;
    }

/*
 * 以下复制的HashMap源码
 */
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
    }

    private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        table = new Entry[toSize];
    }

    static class Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

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

        public final boolean equals(Object o) {
            if (!(o instanceof Map.Entry))
                return false;
            Map.Entry e = (Map.Entry)o;
            Object k1 = getKey();
            Object k2 = e.getKey();
            if (k1 == k2 || (k1 != null && k1.equals(k2))) {
                Object v1 = getValue();
                Object v2 = e.getValue();
                if (v1 == v2 || (v1 != null && v1.equals(v2)))
                    return true;
            }
            return false;
        }

        public final int hashCode() {
            return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
        }

        public final String toString() {
            return getKey() + "=" + getValue();
        }

        void recordAccess(HashMap<K,V> m) {
        }

        void recordRemoval(HashMap<K,V> m) {
        }
    }
}

输出结果

3 , next:7
7 , next:3
3 , next:7
7 , next:3
3 , next:7
7 , next:3

HashMap之所以在并发下的扩容造成死循环,是因为多个线程并发进行扩容时,因为一个线程先期完成了扩容,将原Map的链表重新散列到自己的表中,并且链表变成了倒序,后一个线程再扩容时,又进行自己的散列,再次将倒序链表变为正序链表。于是形成了一个环形链表,当get表中不存在的元素时,造成死循环。

常见问题

HashMap底层数据结构

JDK7:数组+链表
JDK8: 数组+链表+红黑树

JDK8中的HashMap为什么要使用红黑树?

当元素个数小于一个阈值时,链表整体的插入查询效率要高于红黑树,当元素个数大于此阈值时,链表整体的插入查询效率要低于红黑树。此阈值在HashMap中为8

JDK8中的HashMap什么时候将链表转化为红黑树?

当发现链表中的元素个数大于8之后,还会判断一下当前数组的长度,如果数组长度小于64时,此时并不会转化为红黑树,而是进行扩容。只有当链表中的元素个数大于8,并且数组的长度大于等于64时才会将链表转为红黑树

上面扩容的原因是,如果数组长度还比较小,就先利用扩容来缩小链表的长度。

JDK8中HashMap的put方法的实现过程?

  1. 根据key生成hashcode
  2. 判断当前HashMap对象中的数组是否为空,如果为空则初始化该数组
  3. 根据逻辑与运算,算出hashcode基于当前数组对应的数组下标 i
  4. 判断数组的第i个位置的元素(tab[i])是否为空
    a. 如果为空,则将key,value封装为Node对象赋值给tab[i]
    b. 如果不为空:
    ⅰ. 如果put方法传入进来的key等于tab[i].key,那么证明存在相同的key
    ⅱ. 如果不等于tab[i].key,则:
    1. 如果tab[i]的类型是TreeNode,则表示数组的第i位置上是一颗红黑树,那么将key和value插入到红黑树中,并且在插入之前会判断在红黑树中是否存在相同的key
    2. 如果tab[i]的类型不是TreeNode,则表示数组的第i位置上是一个链表,那么遍历链表寻找是否存在相同的key,并且在遍历的过程中会对链表中的结点数进行计数,当遍历到最后一个结点时,会将key,value封装为Node插入到链表的尾部,同时判断在插入新结点之前的链表结点个数是不是大于等于8,如果是,再判断数组的长度是否大于等于64,如果是时则将链表改为红黑树,否则仅仅扩容。
      ⅲ. 如果上述步骤中发现存在相同的key,则根据onlyIfAbsent标记来判断是否需要更新value值,然后返回oldValue
  5. modCount++
  6. HashMap的元素个数size加1
  7. 如果size大于扩容的阈值,则进行扩容

JDK8中HashMap的get方法的实现过程

  1. 根据key生成hashcode
  2. 如果数组为空,则直接返回空
  3. 如果数组不为空,则利用hashcode和数组长度通过逻辑与操作算出key所对应的数组下标 i
  4. 如果数组的第i个位置上没有元素,则直接返回空
  5. 如果数组的第1个位上的元素的key等于get方法所传进来的key,则返回该元素,并获取该元素的value
  6. 如果不等于则判断该元素还有没有下一个元素,如果没有,返回空
  7. 如果有则判断该元素的类型是链表结点还是红黑树结点
    a. 如果是链表则遍历链表
    b. 如果是红黑树则遍历红黑树
  8. 找到即返回元素,没找到的则返回空

JDK7与JDK8中HashMap的不同点

  1. JDK8中使用了红黑树
  2. JDK7中链表的插入使用的头插法(扩容转移元素的时候也是使用的头插法,头插法速度更快,无需遍历链表,但是在多线程扩容的情况下使用头插法会出现循环链表的问题,导致CPU飙升),JDK8中链表使用的尾插法(JDK8中反正要去计算链表当前结点的个数,反正要遍历的链表的,所以直接使用尾插法)
  3. JDK7的Hash算法比JDK8中的更复杂,Hash算法越复杂,生成的hashcode则更散列,那么hashmap中的元素则更散列,更散列则hashmap的查询性能更好,JDK7中没有红黑树,所以只能优化Hash算法使得元素更散列,而JDK8中增加了红黑树,查询性能得到了保障,所以可以简化一下Hash算法,毕竟Hash算法越复杂就越消耗CPU
  4. 扩容的过程中JDK7中有可能会重新对key进行哈希(重新Hash跟哈希种子有关系),而JDK8中没有这部分逻辑
  5. JDK8中扩容的条件和JDK7中不一样,除开判断size是否大于阈值之外,JDK7中还判断了tab[i]是否为空,不为空的时候才会进行扩容,而JDK8中则没有该条件了
  6. JDK8中还多了一个API:putIfAbsent(key,value)
  7. JDK7和JDK8扩容过程中转移元素的逻辑不一样,JDK7是每次转移一个元素,JDK8是先算出来当前位置上哪些元素在新数组的低位上,哪些在新数组的高位上,然后在一次性转移
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

paopaodog

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

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

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

打赏作者

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

抵扣说明:

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

余额充值