OpenJDK: ConcurrentHashMap
现在已经没有 Segment 概念,并发系数不生效。能支持高效的并发:
- 支持懒初始化,第一次写的时候发生,可能需要自旋但不需要加锁。
- 读的时候可能需要自旋,不需要加锁。
- 写的时候如果遇到正在扩容,则加入一起扩容;如果写时槽位为空,则只需要原子操作;写时操作非空,且不处在特殊状态,则需要加对象锁。因而读写互不阻塞、不同槽位的写不会阻塞、扩容不会阻塞(因为扩容时其他竞争线程也会被分配任务)、仅有单个槽位的多写需要阻塞。
为了节省空间,在 Node[]
中用 hash 为负的头结点来表示该拉链处于特殊状态:树结点、转移中、预留等。
转移的时候会以槽为单位进行,如果看到槽位正在转移中,则当前线程不会去抢夺工作,除非两个线程刚好看到了同一个槽位需要转移,这时会加对象锁处理。在遍历时会按照 (hash_of_key:
ph & capacity_of_old_table:
n) 计算出给定位,以此来分组(理想的情况会对半分),然后用头插法生成两条新链表转移到 nextTable。
💡 这当中有个优化过程:计算 lastRun,称链表尾部一连串对应 bit 相同的结点的起始位置为 lastRun,这系列结点将会被转移到 nextTable 的相同位置,因此会直接被转移,而不会对每个结点重新用头插法插入。头插法不仅需要遍历这一系列结点,还要对每个结点申请空间。
如果线程 A 正在转移 table
到 nextTable
,线程 B 读写链上的结点都会直接反映在被转移到的 nextTable
中。但如果线程 B 新创建了一个链(table[i]
从 null
变成有效),线程 A 如何看到?转移是从大下标开始,向小下标转移。转移后会在上面 CAS 放置 ForwardingNode
,这样后来者可以直接写到其中,前来者写的结果会被转移。
添加或者删除的时候怎么办?
为什么不允许键值为空?
- 键值为空实在是无意义。在
putVal
方法中还检查了结点的键值,即便是普通结点也确保 K/V 不为空才进行操作,即存在完善的空检查,所以从效率或实现角度解释不成立。 - get 方法无法通过返回值
null
区分是不存在元素还是值为null
,并发环境下containsKey
返回的结果在下一次写入时可能就不成立了,因而从 API 设计上 value 最好不是null
。