深入剖析Java并发编程利器:ConcurrentHashMap

引言:为什么需要ConcurrentHashMap?

在电商平台的秒杀系统中,某次促销活动遭遇了库存数据不一致的严重事故。技术团队排查发现,问题根源在于使用普通HashMap处理库存扣减时,多个线程同时修改数据导致脏读。这个真实案例揭示了并发环境下数据结构选择的重要性——ConcurrentHashMap正是为解决这类问题而生的并发容器。

一、线程安全Map的演进之路

1.1 HashMap的线程安全问题

// 线程不安全的HashMap使用示例
Map<String, Integer> map = new HashMap<>();

// 线程1
new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        map.put("key", map.getOrDefault("key", 0) + 1);
    }
}).start();

// 线程2
new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        map.put("key", map.getOrDefault("key", 0) + 1);
    }
}).start();

// 最终结果大概率不是2000

问题分析:当多个线程同时执行put操作时,可能导致:

  1. 哈希碰撞引发链表成环

  2. 数据覆盖导致更新丢失

  3. size计数不准确

1.2 传统解决方案的局限性

  • Hashtable:全表锁导致性能瓶颈

  • Collections.synchronizedMap:与Hashtable类似,方法级同步锁

  • 读写锁ReentrantReadWriteLock:写锁会阻塞所有读操作

1.3 ConcurrentHashMap的诞生

JDK1.5引入的ConcurrentHashMap采用分段锁设计,将数据划分为多个Segment(默认为16个),每个Segment独立加锁,实现真正的并发读写。

二、ConcurrentHashMap核心实现原理

2.1 Java 7的分段锁设计

如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。

Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:

// Segment内部类核心结构
static final class Segment<K,V> extends ReentrantLock implements Serializable {

    private static final long serialVersionUID = 2249069246763182397L;

    // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
    /**
    * HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。

    * volatile的特性:
    * 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
    * 禁止进行指令重排序。(实现有序性)
    * volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。
    */
    transient volatile HashEntry<K,V>[] table;

    transient int count;
        // 快速失败(fail—fast)
    transient int modCount;
        // 大小
    transient int threshold;
        // 负载因子
    final float loadFactor;

}

设计特点

  • 每个Segment相当于一个独立的HashMap

  • put操作只需锁定当前Segment

  • get操作完全无锁(volatile保证可见性)

原理上来说,ConcurrentHashMap 采用了分段锁技术(类似于行锁和表锁),其中 Segment 继承于 ReentrantLock。

不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。

每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。

1.7虽然可以支持每个Segment并发访问,但是还是存在一些问题,因为基本上还是数组加链表的方式,我们去查询的时候,还得遍历链表,会导致效率很低,这个跟jdk1.7的HashMap是存在的一样问题,所以他在jdk1.8完全优化了。

2.2 Java 8的重大革新

JDK1.8之后抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。

  1. Node+CAS+synchronized:替代Segment分段锁

  2. 红黑树优化:链表长度>8时转为红黑树

  3. 扩容优化:多线程协同扩容

2.3 并发控制三剑客

  1. CAS(Compare And Swap)

CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。

CAS 操作的流程如下图所示,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。

是一种乐观策略,认为并发操作并不总会发生。

// 典型CAS操作示例
public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

就比如我现在要修改数据库的一条数据,修改之前我先拿到他原来的值,然后在SQL里面还会加个判断,原来的值和我手上拿到的他的原来的值是否一样,一样我们就可以去修改了,不一样就证明被别的线程修改了你就return错误就好了。

SQL代码大概如下:

update a set value = newValue where value = #{oldValue}
//oldValue就是我们执行前查询出来的值 

 CAS中最常见的问题就是ABA问题:

就是说来了一个线程把值改回了B,又来了一个线程把值又改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被人改过,其实很多场景如果只追求最后结果正确,这是没关系的。

但是实际过程中还是需要记录修改过程的,比如资金修改什么的,你每次修改的都应该有记录,方便回溯。

ABA问题的解决:

用版本号去保证就好了,就比如说,我在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。

update a set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision} 
// 判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,但是版本号100%不一样

其实有很多方式,比如时间戳也可以,查询的时候把时间戳一起查出来,对的上才修改并且更新值的时候一起修改更新时间,这样也能保证,方法很多但是跟版本号都是异曲同工之妙,看场景大家想怎么设计吧

CAS性能很高,synchronized性能不好,为啥jdk1.8升级之后反而多了synchronized? 

synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。

针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。

所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的。

  1. volatile变量

transient volatile Node<K,V>[] table; // 保证数组引用的可见性
  1. synchronized同步块

// JDK8的putVal方法核心片段
synchronized (f) {
    if (tabAt(tab, i) == f) {
        // 处理链表或红黑树插入
    }
}

三、关键源码深度解析

3.1 初始化过程

