ConcurrentHashMap的原理

本文详细剖析了ConcurrentHashMap的工作原理,包括其内部结构、读写操作机制及并发控制策略,对比了它与HashMap的区别,解释了为何使用Segment来提高并发性能。

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

大家都知道HashMap是非线程安全的,Hashtable是线程安全的,但是由于Hashtable是采用synchronized进行同步,相当于所有线程进行读写时都去竞争一把锁,导致效率非常低下。

同步Map:效率同样低下,对HashMap的每个操作都进行了同步处理

Map<String, String> map = Collections.synchronizedMap(new HashMap<String, String>());


这样会导致线程排队很严重,严重降低效率。

但是ConcurrentHashMap可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地小,不用对整个ConcurrentHashMap加锁。从而大大提高并发性能。

JDK1.7之前的ConcurrentHashMap的内部结构


ConcurrentHashMap为了提高本身的并发能力,在内部采用了一个叫做Segment的结构,一个Segment其实就是一个类Hash Table的结构,Segment内部维护了一个链表数组,我们用下面这一幅图来看下ConcurrentHashMap的内部结构:


从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部,因此,这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长,但是带来的好处是写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上),所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。

(1).Segment


HashEntry的结构:


可以看到HashEntry的一个特点,除了value以外,其他的几个变量都是final的,这样做是为了防止链表结构被破坏,出现ConcurrentModification的情况。

(2)初始化



CurrentHashMap的初始化一共有三个参数,一个initialCapacity,表示初始的容量,一个loadFactor,表示负载参数,最后一个是concurrentLevel,代表ConcurrentHashMap内部的Segment的数量,ConcurrentLevel一经指定,不可改变,后续如果ConcurrentHashMap的元素数量增加导致ConrruentHashMap需要扩容,ConcurrentHashMap不会增加Segment的数量,而只会增加Segment中链表数组的容量大小,这样的好处是扩容过程不需要对整个ConcurrentHashMap做rehash,而只需要对Segment里面的元素做一次rehash就可以了。

  整个ConcurrentHashMap的初始化方法还是非常简单的,先是根据concurrentLevel来new出Segment,这里Segment的数量是不大于concurrentLevel的最大的2的指数,就是说Segment的数量永远是2的指数个,这样的好处是方便采用移位操作来进行hash,加快hash的过程。接下来就是根据intialCapacity确定Segment的容量的大小,每一个Segment的容量大小也是2的指数,同样使为了加快hash的过程。

  这边需要特别注意一下两个变量,分别是segmentShift和segmentMask,这两个变量在后面将会起到很大的作用,假设构造函数确定了Segment的数量是2的n次方,那么segmentShift就等于32减去n,而segmentMask就等于2的n次方减一。

ConcurrentHashMap的get操作

前面提到过ConcurrentHashMap的get操作是不用加锁的,我们这里看一下其实现:

1
2
3
4
public V get(Object key) {
    int hash = hash(key.hashCode());
    return segmentFor(hash).get(key, hash);
}

java7中的hash函数:

private static int hash(int h) {//使高位与低位充分的混淆,高位影响低位
        // Spread bits to regularize both segment and index locations,
        // using variant of single-word Wang/Jenkins hash.
        h += (h <<  15) ^ 0xffffcd7d;
        h ^= (h >>> 10);
        h += (h <<   3);
        h ^= (h >>>  6);
        h += (h <<   2) + (h << 14);
        return h ^ (h >>> 16);
    }  

看第三行,segmentFor这个函数用于确定操作应该在哪一个segment中进行,几乎对ConcurrentHashMap的所有操作都需要用到这个函数,我们看下这个函数的实现:

1
2
3
final Segment<K,V> segmentFor(int hash) {
    return segments[(hash >>> segmentShift) & segmentMask];
}

   这个函数用了位操作来确定Segment,根据传入的hash值向右无符号右移segmentShift位,然后和segmentMask进行与操作,结合我们之前说的segmentShift和segmentMask的值,就可以得出以下结论:假设Segment的数量是2的n次方,根据元素的hash值的高n位就可以确定元素到底在哪一个Segment中。

     key的hash值经过混淆函数计算后的高4位决定在哪个segment中。假设segment的长度为2^4=16,则segmentShift=32-4=28,segmentMask=16-1=15。(以此类推,如果segment的长度为2^5=32,则segmentShift=32-5=27,segmentMask=32-1=31)。(hash >>> segmentShift)表示32位的hash值右移28位,高位补0。从而得到的值为

(hash >>> segmentShift)=00...00xxxx(前面有28个0),segmentMask=00...001111,对于:(hash >>> segmentShift) & segmentMask,则是hash值的高4位

与掩码的低4位进行计算,从而决定在那个segment中。

