多线程总结
1、基础理论
Java 线程的实现原理是 "内核线程"
多线程存在的问题
- CPU 缓存导致了可见性问题(也叫缓存一致性问题,MESI 协议可以解决它)
- 指令重排导致了有序性问题
- 线程切换导致了原子性问题(问题出在 "寄存器" 上)
如何解决
- CPU:MESI 协议(Store Buffer + Invalidate Queue)、lock、cmpxchg
- Java:volatile、synchronized、final、happens-before
临界区:访问共享资源、包含复合操作
竞态:两个线程竞争执行临界区的这种状态
1.1、Java 内存模型
Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见
从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系
线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读 / 写共享变量的副本
本地内存是 JMM 的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化,Java 内存模型的抽象示意如下图所示
1.2、volatile 和 synchronized
现代操作系统调度的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程
这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量,处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行
Java 支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程可以拥有这个变量的拷贝,所以程序在执行过程中,一个线程看到的变量并不一定是最新的
虽然对象以及成员变量分配的内存是在共享内存中的,但是每个执行的线程还是可以拥有一份拷贝,这样做的目的是加速程序的执行,这是现代多核处理器的一个显著特性
- 关键字 volatile 可以用来修饰字段(成员变量)
就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性 - 关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用
它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性
1.3、LockSupport 和 Unsafe
LockSupport
方法名称 | 描述 |
---|---|
void park() | 阻塞当前线程,如果调用 unpark(Thread thread) 方法或者当前线程被中断,才能从 park() 方法返回 |
void parkNanos(long nanos) | 阻塞当前线程,最长不超过 nanos 纳秒,返回条件在 park() 的基础上增加了超时返回 |
void parkUntil(long deadline) | 阻塞当前线程,直到 deadline 时间(从 l970 年开始到 deadline 时间的毫秒数) |
void unpark(Thread thread) | 唤醒处于阻塞状态的线程 thread |
Unsafe
public native void park(boolean isAbsolute, long time);
park 这个方法会阻塞当前线程,只有以下 4 种情况中的一种发生时,该方法才会返回
- 与 park 对应的 unpark 执行或已经执行时,"已经执行" 是指 unpark 先执行,然后再执行 park 的情况
- 线程被中断时
- 等待完 time 参数指定的毫秒数时
- 异常现象发生时,这个异常现象没有任何原因
1.4、中断
用户线程和守护线程的区别
- 通过 Thread.setDaemon(false) 设置为用户线程(默认)
- 通过 Thread.setDaemon(true) 设置为守护线程
- 主线程结束后,用户线程还会继续运行,JVM 存活
- 主线程结束后,如果没有用户线程,都是守护线程,那么 JVM 结束
2、互斥锁
JUC 提供的锁都是可重入锁,实际上 Java synchronized 内置锁也是可重入锁,"可重入" 是对锁的基本要求
锁的使用问题:死锁、活锁、饥饿
死锁:多个线程互相等待对方持有的资源,而导致线程无法继续执行的问题
解决:统一线程请求锁的顺序、避免线程持有锁并等待锁
2.1、synchronized
synchronized:偏向锁、无锁 + 轻量级锁、重量级锁、JIT 编译优化(锁消除、锁粗化)
- 偏向锁:只进行一次 CAS,不解锁
- 无锁 + 轻量级锁:线程栈创建 Lock Record(轻量级锁解锁时快速恢复为无锁状态),CAS 竞争轻量级锁(如果是无锁,设置 Mark Word 中的 Lock Record 指针)
- 自适应自旋:升级为重量级锁是件很麻烦的事情,因此 CAS 竞争轻量级锁会进行自适应自旋
阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间
如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长 - 重量级锁:Monitor 锁,创建 ObjectMonitor + CAS + 等待队列 + 系统调用(阻塞和唤醒内核线程)-> 中断 + 用户态和内核态的切换
class ObjectMonitor { void *volatile _object; // 该 Monitor 锁所属的对象 void *volatile _owner; // 获取到该 Monitor 锁的线程 ObjectWaiter *volatile _cxq; // 没有获取到锁的线程暂时加入 _cxq, 单链表, 负责存操作 ObjectWaiter *volatile _EntryList; // 存储等待被唤醒的线程, 双链表, 负责取操作 ObjectWaiter *volatile _WaitSet; // 存储调用了 wait() 的线程, 双链表 }
// 下面的代码是不正确的, 仅用来帮助理解 cas(ObjectMonitor._owner, null, currentThread); // Atomic::cmpxchg_ptr int cas(int *dest, int compare_value, int exchange_value) { mov eax, compare_value // 期望 mov ecx, DWORD PTR dest // 实际 mov edx, exchange_value // 新值 lock cmpxchg ecx, edx return ecx; // cmpxchg 失败 return compare_value; // cmpxchg 成功 }
2.2、Lock
相对于 synchronized 内置锁,JUC Lock 锁提供了更加丰富的特性,比如:支持公平锁、可中断锁、非阻塞锁、可超时锁等
// 都是可重入锁, 默认非公平锁 public interface Lock { // 阻塞锁 // 调用该方法当前线程将会获取锁,当锁获得后,从该方法返回 void lock(); // 阻塞锁, 可以被中断 // 可中断地获取锁,和 1ock() 方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程 void lockInterruptibly() throws InterruptedException; // 非阻塞锁 // 尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回 true,否则返回 false boolean tryLock(); // 可超时锁, 也可以被中断(超时的获取锁,当前线程在以下 3 种情况下会返回) // 1、当前线程在超时时间内获得了锁 // 2、当前线程在超时时间内被中断 // 3、超时时间结束,返回 false boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 解锁 void unlock(); Condition newCondition(); }
Java 将 synchronized 设计为只支持非公平锁,而 JUC 提供的 ReentrantLock 既支持公平锁,也支持非公平锁,默认情况下,ReentrantLock 为非公平锁
- 对于公平锁来说:线程会按照请求锁的先后顺序来获得锁,也就是我们经常说的 FIFO
- 对于非公平锁:多个线程请求锁时,非公平锁无法保证这些线程获取锁的先后顺序,有可能后申请锁的线程先获取到锁
- 非公平锁的性能比公平锁的性能更好,我们知道:加入等待队列并调用 park() 函数阻塞线程,涉及到用户态和内核态的切换,比较耗时
对于非公平锁来说,新来的线程直接竞争锁,这样就有可能避免加入等待队列并调用费时的 park() 函数
2.3、AQS
AQS 是抽象类 Abstract Queue Synchronizer 的简称,中文翻译为抽象队列同步器,在 JDK 中基于 Java 语言实现的,因为 JUC 只是 JDK 中的一个并发工具包而已
public abstract class AbstractOwnableSynchronizer { private transient Thread exclusiveOwnerThread; // 独占所有者线程 } public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer { private transient volatile Node head; private transient volatile Node tail; private volatile int state; // 0 表示锁没有被占用、1 表示锁已经被占用、大于 1 的数表示重入的次数 }
一个线程获取锁,无非就是对 state 变量进行 CAS 修改,修改成功则获取锁,修改失败则进入队列
而 AQS 就是负责线程进入同步队列以后的逻辑,如何出入队列?如何阻塞?如何唤醒?一切的核心都在 AQS 里
模板方法
// 独占模式 public final void acquire(int arg) { // ... } public final void acquireInterruptibly(int arg) throws InterruptedException { // ... } public final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { // ... } public final boolean release(int arg) { // ... }
// 共享模式 public final void acquireShared(int arg) { // ... } public final void acquireSharedInterruptibly(int arg) throws InterruptedException { // ... } public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout) throws InterruptedException { // ... } public final boolean releaseShared(int arg) { // ... }
抽象方法
加锁和解锁,就是利用 CAS 对 state、exclusiveOwnerThread 进行操作
由模板方法处理其它逻辑(同步队列):加锁失败后的添加队列和阻塞线程等(当前)、解锁成功后的删除队列和唤醒线程等(其它)
// 用于独占模式的 4 个模板方法 protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }
// 用于共享模式的 4 个模板方法 protected int tryAcquireShared(int arg) { throw new UnsupportedOperationException(); } protected boolean tryReleaseShared(int arg) { throw new UnsupportedOperationException(); }
2.4、ReentrantLock
Lock 和 ReentrantLock 用到了 AQS 的独占模式(排它锁 + 可重入锁)
- 由 ReentrantLock 实现:tryAcquire() 和 tryRelease(),可重入锁、是否公平
- 由 AQS 实现
1、阻塞锁 lock():执行整个 AQS 流程,LockSupport.park(this);
2、可被中断阻塞锁 lockInterruptibly():在 1 的基础上,当线程发生中断时,抛出 InterruptedException
3、非阻塞锁 tryLock():不执行 AQS 流程
4、可超时锁 tryLock(long time, TimeUnit unit):在 2 的基础上,添加超时阻塞 LockSupport.parkNanos(this, nanosTimeout);
2.5、ReadWriteLock
读锁是共享锁、写锁是独占锁、读锁和写锁之间是互斥的
3、无锁编程
CAS + 原子类(AtomicReference 中 CAS 的 ABA 问题)+ 累加器(数据分片、哈希优化、去伪共享、非准确求和)+ ThreadLocal
4、同步工具
4.1、条件变量
粗略地讲,锁可以分为两种,一种是 Java 提供的 synchronized 内置锁,另一种是 JUC 提供的 Lock 锁,同理条件变量也有两种
- 一种是 Java 提供的内置条件变量,使用 Object 类上的 wait()、notify() 等来实现
- 一种是 JUC 提供的条件变量,使用 Condition 接口上的 await()、signal() 等来实现
Java 内置条件变量
在 get() 函数中,"状态变量的检查" 和 "业务代码的执行" 构成了一组复合操作
如果不对其进行加锁,那么就会存在线程安全问题:两个线程同时检测到状态变量满足条件,同时执行业务逻辑,这就有可能导致数组访问越界
public class QueueCond { private List<String> list = new ArrayList<>(); private int count = 0; public void put(String elem) { // 1、加锁 synchronized (this) { list.add(count, elem); count++; // 2、更新状态变量 this.notify(); // 3、通知 } // 4、解锁 } // 当队列为空时,get() 函数会阻塞,直到队列中有数据时再返回 public String get() { // 1、加锁 synchronized (this) { // 2、检查状态变量是否满足条件, while 循环 while (count == 0) { try { this.wait(); // 3、等待并释放锁 4、被唤醒之后重新竞争获取锁 } catch (InterruptedException e) { return null; } } // 以下为业务逻辑 count--; return list.get(count); } // 5、解锁 } }
JUC 提供的条件变量
/** * 条件变量: 支持阻塞读和阻塞写的有限队列 */ public class BlockingQueueCond<E> { private final List<E> list; private int size; private int capacity; private final Lock lock = new ReentrantLock(); private final Condition vacancy = lock.newCondition(); // 条件变量: 等待空位的线程 private final Condition product = lock.newCondition(); // 条件变量: 等待产品的线程 public BlockingQueueCond(int capacity) { this.capacity = capacity; size = 0; list = new LinkedList<>(); } /** * 入队: 队列已满时, 写入操作会被阻塞, 直到队列有空位为止 */ public void enqueue(E e) { lock.lock(); try { while (size == capacity) vacancy.await(); list.add(e); size++; product.signal(); } catch (InterruptedException ex) { throw new RuntimeException(ex); } finally { lock.unlock(); } } /** * 出队: 队列为空时, 读取操作会被阻塞, 直到队列有可读的数据为止 */ public E dequeue() { lock.lock(); try { while (size == 0) product.await(); E ret = list.remove(0); size--; vacancy.signal(); return ret; } catch (InterruptedException e) { throw new RuntimeException(e); } finally { lock.unlock(); } } public int getSize() { return size; } public int getCapacity() { return capacity; } }
4.2、信号量
Semaphore 类基于 AQS 实现,state = permits
- acquireUninterruptibly() -> acquireShared() -> tryAcquireShared() 采用自旋 + CAS 来更新 state--,>= 0 则获得锁 -> state < 0 则 doAcquireShared() 阻塞
- release() -> releaseShared() -> tryReleaseShared() 采用自旋 + CAS 来更新 state++ -> doReleaseShared() 唤醒排队等待许可的其中一个线程
/** * 信号量: 支持阻塞读和阻塞写的有限队列 */ public class BlockingQueueSem<E> { private final List<E> list; private int size; private int capacity; private final Lock lock = new ReentrantLock(); private final Semaphore emptySemaphore; // 入队信号量: 初始为 capacity 个许可 private final Semaphore elementSemaphore; // 出队信号量: 初始为 0 个许可 public BlockingQueueSem(int capacity) { this.capacity = capacity; size = 0; list = new LinkedList<>(); emptySemaphore = new Semaphore(capacity); elementSemaphore = new Semaphore(0); } /** * 入队: 队列已满时, 写入操作会被阻塞, 直到队列有空位为止 */ public void enqueue(E e) { emptySemaphore.acquireUninterruptibly(); lock.lock(); try { list.add(e); size++; elementSemaphore.release(); } finally { lock.unlock(); } } /** * 出队: 队列为空时, 读取操作会被阻塞, 直到队列有可读的数据为止 */ public E dequeue() { elementSemaphore.acquireUninterruptibly(); lock.lock(); try { E ret = list.remove(0); size--; emptySemaphore.release(); return ret; } finally { lock.unlock(); } } public int getSize() { return size; } public int getCapacity() { return capacity; } }
4.3、锁存器、栅栏
CountDownLatch 的计数器只能使用一次,而 CyclicBarrier 的计数器可以使用 reset() 方法重置
所以 CyclicBarrier 能处理更为复杂的业务场景,例如:如果计算发生错误,可以重置计数器,并让线程重新执行一次
- CountDownLatch 内部维护了一个计数器,通过调用 countDown() 方法来减少计数器的值,当计数器的值变为 0 时,所有调用 await() 的线程都会被唤醒
基于 AQS 实现,state = count
await() -> acquireSharedInterruptibly() -> tryAcquireShared() 如果 state == 0 获得锁 -> state != 0 则 doAcquireSharedInterruptibly() 阻塞
countDown() -> releaseShared() -> tryReleaseShared() 采用自旋 + CAS 来更新 state-- -> 如果 state = 0 则 doReleaseShared() 唤醒等待队列中的线程 - CyclicBarrier 内部维护了一个计数器,通过调用 await() 方法来来减少计数器的值并阻塞当前线程,当计数器的值变为 0 时,所有调用 await() 的线程都会被唤醒
基于条件变量实现,await() -> 先将 parties 减一,然后检查 parties 是否为 0 -> 不为 0 则 Condition.await()、为 0 则 Condition.signalAll() - Thread.join() -> Thread.isAlive() -> 如果活着就阻塞 Object.wait()
public class DemoLatch { private static final CountDownLatch latch = new CountDownLatch(2); public static class RunnableForLatch implements Runnable { @Override public void run() { // ... do something ... latch.countDown(); // ... do other thing ... } } public static void main(String[] args) throws InterruptedException { new Thread(new RunnableForLatch()).start(); new Thread(new RunnableForLatch()).start(); latch.await(); // 等待 something 执行完成而非等待线程结束, 并且不需要知道在等谁 // ... 执行后续逻辑 ... } }
public class ApiBenchmark { private static int numThread = 20; // 并发度为 20 private static int numReqPerThread = 1000; // 每个线程请求 1000 次接口 private static CountDownLatch latch = new CountDownLatch(numThread); // 锁存器: 等待各测试线程完成后,唤醒主线程 private static CyclicBarrier barrier = new CyclicBarrier(numThread); // 栅栏: 让各测试线程更加精确地同时开始执行 public static class TestRunnable implements Runnable { public List<Long> respTimes = new ArrayList<>(); @Override public void run() { try { barrier.await(); } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } for (int i = 0; i < numReqPerThread; i++) { long reqStartTime = System.nanoTime(); // ... 调用接口 long reqEndTime = System.nanoTime(); respTimes.add(reqEndTime - reqStartTime); } latch.countDown(); } } public static void main(String[] args) throws InterruptedException { // 创建线程 Thread[] threads = new Thread[numThread]; TestRunnable[] runnables = new TestRunnable[numThread]; for (int i = 0; i < numThread; i++) { runnables[i] = new TestRunnable(); threads[i] = new Thread(runnables[i]); } // 启动线程 long startTime = System.nanoTime(); for (int i = 0; i < numThread; i++) { threads[i].start(); } // 等待测试线程结束 latch.await(); long endTime = System.nanoTime(); // 统计接口性能 long qps = (numThread * numReqPerThread * 1000) / ((endTime - startTime) / 1000000); float avgRespTime = 0.0f; for (int i = 0; i < numThread; i++) { for (Long respTime : runnables[i].respTimes) { avgRespTime += respTime; } } avgRespTime /= (numThread * numReqPerThread); } }
5、并发容器
5.1、写时复制
CopyOnWriteArrayList 和 CopyOnWriteArraySet,它们都是采用写时复制技术来实现,因此称为写时复制并发容器
CopyOnWriteArrayList 的 add() 函数、remove() 函数、set()、get() 函数,即增删改查,查函数没有加锁,增删改加锁写时复制
为了保证写操作的线程安全性,避免两个线程同时执行写时复制,写操作通过加锁来串行执行,也就是说:读读、读写都可以并行执行,唯独写写不可以并行执行
- 弱一致性:写操作的结果并非对读操作立即可见,写操作在新数组上执行,读操作在原始数组上执行
因此在 array 引用指向新数组之前,读操作只能读取到旧的数据,这就导致了短暂的数据不一致,我们把写时复制的这个特点叫做弱一致性 - 连续存储:每次执行写操作时都需要大动干戈,把原始数据重新拷贝一份,对于链表、哈希表来说,因为数据在内存中不是连续存储的,因此拷贝的耗时将非常大
- 读多写少:适用于读多写少的场景,对于需要频繁进行写操作的场景推荐使用 Java 提供的 SynchronizedList
5.2、阻塞等待
ArrayBlockingQueue、LinkedBlockingQueue、LinkedBlockingDeque、PriorityBlockingQueue 的实现原理类似
它们都是基于 ReentrantLock 锁来实现线程安全,基于 Condition 条件变量来实现阻塞等待
我们对 ArrayBlockingQueue 的 put() 函数和 take() 函数的实现原理做下总结:读操作和写操作互相等待,如下图所示
- 读操作调用 notEmpty 上 await() 等待非空条件发生,执行完成之后,调用 notFull 上的 signal(),唤醒阻塞等待写的线程
- 写操作调用 notFull 上的 await() 等待非满条件的发生,执行完成之后,调用 notEmpty 上的 signal(),唤醒阻塞等待读的线程
详细了解 ArrayBlockingQueue 的实现原理之后,我们再来看下跟它比较类似的 LinkedBlockingQueue、LinkedBlockingDeque、PriorityBlockingQueue
- LinkedBlockingQueue 是基于 "链表" 实现的 "有界阻塞并发队列"
默认大小为 Integer.MAX_VALUE,这个值非常大,实际上就相当于无界队列,当然我们也可以在创建对象时指定队列大小 - LinkedBlockingDeque 跟 LinkedBlockingQueue 的区别在于:它是一个双端队列,支持两端读写操作
- PriorityBlockingQueue 是一个 "无界阻塞并发优先级队列",底层基于 "支持扩容的堆" 来实现,因此:写操作永远都不需要阻塞,只有读操作会阻塞
这 3 个并发阻塞队列的实现方式,跟 ArrayBlockingQueue 的实现方式类似
也是使用 ReentrantLock 锁来实现读写操作的线程安全性,使用 Condition 条件变量实现读写操作的阻塞等待,这里就不再展示源码做讲解了
实际上从这 4 个并发阻塞队列的实现方式,我们也可以总结得到,利用锁和条件变量,我们可以实现任何类型的并发阻塞容器,比如:并发阻塞栈、并发阻塞哈希表等
DelayQueue 为 "延迟阻塞并发队列",底层基于优先级队列 PriorityQueue 来实现,因为 PriorityQueue 支持动态扩容,因此 DelayQueue 是无界队列
因为 put() 函数不支持阻塞,实现比较简单,只是通过加锁保证线程安全,所以我们重点看下比较复杂的支持阻塞的 take() 函数,take() 函数的执行流程如下所示
SynchronousQueue 是一个 "特殊的阻塞并发队列",用于两个线程之间传递数据
线程执行 put() 操作必须阻塞等待另一个线程执行 take() 操作,也就是说:SynchronousQueue 队列中不存储任何元素
LinkedTransferQueue 是一个基于 "链表" 实现的 "无界阻塞并发队列"
它是 LinkedBlockingQueue 和 SynchronousQueue 的综合体,既实现了 LinkedBlockingQueue 的功能,又实现了 SychronousQueue 的功能
transfer() 函数:跟 SynchronousQueue 中的 put() 函数的功能类似,调用 transfer() 函数的线程会一直阻塞,直到数据被其他线程消费才会返回
5.3、分段加锁
对读、写、扩容、树化这 4 个操作两两之间的线程安全性分析,我们总结了如下一张表格
从表中我们可以看出,HashMap 在设计实现时完全没考虑线程安全问题,对于 HashMap 中的绝大部分操作,多线程竞态执行都存在问题
是否存在线程安全问题? | 读操作 | 写操作 | 扩容 | 树化 |
---|---|---|---|---|
读操作 | 否 | 否 | 是 | 否 |
写操作 | 否 | 是 | 是 | 是 |
扩容 | 是 | 是 | 是 | 是 |
树化 | 否 | 是 | 是 | 否 |
在 HashTable 或 SynchronziedMap 中,table 数组上只有一把锁,所有的读写操作都争抢这一把锁
而在 ConcurrentHashMap 中,table 数组被分段加锁,如果 table 数组的大小为 n,那么就对应存在 n 把锁
table 数组中的每一个链表独享一把锁,不同链表之间的操作可以多线程并行执行,互不影响,以此来提高 ConcurrentHashMap 的并发性能
- get():不加锁
- put() 写:通过待插入数据的哈希值定位到链表 table[index] 之后
如果链表为空(也就是 table[index] 为 null),那么就通过 CAS 操作将 table[index] 指向写入数据对应的节点
如果链表不为空(也就是 table[index] 不为 null),那么就对链表的头节点(也就是 table[index])使用 synchronized 加锁,然后再执行写操作 - put() 树化:在写入操作执行完成之后,如果链表中的节点个数大于等于树化阈值(默认为 8),那么 put() 函数会执行树化操作,对 table[index] 使用 synchronized 加锁
- put() 扩容
使用写时复制:在老的 oldTable 数组中的数据完全复制到新的 newTable 数组中之后,才将 table 引用指向新创建的 newTable 数组(解决读与扩容)
复制替代搬移:将老的 oldTable 数组中的节点中的 key、value 等数据,复制一份存储在一个新创建的节点中,再将新创建的节点插入到新的 newTable 数组中
扩容操作会针对 table 数组中的每条链表逐一进行复制,在复制某个链表之前,先对这个链表加锁(类似写操作和树化的加锁方式)然后再复制,复制完成之后再解锁
在扩容的过程中,table 数组中会存在三种不同类型的链表:已复制未加锁链表、在复制已加锁链表、未复制未加锁链表
在扩容的过程中,我们需要对已复制未加锁的链表做标记,当对已标记的链表进行读、写、树化操作时,引导在新创建的 newTable 数组中执行
多线程并发扩容:transferIndex(转移索引)初始化为 table.length,多个线程通过 CAS 修改 transferIndex 共享变量为 transferIndex - stride
谁成功更新 transferIndex,谁就获取了下标在 [transferIndex - stride, transferIndex) 之间的 stride(大步走)个链表的复制权
某个线程处理完分配的 stride 个链表之后,可以再次自旋执行 CAS 竞争剩余链表的复制权
ConcurrentHashMap 中的定义了一个 int 类型的共享变量 sizeCtl,用来标记当前正在参与扩容的线程个数
参与扩容 + 1,复制完成 - 1,当 sizeCtl = 0 时,就表示这个线程就是最后一个线程,负责将 table 引用更新为指向新的 table 数组
在扩容的过程中,table 数组中会存在三种不同类型的链表:已复制未加锁链表、在复制已加锁链表、未复制未加锁链表,如下图所示
在扩容的过程中,我们需要对已复制未加锁的链表做标记,当对已标记的链表进行读、写、树化操作时,引导在新创建的 newTable 数组中执行
对于未复制未加锁的链表执行读、写、树化操作,以及对于在复制已加锁的链表执行读操作,应该在老的 oldTable 数组中进行的
而对于已复制未加锁的链表执行读、写、树化操作,应该在新的 newTable 数组中进行
因此在扩容的过程中,我们需要对已复制未加锁的链表做标记,当对已标记的链表进行读、写、树化操作时,引导在新创建的 newTable 数组中执行
那么具体是如何标记某个链表是已复制未加锁的呢
ConcurrentHashMap 定义了一个新的节点类型:ForwardingNode,代码如下所示,ForwardingNode 继承自 Node,将节点中的 hash 值设置为特殊值 -1,以起到标记的作用
static final int MOVED = -1; // hash for forwarding nodes // 特殊链表节点定义 static final class ForwardingNode<K, V> extends Node<K, V> { final Node<K, V>[] nextTable; ForwardingNode(Node<K, V>[] tab) { super(MOVED, null, null, null); this.nextTable = tab; } } // 链表节点定义 static class Node<K, V> implements Map.Entry<K, V> { final int hash; final K key; volatile V val; volatile Node<K, V> next; Node(int hash, K key, V val, Node<K, V> next) { this.hash = hash; this.key = key; this.val = val; this.next = next; } }
当某个链表复制完成之后,ConcurrentHashMap 会将这个链表首节点替换为 ForwardingNode 节点,并且将 ForwardingNode 节点中的 nextTable 属性指向新创建的 table 数组
对于空链表,ConcurrentHashMap 会补充一个 key、value 均为 null 的 ForwardingNode 节点,具体如下图所示
当读、写、树化 table 数组中的某个链表时,ConcurrentHashMap 先检查链表首节点的 hash 值
如果 hash 值等于 -1,那么就在这个节点的 nextTable 属性所指向的 table 数组中重新查找对应的链表,再执行读、写、树化操作
上述讲解了 ConcurrentHashMap 如何让扩容可以跟读、写、树化操作并行执行,接下来我们再来看下 ConcurrentHashMap 如何让扩容和扩容并行执行
在 ConcurrentHashMap 中,多个线程可以协作共同完成扩容,每个线程负责相邻的几个链表的复制工作,具体负责哪几个,这就由共享变量 transferIndex(转移索引)来决定
transferIndex 初始化为 table.length,多个线程通过 CAS 修改 transferIndex 共享变量,如下代码所示
谁成功更新 transferIndex,谁就获取了下标在 [transferIndex - stride, transferIndex) 之间的 stride(大步走)个链表的复制权
如果某个线程竞争执行 CAS 失败,则自旋重新执行 CAS
除此之外,某个线程处理完分配的 stride 个链表之后,可以再次自旋执行 CAS 竞争剩余链表的复制权
public void transfer(Node<K, V>[] tab, Node<K, V> nextTable) { int n = tab.length; int stride; // 每个线程负责相邻的 stride 个链表 int NCPU = Runtime.getRuntime().availableProcessors(); if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE; // MIN_TRANSFER_STRIDE = 16 // 循环获取 stride 个链表的处理权并处理, 直到没有剩余的链表要处理 while (transferIndex > 0) { int oldIndex = tranferIndex; int newIndex = oldIndex > stride ? oldIndex - stride : 0; if (!cas(transferIndex, oldIndex, newIndex)) { continue; // 失败继续, 自旋 CAS } // CAS 成功, 处理下标在 [newIndex, oldIndex) 之间的 table 数组中的链表 } }
ConcurrentHashMap 的扩容是写时复制操作,在将老的 oldTable 数组中的所有链表全部赋值到新的 newTable 数组之后,才会将 table 引用更新为指向新的 newTable 数组
那么多个线程协作扩容,谁来执行最后将 table 引用更新为指向新的 table 数组这一操作呢?显然,谁最后完成就谁来做,怎么来标记谁最后完成呢?
ConcurrentHashMap 中的定义了一个 int 类型的共享变量 sizeCtl,用来标记当前正在参与扩容的线程个数,sizeCtl 初始值为 0
- 当某个线程参与扩容时,就通过 CAS 将 sizeCtl 更新为 sizeCtl + 1
- 当这个线程手上持有的链表都复制完成,并且 table 数组中没有剩余的链表可以分配时,这个线程就通过 CAS 将 sizeCtl 更新为 sizeCtl - 1
- 当某个线程执行完 sizeCtl - 1 操作之后,如果 sizeCtl 变为 0,那么就表示这个线程就是最后一个线程,负责将 table 引用更新为指向新的 table 数组
实际上 sizeCtl 也可以声明为 AtomicInteger 类型,这样就避免了自己实现 CAS 操作
不过尽管使用封装好的 AtomicInteger 更加方便,但性能却没有使用自己实现 CAS 操作高,这也是 ConcurrentHashMap 没有使用 AtomicInteger 的原因
对于以上并发扩容的处理逻辑,我们举例进一步解释,如下图所示
6、线程管理
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17631758.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步