Java常用并发集合知识

177 阅读30分钟

集合类关系图

1. List

1.1 CopyOnWriteArrayList

简介:

它是ArrayList的线程安全版本,采用了一种特殊的“写时复制”策略,即每次修改操作都会先复制一份当前集合,然后再对新的副本进行修改,最后再用新的副本替换旧的副本。这种策略能够保证并发操作时不会影响到其他线程的读操作,从而保证线程安全性。

底层实现:

ArrayList基础上,增加了synchronized代码块和“写时复制”策略。

特性:

  1. 线程安全:

并发访问时不需要使用额外的同步机制,因为每个线程访问的都是不同的副本,所以是线程安全的

  1. 适合“读多写少”场景:

由于读取操作不需要加锁,所以CopyOnWriteArrayList在读取操作上具有很高的性能;

每次修改都需要复制一份新的副本,因此写入操作相对较慢,特别是在集合较大的情况下;

缺点

  1. 内存占用高:

由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc。

  1. 不适用于实时数据

不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求。

核心方法:

public boolean add(E e) {
    synchronized (lock) {
        Object[] es = getArray();
        int len = es.length;
        es = Arrays.copyOf(es, len + 1);
        es[len] = e;
        setArray(es);
        return true;
    }
}

public E set(int index, E element) {
    synchronized (lock) {
        Object[] es = getArray();
        E oldValue = elementAt(es, index);

        if (oldValue != element) {
            es = es.clone();
            es[index] = element;
        }
        // Ensure volatile write semantics even when oldvalue == element
        setArray(es);
        return oldValue;
    }
}

public boolean remove(Object o) {
    Object[] snapshot = getArray();
    int index = indexOfRange(o, snapshot, 0, snapshot.length);
    return index >= 0 && remove(o, snapshot, index);
}
private boolean remove(Object o, Object[] snapshot, int index) {
    synchronized (lock) {
        Object[] current = getArray();
        int len = current.length;
        if (snapshot != current) findIndex: {
            int prefix = Math.min(index, len);
            for (int i = 0; i < prefix; i++) {
                if (current[i] != snapshot[i]
                    && Objects.equals(o, current[i])) {
                    index = i;
                    break findIndex;
                }
            }
            if (index >= len)
                return false;
            if (current[index] == o)
                break findIndex;
            index = indexOfRange(o, current, index, len);
            if (index < 0)
                return false;
        }
        Object[] newElements = new Object[len - 1];
        System.arraycopy(current, 0, newElements, 0, index);
        System.arraycopy(current, index + 1,
                         newElements, index,
                         len - index - 1);
        setArray(newElements);
        return true;
    }
}

问题:

  • 请先说说非并发集合中Fail-fast机制?

非并发集合中的Fail-fast机制是一种用于单线程环境或同步访问场景中的错误检测手段,旨在通过迅速抛出ConcurrentModificationException异常来提醒开发者有潜在的并发问题。
Fail-fast机制的核心在于迭代器(Iterator)的实现。当一个迭代器被创建后开始遍历集合时,它会记录一个“预期”的集合状态标记(modCount:修改次数)。如果在迭代过程中,其他线程(或者未正确同步的同一线程的不同部分)对集合进行了添加、删除、替换等结构修改操作,使得实际集合状态与迭代器记录的预期状态不一致,迭代器会在下一次操作(如next()remove())时检测到这种不一致性,并迅速抛出异常。

  • 再为什么说ArrayList查询快而增删慢?

顾名思义,ArrayList底层实现使用的数组结构,数组结构的特点就是查询效率高,增删效率低(因为需要移动元素)。

  • 对比ArrayList说说CopyOnWriteArrayList的增删改查实现原理? COW基于拷贝

