拓冰建站拓冰建站
首页 / 资讯中心 / 正文

多线程下Java容器安全使用:从HashMap到JUC并发容器

多线程这块很多Java开发刚入门时最容易踩的坑不是语法问题而是容器放错了并发环境里用。明明代码看着逻辑完整一上生产就丢数据、报异常甚至直接把CPU跑满。我见过不少人排查半天最后发现是HashMap在并发put时出了问题。这篇博文就围绕JavaSE多线程下容器的安全使用展开拿常见的HashMap、ArrayList说起逐步拆到JUC包里的并发容器把原理、代码、选型思路一次讲清楚。无论是正在学JavaSE基础的同学还是做Java后端面试准备的朋友这篇文章都值得你花几分钟认真过一遍。内容很实在全是日常开发里真用得上的实践经验。1. 多线程下容器的安全问题到底长什么样要谈容器的安全使用得先明白不安全是什么样。Java里大部分集合类比如HashMap、ArrayList在设计时根本没有考虑过多线程同时写入的情况。单线程下它们性能好、功能全一旦多个线程同时往里写数据各种奇奇怪怪的问题就一个个冒出来了。1.1 线程不安全的本质共享可变状态线程安全问题并不是Java容器独有的它本质上是一个“共享可变状态”问题。单个线程操作自己的局部变量无论如何都不会有并发风险因为没有任何人跟你抢。真正的问题在于多个线程同时访问同一个容器实例并且至少有一个线程在修改它。你可以把容器想象成一张大家轮流填写的Excel表格。如果只有一个人填顺序随意、怎么改都不会乱但如果是十个人同时打开同一个表格往里填数字那你改我删、我加你覆盖最后表格内容根本没法看。多线程访问容器也是同一个道理HashMap的put操作在并发时会产生竞态条件多个线程同时判断“这个桶位是空的”然后同时写入后写的人直接覆盖前写的人的数据这就是数据丢失的根本原因。ArrayList还有一个更隐蔽的问题它在扩容时会创建一个更大的新数组把旧数组的元素复制过去。如果两个线程同时触发扩容一个线程可能已经完成了复制另一个线程基于旧的数组又重新复制了一次引用最终出现数组下标越界、元素位置错乱、甚至部分元素直接变成null。这些都不是小概率事件在并发量大、老年代空间不足导致频繁扩容时很容易复现。1.2 三种典型故障形态我在实际项目中总结过多线程环境下容器报错基本逃不出这三种形态第一种是数据丢失。这是最隐蔽的故障程序不报错、日志不输出但容器里的数据莫名其妙少了或者被覆盖了。比如多个线程同时往HashMap里put同一个key最终结果可能不是任何一个线程写入的值而是混血状态。这种问题最难排查因为你很难从外部看出是哪里写错了。第二种是并发修改异常。也就是你经常在报错里看到的ConcurrentModificationException。比如一个线程正在用迭代器遍历ArrayList另一个线程在中间调用了add或remove迭代器发现自己维护的修改计数跟实际值对不上就直接抛异常终止。这种情况比数据丢失好排查因为它至少会报错但如果你是在生产环境的高峰期第一次看到这个异常血压还是会瞬间拉满。第三种是扩容死循环。这个坑在JDK 7及更早版本中尤其出名。HashMap并发扩容时因为链表是头插法两个线程同时进行rehash可能导致链表形成环。一旦链表成环后续在该桶位上查找key时就会无限循环直接让CPU占用率飙到100%。JDK 8改用尾插法之后这个风险才基本消失但这不代表HashMap就线程安全了数据覆盖和并发修改问题依旧存在。了解这三种形态之后再去看各种线程安全容器的设计就容易理解它们为什么这样设计了。所有并发容器的目的本质上就是在保证数据一致性的前提下想办法把锁的粒度做得更细、让并发度更高。2. 传统同步容器能用但别用错Java早期提供的线程安全容器方案其实很朴素代表就是Hashtable和Vector。它们的设计思路一句话就能说清所有公开方法都加上synchronized关键字同一时刻只有一个线程能进入方法执行。整体逻辑没问题但实际用起来缺点也很明显。2.1 Hashtable与Vector为什么慢Hashtable和Vector的同步是通过在方法上加synchronized实现的你点开源码会发现几乎所有公开方法都是同步方法。这意味着任何线程执行put或get时都要先获取容器对象上的锁其他线程全部被挡在外面等着。容器的容量越大、方法调用越频繁锁竞争就越激烈。你可以把这个锁想象成整栋楼只有一部电梯无论楼上楼下哪个房间的人要下楼都得先跑到这部电梯前排成长队。请求量小的时候还好一旦并发量上来电梯门口就堆满了人等吞吐量直接被锁卡死。更麻烦的是这种全方法加锁的设计还会带来一个反直觉的问题组合操作反而更不安全。比如调用contains方法判断key是否存在再调用put方法写入数据这两个操作虽然是线程安全的但中间隔了一个没加锁的间隙。如果另一个线程在这个间隙里插进来写了相同的key你的判断就失效了。也就是说同步方法不等于同步逻辑这一点在后面实战部分会再次强调。2.2 Collections.synchronizedXXX的隐藏要求除了Hashtable和VectorJava还提供了一组装饰器工具类。Collections.synchronizedMap和Collections.synchronizedList可以对任意Map或List包装一层同步逻辑。使用方式很简单把普通容器传进去拿返回的包装容器来操作就行。不过这里有个很多人不知道的隐藏要求用迭代器遍历包装后的容器时必须手动加同步。看一下Collections源码就能发现它返回的包装类在迭代器相关方法上并没有加synchronized只是在底层把方法转发给原容器。你直接对包装容器进行foreach遍历实际上是用了裸的迭代器此时如果其他线程同时修改容器同样会触发ConcurrentModificationException。正确的遍历姿势是这样MapString, Integer syncMap Collections.synchronizedMap(new HashMap()); synchronized (syncMap) { for (Map.EntryString, Integer entry : syncMap.entrySet()) { // 处理 } }这其实是同步容器最容易被忽视的坑单个操作安全不代表遍历安全。很多人以为拿到synchronizedMap就万事大吉结果一跑就踩到异常原因就在这。2.3 为什么说“绝对安全”只是个错觉即便上面这些都做对了同步容器依然只能保证单个方法调用是原子性的并不能保证复合操作的原子性。像“如果不存在则插入”“先检查后更新”“读取后根据结果删除”这些业务里最常见的逻辑都需要在外部加锁才能保证整体的线程安全。我自己在一套旧系统里见过一段类似的代码伪代码大概是if (!vector.contains(obj)) { vector.add(obj); }看起来逻辑很严谨实际上并发高的时候contains判断和add操作之间完全可能被另一个线程插入同样的对象最终容器里出现重复元素。正确的做法要么是在外面再加一把锁要么换用支持原子方法的并发容器。这也是为什么JUC包里的并发容器越来越受青睐的根本原因它们解决的恰恰是同步容器解决不好的问题。3. JUC并发容器逐个拆解Java并发包java.util.concurrent里提供了很多面向并发场景设计的容器这一节我把实际开发中最常用的几个逐一拆开讲。3.1 ConcurrentHashMap并发度最高的Map方案ConcurrentHashMap是目前多线程环境下最推荐的Map实现。它的设计经历过大版本迭代JDK 8之后的实现思路和之前完全不同。JDK 7时代的ConcurrentHashMap采用分段锁设计把整个Map切成若干段每段有一把锁不同线程操作不同段时可以并行。这种方式把锁竞争分散了比Hashtable的全表锁好很多但在段内依然是全量锁而且根据初始容量计算段数后段数固定扩容时也只能在段内扩容。JDK 8之后的ConcurrentHashMap摒弃了分段锁改为CAS加synchronized的组合策略。核心逻辑可以这样理解put操作先通过哈希算法找到对应的桶位如果桶位为空直接用CAS操作把这个节点放进去整个过程不用加锁如果桶位不为空就对桶位的头节点加synchronized只锁住这一个桶其他桶位依然可以并发操作。这种设计最聪明的地方在于锁粒度的细化。对比一下Hashtable锁整张表JDK 7的分段锁锁半张表JDK 8的ConcurrentHashMap锁链表头并发粒度已经最小化。绝大多数put操作在没有哈希冲突时根本不需要锁整张Map的并发能力自然远超同步容器。除了put和getConcurrentHashMap还内置了一批原子复合方法这是它真正拉开与其他Map差距的地方ConcurrentHashMapString, Integer map new ConcurrentHashMap(); // 如果key不存在则放入返回之前的值 map.putIfAbsent(count, 1); // 如果key存在则按函数计算结果更新 map.compute(count, (k, v) - v null ? 1 : v 1); // 如果key不存在则用函数计算并放入避免重复计算 map.computeIfAbsent(key, k - expensiveQuery());这几个方法在多线程累加计数、缓存填充等场景非常实用一个方法调用就完成了完整的事务性操作省去了外部加锁的麻烦。3.2 CopyOnWrite容器读多写少的终极解药CopyOnWriteArrayList和CopyOnWriteArraySet是另一类思路完全不同的并发容器。它们的核心设计简单粗暴读操作不加锁写操作复制底层数组在新的数组副本上做修改然后原子地替换掉旧的数组引用。听起来很夸张但设计者赌的就是一个前提读操作远多于写操作。在这种场景下写操作带来的复制开销被摊薄到无数次读操作里平均成本极低而读操作因为永远读的是不可变数组完全不用锁。你遍历一个CopyOnWriteArrayList时哪怕其他线程正在往里面加元素你也只会看到你开始遍历时刻的快照内容不会抛ConcurrentModificationException。这个机制用一句话概括就是“空间换时间”。每次写操作都要复制一份全量数据如果集合里有一万个元素每加一个元素就要复制一整份数组内存开销和GC压力都会上升。所以这类容器绝不适用于写多读少的场景否则内存会被频繁复制打爆。实际场景里我最常用它来存储配置类数据、黑白名单、事件监听器列表这类低频修改、高频读取的内容。比如一个系统里配置了一套短信签名列表规则很少变但每次发短信前都要遍历匹配签名CopyOnWriteArrayList就特别合适。3.3 阻塞队列生产者消费者的基础设施BlockingQueue系列算是多线程编程里最实用的基础设施了。它解决的问题是生产者生产的物品如何存放到一个公共缓冲区消费者如何等待并取出这些物品。普通的List配合手写wait和notify也能实现但代码要自己控制锁和条件等待稍不留神就漏了唤醒或死锁。阻塞队列把这些能力全部内置好了。BlockingQueue的接口设计非常清晰四组方法对应四种不同的语义方法类型插入失败处理移除失败处理适用场景add/remove抛异常抛异常不推荐offer/poll返回false返回null非阻塞控制put/take阻塞等待阻塞等待标准生产者消费者offer(timeout)/poll(timeout)超时返回false超时返回null带超时控制我在实际开发里最常用的是ArrayBlockingQueue和LinkedBlockingQueue。ArrayBlockingQueue底层是数组需要指定容量有界队列适合做流量缓冲和削峰LinkedBlockingQueue底层是链表容量不指定就默认Integer.MAX_VALUE一般建议生产环境显式指定容量否则生产速度远超消费速度时内存会无限膨胀。以订单处理系统为例一个典型的生产者消费者模型可以这么写BlockingQueueOrder queue new ArrayBlockingQueue(1000); // 生产者 new Thread(() - { while (true) { Order order fetchOrder(); queue.put(order); // 队列满时阻塞等待 } }).start(); // 消费者 new Thread(() - { while (true) { Order order queue.take(); // 队列空时阻塞等待 processOrder(order); } }).start();这套模型的好处是天然削峰填谷。订单涌入高峰时生产者被阻塞到队列慢下来消费者则按自己的节奏处理不会因为瞬时流量打崩下游系统。做秒杀、消息转发、任务调度的时候这段代码是万能骨架。3.4 非阻塞队列与Set补充除了阻塞队列JUC包还有基于CAS实现的非阻塞队列ConcurrentLinkedQueue。它的特点是入队和出队操作都不加锁通过CAS方式更新队列指针并发度极高但因为没有阻塞机制队列为空时poll会直接返回null需要调用方自己去控制等待逻辑。选择阻塞队列还是非阻塞队列核心看两点一是是否需要对队列为空或满的情况做精细控制阻塞队列自带等待唤醒机制代码简洁二是并发量级别非阻塞队列在高并发下减少了线程挂起和唤醒的开销但调用方需要处理返回null的边界。另外提一下CopyOnWriteArraySet它底层就是包了一个CopyOnWriteArrayList通过遍历查找去重。元素较少时没问题元素多并且写入频繁时性能会明显下降。所以它的使用场景跟CopyOnWriteArrayList是一致的读多写少、元素量可控。4. 实战选型一个热门商品系统的容器设计原理讲再多不如一个实操案例来得直观。下面我用一个典型的电商场景来演示容器选型的完整思考过程。4.1 需求拆解假设要设计一个热门商品系统核心需求有三块高效读取商品信息 页面要频繁展示商品详情读取速度直接影响接口延迟记录每个商品被用户点击的次数 多个用户同时点击同一个商品计数要准确维护一个最近浏览商品列表 用户最近浏览过的商品要实时追加并展示这个需求看起来简单但放到多线程环境里就会暴露容器问题。商品点击计数是典型的写频繁场景多个线程同时更新同一个商品的点击次数用普通HashMap必丢数据最近浏览列表需要不断追加并遍历展示如果直接上ArrayList并发add时轻则丢元素重则数组越界。4.2 选型对比处理这类组合需求首先要做的是把每个子需求拆出去单独判断而不是试图用一个容器包打天下。我习惯列一张对比表来做决策容器类型并发读并发写遍历安全适用前提不适用场景HashMap不支持不安全不安全单线程多线程共享Hashtable支持支持但全锁需要外部锁老项目兼容高并发读写Collections.synchronizedMap支持支持但全锁需要外部锁低并发场景高并发ConcurrentHashMap支持细粒度锁弱一致大部分Map场景需要强一致快照CopyOnWriteArrayList无锁复制数组快照遍历读多写极少写密集对商品点击计数来说最适合的是ConcurrentHashMap加compute原子方法。对最近浏览列表来说如果用户浏览行为频繁触发写入不适合用CopyOnWriteArrayList用普通的LinkedList配合外部锁或者直接用ConcurrentLinkedDeque更合适。4.3 落地代码下面给出一套完整可运行的代码骨架public class HotProductService { // 商品基本信息缓存读多写少使用ConcurrentHashMap private final ConcurrentHashMapString, ProductInfo productCache new ConcurrentHashMap(); // 商品点击计数高频写使用ConcurrentHashMap compute private final ConcurrentHashMapString, AtomicInteger clickCount new ConcurrentHashMap(); // 最近浏览记录追加写 遍历读使用ConcurrentLinkedDeque private final ConcurrentLinkedDequeString recentViewQueue new ConcurrentLinkedDeque(); // 浏览商品时触发的逻辑 public void viewProduct(String productId) { // 1. 记录点击次数采用compute保证原子性 clickCount.compute(productId, (k, count) - count null ? new AtomicInteger(1) : new AtomicInteger(count.get() 1)); // 2. 追加到最近浏览列表 recentViewQueue.removeFirstOccurrence(productId); recentViewQueue.addFirst(productId); // 3. 最多保留最近100条防止内存膨胀 if (recentViewQueue.size() 100) { recentViewQueue.pollLast(); } } // 获取点击量 public int getClickCount(String productId) { AtomicInteger count clickCount.get(productId); return count null ? 0 : count.get(); } // 获取最近浏览列表 public ListString getRecentViewList() { return new ArrayList(recentViewQueue); } }这段代码里最值得注意的地方有两个。一是用ConcurrentHashMap加compute实现点击计数没有用put和get组合彻底避免了竞态条件二是最近浏览列表用ConcurrentLinkedDeque结合removeFirstOccurrence和addFirst两个操作虽然不能做到复合操作原子但在浏览记录这个场景里偶发重复是可以接受的不需要额外加锁做强一致。像这种精度要求不高、允许轻微偏差的场景用非阻塞容器换取性能是非常值的选择。5. 那些年踩过的并发容器坑纸上谈兵没意思下面这些坑我都是实际在生产环境踩过或者帮别人排查过的整理成一个速查清单希望对你有用。5.1 复合操作不是“安全容器”能解决的很多同学换了ConcurrentHashMap之后就放松警惕了觉得put和get都线程安全了逻辑肯定没问题。实际上ConcurrentHashMap只能保证单个方法原子性像“先contains再put”“先get再根据结果更新”这种复合操作依然存在竞态窗口。举个典型的例子// 这段代码即使map是ConcurrentHashMap也不安全 if (!map.containsKey(key)) { map.put(key, loadFromDb()); }两个线程可能同时判断key不存在然后同时执行loadFromDb并写入导致数据库被查两次甚至后写入的数据覆盖了前一个线程精心计算的结果。正解是直接用原子方法map.computeIfAbsent(key, k - loadFromDb());computeIfAbsent的内部逻辑保证了同一个key的并发请求只有一个会执行加载函数其余线程直接等待结果。这个细节是面试高频考点也是实际开发中最实用的优化点之一。5.2 弱一致性迭代器会“骗”你JUC并发容器大多采用弱一致性迭代器意思是迭代器遍历时不会保证看到所有并发修改可能会漏掉刚写入的数据也可能会看到已经被删除的数据。比如ConcurrentHashMap的迭代器在遍历过程中另一个线程往Map里新增了一个key迭代器可能遍历不到这个新的key。这个特性在绝大多数Web应用场景是能接受的因为业务并不会依赖一次遍历看到全量最新数据。但如果你在做一个需要强一致快照的统计任务例如生成报表时必须基于某个时间点的全量数据弱一致性迭代器就会导致结果不准确这时候需要显式复制一份到普通容器再遍历MapString, Integer snapshot new HashMap(concurrentMap); for (Map.EntryString, Integer entry : snapshot.entrySet()) { // 基于快照做统计 }5.3 容器嵌套可能导致死锁如果一个业务逻辑里同时持有多个锁而且不同线程按不同顺序获取锁就可能发生死锁。容器场景中容易出问题的地方是你在一个同步代码块里调用了另一个容器的同步方法两个线程分别持有对方需要的锁互相等待就卡死了。举一个最简单的死锁示例// 线程A synchronized (mapA) { synchronized (mapB) { // 业务逻辑 } } // 线程B synchronized (mapB) { synchronized (mapA) { // 业务逻辑 } }两个线程同时执行时A持有mapA的锁等mapBB持有mapB的锁等mapA谁也等不到谁。规避方式一是保证所有线程按相同顺序加锁二是尽量减少同步块嵌套三是用tryLock配合超时检测。实际开发里我一般会规定一个操作只允许裸持有一把锁涉及多容器协作时优先考虑用ConcurrentHashMap的复合原子方法替代。5.4 ThreadLocal的内存泄漏风险聊容器安全ThreadLocal是绕不开的因为它本质上是一种“线程私有容器”。它给每个线程提供一份独立变量副本不存在跨线程并发问题看起来完美无瑕但用不好就会出内存泄漏。原因是这样的ThreadLocalMap的Entry继承自WeakReferenceKey是弱引用Value是强引用。当ThreadLocal对象被外部置为null但线程还在运行且ThreadLocalMap中残留着它的Entry时Key被GC回收变成nullValue却一直被强引用无法回收。在线程池场景下线程长时间存活这些残留的Entry越积越多最终抛出OutOfMemoryError。避免方法极其简单粗暴用完就removetry { ThreadLocalSession sessionHolder new ThreadLocal(); sessionHolder.set(openSession()); // 业务逻辑 } finally { sessionHolder.remove(); }很多人知道ThreadLocal要remove但不知道为什么。其实记住线程池里的线程会复用这一点就够了线程不销毁ThreadLocalMap的Value就永远被引用链握着。在finally里remove容器的清理工作就会真正执行风险也就消失了。5.5 面试题里的“隐藏考察点”如果你正在准备Java面试多线程容器这块几乎是必考。面试官通常不走寻常路会从这些角度考察ConcurrentHashMap为什么不允许null键和null值 因为并发环境下无法区分“key不存在”和“key对应值为null”这两种状态避免歧义CopyOnWriteArrayList的写入为什么这么贵 因为每次写入都要复制整个底层数组时间复杂度O(n)Hashtable和ConcurrentHashMap锁粒度有什么区别 全表锁和桶级锁/无锁的对比为什么HashMap在JDK 8的并发扩容不再死循环了 因为尾插法避免了环形链表但数据覆盖依旧存在针对这些点最好能自己动手写几个多线程并发测试程序亲自观察丢数据、报异常、性能差异。只有亲手触发过这些问题才会真正记住。最后分享一点我的个人体会并发容器这个东西刚开始学的时候总觉得是个知识清单背下来各种容器的区别就算会了。实际用多了才发现真正的功夫在于理解每个容器背后的取舍。同步容器用锁换安全CopyOnWrite用空间换读性能ConcurrentHashMap用细粒度锁加CAS换并发度每种设计的代价都写在明面上没有万能的银弹。我建议你在动手写代码之前先拿纸笔把业务场景里的读写比例、数据规模、一致性要求写出来再对照着选容器这样基本不会选错。最近在做代码审查的时候我还会额外提醒团队谨慎使用裸HashMap和裸ArrayList作为共享变量JavaSE提供了这么多并发容器多花三十秒选一个合适的比上线后熬夜排查值得多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门