Java中的JUC并发编程(下)

30 阅读35分钟

各种线程池实现

我们上面的线程池是我们自定义的线程池,而实际的线程池结构图如下

image-20230228163106560

其最常用的实现类就是ThreadPoolExecuteor,我们先来讲讲这个实现类

其使用int的高三位来表示线程池状态,其他位表示线程数量

image-20230228163232352

为什么RUNNING111反而是最小的呢?这是因为最高位的01表示正负,1代表负,负数当然比剩余的正数都要小

该数据保存在原子变量ctl中,之所以不用两个属性来表示是为了将这两个表示属性合二为一,这样就可以使用一次cas原子操作进行赋值,可以提高效率

image-20230228163733019

然后我们再来看看其构造方法,其中threadFactory线程工厂可以给线程创建时赋予名字

image-20230228163920311

其工作方式如下,c是核心线程,m是最大线程

image-20230228164102757

线程中最开始是没有线程的,当有任务进入时就会创建线程来滞后性,如果线程数量到达最大数量之后就会将进入放入阻塞队列中,如果阻塞队列也满了那么就会创建最大线程数-核心线程数的值的救急线程,该线程也会进入工作,如果这样还有任务无处执行,此时就会执行用户设定的拒绝策略

值得一提的是,救急线程是有生命周期的,当没有任务执行时,其在经过一段用户指定的时间之后就会消亡

image-20230228164128504

image-20230228164142524

拒绝策略的接口是RejectedExecutionHandler,其下有很多实现类,分别对应不同的拒绝策略

image-20230228164650044

同时其也提供不同的线程池实现类,先来讲newFixedThreadPool

该类的特点是核心线程数==最大线程数,没有任何的救急线程,因此如需指定救急线程的超时时间,其适用于任务量已知且耗时的任务

image-20230228165112882

newCachedThreadPool该类只有救急线程且可以近乎无限创建,其阻塞队列是SynchronousQueue,特点是没有容量,如果没有线程来取任务对象,是不会将任务对象放进该对象中的

image-20230228165729672

image-20230228165738890

整个线程池表现为线程数会根据任务量不断增长,没有上限,当任务执行完毕,空闲 1分钟后释放线 程。 适合任务数比较密集,但每个任务执行时间较短的情况

newSingleThreadExecutor线程池会创建只有一个线程的线程池,不存在任何救急线程

这时候有人就会觉得这不是脱裤子放屁吗?我创建一个线程不就得了吗?这就目光短浅了,单例线程池会保证线程池中一直都有一个线程,这样即使线程突然结束,也会有新的线程创建来处理后面的任务

当然如果我们用上面的newFixedThreadPool并且只指定一个线程也是可以做到上面的效果的,但是上面的线程池是单例线程池,其是只能允许只有一个线程的,这样就能保证单例不被破坏,之所以能做到是因为其实现类没有暴露修改线程池线程数量属性的接口

image-20230301002003397

线程池类中还有对应的方法,主要有以下方法

// 执行任务
void execute(Runnable command);
// 提交任务 task,用返回值 Future 获得任务执行结果
<T> Future<T> submit(Callable<T> task);
// 提交 tasks 中所有任务
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
        throws InterruptedException;
// 提交 tasks 中所有任务,带超时时间
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
                              long timeout, TimeUnit unit)
        throws InterruptedException;
// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks)
        throws InterruptedException, ExecutionException;
// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消,带超时时间
<T> T invokeAny(Collection<? extends Callable<T>> tasks,
                long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;

关闭线程池采用shutdown方法,该方法会先上锁之后修改线程池状态并打断空闲线程,然后尝试终结线程池,此时如果没有运行的线程就可以立刻终结,如果有也不会等,照样终结

image-20230301003758145

shutdownNow()方法前面的步骤和之前一暗影,但是最后其会获取队列中的剩余任务并同样尝试终结,我们可以在获取到队列的剩余任务之后对其做一些其他处理

image-20230301003939354

image-20230301003949910

最后还有一些其他方法

image-20230301004149217

任务调度

令有限的工作线程来轮流处理无限多的任务,可以将其归类为分工模式,其典型实现就是线程池,也体现了享元模式的设计

当然我们也可以无脑让每一个任务都有一个线程进行处理,但这样的效率属实是太低了,为了提升效率同时也为了避免饥饿,我们需要让一定数量的工作线程去处理无限的任务,同时工作线程应该各司其职,各出其力,而不是一个线程身兼多职

image-20230301083335029

饥饿现在指的是在固定大小的线程里由于一个线程身兼多个职能,此时就会出现有些线程完全无法得到任务或者由于线程数限制导致业务需求无法被处理

image-20230301084125946

比如说下面的案例里,由于我们的线程池最大线程数设定为2,而一个线程身兼多职指的是一个线程处理另一个任务时可以同样再调用同一个线程池的新线程来处理之前设定任务。但由于我们的线程池最大数量设置为2,这样链各个线程都同时处理点餐的话,后面就没有线程能被创建出来用来处理做菜任务了,此时业务需求就无法满足,注意,尽管这种情况的表现形式很像死锁,但其并不是死锁,这点要搞清楚

image-20230301083645099

解决的方法也很简单,只要创建两个不同的线程池,处理不同的业务时调用对应的线程池里的线程来处理即可

线程数量如果创建过少就不能充分利用系统资源,也容易导致饥饿,过大就会占用太多内存而得不偿失

image-20230301084632991

如果是CPU密集型计算,我们推荐采用CPU核数+1的线程数

image-20230301084751385

如果是IO密集型运算,那么我们推荐使用下面的公式来得到推荐使用的线程数量

image-20230301084834604

在任务调度线程池功能加入之前,我们可以使用java.util.Timer类来实现线程定时执行任务功能,但是调用该类方法会导致所有任务都由一个线程调度,所有任务都是串行执行的,且前一个任务的延迟或者异常都会影响后面的任务,因此该类目前已不再推荐使用

image-20230301094046863

我们推荐使用ScheduledExecutorService,其也是一种线程池的实现,同样要指定核心线程数,需要设定定时任务时调用schedule方法即可,后面需要指定延迟执行的时间和时间单位,使用该类前一个任务和延迟或者异常就不会影响到后面的任务

image-20230301094245082

调用ScheduledExecutorService对象的scheduleAtFixedRate()方法可以实现自动执行定时任务,其下有四个参数,第一个参数是执行任务的线程,第二个参数是第一次延迟执行的时间,第三个参数是任务执行时间的间隔,第四个参数是时间单位

比如在下面的例子里,其线程的第一次执行的延迟时间是1s,每两次任务执行时间的间隔是1s

image-20230301094736947

但是这个间隔只能尽力完成而不能保证完成,如果你的任务实在是太过耗时,那么两个任务的执行时间间隔会变大

调用scheduleWithFixedDelay方法和上面的效果几乎一样,不同的是其第三个参数是第一个线程执行完毕之后要延迟的时间,而不是两个线程的执行间隔

image-20230301095343031

在线程池中的线程出现了异常会导致该线程无法执行,但是却不会报异常信息,因此我们正确处理的方式可以有两种

第一种是使用try...catch代码块主动捕捉异常,第二种是使用回调函数获得线程返回的Future对象,如果线程执行中出现了异常,其会将该异常封装并返回给Future对象

Tomcat中也使用了线程池,下面是Tomcat的线程池结构和对应的解释

image-20230301100306736

这里值得一提的是在Tomcat线程池中如果总线程数达到了最大线程数不会立刻抛出异常,其会再次尝试将任务放入阻塞队列,如果此时在失败,就会抛出异常

image-20230301100324204

其也有对应的配置,在对应的xml文件中可以修改对应的配置

image-20230301101142983

Executor的配置项比Connector配置项的优先级更高,如果遇上冲突设置,会以前者为准

image-20230301101152574

在Tomcat线程池中,添加新任务时会先判断线程数是否小于核心线程,若是则说明还有线程可以处理,此时将任务加入阻塞队列,反之会继续判断任务数量是否大于最大线程池,若是则此时创建救急线程进行处理,反之则将其加入队列,等待空闲线程进行来对其进行处理

image-20230301101326314

Fork/Join是JDK1.7加入的新的线程池实现,其内部体现一种分治思想,适用于能够将任务进行拆分的cpu密集型。

其执行的原理是在分治的基础上加入了多线程,可以把每个任务的分解和合并交给不同的线程来完成,其默认会创建用于cpu核心数大小相同的线程池

image-20230301110350367

提交给 Fork/Join 线程池的任务需要继承 RecursiveTask(有返回值)或 RecursiveAction(没有返回值),例如下 面定义了一个对 1~n 之间的整数求和的任务

@Slf4j(topic = "c.AddTask")
class AddTask1 extends RecursiveTask<Integer> {
    int n;
    public AddTask1(int n) {
        this.n = n;
    }
    @Override
    public String toString() {
        return "{" + n + '}';
    }
    @Override
    protected Integer compute() {
        // 如果 n 已经为 1,可以求得结果了
        if (n == 1) {
            log.debug("join() {}", n);
            return n;
        }
​
        // 将任务进行拆分(fork)
        AddTask1 t1 = new AddTask1(n - 1);
        t1.fork();
        log.debug("fork() {} + {}", n, t1);
​
        // 合并(join)结果
        int result = n + t1.join();
        log.debug("join() {} + {} = {}", n, t1, result);
        return result;
    }
}

像上面的代码就能够正确完成我们的任务,其计算求和时是多线程并行运算,但最后求和计算结果是是串行,等待对应的加和对象结果出来之后才能进行更进一步的求和

image-20230301110729882

其结果和流程图示如下

image-20230301110746763

上面的代码的缺点也很明显,其要一次次合并的结果太多,图中展示的效果就是流程太长,而由于合并过程是串行执行的,这样就会导致效率降低

我们可以将代码修改如下来避免上面的缺点

class AddTask3 extends RecursiveTask<Integer> {
​
    int begin;
    int end;
    public AddTask3(int begin, int end) {
        this.begin = begin;
        this.end = end;
    }
    @Override
    public String toString() {
        return "{" + begin + "," + end + '}';
    }
    @Override
    protected Integer compute() {
        // 5, 5
        if (begin == end) {
            log.debug("join() {}", begin);
            return begin;
        }
        // 4, 5
        if (end - begin == 1) {
            log.debug("join() {} + {} = {}", begin, end, end + begin);
            return end + begin;
        }
​
        // 1 5
        int mid = (end + begin) / 2; // 3
        AddTask3 t1 = new AddTask3(begin, mid); // 1,3
        t1.fork();
        AddTask3 t2 = new AddTask3(mid + 1, end); // 4,5
        t2.fork();
        log.debug("fork() {} + {} = ?", t1, t2);
        int result = t1.join() + t2.join();
        log.debug("join() {} + {} = {}", t1, t2, result);
        return result;
    }
}

此时我们上面代码执行的流程图如下

image-20230301122210787

可以看到使用二分结合分治的代码其合并结果的过程短得多,这样就能提供我们的代码的效率

然而上面的代码对于普通程序员来说是有难度的,这也是为什么很多人不爱用Fork/join,因为其效率和代码的合并设计息息相关,而很多人又无法做出好的设计,在后续的jdk中将Fork/join的合并代码过程不交给用户,而是交给该框架自己执行,这样能有效保证效率下限

J.U.C

本节我们来讲解JUC中举类下的各种内容,首先我们来讲解aqs,全名是AbstractQueuedSynchronizer,是阻塞式锁相关的同步器工具的框架

其有state属性来表示资源的状态,还提供了基于FIFO的等待队列,支持多个条件变量同时通过条件变量来实现等待、唤醒机制

image-20230301150852844

其子类实现了下面的方法,默认都是抛出UnsupportedOperationException

image-20230301151227559

接着我们来实现一个自定义锁,我们要实现的锁是一个不可重入锁,简单来说即使是同一个拥有锁的线程也不允许重复加锁

@Slf4j(topic = "c.TestAqs")
public class TestAqs {
    public static void main(String[] args) {
        MyLock lock = new MyLock();
        new Thread(() -> {
            lock.lock();
            try {
                log.debug("locking...");
                sleep(1);
            } finally {
                log.debug("unlocking...");
                lock.unlock();
            }
        },"t1").start();
​
        new Thread(() -> {
            lock.lock();
            try {
                log.debug("locking...");
            } finally {
                log.debug("unlocking...");
                lock.unlock();
            }
        },"t2").start();
    }
}
​
// 自定义锁(不可重入锁)
class MyLock implements Lock {
​
    // 独占锁  同步器类
    class MySync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire(int arg) {
            if(compareAndSetState(0, 1)) {
                // 加上了锁,并设置 owner 为当前线程
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }
​
        @Override
        protected boolean tryRelease(int arg) {
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }
​
        @Override // 是否持有独占锁
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }
​
        public Condition newCondition() {
            return new ConditionObject();
        }
    }
​
    private MySync sync = new MySync();
​
    @Override // 加锁(不成功会进入等待队列)
    public void lock() {
        sync.acquire(1);
    }
​
    @Override // 加锁,可打断
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }
​
    @Override // 尝试加锁(一次)
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }
​
    @Override // 尝试加锁,带超时
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(time));
    }
