Java多线程基本概念, 常用关键字及常用锁类

555 阅读29分钟

常用进一步封装的锁类和最佳实践后续还会补充, 先挖个坑

基础概念

  1. 线程的状态
  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。

state-machine-example-java-6-thread-states.png

注意Blocked和Waiting的区别:

  • Blocked: 处于锁竞争状态, 但未获取到锁
  • Waiting: 等待其他线程执行完成, 会将持有的锁释放
  1. 守护线程
  • 所有非守护线程都执行完毕后,无论有没有守护线程,JVM都会自动退出。
  • 使用守护线程的一个常见例子是在后台执行周期性的任务,例如GC, 定时任务或日志清理等。
  1. 线程同步
  • 临界区: 加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。

对于语句:

n = n + 1;

看上去是一行语句,实际上对应了3条指令:

ILOAD
IADD
ISTORE

我们假设n的值是100,如果两个线程同时执行n = n + 1,得到的结果很可能不是102,而是101,原因在于:

image.png

加锁后:

image.png

  • JVM规范定义的几种原子操作(单行, 多行赋值还是需要同步的):

基本类型(longdouble除外)赋值,例如:int n = m

引用类型赋值,例如:List<String> list = anotherList

longdouble是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把longdouble的赋值作为原子操作实现的。

单条原子操作的语句不需要同步。

  1. 死锁

一个线程可以获取一个可重入锁后,再继续获取另一个可重入锁:

public void add(int m) {
    synchronized(lockA) { // 获得lockA的锁
        this.value += m;
        synchronized(lockB) { // 获得lockB的锁
            this.another += m;
        } // 释放lockB的锁
    } // 释放lockA的锁
}

public void dec(int m) {
    synchronized(lockB) { // 获得lockB的锁
        this.another -= m;
        synchronized(lockA) { // 获得lockA的锁
            this.value -= m;
        } // 释放lockA的锁
    } // 释放lockB的锁
}

在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()dec()方法时:

  • 线程1:进入add(),获得lockA
  • 线程2:进入dec(),获得lockB

随后:

  • 线程1:准备获得lockB,失败,等待中;
  • 线程2:准备获得lockA,失败,等待中。

此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。

死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。

因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。

那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序,改写dec()方法如下:

public void dec(int m) {
    synchronized(lockA) { // 获得lockA的锁
        this.value -= m;
        synchronized(lockB) { // 获得lockB的锁
            this.another -= m;
        } // 释放lockB的锁
    } // 释放lockA的锁
}

常用关键字

  1. sycronized:

属于可重入锁:

JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。

由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。

sycronized修饰的实例方法, 等价于sycronized(this){ ... }, 对于静态方法则是sycronized(Foo.class){ ... }, 任何一个类都有一个由JVM自动创建的Class实例

在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁

在 JDK 1.6 之后,synchronized进行了改进优化。其底层实现主要依靠 Lock-Free 的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和 CAS 类似的性能;而线程冲突严重的情况下,性能远高于 CAS。

  1. volatile: 在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的

TODO 安全点

TODO 堆栈一致性

image.png

因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。

如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。


锁的种类

  1. 独占锁和共享锁:
  • 独占锁:也称为互斥锁,同一时刻只能有一个线程持有该锁,其他线程无法获取该锁。在Java中,sycronizedReentrantLock就是一种独占锁。
  • 共享锁:允许多个线程同时持有锁,适用于多个线程可以同时读取共享资源的情况。在Java中,ReadWriteLock提供了共享锁的机制。
  1. 可重入锁:可重入锁允许同一个线程多次获取同一个锁,而不会被自己所持有的锁所阻塞。在Java中,ReentrantLock就是可重入锁的一种实现。

  2. 公平锁和非公平锁: 公平锁:按照线程的请求顺序来获取锁,保证线程获取锁的公平性。在Java中,ReentrantLock可以作为公平锁使用。 非公平锁:对锁的获取没有公平性保证,允许新的线程插队抢占锁。在Java中,ReentrantLock可以作为非公平锁使用,默认情况下为非公平锁。

  3. 悲观锁和乐观锁:

  • 悲观锁:假设并发情况下会发生竞争,因此在每次访问共享资源时都会进行加锁操作,防止其他线程修改资源, 例如ReadWriteLock
  • 乐观锁:假设并发情况下竞争不会发生,因此在访问共享资源时不进行加锁操作,而是在更新资源时检查是否有其他线程对资源进行了修改, 例如StampedLock
  1. 自旋锁:自旋锁是一种特殊的锁,当线程尝试获取锁失败时,不立即阻塞线程,而是进行一段忙等待,不断尝试获取锁,直到成功或超过一定次数。在Java中,Atomic包中的类(如AtomicIntegerAtomicReference等)就是基于CAS算法实现的锁。