其增删改查(CRUD)操作的实现原理基于“写时复制”(Copy-On-Write, COW)策略。
读取操作:读操作无锁,这意味着多个线程可以同时并发地读取列表中的元素,无需任何同步开销。其内部数据结构是一个数组,并且在每次修改操作完成后都会更新引用指向新的数组副本,因此即使在修改操作进行时,其他正在读取的线程看到的总是旧数组的完整视图,不存在数据竞争问题,保证了数据的可见性。
写入操作(增删改) :通过加锁确保同一时刻只有一个线程能修改列表。每次修改前都会创建原数组的副本,在副本上执行修改操作,然后更新内部数组引用。这种做法牺牲了写操作的性能(因为涉及到数组复制),但极大地简化了同步逻辑,并确保了读操作的高效性和无阻塞。
适合于读多写少的并发场景,其中大量线程需要并发读取列表,而写操作相对较少且可以容忍一定的延迟。然而,如果写操作频繁或列表规模较大时,由于频繁的数组复制可能导致较高的内存消耗和较差的写性能,此时可能需要考虑使用其他并发数据结构,如 ConcurrentLinkedQueue 或 CopyOnWriteArraySet

  • 再说下弱一致性的迭代器原理是怎么样的? COWIterator<E>
  1. 非阻塞读

    • CopyOnWriteArrayList的迭代器在读取元素时确实无需获取锁,允许并发读取。这与弱一致性迭代器的特性相符。
  2. 滞后视图

    • 其他线程修改CopyOnWriteArrayList时(例如,添加、删除或更新元素),正在进行迭代的迭代器不会立即反映出这些变化。它基于一个固定的视图进行遍历,这个视图是在迭代器创建时CopyOnWriteArrayList的当前状态
  3. 不抛出异常

    • 由于CopyOnWriteArrayList在写操作时采用“写时复制”策略,迭代器在遍历过程中不会遇到集合结构的变动,因此永远不会抛出ConcurrentModificationException。即使在迭代期间列表被其他线程修改,迭代器仍会安静地完成对创建时数组副本的遍历,而不会受到干扰。
  4. 数据可见性与一致性

    • CopyOnWriteArrayList的迭代器之所以被视为弱一致性,是因为它提供的视图可能滞后于集合的当前状态。这意味着如果在迭代过程中有元素被添加或删除,这些更改不会体现在当前迭代过程中。迭代器会按照创建时集合的状态完成遍历,而不会包含或排除后续添加或删除的元素。
  • CopyOnWriteArrayList为什么性能比Vector好?

对于并发读操作,CopyOnWriteArrayList采用“写时复制”,所以读操作并不需要同步加锁;Vector是所有方法都加了同步锁。所以对于并发读操作,CopyOnWriteArrayList性能比Vector好。
对于并发写操作,CopyOnWriteArrayList使用了同步锁+“写时复制”,Vector只使用了同步锁,所以CopyOnWriteArrayList性能不如Vector。

  • CopyOnWriteArrayList有何缺陷,说说其应用场景?

缺陷: 在写操作时采用“写时复制”策略,效率比较低;占用内存资源较多;并发读操作读到的可能是滞后的数据,数据的实时性不能保证。

应用场景: 适合于读多写少、对数据强一致性/实时性要求较低的并发场景。

1.2 使用 Collections.synchronizedList() 包装

  • 可以通过调用 java.util.Collections.synchronizedList() 方法来获得一个线程安全的列表包装器。例如:

    1List<Object> safeList = Collections.synchronizedList(new ArrayList<>());
    
  • 这个包装器会对所有修改列表的操作(如addremoveset等)进行同步,但需要注意的是迭代器必须在外部手动同步,否则仍然可能在迭代过程中遇到并发修改问题。内部实现原理主要是通过对每个可能改变集合状态的方法进行同步来保证线程安全。它创建了一个包装类,这个包装类的所有方法都会在其内部包裹一个 synchronized 块,锁定某个特定的对象(通常是包装类自身),从而确保同一时间只有一个线程可以修改列表。

例如,对于 add(E e) 和 get(int index) 等方法,其内部实现可能是这样的:

1public class SynchronizedList<E> implements List<E> {
2    // 内部持有原始List的引用
3    private final List<E> list;
4
5    public SynchronizedList(List<E> list) {
6        if (list == null) throw new NullPointerException();
7        this.list = list;
8    }
9
10    public synchronized boolean add(E e) {
11        return list.add(e);
12    }
13
14    public synchronized E get(int index) {
15        return list.get(index);
16    }
17
18    // 其他方法类似,均添加synchronized关键字
19    // ...
20}

2. Queue

2.1 ConcurrentLinkedQueue

简介:

ConcurrentLinkedQueue是一个线程安全的无界链接队列。它是基于链表实现的FIFO(First-In-First-Out,先进先出)队列,通过Java的CAS(Compare-and-Swap)操作来实现原子性的节点插入和移除操作,用于多线程环境下的高效数据交换,特别是在生产者-消费者等并发模式中。

底层实现:

底层通过 单向链表 + CAS算法 实现。

特性:

  1. 线程安全ConcurrentLinkedQueue的设计目标是在高并发环境下能保持高效的性能,它采用了非阻塞算法(Non-blocking algorithm),主要依赖于Java的CAS(Compare-and-Swap)操作来实现原子性的节点插入和移除操作,从而避免了传统的锁机制带来的开销和潜在的死锁问题。
  2. 无界队列ConcurrentLinkedQueue没有预定义的最大容量,理论上它可以无限增长,直至耗尽系统资源。这意味着如果生产者持续添加元素而消费者来不及消费,可能会导致内存占用过高。
  3. 链表结构:内部采用链表结构存储元素,每个节点由一个Node类表示,节点之间的链接关系通过volatile关键字修饰的next属性来维护,以保证多线程环境下的内存可见性。
  4. 非阻塞特性:对于add()offer()peek()poll()等操作,ConcurrentLinkedQueue都不会阻塞调用线程。也就是说,当尝试从空队列中获取元素时,会立即返回null而不是等待。
  5. 高效性:由于使用了CAS操作,ConcurrentLinkedQueue在大多数并发情况下能够提供非常高的吞吐量,尤其在读多写少的场景下表现优秀。

缺点:

  1. 无界特性可能导致资源耗尽

    • 作为一个无界队列,ConcurrentLinkedQueue 不设定固定的容量上限,这意味着如果不加以控制,生产者线程可以不断向队列中添加元素,这在极端情况下可能导致内存耗尽,特别是在消费者处理速度远低于生产者生产速度时
  2. 空间效率不高

    • 使用链表作为底层数据结构意味着每个插入的元素都需要分配一个新的节点对象,这通常会增加内存开销。相比于数组或者其他紧凑的数据结构,链表的内存利用率可能相对较低
  3. 弱一致性

    • 由于其非阻塞设计,ConcurrentLinkedQueue 提供的是最终一致性而非强一致性。在一个线程调用offer()方法成功添加元素之后,另一个线程并不能保证立刻能看到这个新添加的元素,尽管在实际应用中这种情况通常不会成为问题,但在一些需要即时响应的场景下可能需要特别注意
  4. 不适合批量操作

    • 如果应用程序需要进行批量的添加或删除操作,那么每次操作都需要独立 CAS 操作来完成,效率相比起支持批量操作的队列(如某些实现支持批量转移元素的阻塞队列)会低一些
  5. 不能公平地分配资源

    • 对于希望实现公平调度的场景,ConcurrentLinkedQueue 无法像某些具有公平策略的阻塞队列那样保证消费者线程间的公平性
  6. 缺乏阻塞功能

    • 当队列为空时,消费者线程通过poll()获取元素会立即返回null,而不是进入等待状态直到有元素可用。同样,当队列已满时,生产者也无法将元素放入队列而被阻塞等待。对于那些需要在特定条件发生时自动暂停线程执行的需求,可能需要使用支持阻塞功能的队列。

2.2 ArrayBlockingQueue

简介:

