6_并发工具-JUC工具包

并发工具包-JUC

1. AQS原理

1.1 概述

AQS全称是AbstractQueuedSynchronizer,是阻塞式锁和相关同步器工具的框架(抽象队列同步器

AQS通俗的讲就是给你提供一些方法,让你自己自由的实现自定义锁,其核心就是维持锁的状态和阻塞队列

特点:

  • 用state属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁
    • getState - 获取state状态
    • setState - 设置state状态
    • compareAndSetState - CAS机制设置state状态(为了防止多线线程修改state状态而导致的线程安全问题)
    • 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
  • 提供了基于FIFO的等待队列,类似于Monitor的EntryList
  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于Monitor的WaitSet

子类主要实现这样一些方法(默认输出UnsupportedOperationException)

  • tryAcquire
  • tryRelease
  • tryAcquireShared
  • tryReleaseShared
  • isHeldExclusively

获取锁的姿势:

//如果获取锁失败
if(!tryAcquire(arg)){
    // 入队,可以选择阻塞当前线程 --> 使用的是park/unpark机制	
}

释放锁的姿势:

// 如果释放锁成功
if(tryRelease(arg)){
    // 将阻塞线程恢复运行
}

1.2 实现不可重入锁

自定义一个同步器,用于实现不可重入锁:

// 同步器类
class MySynchronizer extends AbstractQueuedSynchronizer{
    // 独占锁

    @Override
    protected boolean tryAcquire(int arg) {
        // 使用CAS讲AQS中表示锁的状态从0改为1则表示获取锁成功
        if (compareAndSetState(0, 1)){
            // 加上了锁,并设置owner为当前线程(当前锁的持有者为当前线程)
            setExclusiveOwnerThread(Thread.currentThread());
            return true;
        } else {
            // 否则加锁失败,直接返回
            return false;
        }
    }

    @Override
    protected boolean tryRelease(int arg) {
        // 设置该锁现在并没有其它线程所占有
        setExclusiveOwnerThread(null);
        // 由于当前锁已经被锁拥有对象已经在占有了,所以拥有该锁的线程释放锁就无需考虑释放锁用CAS,没必要
        setState(0);
        return true;
    }

    // 是否持有独占锁
    @Override
    protected boolean isHeldExclusively() {
        // 如果ASQ(抽象队列同步器) state = 1表示当前占有该锁
        return getState() == 1;
    }
    
    // new condition
    public Condition newCondition(){
        return new ConditionObject();
    }
}

自定义一个不可重入锁:

// 自定义锁(不可重入锁)
class MyLock implements Lock{

    // 创建一个同步器对象
    private MySynchronizer synchronizer = new MySynchronizer();

    // 加锁,不曾共会进入等待队列等待
    @Override
    public void lock() {
        synchronizer.acquire(1);
    }

    // 可打断的加锁
    @Override
    public void lockInterruptibly() throws InterruptedException {
        synchronizer.acquireInterruptibly(1);
    }

    // 尝试加锁(只尝试一次)
    @Override
    public boolean tryLock() {
        return synchronizer.tryAcquire(1);
    }

    // 尝试加锁,带超时时间
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return synchronizer.tryAcquireNanos(1, unit.toNanos(time));
    }

    // 解锁
    @Override
    public void unlock() {
        synchronizer.release(1);
    }

    // 创建条件变量
    @Override
    public Condition newCondition() {
        return synchronizer.newCondition();
    }
}

我们发现,自己写个锁还是非常简单的:),当然,这依赖于我们自己写的同步器。接下来呢,我们测试一下我们自己写的不可重入锁:

public class TestAQS {
    private static final Logger log = LoggerFactory.getLogger(TestAQS.class);

    public static void main(String[] args) {
        // 声明一个我们自定义的不可重入锁
        MyLock lock = new MyLock();

        new Thread(()->{
            lock.lock();
            try {
                log.debug("locking...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                log.debug("unlocking...");
                lock.unlock();
            }
        },"t1").start();

        new Thread(()->{
            lock.lock();
            try {
                log.debug("locking...");
            } finally {
                log.debug("unlocking...");
                lock.unlock();
            }
        },"t2").start();
    }
}
18:55:47.288 [t1] DEBUG org.example3.TestAQS - locking...
18:55:48.292 [t1] DEBUG org.example3.TestAQS - unlocking...
18:55:48.292 [t2] DEBUG org.example3.TestAQS - locking...
18:55:48.292 [t2] DEBUG org.example3.TestAQS - unlocking...

当t1线程如果率先获取到锁后,t2线程如果页想获取到该锁,则得等待t1线程获取锁睡眠1S且释放锁后才能获取到锁

接下来呢,我们演示一下我们自定义的不可重入锁究竟能不是实现不可重入:

public class TestAQS {
    private static final Logger log = LoggerFactory.getLogger(TestAQS.class);

    public static void main(String[] args) {
        // 声明一个我们自定义的不可重入锁
        MyLock lock = new MyLock();

        new Thread(()->{
            lock.lock();
            log.debug("locking first...");
            lock.lock();
            log.debug("locking second...");
            try {
                log.debug("locking third...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                log.debug("unlocking...");
                lock.unlock();
            }
        },"t1").start();
    }
}

在上面的测试代码中,线程t1在获取了lock锁的情况下,仍然要获取lock锁,我们看一下运行结果:

19:06:16.981 [t1] DEBUG org.example3.TestAQS - locking first...

我们发现,线程t1第一次获取到锁以后,想要第二次获取同样的锁,是获取不了的,这也验证了我们自己写的锁是不可重入的!

2. ReentrantLock原理

2.1 ReentrantLock非公平锁加锁成功流程

现在我们了解一下ReentrantLock加锁的原理,手首先看一下ReentrantLock类图:

image-20240805195034095

我们发现,ReentrantLock也拥有一个AQS同步器,而我们知道,ReentrantLock既可以设置为公平锁,也可以设置为非公平锁,这也恰巧对应着同步器的的两个子同步器:公平同步器和非公平同步器。

我们首先看一下ReentrantLock的无参构造方法:

public ReentrantLock() {
    sync = new NonfairSync();
}

我们发现ReentrantLock无参构造器默认使用的是非公平同步器。我们首先查看一下ReentrantLock的加锁流程:

public void lock() {
    sync.lock();
}

我们发现ReentrantLock中的lock其实调用的是同步器中的lock,我们再进去看看:

/**
 * Sync object for non-fair locks
 */
static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;

    /**
     * Performs lock.  Try immediate barge, backing up to normal
     * acquire on failure.
     */
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }

    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

我们发现,这不就是我们自定义锁的代码吗:) 使用CAS将同步器的状态从0改为1,再将该锁所属线程改为当前线程。下面的状态就是没有竞争的状态:

image-20240805200139534

2.2 加锁失败的流程

但是出现了竞争怎么办?就如上面的图,Thread-0已经拿到了锁对象,当前锁所属线程为Thread-0,而此时Thread-1也想获取该锁,则会首先使用CAS操作将同步器中的state状态从0改为1,但这会失败,如下图所示:

image-20240805200347930

竞争锁失败,就会进入上面代码的else代码块: Thread-1会再次使用CAS尝试去加锁,如果这次CAS操作成功就会获取到锁(Thread-0这时候释放锁才有可能)。如果此次CAS仍然失败,就会创建一个节点队列(Node Queue),然后将该节点对象加入到阻塞队列中去。如下面的图所示:

final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);	// 进入此处
}

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}


image-20240805201054590

节点队列是一个双向链表,其中head节点指向头部,tail节点指向尾部,需要注意的是,第一次创建节点时,会创建两个节点,第一个节点时Dummy哑元(哨兵)节点,该节点是用来占位的,并不关联任何线程。只有第二个node节点才会存放CAS失败的Thread-1,如上面的所示。

我们再次看一下Thread-1 CAS失败进入到Node队列中的代码:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

我们发现除了当前线程CAS失败后创建一个节点加入到队列中以外,还会执行acquireQueued方法,我们进入看一下该方法:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            // 获取该节点的前驱节点
            final Node p = node.predecessor();
            // 如果该节点的前驱节点时head节点,还会再做一次挣扎,尝试获取该锁,如果仍然失败,就不执行该if代码块
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 获取锁失败后就会使用park进入阻塞
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}


// 使用park进入阻塞
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

我们发现acquireQueued中是一个死循环,主要进行的步骤如上面的注释。

image-20240805202354851

在使用park进入阻塞后,就会将waitStatus改为-1,此时该节点会变为"灰色":

image-20240805202700241

再次有多个线程经历上述过程竞争失败,变成这个样子:

image-20240805202803269

2.3 解锁竞争成功流程

Threat-0释放锁,进入 tryRelease()流程,如果成功

  • 设置 exclusiveOwnerThread为null--->锁的拥有者设置为空。
  • 设置当前同步器的state状态为0--->state=0

image-20240806234953190

我们查看一下ReentrantLock的unlock方法:

public void unlock() {
    sync.release(1);	// 讲同步器的状态改为1
}

public final boolean release(int arg) {
    if (tryRelease(arg)) {	// 修改同步器的状态
        Node h = head;	
        if (h != null && h.waitStatus != 0)	// 如果headn不为空且等待状态不为0,表明当前等待队列中存在等待锁的线程
            unparkSuccessor(h);		// 唤醒队列中的线程(唤醒后继节点)
        return true;
    }
    return false;
}

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);		// 将同步器的状态改为0
    return free;
}

当前队列不为null,并且head的waitStatus = -1, 进入unparkSuccessor流程
找到队列中离head最近的一个Node(没取消的),unpark恢复其运行,本例中即为 Thread-1
回到 Thread-1 的 acquireQueued 流程

image-20240806235934678

我们查看一下unparkSuccessor方法:

private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);		// 使用CAS讲同步器的状态改为0

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            LockSupport.unpark(s.thread);		// 底层调用了unpark的方法唤醒线程
    }