常用锁类

  1. ReentrantLock: 可重入锁, 和synchronized类似
if (lock.tryLock(1, TimeUnit.SECONDS)) {
   try {
       ...
   }
   catch(...) {
   ...
   }
   finally {
       lock.unlock();
   }
}

优点:

  1. 更灵活, lock()unlock()可以跨多个不同的方法, 不同的代码块调用,
  2. tryLock()可以高性能, 超过时间就不再等待
  3. tryLock()可以提高安全性, 避免死锁

缺点:

  1. 需要在finally代码块手动调用unlock()
  2. 需要自己处理异常
  • Condition: 可以从Lock对象的实例获取其Condition(其它比如ReadWriteLock是没有Condition的),Condition提供的await()signal()signalAll()原理和synchronized锁对象的wait()notify()notifyAll()是一致的,并且其行为也是一样的
class TaskQueue {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private Queue<String> queue = new LinkedList<>();

    public void addTask(String s) {
        lock.lock();
        try {
            queue.add(s);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public String getTask() {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                condition.await();
            }
            return queue.remove();
        } finally {
            lock.unlock();
        }
    }
}

tryLock()类似,await()可以在等待指定时间后,如果还没有被其他线程通过signal()signalAll()唤醒,可以自己醒来

if (condition.await(1, TimeUnit.SECOND)) {
    // 被其他线程唤醒
} else {
   // 指定时间内没有被其他线程唤醒
}
  1. ReadWriteLock: 悲观读锁, 把读写操作分别用读锁和写锁来加锁, 允许多个线程同时读(当有一个线程持有读锁, 其他线程可以获取读锁, 这样就大大提高了并发读的执行效率), 但它只允许一个线程写入(当有一个线程持有写锁, 其他线程读锁和写锁都获取不到)
public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); // 加写锁
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 释放写锁
        }
    }

    public int[] get() {
        rlock.lock(); // 加读锁
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 释放读锁
        }
    }
}
  1. StampedLock: 乐观读锁,StampedLockReadWriteLock相比,不同之处在于: 读的过程中也允许获取写锁后写入,这样一来,我们读的数据就可能不一致,但需要一点额外的代码来判断读的过程中是否有写入
public class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        long stamp = stampedLock.writeLock(); // 获取写锁
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            stampedLock.unlockWrite(stamp); // 释放写锁
        }
    }

    public double distanceFromOrigin() {
        long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
        // 注意下面两行代码不是原子操作
        // 假设x,y = (100,200)
        double currentX = x;
        // 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
        double currentY = y;
        // 此处已读取到y,如果没有写入,读取是正确的(100,200)
        // 如果有写入,读取是错误的(100,400)
        if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
            stamp = stampedLock.readLock(); // 获取一个悲观读锁
            try {
                currentX = x;
                currentY = y;
            } finally {
                stampedLock.unlockRead(stamp); // 释放悲观读锁
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

主要关注读的方法distanceFromOrigin(), validate()获取版本号,如果在读取过程中有写入,版本号和乐观读锁tryOptimisticRead()的不同, 则获取悲观读锁, lock()之后, 再重新获取一次最新值

  1. Semaphore: 可以理解为限流锁, 用于限制同一时间的最大访问数量。比如同一时刻最多创建100个数据库连接,最多允许10个用户下载等。
public class AccessLimitControl {
    // 任意时刻仅允许最多3个线程获取许可:
    final Semaphore semaphore = new Semaphore(3);

    public String access() throws Exception {
        // 如果超过了许可数量,其他线程将在此等待,最多等待3秒:
        if (semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
            try {
                // TODO:
                return UUID.randomUUID().toString();
            } finally {
                semaphore.release();
            }
        }
    }
}

Thread类常用方法

  • join(): 等待线程执行完成, 可指定等待时间, 超过即不再等待;
  • stop(): 强行终止线程, 不推荐使用, 推荐用interrupt设置中断标记 TODO: 原因暂缓了解
  • interrupt(): 设置中断标记, 对于处在Waiting状态的线程, 会立刻抛出InterruptedException;
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread();
        t.start();
        Thread.sleep(1000);
        t.interrupt(); // 中断t线程
        t.join(); // 等待t线程结束
        System.out.println("end");
    }
}

