核心内容摘要
17c1起草免登录网页版:重新定义流畅体验,解锁无限可能
多线程下用 ConcurrentHashMap到底要不要加 volatile先搞懂两个关键角色ConcurrentHashMap 是做什么的volatile 又是做什么的分场景看到底要不要加 volatile场景一ConcurrentHashMap 引用不会改变不需要加 volatile场景二ConcurrentHashMap 引用会被重新赋值必须加 volatile结合实际业务再加深理解再延伸一个容易忽略的点多线程下用 ConcurrentHashMap到底要不要加 volatile这段时间在看并发相关的面试题碰到一个特别容易让人绕晕的问题多线程环境里使用 ConcurrentHashMap要不要把它声明成 volatile 才能保证线程安全单独拎出来 ConcurrentHashMap 和 volatile每个知识点我都能说上几句可把它们放在一起提问瞬间就有种熟悉又陌生的感觉琢磨了好一会儿才理清楚里面的逻辑今天就把自己的思考过程整理出来都是很实在的理解没有什么官方套话。
先把两个核心概念掰扯明白这是搞懂整个问题的基础后续的分析都要围绕这两个点展开。
先搞懂两个关键角色ConcurrentHashMap 是做什么的日常开发里ConcurrentHashMap 算是并发场景的常客面试里也总爱把它和 HashMap 放在一起对比。
大家都知道 HashMap 不支持多线程并发操作在多线程环境下会出现数据错乱的问题而 ConcurrentHashMap 就是 Java 提供的线程安全的哈希表实现。
但这里必须抓住一个核心点ConcurrentHashMap 的线程安全只局限在它自身方法内部的操作。
比如调用它的 put、get、remove 这些方法多个线程同时执行底层通过 CAS 加同步机制等方式能保证单个方法执行的原子性和数据一致性不会出现并发修改导致的异常。
但它管不了的是这个 ConcurrentHashMap 实例的引用在多线程之间的可见性问题。
volatile 又是做什么的volatile 也是并发编程里的高频关键字它的作用其实很明确主要解决两个问题一是保证变量的可见性一个线程修改了被 volatile 修饰的变量其他线程能立刻读取到最新的值不会出现线程本地缓存和主内存数据不一致的情况二是禁止指令重排序避免编译器和处理器对指令的执行顺序做优化导致多线程下出现意料之外的问题。
这里要划一个重点volatile 修饰的是变量也就是对象的引用而不是对象内部的数据。
想把 ConcurrentHashMap 和 volatile 关联起来前提是 ConcurrentHashMap 作为一个引用变量存在被修改的可能否则讨论 volatile 就没有任何意义。
分场景看到底要不要加 volatile这个问题根本没有绝对的“要”或“不要”必须结合实际的代码场景来判断两种情况的区别非常明显。
场景一ConcurrentHashMap 引用不会改变不需要加 volatile当我们在代码中初始化 ConcurrentHashMap 之后全程只调用它的内部方法操作数据从来不会重新给这个变量赋值让它指向新的实例这种情况下完全不需要加 volatile。
最典型的写法就是用final修饰直接锁定引用publicclassCacheService{// 用 final 保证引用不可变全程只会操作这一个 CHM 实例privatestaticfinalConcurrentHashMapString,ObjectconcurrentCachenewConcurrentHashMap();publicvoidputData(Stringkey,Objectvalue){// 仅调用 CHM 自身的方法内部已保证线程安全concurrentCache.put(key,value);}publicObjectgetData(Stringkey){returnconcurrentCache.get(key);}}在这段代码里concurrentCache的引用从初始化后就不会再改变所有线程操作的都是同一个 ConcurrentHashMap 实例。
此时线程安全完全由 ConcurrentHashMap 自身的方法保证volatile 在这里没有任何发挥的空间加上反而属于多余的代码。
场景二ConcurrentHashMap 引用会被重新赋值必须加 volatile如果业务逻辑中需要替换掉原来的 ConcurrentHashMap 实例把新的实例赋值给同一个变量这时候就必须使用 volatile 来保证引用的可见性。
比如常见的缓存全量更新场景代码大概是这样publicclassCacheService{// 引用可能被替换必须加 volatile 保证可见性privatevolatileConcurrentHashMapString,ObjectconcurrentCachenewConcurrentHashMap();/** * 全量更新缓存直接替换整个 CHM 实例 */publicvoidrefreshCache(){// 创建新的缓存实例加载全量数据ConcurrentHashMapString,ObjectnewCachenewConcurrentHashMap();// 模拟加载缓存数据的逻辑newCache.put(user:1,张
;newCache.put(user:2,李
;// 替换原有的缓存引用concurrentCachenewCache;}publicObjectgetData(Stringkey){returnconcurrentCache.get(key);}}在这个场景里concurrentCache这个引用变量会被重新赋值指向新的 ConcurrentHashMap 实例。
如果不加 volatile当一个线程执行了refreshCache方法替换了引用后其他线程可能还在读取旧的引用使用的是过时的缓存数据这就产生了线程安全问题。
而加上 volatile 之后就能保证引用修改的可见性所有线程都能立即获取到最新的实例引用再结合 ConcurrentHashMap 自身的方法安全整个流程才是完整的线程安全。
结合实际业务再加深理解平时做 Spring Web 开发的时候经常会把 ConcurrentHashMap 作为成员变量放在 Controller 里这里就很容易踩坑我们可以看一段实际的示例代码RestControllerpublicclassDataController{// 单例 Bean 下的 CHM 成员变量privateConcurrentHashMapString,StringdataMapnewConcurrentHashMap();GetMapping(/add)publicStringaddData(Stringkey,Stringvalue){dataMap.put(key,value);return添加成功;}GetMapping(/get)publicStringgetData(Stringkey){returndataMap.get(key);}/** * 新增的方法直接替换 CHM 引用 */GetMapping(/reset)publicStringresetData(){// 此处直接重新赋值修改了引用dataMapnewConcurrentHashMap();return缓存已重置;}}Spring 的 Controller 默认是单例作用域所有的请求都会共享同一个 DataController 实例也就共享同一个dataMap变量。
在只调用addData和getData方法时dataMap的引用没有改变依靠 ConcurrentHashMap 自身的安全性不会出现线程问题。
但新增了resetData方法后dataMap会被重新赋值指向新的实例此时没有 volatile 修饰就会出现部分线程读取到旧实例、部分读取到新实例的问题导致数据不一致。
解决这个问题的方式也很清晰给dataMap加上volatile关键字保证引用的可见性给dataMap加上final关键字禁止引用被重新赋值从根源上杜绝问题将 Controller 的作用域改为 prototype每次请求创建新实例让每个线程操作独立的 CHM但这种方式会增加内存开销需要结合业务权衡。
再延伸一个容易忽略的点这里还要补充一个很重要的误区就算我们用了线程安全的 ConcurrentHashMap也不代表所有场景下都绝对安全尤其是涉及到复合操作的时候。
举个简单的例子想要实现“如果 key 不存在就放入数据”的逻辑publicvoidputIfNotExist(Stringkey,Stringvalue){// 先查询再插入两步操作if(!concurrentCache.containsKey(key)){concurrentCache.put(key,value);}}ConcurrentHashMap 的containsKey和put方法都是线程安全的但这两个方法组合在一起就变成了非原子操作。
多线程环境下可能两个线程同时判断 key 不存在然后先后执行 put 方法导致后执行的线程覆盖了先执行的线程的数据。
这种情况ConcurrentHashMap 自身的线程安全解决不了需要我们额外处理比如使用 ConcurrentHashMap 提供的原子方法putIfAbsent或者通过加锁来保证复合操作的原子性。
这也印证了一个道理线程安全是一个全局的问题不能只依赖某一个组件的特性就觉得万事大吉所有的逻辑都要结合具体的使用场景去分析。
回到最开始的问题现在再看答案其实已经很清晰了。
ConcurrentHashMap 负责自身方法的线程安全volatile 负责引用变量的可见性两者的作用颗粒度完全不同。
只有当 ConcurrentHashMap 的引用存在被修改的场景时才需要使用 volatile否则完全没有必要添加。