继承自AbstractQueue并且实现了BlockingQueue接口。顾名思义,其内部数据结构是一个定长数组,所以容量在创建时就必须指定,并且之后不可更改。队列遵循FIFO(First-In-First-Out,先进先出)原则。

底层实现:

  • 数据结构ArrayBlockingQueue使用固定长度的数组存储元素,通过数组索引来跟踪队列头部和尾部的位置。
  • 线程安全:它使用ReentrantLock来确保线程安全,对队列的插入和移除操作都是在其内部锁的保护下进行的。
  • 条件变量:除了锁之外,还使用了两个Condition实例来分别管理生产者等待和消费者等待的情况。当队列满时,生产者线程会被阻塞在队列的not-full条件上;当队列空时,消费者线程会被阻塞在队列的not-empty条件上。

特性:

  1. 有界性队列容量在创建时确定,满了就不能再添加元素,空了就不能再移除元素,防止了资源耗尽问题
  2. 线程安全:线程之间可以通过共享队列进行安全的通信,无需外部同步
  3. 阻塞特性当试图将元素放入已满队列或从空队列中移除元素时,线程将会自动阻塞,直到满足操作条件。
  4. 可选公平性可以通过构造函数选择公平性策略,公平策略意味着等待时间最长的线程将最有可能获得访问权限,从而减少线程饥饿的可能性,但这通常会牺牲一定的吞吐量

缺点:

  1. 容量固定:一旦创建,队列大小不可改变,这可能在某些场景下不够灵活
  2. 内存占用即便队列中的元素数量很少,也始终占用着预先设定好的数组容量所对应的内存空间
  3. 生产/消费不平衡如果生产者速度远高于消费者,且队列已满,多余的生产者线程会被阻塞,可能导致不必要的线程上下文切换和资源浪费;反之,如果消费者速度远高于生产者,当队列空时,消费者线程会过度竞争而导致CPU资源浪费
  4. 吞吐量与公平性权衡在默认配置下,ArrayBlockingQueue不保证线程调度的公平性,这可能导致部分线程长期得不到服务。虽然可以设置为公平模式,但这通常会降低系统的整体吞吐量

2.3 LinkedBlockingQueue

简介:

LinkedBlockingQueue是一个线程安全的、可选有界阻塞队列。它基于链表结构,遵循先进先出(FIFO)原则,适用于多线程环境中的生产者-消费者模式或其他线程间数据传递的场景。

底层实现:

  1. 数据结构:LinkedBlockingQueue 内部采用单链表存储元素,通过一个指向头节点和尾节点的指针来维护队列的结构。

  2. 线程安全机制:

    • 使用两个独立的 ReentrantLock 对象,分别称为 takeLock 和 putLock,用于分离生产和消费操作的同步控制,这样可以提高并发性能,因为生产者和消费者可以在不同的锁上并发执行操作。
    • 使用 notEmpty 和 notFull 两个 Condition 对象,分别关联到这两个锁,当队列为空时,消费者线程在 notEmpty 上等待;当队列满时,生产者线程在 notFull 上等待。
  3. 阻塞与唤醒机制:当队列的操作(插入或移除元素)不符合当前条件时,比如尝试从空队列中取元素或向已满队列中插入元素,相关线程将被挂起,并在满足条件时由其他线程通过调用 signal() 或 signalAll() 方法唤醒。

特性:

  1. 线程安全多个线程可以安全地同时进行读写操作,无需额外同步措施。
  2. 容量可选在创建时可以指定队列的容量,如果未指定,则默认为 Integer.MAX_VALUE,即无界队列,但当达到容量限制时,插入操作会阻塞,直到有空间可用。
  3. 公平性默认情况下是非公平的,即不保证线程获取锁的顺序与其等待的顺序相同。但也可以通过构造方法选择公平策略,公平策略下,线程获取锁的顺序将按照其进入等待队列的顺序
  4. 阻塞特性当试图将元素放入已满队列或从空队列中移除元素时,线程将会自动阻塞,直到满足操作条件。
  5. 高效性相较于基于数组的阻塞队列(如 ArrayBlockingQueue),链表结构在进行插入和删除操作时不需要移动大量元素,因此在大部分并发场景下具有更高的吞吐量