class MyThread extends Thread {
    public void run() {
        Thread hello = new HelloThread();
        hello.start(); // 启动hello线程
        try {
            hello.join(); // 等待hello线程结束
        } catch (InterruptedException e) {
            System.out.println("interrupted!");
        }
        hello.interrupt();
    }
}

class HelloThread extends Thread {
    public void run() {
        int n = 0;
        while (!isInterrupted()) {
            n++;
            System.out.println(n + " hello!");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

main线程通过调用t.interrupt()从而通知t线程中断,而此时t线程正位于hello.join()的等待中,此方法会立刻结束等待并抛出InterruptedException。由于我们在t线程中捕获了InterruptedException,因此,就可以准备结束该线程。t线程结束前,对hello线程也进行了interrupt()调用通知其中断。如果去掉这一行代码,可以发现hello线程仍然会继续运行,且JVM不会退出。

可使用自定义的变量来代替interrupt()以避免抛出异常, 而且更加灵活, 注意要用volatile修饰变量以保证其可见性

  • setDeamon()/isDeamon(): 设定/检查是否为守护线程

  • setPriority(): 设定线程优先级1-10, 默认为5, 优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。

  • wait()notify()/notifyAll():

    • 虽然他们是Object类的方法, 但和Thread类紧密相关, 这里一并讨论

    • wait()使线程进入等待状态, wait()方法返回时需要重新获得锁

    • 使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。

    • 调用wait()notify()/notifyAll()的方法必须是synchronized的, 否则会抛出IllegalMonitorStateException

    • wait()notify()/notifyAll()都是object类的方法, 必须在已获得的锁对象上调用它们

    1. synchronized解决了多线程竞争的问题, 但没有解决并没有解决多线程协调的问题
    class TaskQueue {
        Queue<String> queue = new LinkedList<>();
    
        public synchronized void addTask(String s) {
            this.queue.add(s);
        }
    
        public synchronized String getTask() {
            while (queue.isEmpty()) {
            }
            return queue.remove();
        }
    }
    

    while()循环永远不会退出。因为线程在执行while()循环时,已经在getTask()入口获取了this锁,其他线程根本无法调用addTask(),因为addTask()执行条件也是获取this锁。

    1. 结合一个完整的例子总结下wait()notify()/notifyAll()的特性
    @SpringBootApplication
    public class DemoApplication implements CommandLineRunner {
    
        public static void main(String[] args) {
            SpringApplication.run(DemoApplication.class, args);
        }
    
        @Override
        public void run(String... args) throws Exception {
    
            TaskQueue q = new TaskQueue();
            List<Thread> ts = new ArrayList<>();
            for (int i = 0; i < 3; i++) {
                Thread t = new Thread(() -> {
                    try {
                        String s = q.getTask();
                    } catch (InterruptedException e) {
                        System.out.println(Thread.currentThread().getId() + " is interrupted ");
                        return;
                    }
                }, "tGet" + i);
                t.start();
                ts.add(t);
            }
            Thread add = new Thread(() -> {
                for (int i = 0; i < 2; i++) {
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                    }
                    String s = "t-" + Math.random();
                    System.out.println(LocalTime.now() + " add element: " + s);
                    q.addTask(s);
    
                }
            }, "tAdd");
            add.start();
            add.join();
            Thread.sleep(100);
            for (Thread t : ts) {
                t.interrupt();
            }
            System.out.println("main function end");
    }    
    
    public class TaskQueue {
        Queue<String> queue = new LinkedList<>();
    
        public synchronized void addTask(String s) {
            this.queue.add(s);
            this.notifyAll();
        }
    
        public synchronized String getTask() throws InterruptedException {
            while (queue.isEmpty()) {
                this.wait();
            }
            return queue.remove();
        }
    }
    
    • 看下这段代码会怎么执行, 这里要关注调用wait()的3个线程状态的变化, 以及this锁的持有者

    1. 主线程循环发起3个线程, 调用getTask() 开始等待, getTask()方法会调用wait(), 因此这3个线程会依次获取/释放掉this锁, 状态Runnable -> Waiting

    2. 主线程循环发起2个线程, 每隔5s调用一次addTask(), addTask()方法会获取this锁, 加入1个元素, 以及调用notifyAll()方法唤醒所有等待的线程, 最终释放锁

    3. 加入第一个元素, 调用notifyAll()方法, 唤醒所有线程, 被唤醒的3个线程开始竞争锁, 它们的状态Waiting -> Blocked

    4. 拿到锁的线程, 跳出while循环 remove()元素返回后,getTask()方法调用结束,this锁释放, 该线程的状态Blocked -> Runnable -> Terminated

    5. 没拿到锁的另外2个线程进入下一次 while循环 继续等待 它俩的状态 Blocked -> Waiting

    6. 继续加入第二个元素, 状况和上面两步雷同

    7. 最后, 有3个线程等待获取元素,但我们一共只加入了2个,最终会有1个线程等不到元素还在Waiting,他会被主线程interrupt

    可以加个sleep()方便观察到Blocked状态: stackoverflow.com/q/76748466/…

    可见wait()notify()/notifyAll()用法较为繁琐, 稍不注意就会出问题


进一步封装的类

  1. Atomic: Java的java.util.concurrent包除了提供底层锁、并发集合外,还提供了一组原子操作的封装类,它们位于java.util.concurrent.atomic包。 我们以AtomicInteger为例,它提供的主要操作有:
  • 增加值并返回新值:int addAndGet(int delta)
  • 加1后返回新值:int incrementAndGet()
  • 获取当前值:int get()
  • 用CAS方式设置:int compareAndSet(int expect, int update)
  • CAS(Compare and Set)算法:

  1. 读取内存地址V的值保存在A中
  2. 在原子操作中比较内存地址V的值是否与A相同
  3. 相同时,修改内存地址V的值为B,原子操作成功。

Java 并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现, 通过 JNI 调用。

  • ABA 问题: TODO

incrementAndGet()为例,它基于CAS自旋锁实现, 可用代码简单描述为:

public int incrementAndGet(AtomicInteger var) {
    int prev, next;
    do {
        prev = var.get();
        next = prev + 1;
    } while (!var.compareAndSet(prev, next));
    return next;
}

先获取一次当前值prev, 得到next值为prev + 1, 再循环判断compareAndSet的结果

  • 如果compareAndSet返回true, 即当前值就是最新值, 没有变化, 则将此AtomicInteger实例的值设为next, 跳出循环
  • 如果compareAndSet返回false, 即当前值被其他线程修改, 发生了变化, 则继续循环, 直到compareAndSet返回true为止
  • 自旋锁(SpinLock): 指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环, 其实就是上文的while (!var.compareAndSet(prev, next))

自旋锁的实现基础是CAS算法

  • 自旋锁的优点:
  • 线程在正常执行(Runnable)时, 对操作系统来说该线程是用户线程, 而当线程被阻塞(Blocked)时, 该线程转变为内核线程, 由操作系统内核直接管理
  • 对于syncronize等独占锁,会让没有得到锁资源的线程进入Blocked状态, 在争夺到锁资源后恢复为Runnable状态,这个过程中涉及到操作系统内核态用户态的切换,代价比较高。而自旋锁不会使线程进入Blocked状态,而是一直保持Runnable, 如果自旋锁已经被别的线程持有,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁。
  • 内核态(Kernel Mode): 是计算机操作系统中运行在特权级别最高的一种执行模式。在内核态下,操作系统内核拥有对计算机硬件和资源的完全控制权,并能执行所有的指令和访问所有的系统资源。相比之下,用户态(User Mode)是计算机操作系统中较低的特权级别。在用户态下,应用程序运行在受限的环境中,只能访问受限的硬件资源和执行受限的指令。操作系统会尽量减少用户态和内核态之间的切换次数,以提高系统的性能。
  • 在高度竞争的情况下,还可以使用Java 8提供的LongAdderLongAccumulator, 与AtomicLong相比, LongAdderLongAccumulator能将一个long值拆分成多个部分,每个线程在更新值时独立操作一个部分,最后将所有部分的值相加得到最终结果。这种方式减少了竞争,大大提高了并发性能。但有可能得到一个近似的结果, 而AtomicLong的结果是精准的
  1. Future/FutureTask<T>/CompletableFuture:

先看下两个相关的接口

  • Future接口: 代表Runnable或者Callable<T>任务运行的未来结果,Future定义的方法有:
  • get():调用方线程阻塞, 等待异步任务完成后获取结果
  • get(long timeout, TimeUnit unit):同get(),但只等待指定的时间;
  • cancel(boolean mayInterruptIfRunning):取消当前任务;
  • isDone():判断任务是否已完成。

注意Future是任务的未来结果, 不是执行者, 任务还是要由Thread或者线程池中的Worker执行

  • CompletionStage接口: 定义了任务完成之后执行的回调函数, 所有的回调函数都会返回CompletionStage, 以便于链式调用,可以分为以下几组

单个异步任务完成后

  • thenApply: 对其结果执行Function<T,U>, 返回一个新CompletionStage
  • thenAccept: 对其结果执行Consumer
  • thenRun: 执行一个Runnable
  • thenCompose: 类似thenApply, 他们的回参类型都是CompletionStage, 但thenCompose执行的是不是一个普通的Function<T,U>, 而是Function<T,CompletionStage<U>>, 当现有的方法返回已经是一个CompletionStage时, 相比thenApply, thenCompose不会嵌套, 因此thenCompose适合用来串接多个CompletionStage
// 回调是普通方法
CompletableFuture<Integer> futureApply = CompletableFuture
                                     .supplyAsync(() -> 1)
                                     .thenApply(x -> x+1);
                                   
CompletableFuture<Integer> futureCompose = CompletableFuture
                              .supplyAsync(() -> 1)
                              .thenCompose(x -> CompletableFuture.supplyAsync(() -> x+1));
// 回调是已有的异步方法, thenApply会嵌套一层而thenCompose不会
public CompletableFuture<UserInfo> getUserInfo(userId)
public CompletableFuture<UserRating> getUserRating(UserInfo)

CompletableFuture<CompletableFuture<UserRating>> f =
    userInfo.thenApply(this::getUserRating);

CompletableFuture<UserRating> relevanceFuture =
    userInfo.thenCompose(this::getUserRating);

两个异步任务完成后

  • thenCombine: 对它们的结果执行BiFunction, 返回一个新结果
  • thenAcceptBoth: 对它们的结果执行BiConsumer
  • runAfterBoth: 执行一个Runnable
CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> 10);
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> 20);

CompletableFuture<Integer> combinedFuture = future1.thenCombine(future2, (result1, result2) -> result1 + result2);

int combinedResult = combinedFuture.join(); // 或者使用 get() 方法获取结果

System.out.println(combinedResult); // 输出:30,因为 future1 返回 10,future2 返回 20,合并结果为 10 + 20 = 30

两个异步任务中的任意一个完成后