在确定了需要在哪一个segment中进行操作以后,接下来的事情就是调用对应的Segment的get方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
V get(Object key, int hash) {
    if (count != 0) { // read-volatile
        HashEntry<K,V> e = getFirst(hash);// 获取链表的头结点
        while (e != null) {
            if (e.hash == hash && key.equals(e.key)) {
                V v = e.value;
                if (v != null)
                    return v;
                return readValueUnderLock(e); // recheck
            }
            e = e.next;
        }
    }
    return null;
}

   先看第二行代码,这里对count进行了一次判断,其中count表示Segment中元素的数量,我们可以来看一下count的定义:

1
transient volatile int count;

   可以看到count是volatile的,实际上这里里面利用了volatile的语义:对volatile字段的写入操作happens-before于每一个后续的同一个字段的读操作。

  因为实际上put、remove等操作也会更新count的值,所以当竞争发生的时候,volatile的语义可以保证写操作在读操作之前,也就保证了写操作对后续的读操作都是可见的,这样后面get的后续操作就可以拿到完整的元素内容。

  然后,在第三行,调用了getFirst()来取得链表的头部:

1
2
3
4
HashEntry<K,V> getFirst(int hash) {
    HashEntry<K,V>[] tab = table;
//tab.length=2^n,方便进行位移计算.hash值的低n位决定在那个链表数组中
    return tab[hash & (tab.length - 1)];//定位到链表数组的位置
}

   同样,这里也是用位操作来确定链表的头部,hash值和HashTable的长度减一做与操作,最后的结果就是hash值的低n位,其中n是HashTable的长度以2为底的结果。

  在确定了链表的头部以后,就可以对整个链表进行遍历,看第4行,取出key对应的value的值,如果拿出的value的值是null,则可能这个key,value对正在put的过程中,如果出现这种情况,那么就加锁来保证取出的value是完整的,如果不是null,则直接返回value。

ConcurrentHashMap的put操作

