concurrenthashmap 不是简单加 synchronized,因其采用分段锁(jdk 1.7)和 cas+细粒度 synchronized(jdk 1.8)实现高并发;get 无锁弱一致,put 按需加锁,size() 等非原子,不支持 null 键值。

ConcurrentHashMap 为什么不是“加个 synchronized 就完事”?
因为粗粒度锁会把并发变成排队——Hashtable 就是典型反面例子:整个表一把锁,写操作一来,所有读线程都得等。而 ConcurrentHashMap 的设计目标很实在:读不阻塞、写尽量不互斥、扩容不挂起全量请求。
它靠两套机制分阶段演进:
- JDK 1.7 用
Segment数组 + 分段锁:默认 16 段,哈希值高位决定进哪一段,同一段内才竞争锁;但Segment本身是ReentrantLock,仍有阻塞开销 - JDK 1.8 彻底去掉
Segment,改用CAS+synchronized控制单个桶(Node或TreeBin):锁只落在冲突的链表头或红黑树根节点上,粒度更细,且多数get()完全无锁
put() 和 get() 在 JDK 1.8 里到底发生了什么?
不是“先锁再查”,而是“先查再按需锁”,这是理解行为的关键。
-
get(key):全程无锁,直接根据 hash 定位到桶,遍历链表或红黑树;但注意——它看到的可能是“过期”数据(弱一致性),比如另一个线程刚put完还没刷新到主内存,这不是 bug,是性能换来的设计取舍 -
put(key, value):先无锁尝试CAS插入头节点;失败(说明有竞争)才对桶首节点加synchronized;若桶已转成红黑树,则锁住TreeBin的读写锁 - 扩容时:
put可能触发协助扩容(helpTransfer),多个线程一起搬数据,而不是等一个线程干完才继续
哪些方法看似安全,其实暗藏线程问题?
size()、isEmpty()、containsValue() 这几个最容易误用。
立即学习“Java免费学习笔记(深入)”;
-
size()不是原子快照:它要遍历所有CounterCell并累加,过程中其他线程可能正增删,返回值只是“估算值”;真要精确计数,得自己加锁或用LongAdder -
containsValue(value)必须遍历全部桶,期间值可能被改,结果不可靠;别拿它做业务判断依据 -
keySet().iterator()是弱一致迭代器:不抛ConcurrentModificationException,但可能漏掉新插入项,或重复看到刚删除项——适合监控、日志等容忍误差的场景
初始化参数怎么设才不踩坑?
别盲目抄默认值。关键就两个参数:initialCapacity 和 concurrencyLevel(JDK 1.7 有效,1.8 已忽略但构造函数还留着)。
- JDK 1.8 中
concurrencyLevel只用来估算初始table大小(向上取最近的 2 的幂),实际分段逻辑已消失;传 16 或 32 对性能几乎没影响 -
initialCapacity建议设为“预估最大元素数 ÷ 0.75”(即除以默认负载因子),避免频繁扩容;但别过大,浪费内存且首次扩容耗时明显 - 如果明确知道是读多写少(比如配置缓存),可考虑用
new ConcurrentHashMap(1024, 0.75f, 1)—— 第三个参数parallelismLevel在computeIfAbsent等并行方法中影响线程池拆分粒度,但日常put/get不走这条路
最常被忽略的一点:它不允许 null 键和 null 值,put(null, "a") 或 put("k", null) 会直接抛 NullPointerException,而且这个检查在锁外就做了,不是延迟报错。