  • applyToEither: 对其结果后执行Function, 返回一个新结果, 不需要等待两个任务都完成
  • acceptEither: 对其结果执行Consumer
  • runAfterEither: 执行一个Runnable
CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
    try {
        Thread.sleep(2000); // 模拟任务1耗时2秒
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return 10;
});

CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
    try {
        Thread.sleep(1000); // 模拟任务2耗时1秒
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return 20;
});

CompletableFuture<Integer> resultFuture = future1.applyToEither(future2, result -> result * 2);

int result = resultFuture.join(); // 或者使用 get() 方法获取结果

System.out.println(result); // 输出:40,因为 future2 先完成,结果为 20,应用 fn 函数得到 20 * 2 = 40

异常处理相关

  • exceptionally: 入参是一个Function, 当exceptionally之前的?TODO异步操作抛出异常时,可以对这个异常进行处理,并返回一个新的CompletionStage
  • handle: 和exceptionally类似, 但入参是一个BiFunction, 因此异常和正常的情况可以处理, ,并返回一个新的CompletionStage, 传递给后面
  • whenComplete: 和handle类似, , 可以同时处理正常和异常的情况, 但入参是一个BiConsumer, Consumer是没有回参的, 所以whenComplete不产生新的异步结果
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    // Simulating an exception
    throw new RuntimeException("Oops, something went wrong!");
}).exceptionally(ex -> {
    // Handling the exception
    System.out.println("Caught exception: " + ex.getMessage());
    return 0; // Providing a default value
});

