jdk1.7源码分析集合hashmap的死循环(代码片段)

warehouse warehouse     2022-12-20     775

关键词:

前言

JDK1.7&1.8源码对比分析【集合】HashMap中我们遗留了一个问题:为什么HashMap在调用resize() 方法时会出现死循环?这篇文章就通过JDK1.7的源码来分析并解释这个问题。

如下,并发场景下使用HashMap造成Race Condition,从而导致死循环,现象是CPU 100%被占用。

final HashMap<String, String> map = new HashMap<String, String>();
for (int i = 0; i < 1000; i++) 
    new Thread(new Runnable() 
        @Override
        public void run() 
            map.put(UUID.randomUUID().toString(), "");
        
    ).start();

目录

一、问题症状

二、Hash表数据结构

三、HashMap的rehash源代码

1. 正常的rehash的过程

2. 并发下的rehash过程

一、问题症状

我们在程序中会经常使用HashMap来存储键值对,在单线程场景下使用没有任何问题。当程序性能出现瓶颈,我们开始使用多线程来操作HashMap,但因此也带来了问题:发现程序经常占了100%的CPU,查看堆栈,你会发现程序都Hang在了HashMap.get()这个方法上了,重启程序后问题消失。但是过段时间又会来。而且,这个问题在测试环境里可能很难重现。

我们简单的看一下我们自己的代码,我们就知道HashMap被多个线程操作。而Java的文档说HashMap是非线程安全的,应该用ConcurrentHashMap。

接下来我们分析一下具体的原因。

二、Hash表数据结构

HashMap通常会用一个指针数组(假设为table[])来做分散所有的key,当一个key被加入时,会通过Hash算法通过key算出这个数组的下标i,然后就把这个<key, value>插到table[i]中,如果有两个不同的key被算在了同一个i,那么就叫冲突,又叫碰撞,这样会在table[i]上形成一个链表。

我们知道,如果table[]的尺寸很小,比如只有2个,如果要放进10个keys的话,那么碰撞非常频繁,于是一个O(1)的查找算法,就变成了链表遍历,性能变成了O(n),这是hash表的缺陷(可参看《Hash Collision DoS 问题》)。

 所以,Hash表的尺寸和容量非常的重要。一般来说,Hash表这个容器当有数据要插入时,都会检查容量有没有超过设定的thredhold,如果超过,需要增大hash表的尺寸,但是这样一来,整个hash表里的无素都需要被重算一遍。这叫rehash,这个成本相当的大。

三、HashMap的rehash源代码

下面,我们来看一下Java的HashMap的源代码。

put一个key,value对到hash表中:

public V put(K key, V value) 
    // 判断当前数组是否需要初始化
    if (table == EMPTY_TABLE) 
        inflateTable(threshold);
    
    // 如果 key 为空,则 put 一个空值进去
    if (key == null)
        return putForNullKey(value);
    // 根据 key 计算出 hashcode
    int hash = hash(key);
    // 根据计算出的 hashcode 定位出所在桶
    int i = indexFor(hash, table.length);
    // 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值
    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++;
    // 如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置
    addEntry(hash, key, value, i);
    return null;

检查容量是否超标:

void addEntry(int hash, K key, V value, int bucketIndex) 
    // 判断是否需要扩容
    if ((size >= threshold) && (null != table[bucketIndex])) 
        // 如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    

    // 将当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表
    createEntry(hash, key, value, bucketIndex);

新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的hash表中:

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

    // 创建一个新的hash table
    Entry[] newTable = new Entry[newCapacity];
    // 将old hash table上的数据迁移到new hash table上
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

迁移的源代码,注意粗体部分:

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable, boolean rehash) 
    // 从old table中取一个元素出来,然后放到new table中
    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;
        
    

好了,这个代码算是比较正常的。而且没有什么问题。

1. 正常的rehash的过程

假设我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。

最上面的是old hash 表,其中的Hash表的size = 2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]这里了。

接下来的三个步骤是hash表 resize成4,然后所有的<key, value> 重新rehash的过程。

技术分享图片

2. 并发下的rehash过程

2.1 假设我们有两个线程

我们再回头看一下我们的 transfer代码中的这个细节:

do 
    Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
 while (e != null);

而我们的线程二执行完成了。于是我们有下面的这个样子。

技术分享图片

注意,因为线程一的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。我们可以看到链表的顺序被反转后。

2.2 线程一被调度回来执行

先是执行 newTable[i] = e,然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。

技术分享图片

2.3 一切安好

线程一接着工作。把key(7)摘下来,放到newTable[i]的第一个,然后把e和next往下移。

技术分享图片

2.4 环形链接出现

e.next = newTable[i] 导致  key(3).next 指向了 key(7),注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。

技术分享图片

于是,当我们的线程一调用到,HashTable.get(11)时,悲剧就出现了——Infinite Loop。

 

参考:疫苗:JAVA HASHMAP的死循环

 

多线程下hashmap的死循环问题

多线程下[HashMap]的问题:1、多线程put操作后,get操作导致死循环。2、多线程put非NULL元素后,get操作得到NULL值。3、多线程put操作,导致元素丢失。本次主要关注[HashMap]-死循环问题。为何出现死循环?大家都知道,HashMap采用链... 查看详情

多线程下hashmap的死循环问题(代码片段)

多线程下[HashMap]的问题:1、多线程put操作后,get操作导致死循环。2、多线程put非NULL元素后,get操作得到NULL值。3、多线程put操作,导致元素丢失。本次主要关注[HashMap]-死循环问题。为何出现死循环?大家都知道,HashMap采用链... 查看详情

jdk1.7&1.8源码对比分析集合concurrenthashmap(代码片段)

