并发编程与高并发解决方案:HashMap与ConcurrentHashMap

本文详细解析了HashMap与ConcurrentHashMap的底层结构、工作原理及其区别。包括HashMap的初始化方法、寻址方式、线程安全性问题及解决方案,以及ConcurrentHashMap在不同JDK版本中的实现差异。

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

转自:慕课网实战·高并发探索(十):HashMap与ConcurrentHashMap

HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

HashMap

初始化方法

HashMap的实现方式是:
JDK1.7 数组+链表
JDK1.8:数组+链表+红黑树

初始容量:Hash表中桶的数量
加载因子:是Hash表在自动增加之前可以达到多满的一个尺度。

HashMap在类中定义了这两个参数:

//初始容量,默认16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
//加载因子,默认0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

当Hash表中的条目数量超过了加载因子与当前容量的乘积,将会调用resize()进行扩容,将容量翻倍。

这两个参数在初始化HashMap的时候可以进行设置:可以单独指定初始容量,也可以同时设置 初始容量、加载因子

寻址方式

对于一个新插入的数据或者要读取的数据,HashMap将key按一定规则计算出hash值,并对数组长度进行取模结果作为在数组中查找的index。由于 在计算机中取模的代价远远高于位操作的代价,因此HashMap要求数组的长度为2的N次方。此时它将key的hash值对2的n-1次方进行与运算,等同于取模运算。HashMap并不要求用户一定要设置一个2的N次方的初始化大小,它本身内部会通过运算(tableSizeFor方法)确定一个合理的符合2的N次方的大小去设置

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

Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
通过 h & (length-1) 来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h & (length-1)运算等价于对length取模,也就是hash%length,但是&比%具有更高的效率。

//jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
static int indexFor(int h, int length) { 
 return h & (length-1); //取模运算
}

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现

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

主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
位操作

HashMap的线程不安全原因

多线程put导致的数据覆盖

有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

死循环

HashMap在多线程情况下,执行resize()进行扩容时容易造成死循环。
扩容思路为它要创建一个大小为原来两倍的数组,保证新的容量仍为2的N次方,从而保证上述寻址方式仍然适用。扩容后将原来的数组从新插入到新的数组中。这个过程称为reHash。
并发下的Rehash
因为Thread1的 e 指向了key(3),而e.next=key(7),其在线程二rehash后,指向了线程二重组后的链表。【链表的顺序被反转】

线程一被调度回来执行。

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

fail-fast

modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。

如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast。

abstract class HashIterator {
        ...
        int expectedModCount;  // for fast-fail
        int index;             // current slot

        HashIterator() {
            expectedModCount = modCount;
            Node<K,V>[] t = table;
            current = next = null;
            index = 0;
            if (t != null && size > 0) { // advance to first entry
                do {} while (index < t.length && (next = t[index++]) == null);
            }
        }
        ...
}

在每次迭代的过程中,都会判断modCount跟expectedModCount是否相等,如果不相等代表有人修改HashMap。源码:

final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    if (e == null)
        throw new NoSuchElementException();
    if ((next = (current = e).next) == null && (t = table) != null) {
        do {} while (index < t.length && (next = t[index++]) == null);
    }
    return e;
}

解决办法:可以使用Collections的synchronizedMap方法构造一个同步的map,或者直接使用线程安全的ConcurrentHashMap来保证不会出现fail-fast策略。

ConcurrentHashMap

HashTable 中,是直接在 put 和 get 方法上加上了 synchronized,锁的粒度太大,影响并发性能;

ConcurrentHashMap原理分析(1.7与1.8)有源码分析

java7

数组 + Segment + 分段锁
Java7

  • Java7里面的ConcurrentHashMap的底层结构仍然是数组和链表,与HashMap和Hashtable 最大的不同在于:put和 get 两次Hash到达指定的HashEntry,第一次hash到达Segment,第二次到达Segment里面的Entry,然后在遍历entry链表
  • 当我们读取某个Key的时候它先取出key的Hash值,并将Hash值的高sshift位与Segment的个数取模,决定key属于哪个Segment。接着像HashMap一样操作Segment。
  • 为了保证不同的Hash值保存到不同的Segment中,ConcurrentHashMap对Hash值也做了专门的优化。
  • Segment继承自J.U.C里的ReetrantLock,所以可以很方便的对Segment进行上锁(降低锁的粒度)。即分段锁
//源码
	//Segment的初始化容量是16;HashEntry最小的容量为2
	private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
    private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException {
        // For serialization compatibility
        // Emulate segment calculation from previous version of this class
        int sshift = 0;
        int ssize = 1;
        while (ssize < DEFAULT_CONCURRENCY_LEVEL) {
            ++sshift;
            ssize <<= 1;//最大16位2进制
        }
        int segmentShift = 32 - sshift;
        int segmentMask = ssize - 1;
   		.........省略
    }