future.thenAccept(result -> {
    System.out.println("Final result: " + result);
});
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    return 10 / 2;
});

CompletableFuture<String> handledFuture = future.handle((result, ex) -> {
    if (ex != null) {
        return "Error: " + ex.getMessage();
    } else {
        return "Result: " + result;
    }
});

handledFuture.thenAccept(result -> {
    System.out.println(result);
});
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    return 10 / 2;
});

future.whenComplete((result, ex) -> {
    if (ex != null) {
        System.out.println("Exception occurred: " + ex.getMessage());
    } else {
        System.out.println("Result: " + result);
    }
});

截至目前为止, CompletionStage接口下所有的方法都分类完了, 以上方法都带有一个Async版本和一个可以指定线程池的Async版本(默认提交到ForkJoinPool.commonPool()线程池中执行) AsyncNon-Async的区别, 官方文档上说的很模糊, 以thenRunAsync(Runnable action)为例, 大致可理解为Async方法一定会从线程池中选取一个空闲?(TODO 选取的依据?)worker去执行回调action, 而Non-Async方法会尝试由主线程(准确地说是调用thenRunAsync方法的线程)去执行回调action, 这个主要是看调用 thenRun 的时候任务是否已经完成。如果没有完成,那么action 会在完成任务的线程中执行。如果任务执行的非常快 ,已经完成,则 action 会在调用thenRun方法的线程中执行。

