深入理解Java高并发编程(8) - 线程安全集合类

1. 概述

  • 遗留的安全集合
    • Hashtable 是HashMap的线程安全实现,Vector 是List的线程安全实现,这两者因为出现较早,方法通过synchronized修饰,并发性低,不推荐
  • 修饰的安全集合
    • 通过collections装饰的线程安全集合
    • 本质是装饰器模式,通过给未加线程安全的集合类作为成员变量,调用成员方法时再加上synchronized
  • JUC安全集合
    • Blocking类
      • 类似阻塞队列, 内部基于锁,通常使用reentrantlock,提供阻塞的方法
    • CopyOnWrite类
      • 修改开销相对较重
    • Concurrent类
      • 内部有很多操作CAS优化,一般可以提供吞吐量 推荐
      • 弱一致性问题
        • 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍 历,这时内容是旧的(fail-safe机制)
        • 大小弱一致性,size 操作未必是 100% 准确
        • 读取弱一致性
graph TD %% 第一组:遗留的安全集合 A["遗留的安全集合"] --> B["Hashtable"] A --> C["Vector"] %% 第二组:修饰的安全集合 D["修饰的安全集合"] -->|使用Collections的方法修饰| E["SynchronizedMap"] D -->|使用Collections的方法修饰| F["SynchronizedList"] %% 第三组:J.U.C 安全集合 G["J.U.C 安全集合"] --> H["Blocking类"] G --> I["CopyOnWrite类"] G --> J["Concurrent类"] %% 样式(可选,贴近原图浅紫色背景) classDef box fill:#f0f0ff,stroke:#b0b0e0,stroke-width:1px; class A,B,C,D,E,F,G,H,I,J box;

2. JDK 8 ConcurrentHashMap(死链问题)

ConcurrentHashMap除了有HashMap的基本方法,且这些方法都是原子的。

ConcurrentHashMap还提供了自己的方法,保证一系列的操作是原子的

  • computeIfAbsent():如果缺少一个key,则生成一个value,并将key放在value中。
    • 也用了synchronized锁,但是粒度更细,只加在了一个链表上

HashMap死链

java7 之前HashMap的结构是数组+链表的方式

  • 当元素放如Hashmap中,先计算哈希码,计算桶下标,定位是哪一个数组下标,并放入元素,当同一个桶下标放了对个元素时,就采用链表将他们连接起来,java7之前会加新加入链表的元素采取头插法的策略,往链表头部插入元素(死链产生原因)
  • 当数组元素超过阈值时,会触发数组扩容,重新计算桶下标,放入原本的元素,在多线程情况下这就容易发生死链问题
    • 死链问题是当两个/多个线程同时触发数组扩容时由于其他线程扩容链表后通过头插法改变了链表的顺序,且使用了非线程安全的HashMap,另一个线程由于扩容的慢,再头插的时候就容易产生环形链表,导致无限循环无限头插,直接OOM。
    • 死链问题只存在jdk7,源于多线程下头插法出的问题。

HashMap在java8:

仍然采用 数组 + 链表/红黑树,当链表超过某个个数时候,链表转化为红黑树(当链表过长,查找效率过低,链表就会转为红黑树,但是转红黑树之前会先尝试扩容,当大小超过某个值,再决定使用红黑树),不同于Jdk7,这里链表插入节点采用尾插法,保持链表顺序。

ConcurrentHashMap:

重要属性和内部类

  • sizeCtl:下一次扩容的阈值大小
  • Node内部类,每个元素
  • Node[],整个ConcurrentHashMap就是一个Node[]
  • ForwardingNode:一个数组下标完成transfer会在原先数组加入一个ForwardingNode,表示该桶下标已经完成扩容,告诉其他线程这个桶里面元素已经转移,需要去新的table下操作元素,put方法下是帮忙扩容。
  • TreeBin:红黑树的根节点。
  • TreeNode:红黑树中节点。

构造函数:

  • initialCapacity:初始容量不一定会等于设置的初始容量
  • loaderFactor:扩容用的,由于扩容后还是要保证2的N次方,这样方便用哈希算法。
  • ConcurrencyLevel:并发度,初始容量小于并发度会被赋值为并发度的值

常见方法流程:

  • get流程:首先定位桶下标,判断头节点是否为目标节点,如果不是看hash码是否小于0(treebin的hash为负数),再调用find方法查找,如果都不满足,最后再遍历整个链表。整个get流程中不加锁,效率高。
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // spread 方法能确保返回结果是正数
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 如果头结点已经是要查找的 key
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // hash 为负数表示该 bin 在扩容中或是 treebin,这时调用 find 方法来查找
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        // 正常遍历链表,用 equals 比较
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

3. JDK 7 ConcurrentHashMap

JDK7 中的 ConcurrentHashMap维护了一个Segments数组,数组中每一个元素是一把锁,一个Segment(继承自reentrantlock)对应一个HashEntry数组(也就是实际的哈希表,数组 + 链表)

相比JDK 8,segment默认大小为16且初始化指定后不能容量不可变,且不是懒惰初始化的。
image-20260312232215038

4. BlockingQueue

线程池中也会维护一个BlockingQueue

  • LinkedBlockingQueued

    • 内部类:Node(阻塞队列中元素)
      • item
      • next 通常有三种指向情况
        • 真正的后继节点
        • 自己:出队列时会指向自己,帮助gc
        • null
    • 用了两把锁分别锁住了head和last,允许两个线程(生产者和消费者线程)同时执行
      • 当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是 head 节点的线程安全。两把锁保证了入队和出队没有竞争
      • 当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争
      • 当节点总数等于 1 时(就一个 dummy 节点)这时 take 线程会被 notEmpty 条件阻塞,有竞争,会阻塞
  • ArrayBlockingQueue

    • Linked 支持有界,Array 强制有界
    • Linked 实现是链表,Array 实现是数组
    • Linked 是懒惰的,而 Array 需要提前初始化 Node 数组
    • Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的
    • Linked 两把锁,Array 一把锁
  • ConcurrentLinkedQueue

    • 和LinkedBlockingQueue很像,也有两把锁,dummy节点,区别在于锁用的是CAS锁。

5. CopyOnWriteArrayList

CopyOnWriteArraySet 有一个CopyOnWriteArrayList的成员变量

像CopyOnWrite这类都采用了 写入时拷贝,也就是增删改操作会将底层数组拷贝一份,更改操作会在新数组上执行,不影响其他线程的并发读,读写分离(比如读写锁,这里甚至读写都不会互斥了,直接分离了)

写操作加锁,读操作不加锁

适合读多写少的场景,存在弱一致性

posted @ 2026-04-08 16:41  不会coding的喵酱  阅读(17)  评论(0)    收藏  举报