​
    @Override // 解锁
    public void unlock() {
        sync.release(1);
    }
​
    @Override // 创建条件变量
    public Condition newCondition() {
        return sync.newCondition();
    }
}

可以看到我们这里的首先自定义了一个MyLock类接着实现了Lock接口,其下有内部类MySync继承了AbstractQueuedSynchronizer,这个一个同步器类,我们需要实现自定义的不可重入锁就需要实现该同步器类的对应方法,我们首先重写方法tryAcquire,利用cas尝试进行加锁,如果加上了则设置owner线程为当前线程

接着重写tryRelease方法,由于拿到锁的线程只有一个,因此直接将当前线程清空并重新改写状态即可,不过这里我们需要注意的是,State属性是设置了voliatile关键字的,为了避免指令重排给我们的代码造成影响,因此我们必须要将setState的代码放在下面

后面无非就是判断是否持有独占锁和返回一个休息室的方法了,没什么值得说的,类中拥有MySync成员变量

lock方法直接调用同步器类中的acquire方法并传入1代表加锁,这个加锁一旦不成功就会让线程进入等待队列等待下一次执行,可打断的加锁方法也是如此,无非是中途可以打断而已

加锁一次的方法则是直接调用同步器类中的tryAcquire方法,之后的方法都差不多

然后我们经过测试会发现这个代码的确是可以的,有效的,如果加两次锁会让代码阻塞在第二次的加锁代码里

ReentrantLock原理

接着我们来讲ReentrantLock的实现原理,可以看到其上有Lock接口,其下有对应的实现

image-20230302115355844

我们先来讲ReentrantLock中非公平锁的实现原理,其构造方法中使用的真实对象为NonFairSync,其继承子AQS,其下的结构中有头尾结点和状态属性以及一个存放当前占用锁的线程的exclusiveOwnerThread属性

image-20230302115655046

其加锁方法直接加锁采用cas方式修改状态,若成功则设置当前线程为占有锁的线程

image-20230302120010119

如果竞争失败,那么就会进入acquire方法

image-20230302120353267

该方法会尝试再次加锁,如果还不成功就会将线程设置到头尾结点中

image-20230302120448086

可以看到其创建的线程结点汇总会先创建一个哨兵结点来避免空指针,接着后面接具体结点,同时结点连接尾结点

image-20230302120610057

添加线程到结点中的方法是一个死循环,首先获得该结点的前驱结点,如果前驱结点就是头结点,此时说明当前线程在结点中排第二位,可以尝试竞争,此时就尝试加锁,如果此时还不成功则进入后面的if逻辑

image-20230302120819373

后面的if中第一个方法会将其前驱结点的waitStatus改为-1并返回false,然后继续循环到该方法

image-20230302121435299

重新执行该方法由于前驱结点的waitStatus已经是-1,返回true

image-20230302121635822

此时进入parkAndCheckInterrupt方法,该方法会阻塞当前线程

image-20230302121900837

再有多个线程竞争失败,就全部添加并阻塞到结点中

image-20230302122206632

当释放锁时会调用其release方法,其下会调用tryRelease方法,如果返回真且前驱结点的等待状态不为0,此时就会进入unparkSuccessor方法

image-20230302122410046

tryRelease方法做的事情就是将当前锁的线程设置为null,这也就是释放当前线程并将持有锁的状态设置为0

image-20230302122355924

unparkSuccessor方法会将线程结点正确从链表中移除并令其停止阻塞并尝试竞争

image-20230302122923620

如果没有其他线程竞争,第二个线程就会持有锁并且将原本的head从链表断开,这样能令其进行GC

image-20230302122827602

image-20230302123101066

此时如果有其他线程来竞争并被其获取了锁,那么该线程会重新进入阻塞状态

image-20230302123136628

可重入锁的实现原理的实现如下,首先其会调用继承过来的方法获取线程的状态属性,若为0说明还没人加锁,此时进行加锁,如果已经获得了锁且当前线程和加锁线程一样说明此时再进行锁重入,此时直接增加其标记位即可

image-20230302124737555

调用释放锁的方法时会将加锁状态进行减法,当状态到达时再真正释放当前锁

image-20230302124800849

在可打断模式下,即使线程被打算,也会驻留在AQS队列中,只有等该线程获得锁后才能继续运行,打断标记会设置为ture,但仍然会继续运行

image-20230302131451992

可以看到第一个方法会阻塞线程,如果调用了打断方法打断,那么其回返回是否被打断的布尔值,同时会清楚打算标记来保证第二次的打断是可用的

image-20230302131530729

显然返回的结果是true,此时会进入if块中将inerrupted打断状态设置为ture,设置之后重新进入循环获得锁之后会将该属性返回

返回之后会进入上一级的if块执行selfInterrupt方法,给当前已经获得锁的线程重新执行一次中断

image-20230302131621341

接着我们来再来讲讲可打断的源码,可打断的加锁非常简单,就是park的过程中如果被打断直接而抛出异常跳出死循环

image-20230302131648481

image-20230302131721943

公平锁的原理很简单,就是在获取锁时直接使用CAS令线程自己竞争,而非公平锁则会判断当前线程是否是链表中的老二或者链表有无老二,任何一个不满足都说明当前线程还没有资格去竞争锁

接着我们来讲await的实现原理

image-20230302133235421

首先调用了await的方法会被加入到创建的ConditionObject的对象的链表中,这里使用addConditionWaiter方法

image-20230302133247818

该方法会将线程封装到结点中并将其等待状态设置为-2,也就是Node.CONDITION

image-20230302133324762

然后会进入AQS的fullyRelase释放同步器上所有的锁,包括重入之后的锁

image-20230302144529690

当锁释放之后其会唤醒等待结点后面的线程令其竞争并获得锁

image-20230302152856069

image-20230302152842165

最后在休息室里的线程会被阻塞,也就是在休息室里休息

image-20230302152943532

假设我们的Thread-1线程要唤醒在休息是的Thread-0线程

image-20230302171424813

signal方法首先检查当前线程是否是锁的持有者,如果不是则抛出异常,反之则会获取头结点并执行doSingal方法