缺点:

  1. 内存开销:相对于数组实现,链表结构在每个元素都需要额外的空间存储前驱和后继节点的引用,因此在存储大量元素时,内存开销可能会稍大一些
  2. 无界队列的风险如果不明确设置容量上限,LinkedBlockingQueue 默认为无界队列,这在生产者生产速度快于消费者消费速度时可能导致内存溢出,尤其是在长周期运行的系统中,应当谨慎对待。
  3. 非公平策略下的线程饥饿在默认的非公平策略下,新到来的线程可能抢占已等待的线程,导致某些线程可能长时间得不到服务,进而引发线程饥饿问题

2.4 PriorityBlockingQueue

简介:

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序升序排列。也可以自定义类实现compareTo()方法来指定元素排序规则,或者初始化PriorityBlockingQueue时指定构造参数Comparator来对元素进行排序。但需要注意的是不能保证同优先级元素的顺序。

底层实现:

  1. 数据结构: PriorityBlockingQueue 使用一个可动态扩展的数组来存储元素,基于通过完全二叉树(complete binary tree)实现的小顶堆来排序。每当插入或删除元素时,堆结构都会自动调整以保持堆序

  2. 线程安全机制:用一个ReentrantLock和一个Condition(notEmpty)来确保多线程环境下的同步问题。PriorityBlockingQueue是一个无界阻塞队列,可以一直向队列中插入元素,除非系统资源耗尽,所以该队列也就不需要notFull了

特性:

  1. 支持优先级排序,允许开发者存储元素并根据其自然排序或者提供的Comparator进行排序,其优点在于它能高效地处理需要优先级调度的任务,确保最高优先级的任务总是优先被处理
  2. 阻塞特性当试图将元素放入已满队列或从空队列中移除元素时,线程将会自动阻塞,直到满足操作条件。
  3. 无界特性:作为一个无界队列,在极端情况下可能导致内存耗尽,特别是在消费者处理速度远低于生产者生产速度时。

缺点:

  1. 不能保证严格的FIFO - 由于优先级的影响,无法像普通队列那样严格遵循先进先出(FIFO)的原则。
  2. 空间开销相对大 - 为了维护堆结构,可能需要额外的空间开销,尤其是当元素数量较大而实际队列头部活跃的元素较少时。
  3. 性能相对较差 - 由于入队、出队都需要根据优先级调整堆顺序,这在高并发场景下可能会有一定的性能损耗,相对于其他队列性能差一些。
  4. 无界队列的风险PriorityBlockingQueue 为无界队列,这在生产者生产速度快于消费者消费速度时可能导致内存溢出。
  5. 优先级更新复杂 - 如果需要在线程安全的方式下更新队列中元素的优先级,则需要自行实现相关同步逻辑,或重新插入元素,这增加了复杂性和潜在的并发问题风险。

3. Map

3.1 ConcurrentHashMap

简介:

相较于传统的HashMapConcurrentHashMap允许在不进行外部同步的情况下并发地读写,从而在多线程环境中极大地提高了性能。

底层实现:

在HashMap的基础上进行了改造以支持线程安全和高并发访问.

特性:

  1. 线程安全: ConcurrentHashMap 使用了分段锁(Segment Locks)机制,在Java 8之前,它将数据划分为若干个Segment(类似于细分的哈希表),每个Segment有自己的锁,从而在多线程环境下可以实现更高的并发性。Java 8之后,放弃了Segment结构,改为使用CAS(Compare and Set)算法来实现无锁化操作,进一步提高了并发性能
  2. 锁细化: 当多个线程对不同键值进行操作时,它们可以并发进行,互不干扰,这是因为每个键值对应的操作只会影响到相应段的锁,而非整个数据结构。
  3. 并发修改: 支持并发的插入、删除和查询操作,即使是并发的构造(resize)操作,也能尽可能减少对正在进行的读取操作的影响。
  4. 高效扩容: 在进行容量扩充时,ConcurrentHashMap 采用了更加智能的扩容策略,以减少在并发环境下的锁竞争。
  5. 内存一致性使用volatile变量保证了数据的内存可见性,使得在一个线程修改了Map的内容后,其他线程能够立即看到这些修改

