HashMap详解

本文详细探讨了HashMap的结构,包括1.7和1.8版本的区别,1.8中引入红黑树以优化查询效率。同时,文章指出了HashMap在并发环境下的问题,如数据丢失、扩容问题和可能导致的死循环,并分析了这些问题的原因。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

Map 这样的 Key Value 在软件开发中是非常经典的结构,常用于在内存中存放数据。本文主要详解HashMap的结构和并发下HashMap的问题

说明

HashMap在并发环境下会有并发问题,所以在并发生产环境下请使用ConcurrentHashMap或者Collections.synchronizedMap(new HashMap()) 但是后一种方法性能太低,一般使用前者

HashMap

HashMap底层是采用数组+链表的数据结构,不过这种结构在JDK1.8中稍微有所不同,下面详细介绍:

Base1.7

在这里插入图片描述
实现

  • 先看一下HashMap的成员变量
    在这里插入图片描述

成员变量说明
①初始化桶大小,因为底层是数组,所以这是数组默认的大小。
②桶最大值。
③默认的负载因子(0.75)
④table 真正存放数据的数组。
⑤Map 存放数量的大小。
⑥桶大小,可在初始化时显式指定。
⑦负载因子,可在初始化时显式指定(由于给定的 HashMap 的容量大小是固定的,比如默认初始化, 给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容)

  • 初始化
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
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;
    threshold = initialCapacity;
    init();
}
  • Entry
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
    Entry是存储的是数据基本单位, 是 HashMap 中的一个内部类,从他的成员变量很容易看出:
  • key 就是写入时的键。
    value 自然就是值。
    next 开始的时候就提到 HashMap 是由数组和链表组成,所以这个就是用于实现链表结构。
    hash 存放的是当前 key 的 hashcode。

在这里插入图片描述

  • put
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])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

判断当前数组是否需要初始化。
如果 key 为空,则 put 一个空值进去。
根据 key 计算出 hashcode。
根据计算出的 hashcode 定位出所在桶。
如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。

当调用 addEntry 写入 Entry 时需要判断是否需要扩容。
如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位。
而在 createEntry 中会将当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表。

  • get
public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);
    return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
    if (size == 0) {
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
判断该位置是否为链表。
不是链表就根据 key、key 的 hashcode 是否相等来返回值。
为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。
啥都没取到就直接返回 null 。

Base1.8

1.8和1.7的主要区别在于存储Entry对象的方式,在1.7当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。

因此 1.8 中重点优化了这个查询效率,即将长链表转为红黑树
在这里插入图片描述

  • 成员变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
 * The maximum capacity, used if a higher value is implicitly specified
 * by either of the constructors with arguments.
 * MUST be a power of two <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
/**
 * The bin count threshold for untreeifying a (split) bin during a
 * resize operation. Should be less than TREEIFY_THRESHOLD, and at
 * most 6 to mesh with shrinkage detection under removal.
 */
static final int UNTREEIFY_THRESHOLD = 6;
transient Node<K,V>[] table;
/**
 * Holds cached entrySet(). Note that AbstractMap fields are used
 * for keySet() and values().
 */
transient Set<Map.Entry<K,V>> entrySet;
/**
 * The number of key-value mappings contained in this map.
 */
transient int size;

和 1.7 大体上都差不多,还是有几个重要的区别:
TREEIFY_THRESHOLD 用于判断是否需要将链表转换为红黑树的阈值。
UNTREEIFY_THRESHOLD 用于判断将红黑树转会链表的阈值
HashEntry 修改为 Node。
Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next 等数据。

  • put
    在这里插入图片描述
  • ①判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
    ②根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
    ③如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
    ④如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
    ⑤如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
    ⑥接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
    ⑦如果在遍历过程中找到 key 相同时直接退出遍历。
    ⑧如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
    ⑨最后判断是否需要进行扩容。
  • get
public V get(Object key) {
    Node<K,V> e;
    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;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

首先将 key hash 之后取得所定位的桶。
如果桶为空则直接返回 null 。
否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
如果第一个不匹配,则判断它的下一个是红黑树还是链表。
红黑树就按照树的查找方式返回值。
不然就按照链表的方式遍历匹配返回值。

从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)。

HashMap并发问题

数据丢失

比如A B两个线程(A线程获数据 B线程存数据) 同时操作myHashMap

首先B线程执行存放数据 modelHashMap.put(“1”,“2”);

然后A线程执行get获取数据modelHashMap.get(“1”)

A线程获取的值本来应该是2,但是如果A线程在刚到达获取的动作还没执行的时候,
线程执行的机会又跳到线程B,此时线程B又对modelHashMap赋 modelHashMap.put(“1”,“3”);

然后线程虚拟机又执行线程A,A取到的值为3,这样map中第一个存放的值 就会丢失。。。。。

扩容问题

一般我们声明HashMap时,使用的构造方法:HashMap<K,V>或者HashMap(int initialCapacity, float loadFactor),其中参数initialCapacity为初始容量,loadFactor为加载因子,而之前我们看到的threshold = (int)(capacity * loadFactor); 如果在默认情况下,一个HashMap的容量为16,加载因子为0.75,那么阀值就是12,所以在往HashMap中put的值到达12时,它将自动扩容两倍,如果两个线程同时遇到HashMap的大小达到12的倍数时,就很有可能会出现在将oldTable转移到newTable的过程中遇到问题,从而导致最终的HashMap的值存储异常。

死循环

这个问题出现在1.7以前的版本,在1.8以后已经解决了,常常在生产环境出现程序占100%CPU的情况,测试环境下又很难重现,线上的程序重启之后会解决,可是两三天以后又会出现。。。

实际上这个问题就出现在HashMap的扩容方法resize()

void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //创建一个新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    //将Old Hash Table上的数据迁移到New Hash Table上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面这段代码的意思是:
    //  从OldTable里摘一个元素出来,然后放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            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);
        }
    }
}

仔细观察上面transfer()中的Entry<K,V> next = e.next;

下面画了个图做了个演示old hash表resize为new hash表的过程。

  • 首先假设我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。
  • 下图最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]这里了。
  • 接下来的三个步骤是Hash表 resize成4,然后所有的<key,value> 重新rehash的过程
    -

并发下的Rehash(对应上图的第一步、第二步、第三步)

1)假设当前有两个线程线程一和线程二同时使用这个hashMap,假设线程一在执行至Entry<K,V> next = e.next线程被挂起,开始执行线程二,刚好在线程二执行的时候发生了resize而我们的线程二执行完成了。于是我们有下面的这个样子。
在这里插入图片描述
注意,因为Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。我们可以看到链表的顺序被反转后。

2)线程一被调度回来执行。

  • 先是执行 newTalbe[i] = e;
  • 然后是e = next,导致了e指向了key(7),
  • 而下一次循环的next = e.next导致了next指向了key(3)
    在这里插入图片描述

3)一切安好。
线程一接着工作。把key(7)摘下来,放到newTable[i]的第一个,然后把e和next往下移。
在这里插入图片描述
4)环形链接出现。
e.next = newTable[i] 导致 key(3).next 指向了 key(7)
注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。

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

死循环案例转自 https://coolshell.cn/articles/9606.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值