解锁成功后,哨兵节点和Thread-2节点建立新的索引关系,如上面的图。

2.4 解锁竞争失败的流程

如果加锁成功(没有竞争),会设置

  • exclusiveOwnerThread Thread-1,state =1
  • head指向刚刚Thread-.l所在的Node,该Node清空Thread
  • 原本的head因为从链表断开,而可被垃圾回收

如果这时候有其它线程来竞争(非公平的体现),例如这时有Thread-4来了,这个线程4是新线程,并不是阻塞队列中的线程。这个线程也想获得这个锁对象

image-20240807093424379

此时的Thread-4和阻塞队列中刚刚唤醒的Thread-1产生竞争,如果不巧又被Thread-4抢占了先

  • Thread-4被设置为exclusiveOwnerThread,state=l
  • Thread-I再次进入acquireQueued流程,获取锁失败,重新进入park阻塞

ReentrantLock非公平锁的非公平就体现在这里:阻塞队列中的线程和阻塞队列外的线程对于CPU的竞争是不公平的!只是对于阻塞队列中的线程来讲,才是所谓的公平!

2.5 ReentrantLock可重入原理

现在我们来讨论一下 ReentrantLock 是如何保证可重入的呢?我们开查看一下ReentrantLock获取锁和释放锁的相关代码:

// Sync继承过来的方法,方便阅读,就放在这里
final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();	// 获取同步器的状态
        if (c == 0) {		// 状态为0,表示没有别人获得锁,进入if代码块
            // 使用CAS讲同步器的状态从0改为1
            if (compareAndSetState(0, acquires)) {
                // 如果CAS成功,讲所得拥有者改为自己,并直接返回true
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        // 如果当前锁已经被占有了,但是是自己占有的该锁,则表示发生了可重入
        else if (current == getExclusiveOwnerThread()) {
            // 将同步器的状态+1
            int nextc = c + acquires;
            if (nextc < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }

相应的,我们看一下ReentrantLock释放锁的代码:

// Sync继承过来的方法,方便阅读,就放在这里
protected final boolean tryRelease(int releases) {
   		// 讲同步器的状态值减去传递过来的参数,一般为1
        int c = getState() - releases;	
    	// 如果当前持有该锁的线程并不是自己,直接抛出异常和返回false
        if (Thread.currentThread() != getExclusiveOwnerThread())
            throw new IllegalMonitorStateException();
        boolean free = false;
    	// 继续判断此时的同步器状态值,如果等于0,则代表可以释放该锁了,将该锁锁的拥有者线程改为null,并返回true,进一步去唤醒某些阻塞的线程。
        if (c == 0) {
            free = true;
            setExclusiveOwnerThread(null);
        }
        setState(c);
        return free;
}

2.6 可打断原理

不可打断模式

在此模式下,即使它被打断,仍会驻留在AQS队列中,等获得锁后方能继续运行(是继续运行!只是打断标记被设置为tue)

private final boolean parkAndCheckInterrupt(){
    //如果打断标记已经是true,则park会失效
    LockSupport.park(this);
    //interrupted会清除打断标记
    return Thread.interrupted();
}

final boolean acquireQueued(final Node node,int arg){
    boolean failed true;
    try
        boolean interrupted false;
        for(;) {
            final Node p node.predecessor();
            if (p =head &tryAcquire(arg)){
                setHead(node);
                p.next null;
                failed false;
                //还是需要获得锁后,才能返回打断状态
                return interrupted;
        	}
        if(shouldParkAfterFailedAcquire(p,node) && parkAndCheckInterrupt()) {
            // 尝试获取所仍不成功,则会使用park进入阻塞队列
            //如果是因为interrupt被唤醒,返回打断状态为true
            interrupted = true;
        }
    finally{
        if(failed){
            cancelAcquire(node);
        }
    }
}

在不可打断模式下,这个这个线程驻留在队列中,就一直得不到打断的响应。

可打断模式

public final void acquireInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    // 如果没有获取到锁,进入doAcquireInterruptibly
    if (!tryAcquire(arg))
        doAcquireInterruptibly(arg);
}

// 可打断的获取锁的流程
private void doAcquireInterruptibly(int arg)
        throws InterruptedException {
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    // park过程中如果被interrupt会进入此,这时候抛出异常,就会结束foe循环
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
}

一旦该线程被打断,就会抛出一个异常,从而结束循环

2.7 ReentrantLock公平锁的实现原理

接下来我们看看公平锁的实现原理:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();	// 获取同步器的状态
    // 如果当前同步器的状态为0
    if (c == 0) {
        // 检查当前同步器中的队列是否有前驱节点,如果没有前驱节点。则使用CAS竞争这个锁 ,并且设置线程拥有者为自己
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}



// hasQueuedPredecessors
public final boolean hasQueuedPredecessors() {
    // The correctness of this depends on head being initialized
    // before tail and on head.next being accurate if the current
    // thread is first in queue.
    Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    // 头不等于尾表示队列中还有Node
    return h != t &&
        // 队列中有节点,但只是哨兵节点 ,或者是当前队列中哨兵的下一个节点并不是当前线程 
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

2.8 ReentrantLock条件变量实现原理

每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject,其内部也是维护了一个双向链表作为哪些不满足条件而休息的线程。

await 流程

开始 Thread-O 持有锁,调用await,进入 ConditionObject 的 addCondition Waiter 流程创建新的Node状态为 -2 (Node.CONDITION),关联Thread-0,加入等待队列尾部:

image-20240808095132515

public final void await() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
	// 创建一个状态为2的节点-->其实就是将当前线程加入到条件变量的双向链表中去
    Node node = addConditionWaiter();
    int savedState = fullyRelease(node);	// 将该节点对应的线程上所有的锁全部释放掉
    int interruptMode = 0;
    while (!isOnSyncQueue(node)) {
        LockSupport.park(this);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null) // clean up if cancelled
        unlinkCancelledWaiters();
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
}


// addConditionWaiter方法:
private Node addConditionWaiter() {
    Node t = lastWaiter;
    // If lastWaiter is cancelled, clean out.
    if (t != null && t.waitStatus != Node.CONDITION) {
        unlinkCancelledWaiters();
        t = lastWaiter;
    }
    Node node = new Node(Thread.currentThread(), Node.CONDITION);	// Node.CONDITION = -2
    if (t == null)
        firstWaiter = node;
    else
        t.nextWaiter = node;
    lastWaiter = node;
    return node;
}


// fullyRelease: 释放线程上全部锁的方法-->为了避免锁重入而导致多个锁没有释放完
final int fullyRelease(Node node) {
    boolean failed = true;
    try {
        int savedState = getState();	// 获取同步器的状态值
        if (release(savedState)) {		// 释放该线程所占用的锁,消除同步器的状态值,并唤醒等待队列中的下一个节点
            failed = false;
            return savedState;
        } else {
            throw new IllegalMonitorStateException();
        }
    } finally {
        if (failed)
            node.waitStatus = Node.CANCELLED;
    }
}

接下来进入AQS的fullyRelease流程,释放同步器上的锁

image-20240808100319152

unpark AQS队列中的下一个节点,竞争锁,假设没有其它线程,那么Thread-1竞争成功

image-20240808100448496

signal 流程

假设Thread-1要唤醒Thread-0

image-20240808100650195

我们首先看一下signal代码:

public final void signal() {
    if (!isHeldExclusively())	// 判断调用signal的线程是不是锁的持有者,如果不是,则抛出异常
        throw new IllegalMonitorStateException();
    // 找到条件变量链表中的firstWaiter,也就是队列头元素
    Node first = firstWaiter;
    // 如果队列头元素不为空,则调用 doSignal 方法
    if (first != null)
        doSignal(first);
}


// 调用doSignal方法
private void doSignal(Node first) {
    do { 
        if ( (firstWaiter = first.nextWaiter) == null)
            lastWaiter = null;
        // 将ConditionObject队列中的头节点指向空,断开该节点与条件变量中队列的联系
        first.nextWaiter = null;
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}

进入ConditionObject的doSignal流程,取得等待队列中第一个Node,即Thread-0所在Node

image-20240808101720816

执行transferForSignal流程,将该Node使用尾插法加入AQS队列尾部,将Thread-0的waitStatus改为0,Thread-3的waitStatus改为-1

image-20240808102003203

Thread-1释放锁,进入unlock流程,此过程省略。

3. 读写锁

ReentrantReadWriteLock

当读操作远远高于写操作时,这时候使用读写锁读-读可以并发,提高性能。类似于数据库中的select..from..lock in share mode提供一个数据容器类内部分别使用读锁保护数据的read()方法,写锁保护数据的write()方法。

提供一个数据容器类内部分别使用读锁保护数据read()方法,写锁保护数据的write()方法

class DataContainer{

    private static final Logger log = LoggerFactory.getLogger(DataContainer.class);
    private Object data;    // 要保护的共享数据
    // 定义一个读写锁
    private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

    // 获取读锁和写锁
    ReentrantReadWriteLock.ReadLock rLock = rwLock.readLock();
    ReentrantReadWriteLock.WriteLock wLock = rwLock.writeLock();


    public Object read(){
        // 加读锁
        rLock.lock();
        log.debug("获取读锁");
        try {
            log.debug("读取");
            Thread.sleep(1000);
            return data;
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            log.debug("释放读锁");
            rLock.unlock();
        }
    }

    public void write(){
        // 加写锁
        wLock.lock();
        log.debug("获取写锁");
        try{
            log.debug("写入");
        } finally {
            log.debug("释放写锁");
            wLock.unlock();
        }

    }
}

接着我们测试多个线程读操作,判断它们是否会发生阻塞:

public class ReadWriteLockTest {
    public static void main(String[] args) {
        DataContainer container = new DataContainer();

        new Thread(()->{
            container.read();
        },"t1").start();

        new Thread(()->{
            container.read();
        },"t2").start();
    }
}
11:08:48.268 [t1] DEBUG org.example2.a49.DataContainer - 获取读锁
11:08:48.268 [t2] DEBUG org.example2.a49.DataContainer - 获取读锁
11:08:48.272 [t2] DEBUG org.example2.a49.DataContainer - 读取
11:08:48.272 [t1] DEBUG org.example2.a49.DataContainer - 读取
11:08:49.288 [t1] DEBUG org.example2.a49.DataContainer - 释放读锁
11:08:49.288 [t2] DEBUG org.example2.a49.DataContainer - 释放读锁	

我们发现多个线程进行读操作并不会发生阻塞。

接下来我们看一下一个线程执行读操作,一个线程执行写操作:

public class ReadWriteLockTest {
public static void main(String[] args) throws InterruptedException {
    DataContainer container = new DataContainer();

    new Thread(()->{
        container.read();
    },"t1").start();

    Thread.sleep(100);
    new Thread(()->{
        container.write();
    },"t2").start();
}
11:12:37.703 [t1] DEBUG org.example2.a49.DataContainer - 获取读锁
11:12:37.711 [t1] DEBUG org.example2.a49.DataContainer - 读取
11:12:38.720 [t1] DEBUG org.example2.a49.DataContainer - 释放读锁
11:12:38.720 [t2] DEBUG org.example2.a49.DataContainer - 获取写锁
11:12:38.720 [t2] DEBUG org.example2.a49.DataContainer - 写入
11:12:38.720 [t2] DEBUG org.example2.a49.DataContainer - 释放写锁

我们发现,此时的读锁产生了作用,读锁防的是写锁!一旦有写操作,读锁就产生效果,只有读锁释放掉以后,写线程才会继续运行!

类似的,些-写操作也是互斥的

public class ReadWriteLockTest {
public static void main(String[] args) throws InterruptedException {
    DataContainer container = new DataContainer();

    new Thread(()->{
        container.write();
    },"t1").start();

    Thread.sleep(100);
    new Thread(()->{
        container.write();
    },"t2").start();
}
11:15:08.329 [t1] DEBUG org.example2.a49.DataContainer - 获取写锁
11:15:08.334 [t1] DEBUG org.example2.a49.DataContainer - 写入
11:15:08.334 [t1] DEBUG org.example2.a49.DataContainer - 释放写锁
11:15:08.431 [t2] DEBUG org.example2.a49.DataContainer - 获取写锁
11:15:08.431 [t2] DEBUG org.example2.a49.DataContainer - 写入
11:15:08.431 [t2] DEBUG org.example2.a49.DataContainer - 释放写锁

这里就不解释了。

ReentrantLock注意事项

  • 读锁是不支持条件变量的 ,写锁是支持条件变量的
  • 重入时不支持升级:即持有读锁的情况下去获取写锁,会导致写锁永久等待,如下面的示例代码:
rLock.lock();
try{
    // ...
    wLock.lock();
    try{
        // ...
    } finally {
        wLock.unlock();
    }
} finally{
    rLock.unlock();
}
  • 重入时支持降级:即支持拥有写锁的情况下获取读锁。

读写锁应用-缓存(未完成)

StampedLock

该类自JDK8加入,StampedLock支持tryoptimisticRead()方法(乐观读),读取完毕后需要做一次戳校验如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。
提供一个数据容器类内部分别使用读锁保护数据的read()方法,写锁保护数据的write()方法

class DataContainer{
    
}

读写锁的原理

下面我们分析一下读写锁的原理

读写锁使用的是同一个Sycn同步器,因此等待队列,同步器状态等也是同一个

这里呢,我们以t1线程加写锁,t2线程加读锁为例(t1 wLock; t2 rLock):

  1. t1成功上锁,流程与ReentrantLock加锁相比没有特殊之处,不同是写锁状态占了state的低l6位,而读锁使用的是state的高16位

image-20240808234558675

stampedLock

该类自JDK8加入,是为了进一步优化读性能,有的人可能会问:使用读读并发读取已经够快的了,但是呢,还是不够快,因为读读并发底层还是使用的是ASQ同步器,每次还是要使用CAS来修改同步器的状态修改读锁的高16位,它的特点是在使用读锁、写锁时都必须配合【戳】使用。

加解读锁时

long stamp = lock.readLock();
lock.unlockRead(stamp);

加解写锁:

long stamp = lock.writeLock();
lock.unlockWrite(stamp);

乐观读,StampedLock支持tryoptimisticRead()方法(乐观读),读取完毕后需要做一次戳校验如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。(读写互斥)

long stamp = lock.tryoptimisticRead();
// 校验戳
if(!lock.validate(stamp)){
    // 锁升级
}

下面呢,我们通过一个例子,来演示StampedLock锁的使用。

首先我们准备一个数据容器:

// 数据容器保护共享数据data
class DataContainer{

    private static final Logger log = LoggerFactory.getLogger(DataContainer.class);

    private int data;
    private final StampedLock lock = new StampedLock();

    public DataContainer(int data){
        this.data = data;
    }

    public int read(int readTime) throws InterruptedException {
        // 乐观读操作
        long stamp = lock.tryOptimisticRead();
        log.debug("optimistic read locking {}", stamp);
        Thread.sleep(readTime);
        // 检验戳成功,直接返回数据
        if (lock.validate(stamp)){
            log.debug("read refresh...{}",stamp);
            return data;
        }
        // 校验失败,其它写线程已经把戳改成了最新的值,就要进行锁升级,重写加读锁
        log.debug("updating to read lock...{}", stamp);
        try{
            stamp = lock.readLock();
            log.debug("read lock {}", stamp);
            Thread.sleep(readTime);
            return data;
        } finally {
            log.debug("read unlock {}", stamp);
            lock.unlockRead(stamp);
        }
    }

    public void write(int newData){
        // 加写锁
        long stamp = lock.writeLock();
        log.debug("write lock {}",stamp);
        try{
            Thread.sleep(2000);
            this.data = newData;
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 释放写锁
            log.debug("write unlock {}", stamp);
            lock.unlockWrite(stamp);
        }
    }
}

接着我们写测试代码:

读-读操作

package org.example3;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.locks.StampedLock;

public class TestStampLock {
    public static void main(String[] args) throws InterruptedException {
        DataContainer dataContainer = new DataContainer(1);
        new Thread(()->{
            try {
                dataContainer.read(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"t1").start();

        Thread.sleep(500);

        new Thread(()->{
            try {
                dataContainer.read(0);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"t2").start();
    }
}


// 数据容器保护共享数据data
class DataContainer{

    private static final Logger log = LoggerFactory.getLogger(DataContainer.class);

    private int data;
    private final StampedLock lock = new StampedLock();

    public DataContainer(int data){
        this.data = data;
    }

    public int read(int readTime) throws InterruptedException {
        // 乐观读操作
        long stamp = lock.tryOptimisticRead();
        log.debug("optimistic read locking {}", stamp);
        Thread.sleep(readTime);
        // 检验戳成功,直接返回数据
        if (lock.validate(stamp)){
            log.debug("read refresh...{}",stamp);
            return data;
        }
        // 校验失败,其它写线程已经把戳改成了最新的值,就要进行锁升级,重写加读锁
        log.debug("updating to read lock...{}", stamp);
        try{
            stamp = lock.readLock();
            log.debug("read lock {}", stamp);
            Thread.sleep(readTime);
            return data;
        } finally {
            log.debug("read unlock {}", stamp);
            lock.unlockRead(stamp);
        }
    }

    public void write(int newData){
        // 加写锁
        long stamp = lock.writeLock();
        log.debug("write lock {}",stamp);
        try{
            Thread.sleep(2000);
            this.data = newData;
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 释放写锁
            log.debug("write unlock {}", stamp);
            lock.unlockWrite(stamp);
        }
    }
}
19:48:58.818 [t1] DEBUG org.example3.DataContainer - optimistic read locking 256
19:48:59.327 [t2] DEBUG org.example3.DataContainer - optimistic read locking 256
19:48:59.327 [t2] DEBUG org.example3.DataContainer - read refresh...256
19:48:59.846 [t1] DEBUG org.example3.DataContainer - read refresh...256

我们发现读-读操作并没有加上读锁。且读-读操作是乐观读

接下来我们测试一下读-写操作:

public class TestStampLock {
    public static void main(String[] args) throws InterruptedException {
        DataContainer dataContainer = new DataContainer(1);
        new Thread(()->{
            try {
                dataContainer.read(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"t1").start();

        Thread.sleep(500);

        new Thread(()->{
            dataContainer.write(1000);
        },"t2").start();
    }
}
19:50:09.435 [t1] DEBUG org.example3.DataContainer - optimistic read locking 256
19:50:09.944 [t2] DEBUG org.example3.DataContainer - write lock 384
19:50:10.453 [t1] DEBUG org.example3.DataContainer - updating to read lock...256
19:50:11.951 [t2] DEBUG org.example3.DataContainer - write unlock 384
19:50:11.951 [t1] DEBUG org.example3.DataContainer - read lock 513
19:50:12.954 [t1] DEBUG org.example3.DataContainer - read unlock 513

我们发现,刚开始加的是乐观读锁,之后进行了写操作,写操作后再次读时,乐观锁进行了升级,在读取操作时加上了读锁,此时写锁就需要阻塞等待读锁释放了才能继续运行。

既然StampedLockz在读取时使用乐观锁读起来更快,能能使用StampedLock代理ReentrantLock呢?答案是不能的,这是因为:

  • StampedLock不支持条件变量
  • StampedLock不支持可重入

4. Semaphore

Semaphore:信号量,用来限制能同时访问共享资源的线程上限。对于ReentrantLock来讲,本质上是属于独占临界资源。如下面的示例代码:

public class SemaphoreTest {
    private static final Logger log = LoggerFactory.getLogger(SemaphoreTest.class);
    public static void main(String[] args) {
        // 创建一个Semaphore对象 --> 运行同时有3个线程访问共享资源,且支持公平锁和非公平锁(默认)
        Semaphore semaphore = new Semaphore(3);
        for (int i = 0; i < 10; i++) {
            // 获得semaphore对象
            new Thread(()->{
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                try{
                    log.debug("running...");
                    Thread.sleep(1000);
                    log.debug("end...");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }finally {
                    semaphore.release();
                }
            }).start();
        }
    }
}
20:20:23.555 [Thread-2] DEBUG org.example3.SemaphoreTest - running...
20:20:23.555 [Thread-0] DEBUG org.example3.SemaphoreTest - running...
20:20:23.555 [Thread-1] DEBUG org.example3.SemaphoreTest - running...
20:20:24.569 [Thread-1] DEBUG org.example3.SemaphoreTest - end...
20:20:24.569 [Thread-2] DEBUG org.example3.SemaphoreTest - end...
20:20:24.569 [Thread-0] DEBUG org.example3.SemaphoreTest - end...
20:20:24.570 [Thread-7] DEBUG org.example3.SemaphoreTest - running...
20:20:24.570 [Thread-6] DEBUG org.example3.SemaphoreTest - running...
20:20:24.570 [Thread-4] DEBUG org.example3.SemaphoreTest - running...
20:20:25.580 [Thread-7] DEBUG org.example3.SemaphoreTest - end...
20:20:25.580 [Thread-4] DEBUG org.example3.SemaphoreTest - end...
20:20:25.580 [Thread-5] DEBUG org.example3.SemaphoreTest - running...
20:20:25.580 [Thread-6] DEBUG org.example3.SemaphoreTest - end...
20:20:25.580 [Thread-3] DEBUG org.example3.SemaphoreTest - running...
20:20:25.581 [Thread-8] DEBUG org.example3.SemaphoreTest - running...
20:20:26.589 [Thread-3] DEBUG org.example3.SemaphoreTest - end...
20:20:26.589 [Thread-5] DEBUG org.example3.SemaphoreTest - end...
20:20:26.589 [Thread-8] DEBUG org.example3.SemaphoreTest - end...
20:20:26.589 [Thread-9] DEBUG org.example3.SemaphoreTest - running...
20:20:27.600 [Thread-9] DEBUG org.example3.SemaphoreTest - end...

Semaphore加锁原理

Semaphore有点像一个停车场,permits就好像停车位数量,当线程获得了permits就像是获得了停车位,然后停车场显示空余车位减一

刚开始permits state为3,这时5个线程来获取资源

image-20240809222108727

这里看一下Semaphore的构造方法:

public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}

我们发现Semaphore调用了非公平同步器类,我们再进去看看:

abstract static class Sync extends AbstractQueuedSynchronizer {
    private static final long serialVersionUID = 1192457210091910933L;

    Sync(int permits) {
        setState(permits);
    }

    final int getPermits() {
        return getState();
    }
	// ....
}

我们发现,原来啊, Semaphore的底层也是使用AQS,且把最多允许该资源所访问的线程数赋值给AQS同步器的state状态,比如上面有5个线程获取这个资源,而该资源只允许最大3个线程访问,因此Semaphore底层的AQS同步器的state状态值位3。

假设其中Thread-l,Thread-2,Thread-4cas竞争成功,而Thread-0和Thread-3竞争失败,进入AQS队列park阻塞

final int nonfairTryAcquireShared(int acquires) {
            for (;;) {
                // 获取同步器的state状态值
                int available = getState();
                // 剩余的线程可进入数
                int remaining = available - acquires;
                if (remaining < 0 ||
                    // 使用CAS设置同步器状态值
                    compareAndSetState(available, remaining))
                    return remaining;
            }
        }
}

image-20240809234751093

其实,说白了就是当达到permit限制后(或者说是同步器的state状态变为0)后,剩余要竞争的线程会进入AQS同步器的阻塞队列中排队等待。

Semaphore的解锁原理

这时线程Thread-4释放了permits. 状态如下:

image-20240809235204946

接下来Thread-0竞争成功,permits再次设置为0,设置自己为head节点,断开原来的head节点,unpark接下来的Thread-3节点,但由于permits是0,因此Thread-3在尝试不成功后再次进入park状态。

我们查看一下release方法:

public void release() {
    sync.releaseShared(1);
}
protected final boolean tryReleaseShared(int releases) {
        for (;;) {
            // 获取当前同步器的状态
            int current = getState();
            // 将同步器的状态值+1
            int next = current + releases;
            if (next < current) // overflow
                throw new Error("Maximum permit count exceeded");
            // 使用CAS修改同步器中的状态值
            if (compareAndSetState(current, next))
                return true;
        }
    }

接下来Thread-0竞争成功,permits再次设置为0,设置自己为head节点,断开原来的head节点,unpark接下来的Thread-3节点,但由于permits是0,因此Thread-3在尝试不成功后再次进入park状态:

image-20240810000023856

5. CountdownLatch

CountdownLatch又名倒计时锁,用来进行线程间的同步协作,等待所有线程完成倒计时以后才恢复运行。

其中构造函数的初始化等待计数值,await()用来等待计数归零,countDown()用来让计数-1。

我们来看一下CountdownLatch的源码:

package java.util.concurrent;

import java.util.concurrent.locks.AbstractQueuedSynchronizer;


public class CountDownLatch {
  
    // 使用了AQS同步
    private static final class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = 4982264981922014374L;

        Sync(int count) {
            setState(count);
        }

        int getCount() {
            return getState();
        }

        // 使用的是共享锁
        protected int tryAcquireShared(int acquires) {
            return (getState() == 0) ? 1 : -1;
        }

        // 获取countdownLatch锁
        protected boolean tryReleaseShared(int releases) {
            // Decrement count; signal when transition to zero
            for (;;) {
                // 从AQS中获取状态值
                int c = getState();
                if (c == 0)	// 一旦同步器的状态值减为0则唤醒阻塞队列中的线程
                    return false;
                // 使用CAS操作修改同步器中的值
                int nextc = c - 1;
                if (compareAndSetState(c, nextc))
                    return nextc == 0;
            }
        }
    }

    private final Sync sync;

    public CountDownLatch(int count) {
        if (count < 0) throw new IllegalArgumentException("count < 0");
        this.sync = new Sync(count);
    }

    public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
    }

    public boolean await(long timeout, TimeUnit unit)
        throws InterruptedException {
        return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
    }

    public void countDown() {
        sync.releaseShared(1);
    }

    public long getCount() {
        return sync.getCount();
    }
}

我们发现CountdownLatch其内部也是维护了一个AQS同步器,用的也是共享锁。接下来我们演示一下倒计时锁的用法:

public class CountdownLatchTest {
    private static final Logger log = LoggerFactory.getLogger(CountdownLatchTest.class);
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(3);
        new Thread(()->{
            log.debug("begin...");
            try {
                Thread.sleep(1000);
                // 计数-1
                latch.countDown();
                log.debug("end...");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"t1").start();

        new Thread(()->{
            log.debug("begin...");
            try {
                Thread.sleep(2000);
                // 计数-1
                latch.countDown();
                log.debug("end...");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"t2").start();

        new Thread(()->{
            log.debug("begin...");
            try {
                Thread.sleep(1500);
                // 计数-1
                latch.countDown();
                log.debug("end...");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"t3").start();


        log.debug("waiting...");
        latch.await();  // 等待计数器归0
        log.debug("wait end...");
    }
}
21:21:27.536 [t1] DEBUG org.example2.a49.CountdownLatchTest - begin...
21:21:27.536 [t2] DEBUG org.example2.a49.CountdownLatchTest - begin...
21:21:27.536 [t3] DEBUG org.example2.a49.CountdownLatchTest - begin...
21:21:27.536 [main] DEBUG org.example2.a49.CountdownLatchTest - waiting...
21:21:28.546 [t1] DEBUG org.example2.a49.CountdownLatchTest - end...
21:21:29.050 [t3] DEBUG org.example2.a49.CountdownLatchTest - end...
21:21:29.548 [t2] DEBUG org.example2.a49.CountdownLatchTest - end...
21:21:29.548 [main] DEBUG org.example2.a49.CountdownLatchTest - wait end...

CountdownLatch的改进-配合线程池使用

我们前面学过join关键字也可以实现等待其它线程运行结束获取结果,为什么还要使用CountdownLatch呢?这是因为,join是一个比较底层的API,用起来相对比较繁琐,而CountdownLatch是一个比较高级的API。使用起来比较简单,适用于各种场景。

上面的例子我们使用过了3个线程,但以后我们肯定会使用线程池的情况比较多,不会来自己创建线程,而是从线程池中获取线程达到线程的重用。

下面我们用线程池改进一下:

public class CountdownLatchTest {
    private static final Logger log = LoggerFactory.getLogger(CountdownLatchTest.class);

    public static void main(String[] args) throws InterruptedException {
        ExecutorService service = Executors.newFixedThreadPool(4);
        CountDownLatch latch = new CountDownLatch(3);
        service.submit(()->{
            log.debug("begin...");
            try {
                Thread.sleep(1000);
                // 计数-1
                latch.countDown();
                log.debug("end...");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        service.submit(()->{
            log.debug("begin...");
            try {
                Thread.sleep(2000);
                // 计数-1
                latch.countDown();
                log.debug("end...");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        service.submit(()->{
            log.debug("begin...");
            try {
                Thread.sleep(1500);
                // 计数-1
                latch.countDown();
                log.debug("end...");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        // 再来一个任务等待结果
        service.submit(()->{
            try {
                log.debug("waiting...");
                latch.await();  // 等待计数器归0
                log.debug("wait end...");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
    }
}
23:38:24.914 [pool-1-thread-2] DEBUG org.example2.a49.CountdownLatchTest - begin...
23:38:24.914 [pool-1-thread-3] DEBUG org.example2.a49.CountdownLatchTest - begin...
23:38:24.914 [pool-1-thread-4] DEBUG org.example2.a49.CountdownLatchTest - waiting...
23:38:24.914 [pool-1-thread-1] DEBUG org.example2.a49.CountdownLatchTest - begin...
23:38:25.925 [pool-1-thread-1] DEBUG org.example2.a49.CountdownLatchTest - end...
23:38:26.431 [pool-1-thread-3] DEBUG org.example2.a49.CountdownLatchTest - end...
23:38:26.933 [pool-1-thread-2] DEBUG org.example2.a49.CountdownLatchTest - end...
23:38:26.933 [pool-1-thread-4] DEBUG org.example2.a49.CountdownLatchTest - wait end...

使用较为底层的join也可以实现,不过实现起来比较复杂,我们还是推荐使用JUC并发工具包封装好的更高层的API来完成我们需要的同步功能。

CountdownLatch应用-等待多线程准备完毕

我们使用多个线程来模拟多个玩家的游戏加载进度,直到所有玩家准备好才开始游戏:

public class CountdownLatchTest {
    private static final Logger log = LoggerFactory.getLogger(CountdownLatchTest.class);

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(10);
        Random r = new Random();
        ExecutorService service = Executors.newFixedThreadPool(10);
        // 使用数组表示加载结果
        String[] all = new String[10];

        for (int j = 0; j < 10; j++) {
            int k = j;
            service.submit(() -> {
                for (int i = 0; i <= 100; i++) {
                    try {   // 模拟加载过程
                        Thread.sleep(r.nextInt(100));
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    all[k] = i + "%";
                    System.out.print("\r" + Arrays.toString(all));    // 回车覆盖掉前面的结果(加载更新)
                }
                // countdownLatch计数-1
                latch.countDown();
            });
        }
        latch.await();
        System.out.println();
        System.out.println("游戏开始...");
        service.shutdown();
    }
}
[100%, 100%, 100%, 100%, 100%, 100%, 100%, 100%, 100%, 100%]
游戏开始...

CountdownLatch应用-同步等待多个远程调用结束

我们做分布式开发都需要使用restful方式来进行多次远程调用,那么有时候我们需要进行多次远程调用后才会继续向下执行,举个简单的例子,如下面的示例代码:

// 调用订单信息
@GetMapping("/order/{id}")
public Map<String, Object> order(@PatVariable("id")int id){
    HashMap<String, Object> map = new HashMap<>();
    map.put("id", id);
    map.put("total", 2000);
    return map;
}

// 调用商品信息
@GetMapping("/product/{id}")
public Map<String,Object>product(@PathVariable int id){
    HashMap<string,Object>map new HashMap<>();
    if (id ==1){
        map.put("name","小爱音箱");
        map.put("price",300);
    else if (id ==2){
        map.put("name","小米手机");
        map.put("price",2000);
        map.put("id",id);
    }
    sleep(millis:1000);
    return map;
}


// 调用物流信息
@GetMapping("/logistics/{id}")
public Map<String,Object>logistics(@PathVariable int id){
    HashMap<String,Object>map new HashMap<>();
    map.put("id",id);
    map.put("name","中通快递");
    sleep(millis:2500);
	return map;
}

主线程只有依次调用了订单信息服务,商品信息服务,物流信息服务才会继续执行。我们来做一下这个功能:

private static void test3(){
    RestTemplate restTemplate new RestTemplate();
    Log.debug("begin");
    // 请求订单信息
    Map<String,Object>response restTemplate.getForObject(url:"http://localhost:8080/order/{1)",Map.class,..uriVariables:1);
    Log.debug("end order:{}"response);
    // 请求商品信息
    Map<String,Object>response1 restTemplate.getForObject(url:"http://localhost:8080/product/{1)",Map.class,..uriVariables:1);
    Map<String,Object>response2 restTemplate.getForObject(url:"http://localhost:8080/product/{1)",Map.class,..uriVariables:2);
    Log.debug("end product:{}{}"responsel,response2);
    // 请求物流信息
    Map<String,Object>response3 restTemplate.getForObject(url:"http://localhost:8080/logistics/{1)",Map.class,..uriVariables:1);
    Log.debug("end logistics:{}"response3);
}

上面的三个远程服务调用是依次串行执行的,因此呢,效率是比较低的。所花费的时间是这三个远程调用的时间之和。那么怎么让这三个服务并行执行呢?我们可以使用多线程+CountdownLatch来解决,我们可以改造一下上面的代码:

public void test4(){
    RestTemplate restTemplate new RestTemplate();
    ExecutorService service Executors.newCachedThreadPool();
    CountDownLatch latch new CountDownLatch(4);
    
    // 提交订单服务
    service.submit(()->{
        Map<String,Object>response restTemplate.getForObject(url:"http://localhost:8080/order/{1)",Map.class,...uriVariables:1);
        Log.debug("end order:{}"response);
        latch.countDown();
    });

    // 提交商品服务
    service.submit(()->{
        Map<String,Object>response2 restTemplate.getForObject(url:"http://localhost:8080/product/{1)",Map.class,..uriVariables:1);
        Log.debug("end product:{}{}"response1);
        latch.countDown();
    });

    // 提交物流服务
    service.submit(()->{
        Map<String,Object>response3 restTemplate.getForObject(url:"http://localhost:8080/logistics{1}",Map.class,..uriVariables:1);
        Log.debug("end logistics:{}"response3);
        latch.countDown()
    });
    
    // 等待计数器归零,等待结果
    latch.await();
    log.debug("执行结束");
    service.shutdown();
}

这样的方式花费的时间是这三个服务所花费时间的最大值。并行执行。

6. future应用-等待多个远程调用结束

前面我们用了CountdownLatch完成了主线程等待线程池中四个远程请求任务都完成后才汇总继续向下执行。但是我们看到一个问题,主线程并没有获取到线程池中线程任务完成后的结果。主线程该如何获取呢?此时只使用CountdownLatch就不够用了,我们可以配合Future来使用让主线程获取线程池中任务线程处理后的结果。

public void test5(){
    RestTemplate restTemplate new RestTemplate();
    ExecutorService service Executors.newCachedThreadPool();
    CountDownLatch latch new CountDownLatch(4);
    
    // 提交订单服务
    Future<Map<String, Object>> f1 = service.submit(()->{
        Map<String,Object>response =  restTemplate.getForObject(url:"http://localhost:8080/order/{1)",Map.class,...uriVariables:1);
        // Log.debug("end order:{}"response);
        return response1;
    });

    // 提交商品服务
    Future<Map<String, Object>> f2 = service.submit(()->{
        Map<String,Object>response2=restTemplate.getForObject(url:"http://localhost:8080/product/{1)",Map.class,..uriVariables:1);
        // Log.debug("end product:{}{}"response1);
         return response2;
    });

    // 提交物流服务
    Future<Map<String, Object>> f3 = service.submit(()->{
        Map<String,Object>response3=restTemplate.getForObject(url:"http://localhost:8080/logistics{1}",Map.class,..uriVariables:1);
        // Log.debug("end logistics:{}"response3);
         return response3;
    });
    
    // 主线程调用get()等待线程池中的线程执行完毕
    System.out.println(f1.get());
    System.out.println(f2.get());
    System.out.println(f3.get());
    log.debug("执行结束");
    service.shutdown();
}

7. cyclicBarrier(循环栅栏)

如下面的代码:

public class TestCyclicBarrier {

    private static final Logger log = LoggerFactory.getLogger(TestCyclicBarrier.class);

    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(5);
        CountDownLatch latch = new CountDownLatch(2);

        service.submit(()->{
            log.debug("task1 start...");
            try {
                Thread.sleep(1000);
                latch.countDown();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        service.submit(()->{
            log.debug("task2 start...");
            try {
                Thread.sleep(2000);
                latch.countDown();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        try{
            latch.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.debug("task1 task2 finish...");
        service.shutdown();
    }
}

这是一个简单的使用CountdownLatch处理同步任务的用法。结果如下:

20:51:05.954 [pool-1-thread-1] DEBUG org.example2.a50.TestCyclicBarrier - task1 start...
20:51:05.954 [pool-1-thread-2] DEBUG org.example2.a50.TestCyclicBarrier - task2 start...
20:51:07.958 [main] DEBUG org.example2.a50.TestCyclicBarrier - task1 task2 finish...

但是我现在有一个需求,我希望这个task1和task2被反复运行3遍,同样的,主线程也要运行3次做结果的会送。有人认为,这很简单,把其中的逻辑放在一个for循环里循环3次不就行了,这真的可以吗?我们来试验一下:

public class TestCyclicBarrier {

    private static final Logger log = LoggerFactory.getLogger(TestCyclicBarrier.class);

    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 3; i++) {
            CountDownLatch latch = new CountDownLatch(2);

            service.submit(()->{
                log.debug("task1 start...");
                try {
                    Thread.sleep(1000);
                    latch.countDown();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });

            service.submit(()->{
                log.debug("task2 start...");
                try {
                    Thread.sleep(2000);
                    latch.countDown();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });

            try{
                latch.await();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.debug("task1 task2 finish...");
        }

        service.shutdown();
    }
}
20:55:44.508 [pool-1-thread-2] DEBUG org.example2.a50.TestCyclicBarrier - task2 start...
20:55:44.508 [pool-1-thread-1] DEBUG org.example2.a50.TestCyclicBarrier - task1 start...
20:55:46.521 [main] DEBUG org.example2.a50.TestCyclicBarrier - task1 task2 finish...
20:55:46.524 [pool-1-thread-3] DEBUG org.example2.a50.TestCyclicBarrier - task1 start...
20:55:46.524 [pool-1-thread-4] DEBUG org.example2.a50.TestCyclicBarrier - task2 start...
20:55:48.536 [main] DEBUG org.example2.a50.TestCyclicBarrier - task1 task2 finish...
20:55:48.538 [pool-1-thread-1] DEBUG org.example2.a50.TestCyclicBarrier - task2 start...
20:55:48.538 [pool-1-thread-5] DEBUG org.example2.a50.TestCyclicBarrier - task1 start...
20:55:50.548 [main] DEBUG org.example2.a50.TestCyclicBarrier - task1 task2 finish...

其实是可以的!虽然完成了这个任务,但是我们发现了一个问题,就是这个CountdownLatch这个对象被创建了3次,每循环一次,就创建一次。那能不能重用这个CountdownLatch对象呢?答案是不能的。因为CountdownLatch只能在构造方法上初始化初始值,之后这个值是不能改变的!也就是说CountDownLatch对象其中的计数器是不允许外界改变的。那么有没有更适合我们刚才提出的这种场景使用的工具类呢?答案是有的:cyclicBarrier(循环栅栏)。

循环栅栏,用来进行线程协作,等待线程满足某个计数。构造时设置『计数个数」,每个线程执行到某个需要同步的时刻调用await()方法进行等待,当等待的线程数满足『计数个数』时,继续执行。cyclicBarrier循环栅栏和CountdownLatch最大的区别就是循环栅栏是可以重用的,也就是说循环栅栏中的计数器可以恢复到初始值的。而不是像CountdownLatch那样内部计数器用完了就得重新创建一个新的CountdownLatch对象。

下面呢,我们就使用cyclicBarrier循环栅栏来改造我们上面写的代码:

public class TestCyclicBarrier {

    private static final Logger log = LoggerFactory.getLogger(TestCyclicBarrier.class);

    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(2);
        // 创建一个循环栅栏对象
        CyclicBarrier barrier = new CyclicBarrier(2, ()->{
            //当任务执行完毕就会执行该任务
            log.debug("task1, task2 finished...");
        });

        for (int i = 0; i < 3; i++) {
            service.submit(() -> {
                log.debug("task1 start...");
                try {
                    Thread.sleep(1000);
                    barrier.await();    // 类似于CountdownLatch中的countdown()方法
                } catch (BrokenBarrierException | InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });

            service.submit(() -> {
                log.debug("task2 start...");
                try {
                    Thread.sleep(2000);
                    barrier.await();    // 类似于CountdownLatch中的countdown()方法
                } catch (BrokenBarrierException | InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });
        }

        service.shutdown();
    }
}
21:16:15.477 [pool-1-thread-1] DEBUG org.example2.a50.TestCyclicBarrier - task1 start...
21:16:15.477 [pool-1-thread-2] DEBUG org.example2.a50.TestCyclicBarrier - task2 start...
21:16:17.495 [pool-1-thread-2] DEBUG org.example2.a50.TestCyclicBarrier - task1, task2 finished...
21:16:17.496 [pool-1-thread-2] DEBUG org.example2.a50.TestCyclicBarrier - task2 start...
21:16:17.496 [pool-1-thread-1] DEBUG org.example2.a50.TestCyclicBarrier - task1 start...
21:16:19.509 [pool-1-thread-2] DEBUG org.example2.a50.TestCyclicBarrier - task1, task2 finished...
21:16:19.509 [pool-1-thread-2] DEBUG org.example2.a50.TestCyclicBarrier - task1 start...
21:16:19.509 [pool-1-thread-1] DEBUG org.example2.a50.TestCyclicBarrier - task2 start...
21:16:21.511 [pool-1-thread-1] DEBUG org.example2.a50.TestCyclicBarrier - task1, task2 finished...

我们发现,CyclicBarrier循环栅栏在一次完整的使用结束后就会重新复用该对象,其内部的计时器也会重置为默认值继续使用。

注意:线程池中的线程数要和循环栅栏中的任务数要保持一致,否则就不会正确的执行任务执行完毕时执行的任务!举个例子:

假设线程池中创建了3个线程,而循环栅栏中有两个任务,其中task1(休眠1S), task2(休眠2S),这样,是不是可以有3个任务同时执行:

第一遍task1(1S)->第一遍task2(2S)->第二遍task1(1S),所以最终是两次task1先执行完了,它们两个把循环栅栏中的计数器减为0了,导致任务执行完毕后执行的任务出现了错误,不再是第一次task1和第一次task2执行完毕,而是第一次task1和第二次task1执行完毕了!

8. 线程安全集合类概述

image-20240813194740622

由于线程安全集合类非常的多,这里呢,我们把它分成三大类:

  • 遗留的线程安全集合类,如Hashtable, Vector(synchronized关键字修饰,并发性能比较的低)
  • 使用Collections修饰的线程安全集合类,将原本不安全的集合变为安全的集合。如:
    • Collections.synchronizedCollection
    • Collections.synchronizedList
    • Collections.synchronizedMap
    • Collections.synchronizedset
    • Collections.synchronizedNavigableMap
    • Collections.synchronizedNavigableset
    • Collections.synchronizedSortedMap
    • Collections.synchronizedSortedset
  • java.util.concurrent.* 包下的线程安全集合类(推荐)
    • Blocking类
    • CopyOnWrite类
    • Concurrent类

我们重点介绍JUC包下的线程安全的集合类,我们可以发现它们有规律,里面包含三类关键字:Blocking, CopyOnWrite, Concurrent

  • Blocking大部分实现基于锁,例如阻塞队列,并提供用来阻塞的方法
  • CopyOn Write之类容器修改开销相对较重(在修改时采用拷贝的方式避免多线程访问读写时的并发安全问题)-->适用于读多写少的情形
  • Concurrent类型的容器(推荐)
    • 内部很多操作使用cs优化,一般可以提供较高吞吐量
    • 弱一致性
      • 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
      • 求大小弱一致性,size操作未必是100%准确
      • 读取弱一致性

遍历时如果发生了修改,对于非安全容器来讲,使用fail-fast机制也就是让遍历立刻失败,抛出 ConcurrentModificationException,不再继续遍历。

8.1 ConcurrentHashMap

ConcurrentHashMap的错误用法

练习:单词计数

我们首先看一个示例,如下面的代码:

public class WordCountTest {
    static final String ALPHA = "abcdefghijklmnopqrstuvwxyz";

    public static void main(String[] args) throws FileNotFoundException {
        int length = ALPHA.length();
        int count = 200;
        List<String> list = new ArrayList<>(length * count);
        for (int i = 0; i < length; i++) {
            char ch = ALPHA.charAt(i);
            for (int j = 0; j < count; j++) {
                list.add(String.valueOf(ch));
            }
        }

        // 打乱集合
        Collections.shuffle(list);
        for (int i = 0; i < 26; i++) {
            try (PrintWriter out = new PrintWriter(
                    new OutputStreamWriter(
                            new FileOutputStream("tmp/" + (i + 1) + ".txt")))) {
                String collect = list.subList(i * count, (i + 1) * count).stream()
                        .collect(Collectors.joining("\n"));
                out.print(collect);
            }
        }
    }

    private static <V> void demo(Supplier<Map<String,V>> supplier,
                                 BiConsumer<Map<String,V>,List<String>> consumer) {
        Map<String, V> counterMap = supplier.get();
        List<Thread> ts = new ArrayList<>();
        for (int i = 1; i <= 26; i++) {
            int idx = i;
            Thread thread = new Thread(() -> {
                List<String> words = readFromFile(idx);
                consumer.accept(counterMap, words);
            });
            ts.add(thread);
        }
        ts.forEach(t->t.start());
        ts.forEach(t-> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        System.out.println(counterMap);
    }

    public static List<String> readFromFile(int i) {
        ArrayList<String> words = new ArrayList<>();
        try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream("tmp/"
                + i +".txt")))) {
            while(true) {
                String word = in.readLine();
                if(word == null) {
                    break;
                }
                words.add(word);
            }
            return words;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

如果计数正确,每个字母会出现200次:

{a=200, b=200, c=200, d=200, e=200, f=200, g=200, h=200, i=200, j=200, k=200, l=200, m=200, 
n=200, o=200, p=200, q=200, r=200, s=200, t=200, u=200, v=200, w=200, x=200, y=200, z=200} 

实现的模板代码为:

demo(
     // 创建 map 集合
     // 创建 ConcurrentHashMap 对不对?
     () -> new HashMap<String, Integer>(),
     // 进行计数
     (map, words) -> {
         for (String word : words) {
             Integer counter = map.get(word);
             int newValue = counter == null ? 1 : counter + 1;
             map.put(word, newValue);
         }
     }
);

上面的这段代码有什么问题吗?当然是有的,这段代码涉及到多线程安全问题,每个线程共享的map集合,map属于是共享资源,因此肯定是会有线程安全问题。上面的运行结果也展示了map集合没有使用线程安全保护导致计数不准,那么怎么才能让计数准确呢?创建一个ConcurrentHashMap可以吗?如下面的代码:

demo(
     () -> new ConcurrentHashMap<String, Integer>(),
     // 进行计数
     (map, words) -> {
         for (String word : words) {
             Integer counter = map.get(word);
             int newValue = counter == null ? 1 : counter + 1;
             map.put(word, newValue);
         }
     }
);

答案是不行的!

{a=192,b=194,c=197,d=189,e=198,f=196,g=193,h=198,i=197,j=197,k=194,1=191,m=189
,n=193,0=197,p=195,q=198,r=193,s=198,t=197,u=192,V=198,w=183,X=197,y=196,Z=195}

这是为什么呢?明明ConcurrentHashMap是线程安全的呀,为什么这里就线程不安全了呢?我们以前讲过:所谓的线程安全集合指的是集合中的每一个方法是线程安全的(是原子操作),但不能保证多个原子方法组合在一起使用是线程安全的!

那么我们如何保证多个原子操作组合在一起是安全操作呢?我们该怎么正确使用ConcurrentHashMap呢?

ConcurrentHashMap之computeIfAbsent

为了保证多个原子方法组合在一起使用是线程安全的,我们可以使用加锁的方式来实现解决该问题:

demo(
     () -> new ConcurrentHashMap<String, Integer>(),
     // 进行计数
     (map, words) -> {
         for (String word : words) {
             synchronized(map){
                 Integer counter = map.get(word);
                 int newValue = counter == null ? 1 : counter + 1;
                 map.put(word, newValue);
             }
         }
     }
);

这种加锁的方式可以实现吗?答案是可以的。但是这样做真的好吗,synchronized是重量级锁,影响了并发度,性能有所降低。

下面我们使用ConcurrentHashMap提供的更合适的方法computeIfAbsent()

computeIfAbsent:如果缺少一个k,则计算生成一个值v,并将该k-v放入到map中。

我们来改造一下上面的代码:

demo(
     () -> new ConcurrentHashMap<String, LongAdder>(),	// 使用累加器做原子累加操作
     (map, words) -> {
         for (String word : words) {
         // 注意不能使用 putIfAbsent,此方法返回的是上一次的 value,首次调用返回 null
         map.computeIfAbsent(word, (key) -> new LongAdder()).increment();
         }
     }
);

我们来测试运行一下:

{a=200,b=200,c=200,d=200,e=200,f=200,g=200,h=200,i=200,j=200,k=200,1=200,m=200
,n=200,0=200,p=200,q=200,r=200,s=200,t=200,u=200,V=200,w=200,X=200,y=200,Z=200}

我们可以看到我们的结果是正确的。

8.2 ConcurrentHashMap的原理深究

JDK7 HashMap并发死链问题

测试代码

注意

  • 要在JDK7下运行,否则扩容机制和hash计算方法都变了
  • 以下测试代码是精心准备的,不要随意改动!
package test;

import java.util.HashMap;

public class TestDeadLink {
    public static void main(String[] args) {
        // 测试 java 7 中哪些数字的 hash 结果相等
        System.out.println("长度为16时,桶下标为1的key");
        for (int i = 0; i < 64; i++) {
            if (hash(i) % 16 == 1) {
                System.out.println(i);
            }
        }
        System.out.println("长度为32时,桶下标为1的key");
        for (int i = 0; i < 64; i++) {
            if (hash(i) % 32 == 1) {
                System.out.println(i);
            }
        }
        // 1, 35, 16, 50 当大小为16时,它们在一个桶内
        final HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
        // 放 12 个元素
        map.put(2, null);
        map.put(3, null);
        map.put(4, null);
        map.put(5, null);
        map.put(6, null);
        map.put(7, null);
        map.put(8, null);
        map.put(9, null);
        map.put(10, null);
        map.put(16, null);
        map.put(35, null);
        map.put(1, null);

        System.out.println("扩容前大小[main]:"+map.size());
        new Thread() {
            @Override
            public void run() {
                // 放第 13 个元素, 发生扩容
                map.put(50, null);
                System.out.println("扩容后大小[Thread-0]:"+map.size());
            }
        }.start();
        new Thread() {
            @Override
            public void run() {
                // 放第 13 个元素, 发生扩容
                map.put(50, null);
                System.out.println("扩容后大小[Thread-1]:"+map.size());
            }
        }.start();
    }

    final static int hash(Object k) {
        int h = 0;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
}

运行结果如下:

长度为16时,桶下标为1的key
1
16
35
I
50
长度为32时,桶下标为1的key
1
35
扩容前大小[main]:12
扩容后大小[Thread-1]:13
扩容后大小[Thread-0]:13

死链复现

调试工具用IDEA,在HashMap源码的590行加断点,也就是:

int newCapacity = newTable.length;

断点的条件如下,目的是让HashMap在扩容为32时,并且线程为Thread-0或Thread-1时停下来

newTable.length==32 &&
	(
		Thread.currentThread().getName().equals("Thread-0") || 
		Thread.currentThread().getName().equals("Thread-1")
    )

扩容前Hashmap中旧的哈希table中最后三个元素形成单链表的情况(需要注意的是,JDK7及以前用的是头插法插入新的元素)

image-20240815095657064

接下来进入扩容流程调试
在HashMap源码594行加断点

Entry<K,V>next e.next;//593
if (rehash) //594
// ...

这是为了观察e节点和next节点的状态,Thread-0单步执行到594行,再594处再添加一个断点(条件Thread.currentThreadO.getName().equals("Thread-0")

这时可以在Variables面板观察到e和next变量,使用view as->object查看节点状态

e	(1)->(35)->(16)->null
next	(35)->(16)->null

在Threads面板选中Thread-1恢复运行,可以看到控制台输出新的内容如下,Thread-1扩容已完成

newTable[1]		(35)->(1)->null

这时Thread-0还停在594处,Variables面板变量的状态已经变化为

e		(1)->null
next	(35)->(1)->null	# 问题出现在这里了!

死链的错误思想类似于进行链表增删时,断链和增链在多线程条件下引发的安全问题。

下一轮循环到594,将e搬迁到new Table链表头

newTable[1]		(1)->null
e				(35)->(1)->null
next			(1)->null

下一轮循环到594,将e搬迁到new Table链表头

newTable[1]		(35)->(1)->null
e				(1)->null
next			null

Thread-0此时进行扩容时直接引发死链问题,也就是table表中的的单链表形成了死循环

image-20240815101347942

此时程序就会卡死在这里!

我们最后再看看源码:

e.next = newTable[1];
//这时e(1,35)
//而newTab1e[1](35,1)->(1,35)因为是同一个对象
newTable[1] = e;
//再尝试将e作为链表头,死链已成
e = next;
//虽然next是null,会进入下一个链表的复制,但死链已经形成了

源码分析

JDK7 HashMap发生死链出现在扩容时

//将tab1e迁移至newTable
void transfer(Entry[] newTable,boolean rehash){
    int newcapacity = newTable.length;
    for (Entry<K,V>e : table){
        while(null =!e){
            Entry<K,V>next = e.next;
            // 1处
            if (rehash){
            	e.hash null ==e.key ? 0 : hash(e.key);
            }	
            int i = indexFor(e.hash,newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

小结

  • 究其原因,就是在多线程条件下使用了非线程安全的map集合
  • JDK8虽然讲扩容算法做了调整,不再将新元素插入到链表头部,但并不意味着能够在多线程环境下能够安全进行扩容,可能也还会出现一些问题(例如扩容丢失数据问题)

JDK8 ConcurrentHashMap

重要属性和内部类

// 默认为0
// 1当初始化时,为-1
// 1当扩容时,为-(1+扩容线程数)
// 当初始化或扩容完成后,为下一次的扩容的阈值大小
private transient volatile int sizectl;

// 整个ConcurrentHashMap就是一个Node[]
static class Node<K,V>implements Map.Entry<K,V>{)
    
// hash表
transient volatile Node<K,V>[]table;
                                                
// 扩容时的新hash表
private transient volatile Node<K,V>[]nextTable;
                                                
// 扩容时如果某个bin迁移完毕,用ForwardingNode作为l旧table bin的头结点
static final class ForwardingNode<K,V>extends Node<K,V>{}
                                                
// 用在compute以及computeIfAbsent时,用来占位,计算完成后替换为普通Node
static final class ReservationNode<K,V>extends Node<K,V>{}
                                                
// 链表长度》>=8升级为红黑树,红黑树大小<=6降级为链表                                                
// 作为treebin的头节点,存储root和first
static final class TreeBin<K,V>extends Node<K,V>{}
                                                
// 作为treebin的节点,存储parent,left,right
static final class TreeNode<K,V>extends Node<K,V>{}

重要方法

// 获取Node[]中第i个Node
static final <K,V>Node<K,V>tabAt(Node<K,V>[]tab,int i)
    
// cas修改Node[]中第i个Node的值,c为旧值,v为新值
static final <K,V>boolean casTabAt(Node<K,V>[]tab,int i,Node<K,V>c,Node<K,V>v)

// 直接修改Node[]中第i个Node的值,v为新值
static final <K,V>void setTabAt(Node<K,V>[]tab,int i,Node<K,V>v)

构造器分析

可以看到实现了懒惰初始化,在构造方法中仅仅计算了table的大小,以后在第一次使用时才会真正创建

/**
 * 参数:
 * initialCapacity:初始容量
 * loadFactor:加载因子,默认为0.75,也就是扩容阈值
 * concurrencyLevel:并发度
 */
public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) {
    if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    // 初始容量 < 并发度时->初始容量为并发度
    if (initialCapacity < concurrencyLevel)   // Use at least as many bins
        initialCapacity = concurrencyLevel;   // as estimated threads
    // 初始容量 >= 并发度时
    // 将计算出来的结果保证为2^n,即16, 32, 64...,这和JDK的哈希算法相关
    // 同时将table表大小赋为该值
    long size = (long)(1.0 + (long)initialCapacity / loadFactor);
    int cap = (size >= (long)MAXIMUM_CAPACITY) ?
        MAXIMUM_CAPACITY : tableSizeFor((int)size);	// tableSizeFor保证最终的容量为2^n
    this.sizeCtl = cap;
}

get流程

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // spread确保返回的结果是正整数,因为调用hashCode()方法有可能是整数,也有可能是负数
    int h = spread(key.hashCode());
    // 如果table表不为空且table表中有元素,并寻找该位置所对应的链表的头节点不为空
    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为负数,表示该链表正处于扩容过程中,该节点或者该数组下标会被标记为forwordingNode. 或者是此时该节点为treeBin,也就是树节点,这时调用find()方法来查找。 
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
            // 正常遍历链表,用equals比较
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

put流程

以下数组简称(table),链表简称(bin)

接下来我们来看以下put流程

public V put(K key, V value) {
    return putVal(key, value, false);
}



final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    // spread:保证key的hash是一个正整数
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        // f 是链表头节点
        // fh 是链表头节点的hash值
        // i 是链表在table数组中的索引
        Node<K,V> f; int n, i, fh; K fk; V fv;
        // 判断数组table是否为空或者哈希表的长度是0
        if (tab == null || (n = tab.length) == 0)
            // 使用CAS初始化table数组,无需 synchronized 创建成功,进入下一轮循环
            tab = initTable();
        
        //当前并没有找到tabel表中对应的节点,也就是没有找到头节点,说明此处的位置是空的, 根据hash,k,v创建链表头节点
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 添加链表头使用了CAS, 无需synchronized
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break;                   // no lock when adding to empty bin
        }
        
        // 判断该table位置处的链表是不是处于扩容阶段,如果是处于扩容阶段,
        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) {
                    // 判断头节点的hash是否大于0
                    // 链表
                    if (fh >= 0) {
                        binCount = 1;
                        // 遍历链表
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            /// 找到相同的key
                            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;
                            // 找了一圈都没找到相同的节点,那就尾插法新增该Node节点,追加至链表尾部
                            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;
                        // putTreeVal会看到key是否已经在树中,是,则返回对应的TreeNode
                        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) {
                // 如果链表长度 >= 树化阈值(8),进行链表转为红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 增强size计数
    addCount(1L, binCount);
    return null;
}

initTable流程

// table数组懒惰初始化--只允许一个线程初始化table数组
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    // table数组为空或者长度为0表示table数组还未被创建
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        // 尝试将sizeCtl设置为-1(表述初始化数组)
        else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
            // 获得锁,创建table,这时其它线程会在while()循环中yield直至table数组被创建
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

addCount 流程

接下来我们看看addCount功能

addCount的作用就是增加哈希表中元素的计数。

// check 是之前binCount个数
private final void addCount(long x, int check) {
    CounterCell[] cs; long b, s;
    // 判断累加单元数组counterCells是否为空,还没有的话,向 baseCount 累加
    if ( // 没有累加单元数组
        (cs = counterCells) != null ||
        !U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell c; long v; int m;
        boolean uncontended = true;
        //  还没有累加单元
        if (cs == null || (m = cs.length - 1) < 0 ||
            (c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
            // 累加单元 CAS 增加计数失败
            !(uncontended =
              U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
            // 创建累加单元数组和累加单元,累加尝试
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        // 获取元素个数
        s = sumCount();
    }
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n) << RESIZE_STAMP_SHIFT;
            if (sc < 0) {
                if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
                    (nt = nextTable) == null || transferIndex <= 0)
                    break;
                // newTable 已经创建好了,帮助扩容
                if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            // 需要扩容,这时候newTable未被创建
            else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

size计算流程

size计算实际发生在put,remove改变集合元素的操作之中

  • 没有竞争发生,向baseCount累加计数
  • 有竞争发生,新建counterCells,向其中的一个cell累加计数
    • counterCells初始有两个cell
    • 如果计数竞争比较激烈,会创建新的cll来累加计数
public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}


final long sumCount() {
    CounterCell[] cs = counterCells;
    // 将beseCount 计数与所有累加单元计算相加
    long sum = baseCount;
    if (cs != null) {
        for (CounterCell c : cs)
            if (c != null)
                sum += c.value;
    }
    return sum;
}

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
    
    // 如果新的table是null
    if (nextTab == null) {            // initiating
        try {
            @SuppressWarnings("unchecked")
            // 直接将原有的table数组容量*2
            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
            }
        }
        
        // 链表头为null, 说明该链表已经被处理完了
        else if ((f = tabAt(tab, i)) == null)
            // 将链表头替换为fwd
            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");
                }
            }
        }
    }
}

JDK7 ConcurrentHashMap

未完成----后面填坑

LinkedBlockingQueue原理

  1. 基本的入队出队
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
     static class Node<E> {
        E item;

        /**
         * 下面三种情况之一
         * - 真正的后继节点
         * - 自己,发生在出队时
         * - null,表示没有后继节点,是最后一个节点
         */
        Node<E> next;

        Node(E x) { item = x; }
    }
}

初始化链表 last = head = new Node<\E>(null); Dummy(哑元或哨兵)节点用来占位,item为null.

image-20240820210013577

当一个节点入队时,last = last.next = node:

private void enqueue(Node<E> node) {
    last = last.next = node;
}

image-20240820210126510

再来一个节点入队时, last = last.next = node.

image-20240820210246453

出队

private E dequeue() {
    Node<E> h = head;
    Node<E> first = h.next;
    h.next = h; // help GC
    head = first;
    E x = first.item;
    first.item = null;
    return x;
}

h = head:

image-20240820210439018

first = h.next:

image-20240820210502757

h.next = h:--> 保证能够被安全的进行垃圾回收

image-20240820210546037

head = first:

image-20240820210647864

E x = first.item;
first.item = null;
return x;

image-20240820210744394

LinkedBlockingQueue加锁分析

高明之处-在于用于两把锁和dummy节点

  • 用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
  • 用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
    • 消费者与消费者线程仍然串行
    • 生产者与生产者线程仍然串行

线程安全分析

  • 当节点总数大于2时(包括dummy节点--此时队列中有多个元素--可存可取),putLock保证的是last节点的线程安全,takeLock保证的是head节点的线程安全。两把锁保证了入队和出队没有竞争

  • 当节点总数等于2时(即一个dummy节点,一个正常节点--此时队列有一个元素-可取可存)这时候,仍然是两把锁锁两个对象,不会竞争

  • 当节点总数等于1时(就一个dummy节点--此时队列中没有元素--可存不可取)这时take线程会被notEmpty条件阻塞,有竞争,会阻塞

 // 用于put(阻塞)offer(非阻塞)
private final ReentrantLock takeLock = new ReentrantLock();

// 用户take(阻塞)po11(非阻塞)
private final ReentrantLock putLock = new ReentrantLock();

put 流程

public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    final int c;	// 用来检查空位
    final Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    // count用来维护元素个数
    final AtomicInteger count = this.count;
    // putLock上一个可打断的锁
    putLock.lockInterruptibly();
    try {
        // 队列满了则等待
        while (count.get() == capacity) {
            notFull.await();
        }
        // 执行入队操作
        enqueue(node);
        // 维护队列元素的计数器+1
        c = count.getAndIncrement();
        // 判断此时队列中元素个数是否小于队列存储元素的上限,如果仍然小于,则唤醒其它put线程(注意是唤醒一个线程,而不是唤醒全部put线程,避免put线程竞争)
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        // 释放putLock锁
        putLock.unlock();
    }
    // 如果队列中有一个元素,唤醒 take 线程
    if (c == 0)
        // 这里调用的是notEmpty.signal()而不是notEmpty.signalAll()是为了减少竞争
        signalNotEmpty();
}

take

public E take() throws InterruptedException {
    final E x;
    final int c;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly();
    try {
        while (count.get() == 0) {
            notEmpty.await();
        }
        x = dequeue();
        c = count.getAndDecrement();
        if (c > 1)
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    
    //如果队列中只有一个空位时,叫醒put线程
	//如果有多个线程进行出队,第一个线程满足c=capacity,但后续线程c < capacity
    if (c == capacity)
        // 这里调用的是notFull.signa1()而不是notFull.signalAll()是为了减少竞争
        signalNotFull();
    return x;
}

性能比较

我们主要列举LinkedBlockingQueue与ArrayBlockingQueue的性能比较(ArrayBlockingQueue的性能是不如LinkedBlockingQueue的性能):

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

我们推荐在更多的场景下使用LinkedBlockingQueue作为阻塞队列。

ConcurrentLinkedQueue

ConcurrentLinkedQueue的设计与LinkedBlockingQueue非常像,也是

  • 两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
  • dummy节点的引入让两把【锁】将来锁住的是不同对象,避免竞争
  • 只是这【锁】使用了cas来实现

事实上,ConcurrentLinkedQueue应用还是非常广泛的。例如之前讲的Tomcat的Connector结构时,Acceptor作为生产者向Poller消费者传递事件信息时,正是采用
了ConcurrentLinkedQueue将SocketChannel给Poller使用

image-20240820233033737

CopyOnWriteArrayList

CopyOnWriteArraySet是它的马甲
底层实现采用了写入时拷贝的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其它线程的并发读,读写分离

以新增为例:

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;
    }
}

这里的源码版本是Java 11,在Java1.8中使用的是可重入锁而不是synchronized

其它读操作并未加锁,例如:

public void forEach(Consumer<? super E> action) {
    Objects.requireNonNull(action);
    for (Object x : getArray()) {
        @SuppressWarnings("unchecked") E e = (E) x;
        action.accept(e);
    }
}

COW适合【读多写少】的场景。

get弱一致性

image-20240820235510970

image-20240820235740598

不容其测试,但是确实存在。

迭代器弱一致性

public class CopyOnWriteArrayListTest {
    public static void main(String[] args) throws InterruptedException {
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        Iterator<Integer> iter = list.iterator();
        new Thread(()->{
            list.remove(0);
            System.out.println(list);
        }).start();

        Thread.sleep(1000);
        while (iter.hasNext()) {
            System.out.println(iter.next());
        }
    }
}

测试结果如下:

[2, 3]
1
2
3

我们发现,尽管子线程已经移除了索引为0的元素,但是对于主方法中的迭代器来讲,使用迭代器遍历的仍然是旧值!这就是一个特别典型的弱一致性例子!

但是不要觉得弱一致性就不好

  • 数据库的MVCC都是弱一致性的表现
  • 并发高和一致性是矛盾的,需要权衡
posted @   LilyFlower  阅读(29)  评论(0编辑  收藏  举报
编辑推荐:
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
历史上的今天:
2021-08-26 计算机网络-5-2-UDP
点击右上角即可分享
微信分享提示