public ConcurrentHashMap(int initialCapacity) {
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
               MAXIMUM_CAPACITY :
               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
    // 计算实际容量,保证是2的幂次方
    this.sizeCtl = cap; // 控制初始化的状态标记
}

初始化策略

  • 延迟初始化:首次put时创建table

  • sizeCtl字段的多重作用(初始化大小、扩容阈值、状态标志)

3.2 put操作流程

  1. 根据 key 计算出 hashcode 。
  2. 判断是否需要进行初始化。
  3. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  5. 如果都不满足,则利用 synchronized 锁写入数据。
  6. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
/*
     * 当添加一对键值对的时候,首先会去判断保存这些键值对的数组是不是初始化了,
     * 如果没有的话就初始化数组
     *  然后通过计算hash值来确定放在数组的哪个位置
     * 如果这个位置为空则直接添加,如果不为空的话,则取出这个节点来
     * 如果取出来的节点的hash值是MOVED(-1)的话,则表示当前正在对这个数组进行扩容,复制到新的数组,则当前线程也去帮助复制
     * 最后一种情况就是,如果这个节点,不为空,也不在扩容,则通过synchronized来加锁,进行添加操作
     *    然后判断当前取出的节点位置存放的是链表还是树
     *    如果是链表的话,则遍历整个链表,直到取出来的节点的key来个要放的key进行比较,如果key相等,并且key的hash值也相等的话,
     *          则说明是同一个key,则覆盖掉value,否则的话则添加到链表的末尾
     *    如果是树的话,则调用putTreeVal方法把这个元素添加到树中去
     *  最后在添加完成之后,会判断在该节点处共有多少个节点(注意是添加前的个数),如果达到8个以上了的话,
     *  则调用treeifyBin方法来尝试将处的链表转为树,或者扩容数组
     */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();//K,V都不能为空,否则的话跑出异常
        int hash = spread(key.hashCode());    //取得key的hash值
        int binCount = 0;    //用来计算在这个节点总共有多少个元素,用来控制扩容或者转移为树
        for (Node<K,V>[] tab = table;;) {    //
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)    
                tab = initTable();    //第一次put的时候table没有初始化,则初始化table
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {    //通过哈希计算出一个表中的位置因为n是数组的长度,所以(n-1)&hash肯定不会出现数组越界
                if (casTabAt(tab, i, null,        //如果这个位置没有元素的话,则通过cas的方式尝试添加,注意这个时候是没有加锁的
                             new Node<K,V>(hash, key, value, null)))        //创建一个Node添加到数组中区,null表示的是下一个节点为空
                    break;                   // no lock when adding to empty bin
            }
            /*
             * 如果检测到某个节点的hash值是MOVED,则表示正在进行数组扩张的数据复制阶段,
             * 则当前线程也会参与去复制,通过允许多线程复制的功能,一次来减少数组的复制所带来的性能损失
             */
            else if ((fh = f.hash) == MOVED)    
                tab = helpTransfer(tab, f);
            else {
                /*
                 * 如果在这个位置有元素的话,就采用synchronized的方式加锁,
                 *     如果是链表的话(hash大于0),就对这个链表的所有元素进行遍历,
                 *         如果找到了key和key的hash值都一样的节点,则把它的值替换到
                 *         如果没找到的话,则添加在链表的最后面
                 *  否则,是树的话,则调用putTreeVal方法添加到树中去
                 *  
                 *  在添加完之后,会对该节点上关联的的数目进行判断,
                 *  如果在8个以上的话,则会调用treeifyBin方法,来尝试转化为树,或者是扩容
                 */
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {        //再次取出要存储的位置的元素,跟前面取出来的比较
                        if (fh >= 0) {                //取出来的元素的hash值大于0,当转换为树之后,hash值为-2
                            binCount = 1;            
                            for (Node<K,V> e = f;; ++binCount) {    //遍历这个链表
                                K ek;
                                if (e.hash == hash &&        //要存的元素的hash,key跟要存储的位置的节点的相同的时候,替换掉该节点的value即可
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)        //当使用putIfAbsent的时候,只有在这个key没有设置值得时候才设置
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {    //如果不是同样的hash,同样的key的时候,则判断该节点的下一个节点是否为空,
                                    pred.next = new Node<K,V>(hash, key,        //为空的话把这个要加入的节点设置为当前节点的下一个节点
                                                              value, null);
                                    break; 
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {    //表示已经转化成红黑树类型了
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,    //调用putTreeVal方法,将该元素添加到树中去
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)    //当在同一个节点的数目达到8个的时候,则扩张数组或将给节点的数据转为tree
                        treeifyBin(tab, i);    
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);    //计数
        return null;
    }

里面的关键点就是两个点,首次是用自旋锁去插入数据,失败后才采用了重量级锁synchronized

3.3 get流程

get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。

由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁

/*
     * 相比put方法,get就很单纯了,支持并发操作,
     * 当key为null的时候回抛出NullPointerException的异常
     * get操作通过首先计算key的hash值来确定该元素放在数组的哪个位置
     * 然后遍历该位置的所有节点
     * 如果不存在的话返回null
     */
    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

3.4 ConcurrentHashMap几个重要方法

在ConcurrentHashMap中使用了unSafe方法,通过直接操作内存的方式来保证并发处理的安全性,使用的是硬件的安全机制。

/*
     * 用来返回节点数组的指定位置的节点的原子操作
     */
    @SuppressWarnings("unchecked")
    static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
        return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    }

    /*
     * cas原子操作,在指定位置设定值
     */
    static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }
    /*
     * 原子操作,在指定位置设定值
     */
    static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
        U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
    }