为避免不确定性, 以及防止主线程阻塞, 实践中应避免使用Non-Async版本

参考:

juejin.cn/post/710040…

stackoverflow.com/a/46060517/…

// supplyAsync任务执行的非常快, thenRun方法由主线程执行, 导致主线程被阻塞10s
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
    System.out.println("[" + Thread.currentThread().getName() + "] " + " in task");
    return 1;
}).thenRun(() -> {
    System.out.println("[" + Thread.currentThread().getName() + "] " + " in action start");
    try {
        TimeUnit.SECONDS.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("[" + Thread.currentThread().getName() + "] " + " in action end");
});
future.get();

再看相关的实现类

  • FutureTask<T>: 同时继承了RunnableFuture

手动创建一个FutureTask<T>, 再创建一个Thread执行FutureTask<T>, 最后由Future获取结果:

FutureTask<String> futureTask =
        new FutureTask<String>(() -> {
            TimeUnit.SECONDS.sleep(3);
            return "hello";
        });
Thread t = new Thread(futureTask);
t.start();
String result = futureTask.get();
System.out.println(result);

Thread的构造只接受Runnable, 不能执行Callable<T>, 而FutureTask<T>同时继承了RunnableFuture, 意味着FutureTask<T>既是可被执行的任务本身, 又是任务未来结果

由线程池直接执行Callable<T>, submit方法会返回Future, 最后由Future获取结果:

ExecutorService executor = Executors.newFixedThreadPool(4);
Future<String> future = executor.submit(() -> {
    TimeUnit.SECONDS.sleep(3);
    return "hello";
});
String result = future.get();
System.out.println(result);
  • CompletableFuture: 同时继承了CompletionStageFuture

上文已经详细说了CompletionStageFuture所定义的方法, 除了这些方法外, CompletableFuture还提供了一些实用的扩展, 我们先看静态方法:

  • runAync/supplyAsync: 执行异步任务, 可指定提交的线程池, 默认使用ForkJoinPool.commonPool()
  • anyOf/allOf: applyToEither/applyToBoth,acceptEither/acceptBoth,runAfterEither/runAfterBoth的强化版, 可以组合2个以上的CompletableFuture
  • completedFuture: 创建一个已完成的任务, 并指定它的返回值, 可用于在异步调用链中返回常量

再看实例方法:

  • join(): 调用方线程阻塞, 等待异步任务完成
  • getNow(T valueIfAbsent): 不会`阻塞调用方线程, 如果任务已完成则返回结果, 否则返回给定的缺省值
  • complete(T value)/completeExceptionally(Throwable ex): 直接手动完成异步任务, 返回给定的正常或异常结果, 如果在调用该方法之前已经有一个结果(包括正常结果或异常),则该方法不会生效, 这个方法可以用于模拟异步任务的完成,并将结果传递给等待该任务的其他部分。
  • obtrudeValue(T value)/obtrudeException(Throwable ex): 类似complete(T value)/completeExceptionally(Throwable ex), 但会无视之前的结果, 强制替换为给定的值
  • Future接口下的方法get()CompletableFuture类下的join()方法的区别在于get()会抛出checked exception, 需要try...catch...手动处理异常, 而join()不需要, 发生异常时join()会抛出一个checked CompletionException, CompletionException中包裹着真正的异常信息
  • 对异常的处理更推荐使用exceptionally
  • Checked Exception: 已知的可能抛出的异常, 方法签名后用throws标记, 暗示调用方可能发生的异常, 并要求调用方必须处理, 否则无法通过编译检查
public void readFile(String fileName) throws IOException {
    // 读取文件的代码,可能抛出IOException
}
  • Unchecked Exception: 这种异常不强制要求调用方处理

一种是在运行时发生的意料外的异常, 常见的例如NullPointerException, ArrayIndexOutOfBoundsException

还有一种情况是手动抛出的异常, 并且方法签名后没有加throws

public void divide(int dividend, int divisor) {
    if (divisor == 0) {
        throw new ArithmeticException("Division by zero");
    }
    int result = dividend / divisor;
}

最后再看属性:

  • isCompletedExceptionally:如果异步任务异常结束时返回true, 未完成或已正常完成返回false
  • getNumberOfDependents: NumberOfDependents指的是指的是通过类似 thenApplythenAcceptthenRunthenComposethenCombinewhenCompletehandle 等方法创建的异步任务, getNumberOfDependents返回正在等待主要(第一个)异步任务完成的依赖任务的数量, 注意不是未完成(包括正在执行中)的依赖任务(我原先的理解是错的)

注意不是

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    return "Hello, CompletableFuture!";
});
future.thenApplyAsync(result -> {
    try {
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "Transformed1: " + result;
});

future.thenApplyAsync(result -> {
    try {
        Thread.sleep(5000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "Transformed1: " + result;
});
System.out.println("Number of dependents for future: " + future.getNumberOfDependents());
TimeUnit.SECONDS.sleep(2);
System.out.println("Number of dependents for future: " + future.getNumberOfDependents());
TimeUnit.SECONDS.sleep(4);
System.out.println("Number of dependents for future: " + future.getNumberOfDependents());
TimeUnit.SECONDS.sleep(6);
System.out.println("Number of dependents for future: " + future.getNumberOfDependents());

// Number of dependents for future: 2
// Number of dependents for future: 0
// Number of dependents for future: 0
// Number of dependents for future: 0
// 1秒之后主任务已完成, 因此后续没有等待它的以来任务
// 如果按照错误的"未完成(包括正在执行中)的依赖任务"去理解 应该是2 2 1 0才对了
  1. ForkJoin: TODO 如何确保利用到多个核心? 在容器内的表现?
  2. ThreadLocal: TODO 其Entry使用的是K-V方式来组织数据,Entry中key是ThreadLocal对象,且是一个弱引用
  • 弱引用: 生命周期只能存活到下次GC前

线程池

  • 使用线程池主要有以下三个原因:
  1. 创建/销毁线程需要消耗系统资源,线程池可以复用已创建的线程
  2. 控制并发的数量。并发数量过多,可能会导致资源消耗过多,从而造成服务器崩溃。(主要原因)
  3. 可以对线程做统一管理
  • 线程的状态

image.png

  1. 线程池创建后处于RUNNING状态。

  2. 调用shutdown()方法后处于SHUTDOWN状态,线程池不能接受新的任务,清除一些空闲worker,不会等待阻塞队列的任务完成。

  3. 调用shutdownNow()方法后处于STOP状态,线程池不能接受新的任务,中断所有线程,阻塞队列中没有被执行的任务全部丢弃。此时,poolsize=0,阻塞队列的size也为0。

  4. 当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。接着会执行terminated()函数。

    ThreadPoolExecutor中有一个控制状态的属性叫ctl,它是一个AtomicInteger类型的变量。线程池状态就是通过AtomicInteger类型的成员变量ctl来获取的。

    获取的ctl值传入runStateOf方法,与~CAPACITY位与运算(CAPACITY是低29位全1的int变量)。

    ~CAPACITY在这里相当于掩码,用来获取ctl的高3位,表示线程池状态;而另外的低29位用于表示工作线程数

  5. 线程池处在TIDYING状态时,执行完terminated()方法之后,就会由 TIDYING -> TERMINATED, 线程池被设置为TERMINATED状态。

  • 任务提交的处理流程 image.png

Java标准库提供了ExecutorService接口表示线程池, 几个常用实现类有:

  1. FixedThreadPool:线程数固定的线程池
// 创建固定大小的线程池:
ExecutorService executor = Executors.newFixedThreadPool(3);
// 提交任务:
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.submit(task4);
executor.submit(task5);
  1. CachedThreadPool:线程数根据任务动态调整的线程池

用法和FixedThreadPool类似, 但如果我们想把线程池的大小限制在4~10个之间动态调整怎么办?我们查看Executors.newCachedThreadPool()方法的源码:

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                    60L, TimeUnit.SECONDS,
                                    new SynchronousQueue<Runnable>());
}

因此,想创建指定动态范围的线程池,可以这么写:

int min = 4;
int max = 10;
ExecutorService es = new ThreadPoolExecutor(min, max,
        60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());

创建线程池时, 建议使用ThreadPoolExecutor可以进行更详细的设置, 避免Executors创建线程池导致的因最大线程数过大(Integer.MAX_VALUE)或者使用无界队列导致的OOM

  • min: 线程池的最小线程数, 又叫核心线程数,表示线程池中始终保持的活动线程数。即使线程是空闲的,也不会被销毁,除非设置了适当的线程超时策略。在你的代码中,核心线程数为4,意味着线程池会始终保持至少4个活动线程。
  • max: 线程池的最大线程数,表示线程池允许创建的最大线程数。如果活动线程数达到最大线程数并且任务队列也已满,后续的任务会触发线程池的拒绝策略。在你的代码中,最大线程数为10,所以线程池允许最多同时创建10个线程。
  • 60L: 线程的存活时间,即非核心线程(超出核心线程数的线程)在空闲状态下的最大存活时间。如果线程池中的线程数超过核心线程数,且空闲时间超过指定时间,这些非核心线程会被回收。在你的代码中,非核心线程的空闲时间为60秒,如果一个非核心线程在60秒内没有任务可执行,它将被销毁。
  • TimeUnit.SECONDS: 存活时间的时间单位,这里使用的是秒。
  • new SynchronousQueue<Runnable>(): 任务阻塞队列的实现,SynchronousQueue是一个零容量的队列,它不会保存任务,而是直接将任务交给消费者线程处理。这意味着每个任务都需要等待一个线程来处理,所以线程池在这里使用了SynchronousQueue来确保每个任务都会创建一个新线程,直到达到最大线程数。

阻塞队列: TODO

  1. ScheduledThreadPool:有一种任务,需要定期反复执行,例如,每秒刷新证券价格。

创建一个ScheduledThreadPool仍然是通过Executors类:

ScheduledExecutorService ses = Executors.newScheduledThreadPool(4);

我们可以提交一次性任务,它会在指定延迟后只执行一次:

// 1秒后执行一次性任务:
ses.schedule(new Task("one-time"), 1, TimeUnit.SECONDS);

如果任务以固定的每3秒执行,我们可以这样写:

// 2秒后开始执行定时任务,每3秒执行:
ses.scheduleAtFixedRate(new Task("fixed-rate"), 2, 3, TimeUnit.SECONDS);

如果任务以固定的3秒为间隔执行,我们可以这样写:

// 2秒后开始执行定时任务,以3秒为间隔执行:
ses.scheduleWithFixedDelay(new Task("fixed-delay"), 2, 3, TimeUnit.SECONDS);

注意FixedRate和FixedDelay的区别。FixedRate是指任务总是以固定时间间隔触发,不管任务执行多长时间:

image.png

而FixedDelay是指,上一次任务执行完毕后,等待固定的时间间隔,再执行下一次任务:

image.png

线程复用的原理:

juejin.cn/post/684490…

  1. shutdown()/ shutdownNow(): 都可用于关闭线程池
  • 调用后都不会再接收新的任务, 如果调用了submit(), 会抛出RejectedExecutionException
  • 调用后都会立即返回, 等待不会造成阻塞, 如果希望阻塞等待可以调用awaitTermination()方法。
  • shutdown()会将线程池状态设为SHUTDOWN, 调用后等待所有已经提交的任务执行完成, 包括正在执行的和在阻塞队列中等待执行的, 同时向空闲的核心线程发送interrupt信号
  • shutdownNow()会将线程池状态设为STOP, 调用后会向线程池中的所有线程发送interrupt信号, 最后返回阻塞队列中等待执行的任务(List<Runnable>)
  1. awaitTermination(): 阻塞等待线程池关闭, 可指定最大等待时间, 并返回布尔值告知目前线程池是否已经真正关闭了(TERMINATED)
  • 一个典型的场景是调用shutdown()之后再调用awaitTermination(), 最后shutdownNow()确保所有线程都收到中断信号
ExecutorService es = Executors.newFixedThreadPool(10);
es.execute(new Thread(() - > {...}));
try {
    es.shutdown();
    if(!es.awaitTermination(5, TimeUnit.SECONDS)){
        // 到达5s指定时间,还有线程没执行完,不再等待
        es.shutdownNow();
    }
} catch (Throwable e) {
    es.shutdownNow();
}
  1. Guava的线程池 // TODO

最佳实践

分场景: eg 读多写少