Java之ConcurrentHashMap详解

402 阅读4分钟

在Java并发编程中,ConcurrentHashMap以其出色的并发性能和数据一致性,被广泛使用。从Java 5引入之后,ConcurrentHashMap经历了多次重大的改进和优化。

本文将通过源码,带你了解Java 8及之前版本,ConcurrentHashMap实现原理及其变化。

1 为什么需要ConcurrentHashMap

HashMap是非线程安全的;Hashtable虽然线程安全,但效率低下。因此,我们需要线程安全且高效的Map实现。

1.1 非线程安全的HashMap

1.1.1 并发扩容导致死循环

JDK7及之前,哈希桶的链表采用头插法。在HashMap并发扩容时,可能使链表形成环形结构,造成死循环。 image.png

如下代码,在JDK7下执行时,导致CPU利用率飙升至100%。

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

public class HashMapTest {

  public static void main(String[] args) throws InterruptedException {
    final Map<String, String> map = new HashMap<>(2);
    Thread test = new Thread(new Runnable() {
      @Override
      public void run() {
        for (int i = 0; i < 10000; i++) {
          new Thread(new Runnable() {
            @Override
            public void run() {
              map.put(UUID.randomUUID().toString(), "");
            }
          }, "concurrent-put-" + i).start();
        }
      }
    }, "test");
    test.start();
    test.join();
  }
}

image.png image.png 注意:在JDK8中,HashMap的put、resize方法,改用尾插法,已能避免死循环。

1.1.2 并发put导致值丢失

对HashMap并发put操作时,多个线程同时修改一个哈希桶,可能会导致键值对覆盖丢失。 image.png

1.2 Hashtable效率低下

从JDK1.0就有的Hashtable类,几乎所有的方法都被synchronized修饰;即使get()方法也不例外,读与读都互斥,效率自然很低。 image.png

2 Java 7中ConcurrentHashMap实现

Java7中,ConcurrentHashMap通过分段锁(Segmentation Lock)机制,实现了高并发性能。

  • ConcurrentHashMap是由Segment数组组成; image.png
  • Segment是ReentrantLock的子类,结构和HashMap类似,即哈希表,它持有一个HashEntry数组; image.png
  • HashEntry用于存储键值对,使用链表结构解决哈希冲突; image.png
  • 当对HashEntry数组进行修改时,必须先获得与它对应的Segment锁。 image.png

2.1 初始化Segment数组

concurrencyLevel即并发度,用于初始化ConcurrentHashMap时声明segments数组长度。每个segment都是一把锁,segments数组长度也就是Map中分段锁的数量。 image.png

segments数组的长度是2的N次方,方便与hashCode按位与计算索引。 image.png

2.2 put操作

2.2.1 定位Segment

在插入元素时,必须先通过散列算法定位到某个Segment。ConcurrentHashMap通过对key的hash值做二次散列,来使key更均匀地分布在不同的Segment上。 image.png 假设所有key都散列到同一个Segment,所有并发更新都争抢同一把锁,分段锁就失去意义了。

2.2.2 获取分段锁

首先尝试一次非阻塞的tryLock,成功则继续;否则while循环tryLock,失败重试次数超过MAX_SCAN_RETRIES时,调lock()阻塞式获取锁。

tryLock多次重试的意义在于,尽量非阻塞的获取分段锁,减少线程上下文切换。在多核系统下,可重试tryLock 64次。 image.png image.png

put成功时,当segment的容量大于threshold时,就对该segment的哈希表进行扩容。 注意,不是对ConcurrentHashMap的segment数组扩容。

2.3 get操作

get操作的高效之处在于,整个过程不需要加锁。因为HashEntry的value被volatile修饰,能够在线程之间保持可见性。 image.png

2.4 总结

Java 7中,ConcurrentHashMap通过将哈希表划分为多个段,对每个段的访问使用不同的锁。从而大大减少了锁竞争,提高了并发性能。然而,也出现了一些问题:

  • 由于采用两层哈希表,内存占用较大,扩容操作复杂;
  • 当多个线程对同一个segment段做修改时,会导致锁竞争,从而降低了性能;
  • concurrencyLevel在ConcurrentHashMap初始化时一经设定,不可修改;设置不当时影响性能。
  • size操作可能锁住整个Map。 image.png

3 Java 8中ConcurrentHashMap实现

在Java 8中,ConcurrentHashMap摒弃了之前版本中的分段锁(Segmentation Lock)机制,采用CAS(Compare-and-Swap)操作结合synchronized同步块,实现更为高效和灵活的并发控制。

3.1 数据结构

Java 8中的ConcurrentHashMap底层数据结构由数组、链表和红黑树组成,与HashMap类似。 image.png

3.2 put操作

  • 如果是第一次put,则初始化哈希表;
  • 如果key散列到的哈希桶空着,即没有哈希冲突,则CAS插入entry;
  • 如果桶不为空,或者CAS失败,则只对当前桶加synchronized锁,将entry添加到链表尾部。 image.png

3.3 size实现

JDK8中,ConCurrentHashMap的size,通过 baseCountcounterCells 两个变量维护:

  • 在没有并发的情况下,使用一个volatile baseCount变量即可; image.png
  • 当有并发时,CAS 修改 baseCount 失败后,会使用 CounterCell 类,更新它的volatile value属性。CounterCell保持在数组中。 image.png image.png

size()调用sumCount()计数过程不加锁image.png 在获取size时,如果有并发插入或者删除操作,size()返回值可能已与实际数量不符。

3.4 总结

与Java7相比,Java8的ConcurrentHashMap实现,锁的粒度更小,且尽可能通过CAS实现更新,只在CAS失败时对某个哈希桶使用synchronized锁。因此具有更好的并发性能。