看完了get操作,再看下put操作,put操作的前面也是确定Segment的过程,这里不再赘述,直接看关键的segment的put方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
V put(K key, int hash, V value, boolean onlyIfAbsent) {
    lock();
    try {
        int c = count;
        if (c++ > threshold) // ensure capacity
            rehash();
        HashEntry<K,V>[] tab = table;
        int index = hash & (tab.length - 1);//定位数组链表的位置
        HashEntry<K,V> first = tab[index];
        HashEntry<K,V> e = first;
        while (e != null && (e.hash != hash || !key.equals(e.key)))
            e = e.next;
        V oldValue;
        if (e != null) {
            oldValue = e.value;
            if (!onlyIfAbsent)
                e.value = value;
        }
        else {
            oldValue = null;
            ++modCount;
            tab[index] = new HashEntry<K,V>(key, hash, first, value);
            count = c; // write-volatile
        }
        return oldValue;
    finally {
        unlock();
    }
}

   首先对Segment的put操作是加锁完成的,然后在第五行,如果Segment中元素的数量超过了阈值(由构造函数中的loadFactor算出)这需要进行对Segment扩容,并且要进行rehash,关于rehash的过程大家可以自己去了解,这里不详细讲了。

  第8和第9行的操作就是getFirst的过程,确定链表头部的位置。

  第11行这里的这个while循环是在链表中寻找和要put的元素相同key的元素,如果找到,就直接更新更新key的value,如果没有找到,则进入21行这里,生成一个新的HashEntry并且把它加到整个Segment的头部,然后再更新count的值。

ConcurrentHashMap的remove操作

Remove操作的前面一部分和前面的get和put操作一样,都是定位Segment的过程,然后再调用Segment的remove方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
V remove(Object key, int hash, Object value) {
    lock();
    try {
        int c = count - 1;
        HashEntry<K,V>[] tab = table;
        int index = hash & (tab.length - 1);
        HashEntry<K,V> first = tab[index];
        HashEntry<K,V> e = first;
        while (e != null && (e.hash != hash || !key.equals(e.key)))
            e = e.next;
        V oldValue = null;
        if (e != null) {
            V v = e.value;
            if (value == null || value.equals(v)) {
                oldValue = v;
                // All entries following removed node can stay
                // in list, but all preceding ones need to be
                // cloned.
                ++modCount;
                HashEntry<K,V> newFirst = e.next;
                for (HashEntry<K,V> p = first; p != e; p = p.next)
                    newFirst = new HashEntry<K,V>(p.key, p.hash,
                                                  newFirst, p.value);
                tab[index] = newFirst;
                count = c; // write-volatile
            }
        }
        return oldValue;
    finally {
        unlock();
    }
}

   首先remove操作也是确定需要删除的元素的位置,不过这里删除元素的方法不是简单地把待删除元素的前面的一个元素的next指向后面一个就完事了,我们之前已经说过HashEntry中的next是final的,一经赋值以后就不可修改,在定位到待删除元素的位置以后,程序就将待删除元素前面的那一些元素全部复制一遍,然后再一个一个重新接到链表上去,看一下下面这一幅图来了解这个过程:

ConcurrentHashMap的size操作

在前面的章节中,我们涉及到的操作都是在单个Segment中进行的,但是ConcurrentHashMap有一些操作是在多个Segment中进行,比如size操作,ConcurrentHashMap的size操作也采用了一种比较巧的方式,来尽量避免对所有的Segment都加锁。

  前面我们提到了一个Segment中的有一个modCount变量,代表的是对Segment中元素的数量造成影响的操作的次数,这个值只增不减,size操作就是遍历了两次Segment,每次记录Segment的modCount值,然后将两次的modCount进行比较,如果相同,则表示期间没有发生过写入操作,就将原先遍历的结果返回,如果不相同,则把这个过程再重复做一次,如果再不相同,则就需要将所有的Segment都锁住,然后一个一个遍历了,具体的实现大家可以看ConcurrentHashMap的源码,这里就不贴了。

引用:http://www.cnblogs.com/dolphin0520/p/3932905.html

问题一:为什么需要segment呢?如果没有segment,直接是键值对,在写的时候加锁直接只锁住那一个键值对,这样做有什么问题呢?

个人认为ConcurrentHashMap之所以没有采用像Hashmap一样的数组+链表,而是在其基础上增加了Segment数组,每个Segment下又相当于一个hash table的原因是:
1、ConcurrentHashMap的Segment数组从 new ConcurrentHashMap()后就不能改了,被称为concurrentLevel-并发级别,即并发级别是一定的,如果对HashMap的每个Entry元素都有一把锁,随着HashMap容量的不断扩大,并发性是提高了,可在进行一些需要统计整个Map的操作时就变得很慢了,如size(),因为在两次统计count和modCount后,如果sum值对不上,就需要依次对所有Entry上锁去统计count,Map容量越大,锁越多,一个size()操作可能很慢。
其实换一个形象的比喻,好比一个班50个学生,同一时间都干一件事很浪费,50个人每个干一件事,并发是高了,但老是想知道谁有空,可以帮他忙的时候,就费劲了,得一个个问,还得等他们闲的时候。可如果分成几个小组,那就既保证了并发,有比较好统计谁有空。
2、因为Map在resize()的过程中,会将Entry从old table迁移到new table,ConcurrentHashMap这个过程是在一个Segment内部做的,获取一把锁即可。
resize()过程中,原来下标是index的元素,可能会被放到index+oldCapacity的位置去,如果每个Entry都有一把锁,那么会出现,当前线程已经获取了index下标元素的锁后,需要将元素迁移到index+oldCapacity位置去,又要获取一把锁,这种已经有一把锁,又要获取一把锁的行为,很容易产生死锁。
所以,ConcurrentHashMap在兼顾并发性的同时,避免做size()等统计操作过慢 和 产生死锁等问题,就增加了Segment数组,即分段锁机制。



java8中对ConcurrentHashMap的改进:http://blog.youkuaiyun.com/wangxiaotongfan/article/details/52074160

ConcurrentHashMap是一个线程安全的HashMap,它的原理是通过使用一种叫做分段锁(Segment)的机制来实现并发安全。它将整个数据结构分成了多个段(Segment),每个段都是一个独立的HashMap,拥有自己的锁。这样,在并发操作时,不同的线程可以同时访问不同的段,从而提高了并发性能。每个段内部的操作仍然是非线程安全的,但由于不同的线程访问的段是不同的,所以并发操作不会产生竞争。这种设计方式既保证了线程安全,同时也提高了并发性能。 在ConcurrentHashMap中,每个段(Segment)实际上是一个类似于HashMap的数据结构,它包含了一个数组(Node[])来存储键值对。每个节点(Node)实际上也是一个链表的头节点,用来解决哈希冲突。每个节点包含了键、值以及下一个节点的引用。而且,ConcurrentHashMap中使用了volatile修饰的节点数组,保证了对数组的读写操作的可见性。 当进行插入、删除或者查找操作时,ConcurrentHashMap会根据键的哈希值来选择对应的段,并对该段进行加锁,保证同一段内的操作的原子性。这样,不同的线程可以同时对不同的段进行操作,提高了并发性能。同时,ConcurrentHashMap还使用了一种叫做CAS(Compare And Swap)的机制来保证对节点数组的原子性操作。通过这些机制的组合,ConcurrentHashMap实现了高效的并发安全操作。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* [ConcurrentHashMap原理详解(太细了)](https://blog.youkuaiyun.com/qq_42068856/article/details/126091526)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *3* [ConcurrentHashMap 原理](https://blog.youkuaiyun.com/liuwg1226/article/details/119548439)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值