缺点:

  1. 内存开销: 为了实现线程安全和高并发性能,ConcurrentHashMap需要额外的数据结构和空间来存储锁或者其他同步机制。例如,在JDK 1.7中,每个Segment都会有一个锁,而在1.8及之后版本中,虽然没有了Segment结构,但仍需存储额外的偏移量和头结点指针等信息以支持CAS操作。
  2. 读写比例不均衡时的性能问题: 在极端情况下,如果写操作远高于读操作,ConcurrentHashMap的性能优势可能不如预期。尤其是在高并发写入场景下,CAS操作可能会频繁失败,导致较多的重试,进而影响性能。
  3. 扩容时的性能波动: 当ConcurrentHashMap需要扩容时,虽然采用了尾部迁移算法减少锁竞争,但在大规模数据集上进行扩容仍然可能会造成短暂的性能下降,尤其是在高并发环境下
  4. 弱一致性: ConcurrentHashMap提供了弱一致性保证,这意味着在并发环境下,其他线程可能无法立即看到另一个线程对Map所做的修改,直到修改后的值被传播出去。虽然这对于大部分并发场景是可接受的,但在某些需要强一致性的场景下可能不合适
  5. 序列化与反序列化成本较高: ConcurrentHashMap的序列化和反序列化过程较为复杂,且序列化后的字节流可能较大,这在需要在网络上传输或持久化到磁盘时会带来额外的成本。
  6. 非阻塞操作的局限性: 尽管大部分操作是非阻塞的,但在某些情况下,如扩容、树化等操作,还是需要进行同步。在这些同步点,如果有大量线程等待,性能会受到影响
  7. 不支持条件等待Collections.synchronizedMap()方法返回的同步映射相比,ConcurrentHashMap不支持wait()notify()notifyAll()方法,这意味着不能直接在ConcurrentHashMap上实现基于条件的等待和通知

核心方法:

  1. putVal方法:put、putIfAbsent 方法的实现
  2. replaceNode方法:remove、replace 方法的实现
  3. get方法:get、containsKey 方法的实现
  4. transfer方法:动态扩容方法

putVal方法:

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh; K fk; V fv;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else if (onlyIfAbsent // check first node without acquiring lock
                 && fh == hash
                 && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                 && (fv = f.val) != null)
            return fv;
        else {
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key, value);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                    else if (f instanceof ReservationNode)
                        throw new IllegalStateException("Recursive update");
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

replaceNode方法:

/**
 * Implementation for the four public remove/replace methods:
 * Replaces node value with v, conditional upon match of cv if
 * non-null.  If resulting value is null, delete.
 */
final V replaceNode(Object key, V value, Object cv) {
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0 ||
            (f = tabAt(tab, i = (n - 1) & hash)) == null)
            break;
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            boolean validated = false;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        validated = true;
                        for (Node<K,V> e = f, pred = null;;) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                V ev = e.val;
                                if (cv == null || cv == ev ||
                                    (ev != null && cv.equals(ev))) {
                                    oldVal = ev;
                                    if (value != null)
                                        e.val = value;
                                    else if (pred != null)
                                        pred.next = e.next;
                                    else
                                        setTabAt(tab, i, e.next);
                                }
                                break;
                            }
                            pred = e;
                            if ((e = e.next) == null)
                                break;
                        }
                    }
                    else if (f instanceof TreeBin) {
                        validated = true;
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> r, p;
                        if ((r = t.root) != null &&
                            (p = r.findTreeNode(hash, key, null)) != null) {
                            V pv = p.val;
                            if (cv == null || cv == pv ||
                                (pv != null && cv.equals(pv))) {
                                oldVal = pv;
                                if (value != null)
                                    p.val = value;
                                else if (t.removeTreeNode(p))
                                    setTabAt(tab, i, untreeify(t.first));
                            }
                        }
                    }
                    else if (f instanceof ReservationNode)
                        throw new IllegalStateException("Recursive update");
                }
            }
            if (validated) {
                if (oldVal != null) {
                    if (value == null)
                        addCount(-1L, -1);
                    return oldVal;
                }
                break;
            }
        }
    }
    return null;
}