put操作 Segment实现了ReentrantLock,也就带有锁的功能,当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒(美团面试官问道的,多个线程一起put时候,currentHashMap如何操作)
size操作
计算ConcurrentHashMap的元素大小是一个有趣的问题,因为他是并发操作的,就是在你计算size的时候,他还在并发的插入数据,可能会导致你计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据),要解决这个问题,JDK1.7版本用两种方案

1、第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的(3次获取比较值)

2、第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回(美团面试官的问题,多个线程下如何确定size)【所有segment加锁

java8

java8
Java8 ConcurrentHashMap结构基本上和Java8的HashMap一样,但其保证线程安全性。

Java8废弃了Java7中ConcurrentHashMap中分段锁的方案,并且不使用Segment,转为使用大的数组。同时为了提高Hash碰撞下的寻址做了性能优化(内部大量采用CAS操作)。
Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。

class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    //... 省略部分代码
}


/**
 * Initializes table, using the size recorded in sizeCtl.
 */
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)//有线程在扩容,让出cpu
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {//讲sizeCtl改为-1,表示正在扩容
            try {
                if ((tab = table) == null || tab.length == 0) {
                //首次初始化后对sc赋值为下次扩容的大小
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);//计算下次扩容的大小,当前容量的3/4
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}
  • Java8在列表的长度超过了一定的值(默认8)时,将链表转为红黑树实现。寻址的时间复杂度从O(n)转换为Olog(n)。

sizeCtl=-1 占位符,表示当前正在初始化数组。
sizeCtl=0 默认状态,表示数组还没有被初始化。

Java7vsJava8

其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。

1.数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
2.保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
3.锁的粒度:原来是对 需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁【Node(HashEntry在1.8中称为Node)】。
4.链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
5.查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

HashMap与ConcurrentHashMap对比

  • HashMap非线程安全、ConcurrentHashMap线程安全
  • HashMap允许Key与Value为空,ConcurrentHashMap不允许
  • HashMap不允许通过迭代器遍历的同时修改,ConcurrentHashMap允许。并且更新可见

高并发编程系列:ConcurrentHashMap的实现原理(JDK1.7和JDK1.8)
阿里P8架构师谈:深入探讨HashMap的底层结构、原理、扩容机制
JDK1.8为什么使用synchronized来代替重入锁ReentrantLock

  • 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
  • JVM的支持 JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
  • 减少内存开销 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据

为什么 key 和 value 不允许为 null

在 HashMap 中,key 和 value 都是可以为 null 的,但是在 ConcurrentHashMap 中却不允许,这是为什么呢?
作者 Doug Lea 本身对这个问题有过回答,在并发编程中,null 值容易引来歧义, 假如先调用 get(key) 返回的结果是 null,那么我们无法确认是因为当时这个 key 对应的 value 本身放的就是 null,还是说这个 key 值根本不存在,这会引起歧义,如果在非并发编程中,可以进一步通过调用 containsKey 方法来进行判断,但是并发编程中无法保证两个方法之间没有其他线程来修改 key 值,所以就直接禁止了 null 值的存在。
而且作者 Doug Lea 本身也认为,假如允许在集合,如 map 和 set 等存在 null 值的话,即使在非并发集合中也有一种公开允许程序中存在错误的意思,这也是 Doug Lea 和 Josh Bloch(HashMap作者之一) 在设计问题上少数不同意见之一,而 ConcurrentHashMap 是 Doug Lea 一个人开发的,所以就直接禁止了 null 值的存在。

精妙的计数方式

在 HashMap 中,调用 put 方法之后会通过 ++size 的方式来存储当前集合中元素的个数,但是在并发模式下,这种操作是不安全的,所以不能通过这种方式,那么是否可以通过 CAS 操作来修改 size 呢?

直接通过 CAS 操作来修改 size 是可行的,但是假如同时有非常多的线程要修改 size 操作,那么只会有一个线程能够替换成功,其他线程只能不断的尝试 CAS,这会影响到 ConcurrentHashMap 集合的性能,所以作者就想到了一个分而治之的思想来完成计数。

作者定义了一个数组来计数,而且这个用来计数的数组也能扩容,每次线程需要计数的时候,都通过随机的方式获取一个数组下标的位置进行操作,这样就可以尽可能的降低了锁的粒度,最后获取 size 时,则通过遍历数组来实现计数:

//用来计数的数组,大小为2的N次幂,默认为2
private transient volatile CounterCell[] counterCells;
@sun.misc.Contended static final class CounterCell {//数组中的对象
        volatile long value;//存储元素个数
        CounterCell(long x) { value = x; }
    }

连接:
面试中常见的 ConcurrentHashMap 拷问,你能扛多久?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值