3.5 扩容机制(transfer)

扩容触发条件

  • 元素数量超过sizeCtl阈值

  • 链表长度超过TREEIFY_THRESHOLD(默认8)

  • 调用putAll等批量操作

并行扩容策略

  1. 每个线程处理固定步长的桶位

  2. 使用ForwardingNode标记迁移完成

  3. 迁移时保持旧table可用

四、性能优化与最佳实践

4.1 参数调优指南

// 创建调优后的ConcurrentHashMap
Map<String, Object> configMap = new ConcurrentHashMap<>(
    64,      // 初始容量
    0.75f,   // 负载因子
    16       // 并发级别(JDK8已弃用,但影响初始大小)
);

调优建议

  • 预估最大容量避免频繁扩容

  • 根据并发线程数设置合理并发级别

  • 监控节点分布是否均匀

4.2 使用场景分析

适用场景

  • 高频读写的缓存系统(如商品库存)

  • 实时统计系统(如UV计数器)

  • 分布式计算的本地聚合

不适用场景

  • 需要强一致性的金融交易系统

  • 需要范围查询的有序集合

  • 元素数量极少的简单映射

4.3 常见陷阱与规避

问题案例

// 错误的使用方式:非原子性操作
if (!map.containsKey(key)) {
    map.put(key, value); // 存在竞态条件
}

// 正确方式:使用putIfAbsent
map.putIfAbsent(key, value);

其他注意事项

  1. 不要依赖size()方法做精确统计

  2. 迭代器的弱一致性特征

  3. 自定义对象必须正确实现hashCode和equals

五、实战案例:构建高并发缓存系统

5.1 需求分析

某社交平台需要实现:

  • 支持每秒10万+的查询请求

  • 数据变更实时生效

  • 缓存自动过期和刷新

5.2 架构设计

public class ConcurrentCache<K,V> {
    private final ConcurrentHashMap<K, CacheEntry<V>> map;
    private final ScheduledExecutorService cleaner;
    
    // 缓存条目(带过期时间)
    static class CacheEntry<V> {
        V value;
        long expireTime;
        
        boolean isExpired() {
            return System.currentTimeMillis() > expireTime;
        }
    }
    
    public V get(K key) {
        CacheEntry<V> entry = map.get(key);
        if (entry != null && !entry.isExpired()) {
            return entry.value;
        }
        map.remove(key);
        return null;
    }
    
    public void put(K key, V value, long ttl) {
        CacheEntry<V> entry = new CacheEntry<>();
        entry.value = value;
        entry.expireTime = System.currentTimeMillis() + ttl;
        map.put(key, entry);
    }
    
    // 定期清理线程
    private void startCleaner() {
        cleaner.scheduleAtFixedRate(() -> {
            map.forEach((k, v) -> {
                if (v.isExpired()) {
                    map.remove(k);
                }
            });
        }, 1, 1, TimeUnit.MINUTES);
    }
}

5.3 性能测试对比

方案QPS(读)QPS(写)内存占用
HashMap+同步锁12,0008,500
ConcurrentHashMap98,00076,000中等
Redis集群150,000+120,000+

结论:ConcurrentHashMap在单机场景下表现出优异的读写性能,适合作为本地缓存方案。

六、未来演进与替代方案

6.1 Java内存模型的影响

随着JEP 188引入新的内存访问模式,ConcurrentHashMap可能采用:

  • VarHandle替代Unsafe

  • 更细粒度的内存屏障

  • 与虚拟线程的深度整合

6.2 替代方案对比

方案优点缺点
ConcurrentHashMap内置实现、高性能单机限制
Redis分布式、持久化网络开销
Caffeine更丰富的缓存策略第三方依赖
Ehcache企业级特性配置复杂

结语

ConcurrentHashMap作为Java并发容器的集大成者,其设计哲学体现了以下编程智慧:

  1. 空间换时间:通过分段/分桶降低锁竞争

  2. 乐观锁优先:CAS操作减少同步开销

  3. 渐进式优化:动态适应不同并发场景

在分布式架构盛行的今天,ConcurrentHashMap仍然是单机高并发场景的首选解决方案。理解其实现原理不仅能帮助开发者编写更高效的代码,更能培养并发编程的底层思维。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值