get方法:

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

transfer方法:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    if (nextTab == null) {            // initiating
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n;
    }
    int nextn = nextTab.length;
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false; // to ensure sweep before committing nextTab
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) {
            int nextIndex, nextBound;
            if (--i >= bound || finishing)
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            else if (U.compareAndSetInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            }
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                    else if (f instanceof ReservationNode)
                        throw new IllegalStateException("Recursive update");
                }
            }
        }
    }
}

问题:

  • ConcurrentHashMap在JDK1.7和JDK1.8中实现有什么差别? JDK1.8解決了JDK1.7中什么问题?

JDK 1.7: Segment + HashEntry

  1. 数据结构:ConcurrentHashMap使用了一个名为Segment的数组,每个Segment继承自ReentrantLock,充当锁的角色。每个Segment内部又包含一个HashEntry数组,用于存储键值对。这种设计相当于将整个容器分成了若干个可独立加锁的子部分,每个子部分(即一个Segment)管理一部分键值对。
  2. 锁机制:当进行插入、删除或查询操作时,首先通过散列函数确定键所在的Segment,然后对该Segment加锁,使得同一时刻只有一个线程能修改该Segment内的数据,实现了分段锁(segment lock)机制。这种设计降低了锁的竞争程度,提高了并发性能。
  3. 扩容:扩容时需要新建一个更大的Segment数组,并将旧数组中的键值对迁移到新数组。这个过程同样需要对每个Segment单独加锁,且迁移过程中旧数组仍需保持可用,以应对其他线程的读写请求。

JDK 1.8: Node + CAS + Synchronized + 红黑树

  1. 数据结构:JDK 1.8中,ConcurrentHashMap摒弃了Segment结构,转而使用一个简单的Node数组(类似于HashMap的桶数组)。每个Node代表一个键值对,并且可以链接成链表或转换为红黑树。这种设计简化了数据结构,消除了Segment带来的额外开销。
  2. 锁机制:使用了更细粒度的锁控制。每个Node在插入、删除和更新时,首先尝试使用CAS(Compare-And-Swap)操作来原子地修改引用,避免加锁。只有在CAS失败(即存在竞争)的情况下,才对相应Node或索引位置上的头节点(head)采用synchronized关键字进行同步。这种设计进一步减少了锁的粒度,仅针对受影响的单个节点或桶进行同步,而不是整个段。
  3. 红黑树转换:当链表长度超过一定阈值(默认为8)时,链表会自动转换为红黑树,以提供更高效的查找、插入和删除操作。红黑树的引入将链表的O(n)查找时间复杂度降到了O(log n),提升了在高度竞争和长链表情况下的性能。

JDK 1.8解决JDK 1.7中的问题