image-20230302171701393

doSingal方法会执行循环获取头结点的下一个结点执行transFerForSingal方法,该方法会将当前线程加入到NonfairSync竞争链表中

image-20230302171950599

如果成功则会跳出循环,反之则会进入下一个结点中继续将其转换到竞争链表中

image-20230302171934590

转移方法transferForSingal方法会首先将线程的等待属性改为0,如果失败了说明属性已经被改了,此时直接返回false,然后进入enq方法,该方法会将结点加入到竞争列表的最后一位并返回倒数第二位的结点,获得该结点的状态属性,如果大于0说明或者是将结点修改为-1的方法失败都进入unpark方法将其唤醒

将其设置为-1的目的是为了令其具有唤醒后面线程的责任

ReentrantReadWriteLock

当读操作远远高于写操作时,这时我们可以使用读写锁来让读读事件可以并发,这个读写锁类就是ReentrantReadWriteLock

image-20230302233122570

在这个类中加锁中读锁可以并发,但是写锁不行,且从持有读锁的情况下去获取写锁会导致方法阻塞在获取写锁的代码中

image-20230302233337651

但是可以在拥有写锁的情况下去拥有读锁

我们可以利用其去做一个缓存,在读写的情况下,如果数据在缓存中能够查询到,则返回缓存数据

public class TestGenericDao {
    public static void main(String[] args) {
        GenericDao dao = new GenericDaoCached();
        System.out.println("============> 查询");
        String sql = "select * from emp where empno = ?";
        int empno = 7369;
        Emp emp = dao.queryOne(Emp.class, sql, empno);
        System.out.println(emp);
        emp = dao.queryOne(Emp.class, sql, empno);
        System.out.println(emp);
        emp = dao.queryOne(Emp.class, sql, empno);
        System.out.println(emp);
​
        System.out.println("============> 更新");
        dao.update("update emp set sal = ? where empno = ?", 800, empno);
        emp = dao.queryOne(Emp.class, sql, empno);
        System.out.println(emp);
    }
}
​
/**
 * 继承缓存类令其拥有自定义的缓存功能
 */
class GenericDaoCached extends GenericDao {
    private GenericDao dao = new GenericDao();
    /**
     * 缓存属性
     */
    private Map<SqlPair, Object> map = new HashMap<>();
​
    /**
     * 读写锁
     */
    private ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
​
    @Override
    public <T> List<T> queryList(Class<T> beanClass, String sql, Object... args) {
        return dao.queryList(beanClass, sql, args);
    }
​
​
    @Override
    public <T> T queryOne(Class<T> beanClass, String sql, Object... args) {
        // 先从缓存中找,找到直接返回,加入读锁
        SqlPair key = new SqlPair(sql, args);;
        rw.readLock().lock();
        try {
            T value = (T) map.get(key);
            if(value != null) {
                return value;
            }
        } finally {
            //释放读锁
            rw.readLock().unlock();
        }
        rw.writeLock().lock();
        try {
            // 多个线程进入需要双重判断缓存中有无对应的数据
            T value = (T) map.get(key);
            if(value == null) {
                // 缓存中没有,查询数据库
                value = dao.queryOne(beanClass, sql, args);
                map.put(key, value);
            }
            return value;
        } finally {
            rw.writeLock().unlock();
        }
    }
​
    @Override
    public int update(String sql, Object... args) {
        rw.writeLock().lock();
        try {
            // 先更新库
            int update = dao.update(sql, args);
            // 清空缓存
            map.clear();
            return update;
        } finally {
            rw.writeLock().unlock();
        }
    }
​
    /**
     * Sql类,具有sql字符串和参数
     */
    class SqlPair {
        private String sql;
        private Object[] args;
​
        public SqlPair(String sql, Object[] args) {
            this.sql = sql;
            this.args = args;
        }
​
        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            SqlPair sqlPair = (SqlPair) o;
            return Objects.equals(sql, sqlPair.sql) &&
                    Arrays.equals(args, sqlPair.args);
        }
​
        @Override
        public int hashCode() {
            int result = Objects.hash(sql);
            result = 31 * result + Arrays.hashCode(args);
            return result;
        }
    }
}

这里我们需要提一下为什么我们要先更新数据库之后再更新缓存,是因为如果先清除缓存的话,可能会出现后续查询一直都是旧值的情况

image-20230302234628659

但如果先更新数据库虽然仍然会产生这种问题,但是这个问题是可以得到解决的

image-20230302234649041

最后是我们上面的缓存实现的注意事项

image-20230302235247081

读写锁原理

读写锁使用的是同一个Sycn同步器,因此其等待队列、状态属性也是同一个,接着我们以t1加写锁,t2加读锁的案例来讲述其原理

首先t1成功上锁,流程人ReentrantLock加锁大同小异,写锁状态占据state的低16位,读锁使用其高16位

image-20230302235957391

在读写锁中的读锁中tryAcqiure方法的实现如下,首先其会判断c是否等于0,若是则说明还没有进行加锁,进入if语句中执行,第一个方法是在公平锁时才会执行,其做的事情是判断当前写锁是否是第二个写锁,若不是则没有资格进行阻塞,但在非公平锁里会直接返回false,接着会进行状态设置,如果设置成功就继续设置当前线程为持有锁的线程,反之则直接返回false

w代表的是写锁,如果写锁为0且加锁线程并不是当前线程,则直接返回false,如果大于最大线程数就抛出异常,否则就正常设置

image-20230303000451214

最后t1线程成功加入写锁,接着到t2线程加入读锁

读锁是调用acqireShared方法时会先调用tryAcquireShared方法,若返回的结果小于0则执行doAcquireShared方法

image-20230303000752757

t2执行读锁的添加,会经过上面所说的流程,会返回对应的结果,不过在我们这里简单的程序里,其只会返回-1或1

image-20230303000842547

tryAcquireShared内部的方法会判断当前状态是否不等于0,若是则说明已经有人加上了写锁,此时判断写锁是否是自己加的,若不是则说明已经有线程加锁,此时直接返回false,若成功则会返回1

如果没有线程加入写锁,则其会判断当前读锁是需要阻塞,后续判断写锁重入的线程是否超过的规定允许的最大线程,都没有则执行加锁,r==0后执行的就是将读锁的计数+1,同时我们可以看到其修改状态时并不是直接加1,而是加一个较大的数,这究其原因还是以为我们的读锁使用前十六位来表示的,因此对其进行+1时就需要使用大小的数字才能正确增加