前言在JDK1.7&1.8源码对比分析【集合】HashMap中我们对比分析了JDK1.7和1.8版本的HashMap源码,趁热打铁,这篇文章就来看看JDK1.7和1.8版本的ConcurrentHashMap有哪些区别。目录一、对比分析1.1.7版本2.1.8版本一、对比分析1.1.7版本先来看... 查看详情

hashmap简单源码及多线程下的死循环

主要记录hashMap的一些基本操作源码实现原理以及多线程情况下get()操作的死循环引发原因一、hashMap简介1.hashMap集合的主要属性及方法(默认初始化容量)DEFAULT_INITIAL_CAPACITY=16(默认最大容量)MAXIMUM_CAPACITY=1<<30(默认加载因... 查看详情

concurrenthashmap源码分析(jdk1.7和1.8对比)

一、ConcurrentHashMap简介    并发编程大师DougLea开发的并发容器之一。ConcurrentHashMap是线程安全且高效的HashMap,在HashMap的基础上增加了线程安全,当然结构方面也有所改变。  为什么要使用ConcurrentHashMap?    1、多线... 查看详情

hashmap实现原理(jdk1.7),源码分析(代码片段)

HashMap实现原理(jdk1.7),源码分析?HashMap是一个用来存储Key-Value键值对的集合,每一个键值对都是一个Entry对象,这些Entry被以某种方式分散在一个数组中,这个数组就是HashMap的主干。一、几大常量//默认容量16staticfinalintDEFAULT_INITIA... 查看详情

hashmap源码分析(代码片段)

JDK1.7和jdk1.8对于HashMap实现的异同总结:具体可以看这篇文章,但这篇文章几个地方存在歧义,下面做以补充:(1)美团面试题:Hashmap的结构,1.7和1.8有哪些区别,史上最深入的分析_王伟的博客-CSDN博客_hashmap1.7和1.... 查看详情

深入理解java集合系列三:hashmap的死循环解读

由于在公司项目中偶尔会遇到HashMap死循环造成CPU100%,重启后问题消失,隔一段时间又会反复出现。今天在这里来仔细剖析下多线程情况下HashMap所带来的问题:1、多线程put操作后,get操作导致死循环。2、多线程put非null元素后,... 查看详情

高并发下的hashmap为什么会死循环

作者| tech-bus.七十一来源| 程序员巴士前言  HashMap并发情况下产生的死循环问题在JDK1.7及之前版本是存在的,JDK1.8通过增加loHead头节点和loTail尾节点进行了修复,虽然进行了修复,但是如果涉及到并发情况下需要... 查看详情

jdk1.7hashmap源码分析

概述HashMap是Java里基本的存储Key、Value的一个数据类型,了解它的内部实现,可以帮我们编写出更高效的Java代码。本文主要分析JDK1.7中HashMap实现,JDK1.8中的HashMap已经和这个不一样了,后面会再总结。正文HashMap概述HashMap根据键的... 查看详情

java集合linkedhashmap及其源码分析

以下内容基于jdk1.7.0_79源码;什么是LinkedHashMap继承自HashMap,一个有序的Map接口实现,这里的有序指的是元素可以按插入顺序或访问顺序排列;LinkedHashMap补充说明与HashMap的异同:同样是基于散列表实现,区别是,LinkedHashMap内部... 查看详情

hashmap为什么线程不安全?(代码片段)

...1.8中的数据覆盖举例说明总结解决线程不安全线程不安全HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖等问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,但是1.8中仍会有数... 查看详情

hashmap源码分析(代码片段)

前言:上篇文章,笔者分析了jdk1.7中HashMap的源码,这里将对jdk1.8的HashMap的源码进行分析。注:jdk版本:jdk1.8.0_1721.再看put操作1publicVput(Kkey,Vvalue)2returnputVal(hash(key),key,value,false,true);3jdk1.8中的hash算法:1staticfinalinthash(Objectkey)2inth;3/... 查看详情

学习笔记-集合hashmap源码浅析

/** *HashMap主要方法解析,jdk1.7版本的HashMap *一、构造 *4个构造相对之前的jdk版本功能基本不变,但是代码封装更完善。 *构造前一个参数是容量,相当于数组大小,后一个是负载因子 */publicHashMap(intinitialCapacity,... 查看详情

java集合面试题(代码片段)

...ist异同补充:数据结构基础之双向链表ArrayList与Vector区别HashMap的底层实现JDK1.8之前JDK1.8之后HashMap和Hashtable的区别HashMap的长度为什么是2的幂次方HashMap多线程操作导致死循环问题HashSet和HashMap区别ConcurrentHashMap和Hashtable的区别Concur... 查看详情

java并发集合类concurrenthashmap底层核心源码解析

Java并发集合类ConcurrentHashMap底层核心源码解析,面试必问一、概述ConcurrentHashMap是java并发包下一个常用的并发集合类,在面试中经常会被问及,一般在讲述了线程不安全的HashMap之后,面试官会问这个。在这篇文章中,我会详细... 查看详情

同事:求求你别再这样用hashmap了

前言:我们都知道HashMap是线程不安全的,在多线程环境中,或者读写操作时不建议使用,但是其线程不安全主要体现在什么地方呢,本文将对该问题进行解密。1.jdk1.7中的HashMap在jdk1.8中对HashMap做了很多优化,这里先分析在jdk1.7... 查看详情

treemap源码剖析

...jdk1.7.0_11之前介绍了一系列Map集合中的具体实现类,包括HashMap,HashTable,LinkedHashMap。这三个类都是基于哈希表实现的,今天我们介绍另一种Map集合,Tree 查看详情