只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

多线程总结

美团技术团队:不可不说的 Java "锁" 事
JUC 源码解读

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 内存模型的抽象示意如下图所示
image

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 内置锁也是可重入锁,"可重入" 是对锁的基本要求

锁的使用问题:死锁、活锁、饥饿
死锁:多个线程互相等待对方持有的资源,而导致线程无法继续执行的问题
解决:统一线程请求锁的顺序、避免线程持有锁并等待锁
image

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 成功
}

image
image

2.2、Lock

相对于 synchronized 内置锁,JUC Lock 锁提供了更加丰富的特性,比如:支持公平锁、可中断锁、非阻塞锁、可超时锁等
image

// 都是可重入锁, 默认非公平锁
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();
}

image

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

读锁是共享锁、写锁是独占锁、读锁和写锁之间是互斥的
image

3、无锁编程

CAS + 原子类(AtomicReference 中 CAS 的 ABA 问题)+ 累加器(数据分片、哈希优化、去伪共享、非准确求和)+ ThreadLocal
image

4、同步工具

4.1、条件变量

粗略地讲,锁可以分为两种,一种是 Java 提供的 synchronized 内置锁,另一种是 JUC 提供的 Lock 锁,同理条件变量也有两种

  • 一种是 Java 提供的内置条件变量,使用 Object 类上的 wait()、notify() 等来实现
  • 一种是 JUC 提供的条件变量,使用 Condition 接口上的 await()、signal() 等来实现

image

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、并发容器

image

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(),唤醒阻塞等待读的线程

image

详细了解 ArrayBlockingQueue 的实现原理之后,我们再来看下跟它比较类似的 LinkedBlockingQueue、LinkedBlockingDeque、PriorityBlockingQueue

  • LinkedBlockingQueue 是基于 "链表" 实现的 "有界阻塞并发队列"
    默认大小为 Integer.MAX_VALUE,这个值非常大,实际上就相当于无界队列,当然我们也可以在创建对象时指定队列大小
  • LinkedBlockingDeque 跟 LinkedBlockingQueue 的区别在于:它是一个双端队列,支持两端读写操作
  • PriorityBlockingQueue 是一个 "无界阻塞并发优先级队列",底层基于 "支持扩容的堆" 来实现,因此:写操作永远都不需要阻塞,只有读操作会阻塞

这 3 个并发阻塞队列的实现方式,跟 ArrayBlockingQueue 的实现方式类似
也是使用 ReentrantLock 锁来实现读写操作的线程安全性,使用 Condition 条件变量实现读写操作的阻塞等待,这里就不再展示源码做讲解了
实际上从这 4 个并发阻塞队列的实现方式,我们也可以总结得到,利用锁和条件变量,我们可以实现任何类型的并发阻塞容器,比如:并发阻塞栈、并发阻塞哈希表等

DelayQueue 为 "延迟阻塞并发队列",底层基于优先级队列 PriorityQueue 来实现,因为 PriorityQueue 支持动态扩容,因此 DelayQueue 是无界队列

因为 put() 函数不支持阻塞,实现比较简单,只是通过加锁保证线程安全,所以我们重点看下比较复杂的支持阻塞的 take() 函数,take() 函数的执行流程如下所示
image

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 数组中会存在三种不同类型的链表:已复制未加锁链表、在复制已加锁链表、未复制未加锁链表,如下图所示

image

在扩容的过程中,我们需要对已复制未加锁的链表做标记,当对已标记的链表进行读、写、树化操作时,引导在新创建的 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 数组中重新查找对应的链表,再执行读、写、树化操作
image

上述讲解了 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 的原因

对于以上并发扩容的处理逻辑,我们举例进一步解释,如下图所示
image

6、线程管理

image

posted @   lidongdongdong~  阅读(19)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开