image-20230303001319355

当然我们都知道上面并没有加锁成功,因为加入写锁的线程不是其自己,因此其会返回-1,然后其会执行doAcquireShared方法,这个方法跟之前的差不多,无非就是死循环改变状态将线程包装并加入链表而已

image-20230303001752994

下面是图解

image-20230303001832866

image-20230303002130139

如果在上面的情况下又有t3线程加读锁,t4线程加写锁,那么其链表就会编变成下面到的样子,所有读线程状态为Shared,写则为Ex

image-20230303005742904

接着我们来学习写锁的释放过程,释放锁会执行其release方法

image-20230303025427695

调用tryRelease

image-20230303025439113

可以看到该方法就是去修改锁的状态,当然由于有锁重入的原因,因此只有当其状态属性正确到0时才会移除当前拥有锁的线程,该方法会执行唤醒来唤醒连接链表头结点的下一个结点,然后通过循环令读锁技术+1,接着在上一级的线程里会执行唤醒下一个线程的unparkSuccessor方法

image-20230303010039950

唤醒之后其会在parkAndCheckInterrput方法中继续循环运行

image-20230303025756835

其中会调用tryAcquireShared方法去竞争锁,此时进入之后由于独占锁c为0,因此不会返回-1,接着继续执行下面的内容成功获得锁

image-20230303025925841

image-20230303010014531

线程t2被唤醒并确定占有锁后会调用setHeadAndPropagate将原本节点的位置设置为头结点

image-20230303010645431

但是这还没完,其调用setHeadAndPropagate方法调用结点线程和头结点位置的时候会执行下面的方法,其会判断该结点的后继结点标记是否为Shared,显然是,那么其会继续执行doReleaseShared()方法

image-20230303014848300

该方法首先会将前驱结点设置为状态设置为0,这样是为了防止多线程问题导致该线程在执行任务时新线程进来认为前驱结点有必要唤醒后继结点导致冲突

image-20230303014920423

该方法在设置前驱结点的状态属性后还会同样唤醒该结点,然后继续走一遍之前的流程直到到了最后的结点

image-20230303030549852

同时每次执行释放都会令读锁计数+1,这也是为什么我们说在多线程情况下执行tryAcquireShared方法会返回超过1的结果

image-20230303015232545

最后到我们之前设置的写锁的独占结点就会停止

image-20230303015329480

最后我们来看看读锁的释放流程,读锁要释放,先调用unlock方法

image-20230303030802870

之后会调用tryReleaseShared方法,其跟之前的差不多,同样是只调用tryReleaseShared方法

image-20230303030826612

该方法同样会进入一个循环并且会执行锁状态的减少,并返回状态是否为0的布尔结果

image-20230303021313799

image-20230303021215376

同样t3也进入对应的方法并释放锁,此时锁状态值到达0返回真,那么上一级就会执行doReleaseShared方法

image-20230303021618193

这个方法我们也是都见过了,其会先判断头结点是否为-1,若是就尝试cas更新-1为0,不成功重试,成功则唤醒头结点的后继结点

image-20230303031217216

该方法主要作用就是唤醒该结点的后继结点,在这里体现为唤醒t4线程

image-20230303031331901

被唤醒的写锁线程会在acquireQueued中的parkAndCheckInterrupt处恢复运行

image-20230303031535696

同样是判断其是不是老二并尝试获取写锁,获取成功之后进行相应的设置即可

image-20230303021915068

当然,写锁状态变为1和拥有写锁的线程设置为t4线程的动作是在tryAcquire方法中执行的,其源码如下

@ReservedStackAccess
protected final boolean tryAcquire(int acquires) {
    /*
     * Walkthrough:
     * 1. If read count nonzero or write count nonzero
     *    and owner is a different thread, fail.
     * 2. If count would saturate, fail. (This can only
     *    happen if count is already nonzero.)
     * 3. Otherwise, this thread is eligible for lock if
     *    it is either a reentrant acquire or
     *    queue policy allows it. If so, update state
     *    and set owner.
     */
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c);
    if (c != 0) {
        // (Note: if c != 0 and w == 0 then shared count != 0)
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // Reentrant acquire
        setState(c + acquires);
        return true;
    }
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}

显然此时c==0,那么就执行下面的if逻辑,进行写线程公平锁的判断并执行cas修改,若成功就设置当前线程为拥有锁的线程

StampedLock

StampedLock是从JDK8加入的为了进一步优化读性能的而使用的类,其特点是在使用读写锁 时都必须配合戳使用

image-20230303124915581

其支持支持乐观读的tryOptimisticRead方法,读取数据之后需要做戳校验,如果戳一致这说明没有写锁对数据进行修改,此时允许通过,反之则会需要重新获取读锁来保证数据的安全