JDK 1.8的ConcurrentHashMap设计解决了JDK 1.7版本中的以下问题:

  1. 锁粒度:1.8版本通过使用CAS和更细粒度的synchronized同步,将锁粒度从整个Segment缩小到了单个Node或桶,大大减少了并发操作时的锁竞争,提高了并发度和吞吐量。
  2. 内存占用:移除Segment结构后,减少了额外对象的创建和内存开销,使得ConcurrentHashMap更为轻量化。
  3. 扩容效率:扩容时不再需要复制整个Segment数组,而是采用了一种“懒”重定位策略。新数组创建后,旧数组和新数组可以并存,新插入的元素直接放入新数组,而旧数组中的元素会在访问时被移动到新数组。这种异步、渐进式的迁移方式降低了扩容期间对系统性能的影响。
  4. 查找性能:通过引入红黑树,解决了在某些极端情况下链表过长导致的查找性能问题,确保了在高度竞争和长链表情况下依然能保持较好的性能。
  • ConcurrentHashMap JDK1.7实现的原理是什么? 分段锁机制
  • ConcurrentHashMap JDK1.8实现的原理是什么? 数组+链表+红黑树,CAS
  • ConcurrentHashMap JDK1.7是如何扩容的? rehash(注:segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容)
  • ConcurrentHashMap JDK1.8是如何扩容的? tryPresize
  • ConcurrentHashMap JDK1.8链表转红黑树的时机是什么? 临界值为什么是8?
  • ConcurrentHashMap JDK1.8是如何进行数据迁移的? transfer

transfer方法迁移数据的过程:

1. 初始化新表

当检测到需要扩容(通常是由于现有表的负载因子超过阈值)时,首先创建一个大小为原表两倍的新表(nextTab)。新表的初始状态为空。

2. 设置迁移标志

在旧表(tab)中,每个桶的头节点可能会被替换为一个特殊的转发节点(ForwardingNode),它指向新表,表示该桶的数据已经成功迁移到新表中。在迁移开始时,将旧表的transferIndex设置为旧表的长度,作为迁移过程中的一个动态边界。

3. 并行迁移

多个线程可以参与到数据迁移过程中,每个线程负责迁移一部分桶。迁移过程遵循以下逻辑:

  • 循环:从transferIndex开始倒序遍历旧表的桶索引,直至0。

  • 处理桶:对于每个桶,获取其当前头节点。如果节点为空或已经被标记为已迁移(即头节点为ForwardingNode),则跳过该桶。否则,根据节点类型(链表节点或树节点)进行相应的迁移操作:

    • 链表迁移:遍历链表,根据节点的hash值与新表长度的关系,将节点插入到新表的对应桶中。迁移过程中,新链表保持原有链表的顺序。
    • 树节点迁移:遍历红黑树,同样根据节点的hash值拆分成两个子树,分别插入到新表对应的桶中。如果子树大小满足条件,还可能降级为链表。
  • 更新旧桶:迁移完成后,使用CAS操作将旧桶的头节点替换为ForwardingNode,表明该桶已完成迁移。

  • 更新迁移边界:每次循环结束后,尝试更新transferIndex,使其递减(步长通常根据CPU核心数进行调整),从而定义下一轮迁移的边界。

4. 最终阶段

当所有桶都被设置为ForwardingNode时,进入最终阶段。此时,所有后续写入操作都会直接定位到新表,而读操作会通过ForwardingNode找到新表。最后,更新table引用指向新表,设置transferIndex为-1,清空nextTable以释放旧表,调整sizeCtl为新表合适的阈值,至此,数据迁移完成。 ConcurrentHashMaptransfer方法是一个复杂的迁移过程,旨在将数据从当前表并行、高效地迁移到新扩容的表。主要特点如下:

  • 并行迁移:通过计算步长stride,使多个线程同时参与迁移不同范围的桶,提高迁移效率。
  • CAS与同步:对桶的迁移操作使用CAS替换节点,减少锁的使用;对链表或树节点的迁移,仅对头节点加锁,保证数据迁移期间的线程安全。
  • 链表拆分:根据节点的hash值将链表拆分为两部分,分别放入新表对应桶,保持数据分布的一致性。
  • 树节点处理:树节点同样按hash拆分,并根据节点数量决定是否降级为链表或保留为树结构,确保数据结构在新表中的正确性。
  • 迁移控制:通过advancefinishing标志控制迁移进度,通过更新transferIndex调整迁移阶段边界,实现迁移过程的动态调度。