class DataContainerStamped {
    private int data;
    /**
     * 读写戳锁对象
     */
    private final StampedLock lock = new StampedLock();
    public DataContainerStamped(int data) {
        this.data = data;
    }
​
    public int read(int readTime) {
        //乐观锁
        long stamp = lock.tryOptimisticRead();
        log.debug("optimistic read locking...{}", stamp);
        sleep(readTime);
        //校验戳
        if (lock.validate(stamp)) {
            log.debug("read finish...{}, data:{}", stamp, data);
            return data;
        }
        // 获取信息时发生了数据修改就将乐观锁升级为读锁
        log.debug("updating to read lock... {}", stamp);
        try {
            stamp = lock.readLock();
            log.debug("read lock {}", stamp);
            sleep(readTime);
            log.debug("read finish...{}, data:{}", stamp, data);
            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 {
            sleep(2);
            this.data = newData;
        } finally {
            log.debug("write unlock {}", stamp);
            lock.unlockWrite(stamp);
        }
    }
​
    public static void main(String[] args) {
        DataContainerStamped dataContainer = new DataContainerStamped(1);
        new Thread(() -> {
            dataContainer.read(1);
        }, "t1").start();
        sleep(0.5);
        new Thread(() -> {
            dataContainer.read(0);
        }, "t2").start();
    }
}

尽管我们之前已经学过读写锁了,但是这个读写锁的要比之前支持可重入的读写锁的效率更高一重,不过也不是说就可以完全取代我们以前学习过的锁的,比如这个读写戳锁不支持条件变量也不支持可重入,总之要使用什么锁还是要看我们自己的需求

image-20230303130330471

Semaphore

Semaphore指的是信号量,其用于限制能同时访问共享资源的线程上限

image-20230303132406167

我们可以使用Semaphore来改造我们的连接数据库的案例,其下拥有信号量锁Semaphore属性,其限制的线程数量与线程池大小一样

public class TestPoolSemaphore {
    public static void main(String[] args) {
        Pool pool = new Pool(2);
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                Connection conn = pool.borrow();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                pool.free(conn);
            }).start();
        }
    }
}
​
@Slf4j(topic = "c.Pool")
class Pool {
    // 1. 连接池大小
    private final int poolSize;
​
    // 2. 连接对象数组
    private Connection[] connections;
​
    // 3. 连接状态数组 0 表示空闲, 1 表示繁忙
    private AtomicIntegerArray states;
​
    private Semaphore semaphore;
​
    // 4. 构造方法初始化
    public Pool(int poolSize) {
        this.poolSize = poolSize;
        // 让许可数与资源数一致
        this.semaphore = new Semaphore(poolSize);
        this.connections = new Connection[poolSize];
        this.states = new AtomicIntegerArray(new int[poolSize]);
        for (int i = 0; i < poolSize; i++) {
            connections[i] = new MockConnection("连接" + (i+1));
        }
    }
​
    // 5. 借连接
    public Connection borrow() {// t1, t2, t3
        // 获取许可
        try {
            semaphore.acquire(); // 没有许可的线程,在此等待
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        for (int i = 0; i < poolSize; i++) {
            // 获取空闲连接
            if(states.get(i) == 0) {
                if (states.compareAndSet(i, 0, 1)) {
                    log.debug("borrow {}", connections[i]);
                    return connections[i];
                }
            }
        }
        // 不会执行到这里
        return null;
    }
    // 6. 归还连接
    public void free(Connection conn) {
        for (int i = 0; i < poolSize; i++) {
            if (connections[i] == conn) {
                states.set(i, 0);
                log.debug("free {}", conn);
                semaphore.release();
                break;
            }
        }
    }
}

在借链接的代码中,我们首先获取信号量锁然后获取空闲链接,这样能保证超过指定信号量的线程会被阻塞,每次归还链接时则释放对应的信号锁

然后我们来看看信号量锁的原理,先来看看其获得锁的原理,Semaphore就像一个停车场,而传入的构造方法的参数permits相当于指定停车场中有几个车位

image-20230303141556159

首先我们调用其构造方法,其下创建的实际对象是NonfairSync

image-20230303141738660

其下实际会调用父类的构造方法,也就是Sync的构造方法

image-20230303141836217

父类的构造方法会设置该permits为State的值

image-20230303141904494

接着其要得到锁就调用acquire方法

image-20230303142002353

其下会调用acquireSharedInterruptibly方法,该方法同样会进行if块中调用tryAcquireShared

image-20230303142021017

tryAcquireShared方法实际调用的是nonfairTryAcquireShared方法

image-20230303142135236

该方法会进入死循环中尝试减去状态值并执行修改,如果修改失败或者最后值小于零就会返回小于0的值

image-20230303142149962

如果返回小于0的值,就会进入if块中执行方法doAcquireSharedInterruptibly方法,这个方法我们之前也见得多了,无非就是死循环并将其加入到结点并阻塞而已,我们这里就不重复提了

image-20230303142424879

可以看到竞争失败的锁会进入阻塞

image-20230303142527686

接着我们来讲释放线程的源码,首先我们可以看到线程4释放了permits,其状态如下

image-20230303150709982

首先其调用release释放方法

image-20230303145704983

其会继续调用releaseShared方法

image-20230303145739245

仍然是在if块中调用tryReleaseShared方法看,其下会尝试修改线程状态来获得锁

image-20230303145807622

成功获得之后会进入上一级的if块中去执行doReleaseShared方法,其下会设置唤醒下一个线程

image-20230303145844051

唤醒的线程原本阻塞在doAcquireSharedInterrupibly中的parkAndCheckInterrupt方法中,唤醒之后从该处循环

image-20230303145932223

循环执行获取锁方法,获得所之后会执行setHeadAndPropagate方法,该方法会将当前线程的结点从链表中去除并让头结点占据当前结点的位置

image-20230303150323973

唤醒之后其会继续执行doReleaseShared方法,该方法同样是唤醒写下一个线程

image-20230303145844051

最后我们的信号量锁中已经有了三个线程,此时唤醒最后一个线程其会尝试获得锁,当然由于此时permits已经为0了,所以其会再次进入park状态

image-20230303150648877

倒计时锁

CountdownLatch倒计时锁用来进行线程同步协作,等待所有线程完成倒计时。其中构造参数用来初始化等待计数值,await() 用来等待计数归零,countDown() 用来让计数减一

可以看到其下的实现方式就是每次释放时令状态减一,直到0时返回真

image-20230303164147686

我们可以利用倒计时锁来实现一个当其他所有线程都结束之后才执行的线程,首先创建对应的锁对象并指定倒计时数,每次执行线程时调用该锁的countDown方法即可实现倒计数建议,最后的等待线程则调用其await方法即可,这样其会阻塞在对应的位置直到倒计数准确减少到0为止

public class homework {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(3);
        ExecutorService service = Executors.newFixedThreadPool(4);
        service.submit(() -> {
            log.debug("begin...");
            sleep(1);
            latch.countDown();
            log.debug("end...{}", latch.getCount());
        });
        service.submit(() -> {
            log.debug("begin...");
            sleep(1.5);
            latch.countDown();
            log.debug("end...{}", latch.getCount());
        });
        service.submit(() -> {
            log.debug("begin...");
            sleep(2);
            latch.countDown();
            log.debug("end...{}", latch.getCount());
        });
        service.submit(()->{
            try {
                log.debug("waiting...");
                latch.await();
                log.debug("wait end...");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
}

其经典的应用可以是LOL里等待十个人加载完成之后启动游戏开始的线程又或者是在处理业务需求时原本需要串行等待所有需要的数据都得到之后再执行业务处理,但是有了倒计时锁之后就可以并行执行等待结果返回了

这个时候有人可能会说这个锁有点多此一举,因为我们知道在线程中join方法不是也可以等待线程停止返回结果然后再继续执行吗?的确是这样的,但是像线程池中有时候这个线程根本不会停止,这时候还怎么等待返回结果?这显然不合理,因此我们使用倒计时锁显然更加好

CountdownLatch倒计时锁适用于没有返回值的线程并发,而对于有返回值的线程,我们推荐使用Future对象来获取结果

CyclicBarrier] 意为循环栅栏,用来进行线程协作,等待线程满足某个计数。构造时设置『计数个数』,每个线程执行到某个需要“同步”的时刻调用 await() 方法进行等待,当等待的线程数满足『计数个数』时,继续执行

其和之前学习的CountdownLatch同样为倒计时锁,但是不同的是该倒计时锁在计数清零0后会重置,不需要重新创建,而ConutdownLatch创建之后则无法重置标记,其创建倒计时结束执行的线程直接在构造方法中创建,而其减少倒计数调用的方法是await

@Slf4j(topic = "c.TestCyclicBarrier")
public class TestCyclicBarrier {
​
    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(3);
        CyclicBarrier barrier = new CyclicBarrier(2, ()-> {
            log.debug("task1, task2 finish...");
        });
        for (int i = 0; i < 3; i++) { // task1  task2  task1
            service.submit(() -> {
                log.debug("task1 begin...");
                sleep(1);
                try {
                    barrier.await(); // 2-1=1
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            });
            service.submit(() -> {
                log.debug("task2 begin...");
                sleep(2);
                try {
                    barrier.await(); // 1-1=0
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            });
        }
        service.shutdown();
​
    }
​
    private static void test1() {
        ExecutorService service = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 3; i++) {
            CountDownLatch latch = new CountDownLatch(2);
            service.submit(() -> {
                log.debug("task1 start...");
                sleep(1);
                latch.countDown();
            });
            service.submit(() -> {
                log.debug("task2 start...");
                sleep(2);
                latch.countDown();
            });
            try {
                latch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            log.debug("task1 task2 finish...");
        }
        service.shutdown();
    }
}

线程安全集合类

线程安全集合类一共分为三种,分别是遗留的安全集合,分别是Hashtable和Vector,这些类中的方法都有Synchronizer关键字修饰,是线程安全的,但由于效率不咋地所以现在都不用了

image-20230304172348767

还有就是的修饰的安全集合,这些方法可以将一个不安全的线程集合可以变为线程安全的,其中做的事情是重写其下的所有方法并在调用方法前加入synchronized关键字

image-20230304172840071

我们重点要介绍的还是J.U.C下的线程安全集合,这些安全类可以分别是Bocking、CoypyOnWrite,前者大部分实现基于锁、并提供阻塞方法、而后者则适用于大部分读小部分写的情况,因为它的修改开销相对较重

还有一类是Concurrent,该类内部操作使用cas,提供较高吞吐量,使用弱一致性

接着我们来将一个例子,我们这个例子里会生成多个文件,每个文件里都有多个26个字符,全部总计每个字符都有200个,我们计算时会创建多个线程,统计每个文件的字符并通过map计算总和,我们这里的逻辑是每个线程直接从Map中得到内容,如果内容为null就添加,若不为null则自增后覆盖

这样做的问题是由于Map是共享资源,此时开启多线程必然会存在冲突,那么就会出现最后统计的字符不为200的问题,即使我们将HashMap实现改为ConcurrentHashMap也不行,因为这样虽然能保证任何一个方法是线程安全的,但是方法的组合却不行

当然我们可以通过对外部的整个统计方法加入synchronized关键字的方法来解决问题,但是这样的话会降低我们的效率,因此我们的最好的方法其实是调用ConcurrentHashMap其下的computeIfabsent方法,该方法会判断map集合中是否有对应的key,若无则添加入我们事先指定的key和value并返回,若有则直接返回value,那么我们这里指定key为对应的字符,value则为LongAdder对象,这是我们之前学过的专门用于自增的对象,这样每次取得该对象时直接调用其自增方法即可,这就是我们的最好的解决办法了

public class TestWordCount {
    public static void main(String[] args) {
        demo(
                // 创建 map 集合
                // 创建 ConcurrentHashMap 对不对?
                () -> new ConcurrentHashMap<String, LongAdder>(8,0.75f,8),
​
                (map, words) -> {
                    for (String word : words) {
​
                        // 如果缺少一个 key,则计算生成一个 value , 然后将  key value 放入 map
                        //                  a      0
                        LongAdder value = map.computeIfAbsent(word, (key) -> new LongAdder());
                        // 执行累加
                        value.increment(); // 2
​
                        /*// 检查 key 有没有
                        Integer counter = map.get(word);
                        int newValue = counter == null ? 1 : counter + 1;
                        // 没有 则 put
                        map.put(word, newValue);*/
                    }
                }
        );
    }
​
​
    private static void demo2() {
​
        Map<String, Integer> collect = IntStream.range(1, 27).parallel()
                .mapToObj(idx -> readFromFile(idx))
                .flatMap(list -> list.stream())
                .collect(Collectors.groupingBy(Function.identity(), Collectors.summingInt(w -> 1)));
        System.out.println(collect);
    }
​
    private static <V> void demo(Supplier<Map<String, V>> supplier, BiConsumer<Map<String, V>, List<String>> consumer) {
        Map<String, V> counterMap = supplier.get();
        // key value
        // a   200
        // b   200
        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);
        }
    }
}

ConcurrentHashMap

在JDK7的HashMap中,由于其使用头插法,后面加入的在同一个结点中的内容会插入到哈希头中,这就会导致死链问题,死链问题简单来说就是由于多线程下线程扩容会导致对应结点位置的内容改变而导致的死循环

但是在JDK8之后,由于废弃了头插法,因此死链问题就消失了,但是即使如此,其HashMap在多线程扩容下仍然会存在数据丢失的问题,因此,为了提高性能和保证安全性,ConcurrentHashMap就应运而生

我们首先来看看其重要属性和内部类

image-20230304205936130

image-20230304210107348

其下最值得说的是ReservationNode方法和TreeBin方法,前者是在扩容之后转移旧址到新址时会在转移完毕后往旧址中加一个标识,新线程发现该标识就不会再尝试转移。后者是在哈希表数组长度超过64位且对应链表超过8位时会将其转化为红黑树结构来提高效率,当链表结点被删除后会重新转化为链表结构

其重要方法下,其中casTabAt方法会用cas线程安全的方式来修改

image-20230304210433429

其构造方法实现了懒惰初始化,其下有三个参数,第一个指定其默认的大小,第二个指定其负载因子,简单来说就是哈希表长度超过多少时扩容,最后一位指的是并发度

如果初始大小小于并发度,那么其会将大小优化到和并发度相同大小,计算容量时其会将优化之后的大小除于负载因子同时优化到该大小必然是2^n的大小

image-20230304210807199

get方法在类中是没有加锁的方法,其首先获得传入对象的哈希码并且将其转换为正数,如果此时map不为null且数组长度大于0则说明此时有值,这是将长度-1并对哈希值取模寻找对应值若值不为null则将值与哈希值比较,若一样此时则将找到的值于我们传入的值进行比较,若一样则返回,若不一样在此时再调用equals方法,若一样也返回。如果还不一样则说明hash可能是负数,因为如果我们的值已经被转移并且打入了标记,此时会返回-1,如果目标值在红黑树中,此时也会返回负数的哈希值,这时会调用其对应的find方法,可以去新的结点位或者是红黑树中寻找值

上面的方法都不行则会循环遍历寻找,还不行就直接返回null

image-20230304212926786

调用put方法会继续调用其下的putVal方法,三个参数分别是key、value,还有一个是要不要覆盖原值的参数,默认为false,在添加时如果查找到已有值则会进行覆盖,反之则不做任何操作

ConcurrentHashMap不允许存入null值,同样先获得其哈希值,获得Map长度之后进行死循环,如果tab不存在也就是说此时我们的ConMap还没被创建,则此时会调用initTable进行ConMap的初始化,其初始化是利用cas进行的

接着如果此时不存在头结点则其会创建头结点并存入数据,如果此时该位置正在进行扩容则其会调用helpTransfer方法来帮忙扩容,简单来说就是锁住其链表

image-20230305010416107

如果还没找到此时就说明对应的值根本不在哈希数组里,此时需要锁住所寻找到的链表的结点,再次确定链表头结点没有被移动此时找到相同key之后再根据onlyIfAbsent的值来进行对应的操作

如果遍历之后还没有说明其是新节点,直接新增结点即可,其next也就是加一个结点赋值为null

image-20230305010546257

如果还没有成功说明可能链表已经转换为了红黑树结构,确定成功之后调用红黑树的对应方法进行寻找,只要确定不为null则进行更新

image-20230305011203270

然后还需要表头结点的锁 ,然后对binCount进行对应的赋值,如果binCount大于对应的长度则将链表转化为红黑树,最后增加size的计数

image-20230305011246732

initTable方法创建ConMap时首先会进入就判断ConMap为null的while循环,若容量小于0,则说明此时有其他线程正在创建,此时调用yield建议让出CPU的时间片,否则则使用cas尝试修改sc为-1,修改你成功同样继续判断ConMap是否为null,不为null在判断容量是否大于0,若大于则创建,反之则使用默认容量16,接着在计算下一次扩容的值赋值给sc

image-20230305013619131

调用addCount方法传入要增加的个数和binCount的个数,其增加也使用了LongAdder的思想,如果没有累加数组或者没有累加单元调用fullAddCount方法进行累加重试,都有则会直接像累加单元中进行累加

如果BigCount,也就是哈希表中的驻足长度小于等于一则直接返回,反之则或许需要进行扩容,此时获得元素个数

image-20230305015543956

如果BigCount大于0同时元素个数大于指定的最大个数,此时则会进行扩容,扩容是首先判断sc是否小于0,若是则说明已经有人在创建了,反之则使用cas将sc修改为0,成功之后调用transfer方法进行创建,其需要传入当前的ConMap和新创建的ConMap对象,由于第二个参数还没创建,此时直接传入null,sc如果小于0则说明新线程已经被创建,此时帮忙扩容的方法即可

image-20230305020036456

size的计算咋没有竞争发生时会直接向baseCount累加计数,若有竞争发生,则会新建一个counterCells来累加计数

我们可以看到其计算的方法就是直接将所有的cell计数累加而已,同时这个计算只能获得一个大概值,而不是绝对准确的值

image-20230305021059866

transfer转移方法中先判断第二个ConMap是否为null,若是则创建,直接将原来的ConMap的容量扩大一倍创建并赋值,反之则进行循环以链表为单位进行转义,首先判断该链表头是否为null,若是则说明已经被转义完了,此时通过cas将其设置上一个fwd标记即可,若已经设置上了fwd标记则会去处理下一个链表

最后一个会将链表头锁住,然后进行转移,其下也分两种类型,第一种是普通结点,此时进行普通转移,如果是红黑树结点,则用红黑树的方法进行转移

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

LinkedBlockingQueue

接着我们来学习其链表中的结点类,首先是LinkedBlockingQueue,其结点的指向可能是后继结点、或者是自己、又或者是null

初始化链表会创建last和head结点都指向一个Dummy站位结点,item为null

image-20230305025304380

当一个结点入队时会新创建一个结点并让last结点指向新创建结点,继续入队也是这样

image-20230305025638921

如果是出队,那么首先其会将头结点赋予给一个新的引用,然后让新创建一个结点指向老二结点

image-20230305025902769

然后再让头结点自己指向自己,这样可以帮助其进行GC,最后让头结点也就是老二结点成为head

image-20230305030134687

将其对应的item值赋值为null,这样可以保证其作为头结点的正确性

image-20230305030307295

该类线程安全上的高明之处在于只使用了一把锁,当结点总数大于等于2时,会有两个锁分别保证出队入队的操作是安全的,反之则只有一个线程

image-20230305031201310

其put方法首先不允许存入null,然后会在put时加锁,如果当前的结点数量正好等于容量,此时就会令线程进入等待其他线程进行扩容,如果有空位则令其入队并且计数+1,如果计算出当前的数量+1还小于最大容量,此时表示或许有其他线程正在等待,此时唤醒其中一个线程,之所以唤醒一个而不是唤醒全部,是因为反正都只有一个可以获得锁,那唤醒一个可以有效减少竞争提高效率

最后如果只有一个结点那么就调用其对应的唤醒一个线程的方法

image-20230305032732114

下面是LinkedBlockcingQueue和ArrayBlockingQueue的性能比较,总之就是我们推荐使用前者

image-20230305033032614

ConcurrentLinkedQueue与上一个设计的不同在于其使用cas来实现

image-20230305033522874

CopyOnWriteArrayList

CopyOnWriteArrayList底层实现了写入时拷贝的思想,CUR操作都会将底层数组拷贝一份再执行操作,可以实现读读、读写并发

image-20230305040637526

其适合读多写少的应用场景,同样其也具有弱一致性,下面是出现弱一致性的场景

image-20230305040746349

其迭代器也不存在弱一致性,但是弱一致性并不是就不好,其和高并发是矛盾的,需要程序员自己权衡

image-20230305040855768