Java21手册(二):并发编程

avatar
@比心

2.1 结构化并发 Structured Concurrency

2.1.1 什么是结构化并发

想要理解什么是结构化并发,我们先要了解什么是结构化编程

结构化编程是一种编程范例,它通过广泛使用选择(if/then/else)和重复(while和for)、块结构(大括号等表示生命周期的代码块)和子程序(如方法、函数)的结构化控制流构造,来改善程序的可读性、质量和开发时间。其理论基础是1966年提出的结构化程序程序定理,它指出,如果将子程序仅以三种特定的方式(控制结构)组合起来,即可计算出任何可计算函数。这三种方式分别是:

1. 执行一个子程序,然后执行另一个子程序(序列)

2. 根据布尔表达式的值执行两个子程序中的一个(选择)

3. 当布尔表达式为真时重复执行一个子程序(迭代)

现如今我们在串行编程中使用最多的就是结构化编程范式,这也是我们一开始学习使用高级语言编程时最先接触的编程知识。但当我们进行并发编程时,由于执行子任务的线程并不能像串行编程一样感知到程序的结构化信息,程序的运行会有很多难以察觉的异常情况。

例如,下面这个返回Response的handle()方法,它提交了两个子任务到ExecutorService,一个子任务执行findUser()方法,另一个子任务执行fetchOrder()方法,handle()方法通过对子任务的Future的阻塞调用其get()方法等待子任务的结果:

Response handle() throws ExecutionException, InterruptedException {
    Future<String>  user  = esvc.submit(() -> findUser());
    Future<Integer> order = esvc.submit(() -> fetchOrder());
    String theUser  = user.get();   // Join findUser
    int    theOrder = order.get();  // Join fetchOrder
    return new Response(theUser, theOrder);
}

由于子任务并发执行,每个子任务可以独立成功或失败(在此上下文中,失败意味着抛出异常)。例如这个例子里,handle()的任务应在任何子任务失败时失败。当发生故障时,独立执行子任务的线程可能会出现意外的复杂情况:

  1. 如果findUser()抛出异常,则在调用user.get()时,handle()将抛出异常,但fetchOrder()将继续在其自己的线程中运行。这是线程泄漏,最好情况下会浪费资源,最坏情况下,fetchOrder()线程将干扰其他任务。

  2. 如果执行handle()的线程被中断,中断不会传播到子任务。无论是findUser()还是fetchOrder()线程都会泄漏,在handle()失败后继续运行。

  3. 如果findUser()执行时间很长,但fetchOrder()在此期间失败,那么handle()将不必要地等待findUser(),因为它会在user.get()上阻塞而不是取消它。只有在findUser()完成并且user.get()返回后,order.get()才会抛出异常,导致handle()失败。

这些问题出现的根源,在于我们的程序在逻辑上是按照 “任务 - 子任务” 关系进行结构化的,但这些关系仅存在于开发人员的思想中,实际并发执行的子任务是非结构化执行的。这不仅增加了错误的可能性,而且使得诊断和排查此类错误更加困难,比如thread dump等工具将在不相关线程的调用堆栈中显示handle()、findUser()和fetchOrder(),而没有 “任务 - 子任务” 关系的信息。

除了子任务无关联的问题,非结构化并发程序的另一个问题是,ExecutorService和Future没有对涉及并发任务的线程做任何控制,也就是说,一个线程可以创建一个ExecutorService,第二个线程可以将工作提交给它,执行工作的线程与第一个或第二个线程没有任何关系。此外,一个线程在提交工作之后,可以由完全不同的线程等待执行结果,任何具有对Future的引用的线程都可以加入(即通过调用get()等待其结果),即使在获取Future的线程之外的线程中的代码也可以如此。由一个任务启动的子任务不必返回给提交它的任务,它可以返回给任何数量的其他任务,甚至可以不返回给任何任务。

通过上面对结构化编程的定义,以及这个范式在并发场景下的问题描述,我们可以大致总结一下结构化并发的特点和需要提供的能力:

  1. 能够像串行执行一样,通过代码结构来直接反映 “任务 - 子任务” 的执行层次结构;

  2. 子任务的结果必须且只能返回给创建子任务的父任务;

  3. 子任务的执行不能超过父任务的生命周期;

  4. 当一个子任务的结果已满足父任务的执行结果时(如要求全部子任务执行成功时,其中一个子任务执行失败),父任务可以直接返回结果,其他子任务的执行应该被终止。

结构化并发是一种很新的编程范式,在2016年第一次由C语言和Go语言实现,2017年由Python实现的Trio进一步完善,同时也被Kotlin语言的协程库所采用。在2021年Swift语言引入了结构化并发,Java引入结构化并发的草案也在这一年被提交。

结构化并发编程非常适合搭配虚拟线程一起使用,我们在上一篇中已经对虚拟线程做了大量的介绍。虚拟线程允许非常大量地创建,并且足够廉价,可以表示任何涉及I/O的并发任务单元。这意味着服务器应用程序可以使用结构化并发同时处理成千上万个传入请求,它可以为处理每个请求的任务分配一个新的虚拟线程,并且当任务通过提交并发执行的子任务进行扩展时,它可以为每个子任务分配一个新的虚拟线程。任务 - 子任务的关系通过确保每个虚拟线程携带对其唯一父任务的引用来实体化为一棵树,类似于调用堆栈中的帧引用其唯一调用者。

在结构化并发之前,Java也有ForkJoinPool和ForkJoinTask等API,这些API跟结构化并发有类似之处,但ForkJoinPool的作用仅限于内存计算任务,不能处理IO密集型任务,另外结构化并发提供的能力,ForkJoinPool也并不具备。

2.1.2 结构化并发的基本使用

Java的结构化并发API中最核心的类是StructuredTaskScope,这个类的使用方式很像ExecutorService,它负责构建一个结构化并发的根任务,通过 fork 方法来创建和启动子任务,再通过 join 方法来等待子任务的执行结果。初始化StructuredTaskScope有两个默认的策略,ShutdownOnFailure 代表任一个子任务执行失败则任务执行失败,以及 ShutdownOnSuccess 代表任意一个子任务执行成功则任务执行成功。让我们把上一篇介绍虚拟线程的例子稍作改动,用结构化并发的方式实现:

var a = new AtomicInteger(0);
var begin = System.currentTimeMillis();
// 创建一个ShutdownOnFailure策略的StructuredTaskScope
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    List<StructuredTaskScope.Subtask<Integer>> subtasks = new ArrayList<>();
    // 启动100万个执行sleep 1s的子任务
    for (int i = 0; i < 1_000_000; i++) {
        subtasks.add(scope.fork(() -> {
            int x = a.addAndGet(1);
            Thread.sleep(Duration.ofSeconds(1));
            return x;
        }));
    }
    // 等待子任务执行完毕,任意子任务执行失败则抛出异常
    scope.join().throwIfFailed();
    // 打印结果
    for (var task : subtasks) {
        var i = task.get();
        if (i % 10000 == 0) {
            System.out.print(i + " ");
        }
    }
} finally {
    // 打印总耗时
    System.out.println("Exec finished.");
    System.out.printf("Exec time: %dms.%n", System.currentTimeMillis() - begin);
}

这段程序可以通过命令行单文件方式执行,在Java21中结构化并发是preview特性,因此执行命令必须添加 --enable-preview --source 21。关于单文件执行的讲解,将在第10篇中继续介绍:

~/Downloads/jdk-21.jdk/Contents/Home/bin/java --enable-preview --source 21 StructuredConcurrencyDemo.java

执行结果如下:

10000 20000 30000 40000 50000 60000 70000 80000 90000 100000 110000 120000 130000 140000 150000 160000 170000 180000 190000 200000 210000 220000 230000 240000 250000 260000 270000 280000 290000 300000 310000 320000 330000 340000 350000 360000 370000 380000 390000 400000 410000 420000 430000 440000 450000 460000 470000 480000 490000 500000 510000 520000 530000 540000 550000 600000 620000 650000 660000 670000 690000 700000 710000 720000 730000 740000 750000 760000 770000 780000 790000 800000 810000 820000 830000 840000 850000 860000 870000 880000 890000 900000 910000 920000 930000 940000 950000 960000 970000 980000 990000 1000000 560000 580000 570000 590000 610000 630000 640000 680000 
Exec finished.Exec time: 13370ms.

执行这个StructuredTaskScope版本的程序,结果的输出打印跟上一篇中使用虚拟线程ExecutorService版本的程序有很大区别,ExecutorService版本遍历futures,输出结果是随着子任务的执行一点一点打印的,而StructuredTaskScope版本需要调用scope.join(),也就是等待子程序执行结束才能从Subtask中获取结果,因此输出结果是瞬间打印完成的。

我们在虚拟线程这一篇中介绍了新的线程堆栈格式JSON,使用这种堆栈格式可以在thread dump文件中看到虚拟线程的堆栈,而在StructuredTaskScope创建的子任务执行线程中,我们还可以看到线程之间的任务从属关系,相同父任务的子任务线程会归属在同一个container下。我们可以通过HotSpotDiagnosticMXBean来动态 dump 线程堆栈文件,代码如下:

Thread.startVirtualThread(() -> {
    try {
        Thread.sleep(Duration.ofMillis(500));
        HotSpotDiagnosticMXBean hotSpotDiagnosticMXBean = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
        hotSpotDiagnosticMXBean.dumpThreads(System.getProperty("user.dir") + "/threads.json", HotSpotDiagnosticMXBean.ThreadDumpFormat.JSON);
    } catch (InterruptedException | IOException e) {
        throw new RuntimeException(e);
    }
});

Dump文件中跟StructuredTaskScope相关的子任务线程堆栈如下:

{  "container": "java.util.concurrent.StructuredTaskScope$ShutdownOnFailure@7f3b84b8",  "parent": "<root>",  "owner": "1",  "threads": [   {     "tid": "28",     "name": "",     "stack": [        "java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:631)",        "java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:803)",        "java.base/java.lang.Thread.sleep(Thread.java:590)",        "LoomTest.lambda$main$1(LoomTest.java:133)",        "java.base/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:883)",        "java.base/java.lang.VirtualThread.run(VirtualThread.java:311)"     ]   },   {     "tid": "29",     "name": "",     "stack": [        "java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:631)",        "java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:803)",        "java.base/java.lang.Thread.sleep(Thread.java:590)",        "LoomTest.lambda$main$1(LoomTest.java:133)",        "java.base/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:883)",        "java.base/java.lang.VirtualThread.run(VirtualThread.java:311)"     ]   },    ...  ],  "threadCount": "91237"}

程序输出:

Thread.startVirtualThread(() -> {
    try {
        Thread.sleep(Duration.ofMillis(500));
        HotSpotDiagnosticMXBean hotSpotDiagnosticMXBean = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
        hotSpotDiagnosticMXBean.dumpThreads(System.getProperty("user.dir") + "/threads.json", HotSpotDiagnosticMXBean.ThreadDumpFormat.JSON);
    } catch (InterruptedException | IOException e) {
        throw new RuntimeException(e);
    }
});

通过上面的例子我们可以看出,StructuredTaskScope带来了一些很有价值的特性:

  1. 良好的可读性,通过 “try-with-resource 创建scope作用域 -> fork 方法启动子任务并获取Subtask -> join 方法等待子任务完成 -> Subtask::get 方法获取子任务结果 -> 退出try-with-resource 自动调用close方法关闭scope” 这样的顺序流程来准确描述并发任务的执行过程,所见即所得。
  2. 能够关联子任务执行线程的关系,线程堆栈可以反映这部分信息,我们可以通过它来轻易构建出线程执行的关系结构树。
  3. 退出传递,父任务的退出状态会传递给所有子任务,使子任务能够及时退出。
  4. 更快的错误处理,ShutdownOnFailure可以在一个子任务执行失败后立刻停止其他子任务,及时释放资源。

2.1.3 结构化并发API

StructuredTaskScope

StructuredTaskScope的API定义如下:

public class StructuredTaskScope<T> implements AutoCloseable {

  public StructuredTaskScope(String name, ThreadFactory factory);
  public StructuredTaskScope();

  public <U extends T> Subtask<U> fork(Callable<? extends U> task);
  public void shutdown();

  public StructuredTaskScope<T> join() throws InterruptedException;
  public StructuredTaskScope<T> joinUntil(Instant deadline)
  throws InterruptedException, TimeoutException;
  public void close();

  protected void handleComplete(Subtask<? extends T> handle);
  protected final void ensureOwnerAndJoined();

}

大部分方法都已经在上文的使用中有所介绍,使用默认构造函数 StructuredTaskScope() 创建的 scope 作用域,fork 出的子任务会使用虚拟线程来执行,子任务可以创建自己的嵌套StructuredTaskScope以 fork 自己的子任务,从而创建一个层次结构,该层次结构体现在代码的块结构中,它限制了子任务的生命周期:一旦作用域关闭,所有子任务的线程都保证已终止,代码块退出时不会留下任何线程。

父任务 scope 中的任何子任务、子任务嵌套 scope 中的任何子任务,以及 scope 的持有者父任务,都可以随时调用 StructuredTaskScope 的 shutdown() 方法来表示任务已完成,shutdown() 方法会中断仍在执行子任务的线程,并导致 join() 或 joinUntil(Instant) 方法返回。在调用 shutdown() 之后 fork 的新子任务将处于 UNAVAILABLE 状态,不会被执行。可以理解为,shutdown() 是并发代码中与顺序代码中的 break 语句相对应的并发模拟。

SubTask的API定义如下:

public sealed interface Subtask<T> extends Supplier<T> permits SubtaskImpl {
    Callable<? extends T> task();
    enum State {
        UNAVAILABLE, SUCCESS, FAILED,
    }

    State state();
    T get();
    Throwable exception();
}

Subtask的接口定义使用到了sealed和permits,它代表Subtask接口只能被SubtaskImpl这个类实现,我们无法自定义Subtask的实现,关于这部分特性我们会在下一篇中详细介绍。

在正常使用的场景下,我们使用Subtask只应该使用 get() 方法来获取子任务的执行结果,其他方法只有当我们自定义shutdown策略时才可以使用。正因为这个原因,使用StructuredTaskScope::fork 返回的Subtask子任务对象,更推荐的做法是把子任务直接声明为 Supplier。前面的例子为了更好地介绍特性,我们使用了Subtask来声明,实际在编码中更推荐用Supplier来声明。

Shutdown 策略

使用结构化并发执行子任务,一般会使用短路模式来避免资源浪费,通常包含两种策略:一是任意子任务执行失败即失败(也称作 invoke all 模式),二是任意子任务执行成功即成功(也称作 invoke any 模式)。StructuredTaskScope就包含实现这两种策略的子类,分别是 ShutdownOnFailure 和 ShutdownOnSuccess。

ShutdownOnFailure 的用法上文中已介绍过,再举一个更直观的例子,这个方法执行一批 Callable tasks 并返回结果,任意 task 执行失败则抛异常:

public sealed interface Subtask<T> extends Supplier<T> permits SubtaskImpl {
    Callable<? extends T> task();
    enum State {
        UNAVAILABLE, SUCCESS, FAILED,
    }

    State state();
    T get();
    Throwable exception();
}

ShutdownOnSuccess 的例子如下:

<T> T race(List<Callable<T>> tasks, Instant deadline) 
        throws InterruptedException, ExecutionException, TimeoutException {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
        for (var task : tasks) {
            scope.fork(task);
        }
        return scope.joinUntil(deadline)
                    .result();  // result方法返回第一个执行成功的子任务结果,若没有子任务执行成功则抛异常
    }
}

ShutdownOnSuccess跟ShutdownOnFailure的使用方式有很大区别,由于策略本身会处理子任务结果,应完全避免使用fork()返回的Subtask对象,并将fork()方法视为返回void。

如果我们需要无论子任务执行成功与否,都要把结果和异常返回,那么可以通过封装Callable为Future,用invoke all策略来实现,例如:

<T> List<Future<T>> executeAll(List<Callable<T>> tasks)
        throws InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        List<? extends Supplier<Future<T>>> futures = tasks.stream()
            .map(task -> asFuture(task))
             .map(scope::fork)
             .toList();
        scope.join();
        return futures.stream().map(Supplier::get).toList();
    }
}

static <T> Callable<Future<T>> asFuture(Callable<T> task) {
   return () -> {
       try {
           return CompletableFuture.completedFuture(task.call());
       } catch (Exception ex) {
           return CompletableFuture.failedFuture(ex);
       }
   };
}

子任务执行task.call()方法,如果执行成功,将执行结果保存在Future中,通过resultNow()方法获取;如果执行失败,将Exception同样保存在Future中,通过exceptionNow()方法获取。resultNow() 和 exceptionNow() 是在 Java19 版本添加到 Future 中的新方法。

自定义 shutdown 策略

通过继承StructuredTaskScope,并重写handleComplete等方法,我们可以自定义 shutdown 策略,当任意一个子任务执行完成,handleComplete都会被调用 。先看下ShutdownOnSuccess的处理,接收到第一个成功执行的子任务的返回结果时 shutdown scope,代码如下:

@Override
protected void handleComplete(Subtask<? extends T> subtask) {
    super.handleComplete(subtask);

    if (firstResult != null) {
        // already captured a result
        return;
    }

    if (subtask.state() == Subtask.State.SUCCESS) {
        // task succeeded
        T result = subtask.get();
        Object r = (result != null) ? result : RESULT_NULL;
        if (FIRST_RESULT.compareAndSet(this, null, r)) {
            super.shutdown();
        }
    } else if (firstException == null) {
        // capture the exception thrown by the first subtask that failed
        FIRST_EXCEPTION.compareAndSet(this, null, subtask.exception());
    }
}

类似的,我们定义一个返回所有执行成功结果的策略,添加一个新方法 results() 来返回结果Stream,代码如下:

class MyScope<T> extends StructuredTaskScope<T> {

    private final Queue<T> results = new ConcurrentLinkedQueue<>();

    MyScope() { super(null, Thread.ofVirtual().factory()); }

    @Override
    protected void handleComplete(Subtask<? extends T> subtask) {
        if (subtask.state() == Subtask.State.SUCCESS)
            results.add(subtask.get());
    }

    @Override
    public MyScope<T> join() throws InterruptedException {
        super.join();
        return this;
    }

    public Stream<T> results() {
        super.ensureOwnerAndJoined();
        return results.stream();
    }

}

使用方式如下:

<T> List<T> allSuccessful(List<Callable<T>> tasks) throws InterruptedException {
    try (var scope = new MyScope<T>()) {
        for (var task : tasks) scope.fork(task);
        return scope.join()
                    .results().toList();
    }
}

2.1.4 构建服务工作线程

我们上面的例子都是把一个任务拆分为多个子任务来并发执行的场景,而在服务端开发领域,使用StructuredTaskScope用作处理服务请求,同样非常合适。假设我们有一个简单的web服务,请求处理方法为handle,代码如下:

public static void main(String[] args) throws IOException { {
    try (var serverSocket = new ServerSocket(8080)) {
        System.out.println("Server started. Listening on port 8080...");

        while (true) {
            Socket clientSocket = serverSocket.accept();
            handle(clientSocket);
        }
    }
}

可以改为使用StructuredTaskScope创建子任务执行handle操作:

public static void main(String[] args) throws IOException { {
    try (var serverSocket = new ServerSocket(8080)) {
        System.out.println("Server started. Listening on port 8080...");

        while (true) {
            Socket clientSocket = serverSocket.accept();
            handle(clientSocket);
        }
    }
}

在这个例子中,scope作用域的持续时间等同于服务运行时间,作用域内的所有子任务都是通过服务的外部请求来动态创建的。相比于我们在Java8开发中使用平台线程的线程池来处理请求,StructuredTaskScope为每个请求创建一个虚拟线程来执行,为服务提供了更大的伸缩度,也更符合“单请求单线程”的设计原则。相比于直接使用新建虚拟线程来处理请求,StructuredTaskScope又能为执行请求任务的线程提供作用域信息,通过线程堆栈可以更加清晰地知道哪些线程正在执行处理服务请求。另外,将整个服务作为一个作用域单元可以让服务关闭变得更加容易。

总结:结构化并发与虚拟线程一样,是Java近两年来最受瞩目的更新。如果说虚拟线程是从并发机制上提升了Java执行并发程序的效率和服务资源使用率,那么结构化并发则是通过引入并发编程的范式,从编程上提升了Java开发者对并发程序的编写质量、效率和对程序执行的掌控力,也大大降低了并发编程的复杂度。因此,结构化并发也是我们升级Java21优先要尝试使用的功能。

2.2 作用域值 Scoped Values

2.2.1 ThreadLocal与作用域值

我们在上一篇中提到了ThreadLocal在虚拟线程下的一些问题,通过池化线程本地资源来降低任务执行开销,这种做法在虚拟线程中并不提倡。但总体来说,ThreadLocal在服务开发领域是非常重要的,在很多场景下Java开发者需要通过ThreadLocal来实现跨组件的数据共享。

例如,Spring框架的RequestContextHolder就是一个典型的通过ThreadLocal来存放和获取请求属性的API,项目中的任何组件都可以直接通过这个API获取到请求信息,这也避免了由于组件之间显示传参而带来不必要的依赖。也正因为这一功能对后台开发十分重要,ThreadLocal自Java1.2引入以来就得到了广泛的使用。

然而ThreadLocal除了在虚拟线程中不被推荐外,这个API的设计本身也有一定问题,包括:

  • 无限制的可变性:任何能调用 ThreadLocal 的 get 方法的代码都可以随时调用该变量的 set 方法,数据流过于复杂也不容易追溯变更,实际开发中由 ThreadLocal 变更引起的问题也很常见。

  • 无限的生命周期:一旦通过 set 方法写入 ThreadLocal 的实例,如果后续没有调用 remove 方法,那么该实例将在线程的生命周期内一直保留。如果使用线程池,一个任务中设置的 ThreadLocal 值又很可能会意外地泄露到另一个不相关的任务中,造成逻辑理解之外的程序错误。

  • 线程继承的开销:使用大量线程时,ThreadLocal 的开销可能更大,因为子线程会继承父线程的ThreadLocal变量。当开发人员选择创建继承线程本地变量的子线程时,子线程必须为父线程中的每个ThreadLocal变量分配存储空间,这可能会增加显著的内存占用,在大多数情况下,子线程并不会使用父线程的ThreadLocal变量,开发者对此也没有明显的感知。

作用域值(Scoped Values)就是Java在虚拟线程时代下引入的ThreadLocal替代方案,它通过维护不可变的线程本地数据,在有界的生命周期内实现ThreadLocal提供的数据共享能力。由于作用域值维护的数据不可变,在线程继承的场景中,父线程的数据也可以通过共享内存的方式,高效地被子线程共享。

2.2.2 作用域值的使用

作用域值的核心class是ScopedValue,基本用法如下:

// 创建保存String值的ScopedValue对象NAME
private static final ScopedValue<String> NAME = ScopedValue.newInstance();

// runWhere方法,将NAME赋值为“duke”,指定其作用域为doSomething()方法并执行
ScopedValue.runWhere(NAME, "duke", () -> doSomething());

// 等同于使用where方法绑定ScopedValue对象的值,where方法可调用多次绑定多个值,再调用run方法指定作用域并执行
ScopedValue.where(NAME, "duke").run(() -> doSomething());

ScopedValue的使用大致分为三步:第一步,声明ScopedValue对象;第二步,对ScopedValue赋值,指定ScopedValue的作用域并执行;第三步,在作用域范围内调用ScopedValue的get()方法获取值。在ScopedValue的作用域内,子线程也同样可访问,看下面的例子:

privatestatic final ScopedValue<String> NAME = ScopedValue.newInstance();

ScopedValue.runWhere(NAME, "duke", () -> {
   try (var scope = new StructuredTaskScope<String>()) {

       scope.fork(() -> childTask1());
       scope.fork(() -> childTask2());
       scope.fork(() -> childTask3());

       ...
    }
});

在这个例子中,使用StructuredTaskScope创建了三个子任务,分别执行三个不同的方法,在这些方法中调用NAME.get()也是可以获取到"duke"值的。也就是说,基于结构化并发和ScopedValue,开发者可以轻易地实现父子任务的线程数据共享,相比于使用线程池执行子任务时要使用特殊方式传递ThreadLocal,这一点实在是方便了不少。

ScopedValue的值在作用域内虽然是不可变的,但可以在作用域内创建嵌套作用域时重新赋值,并且不会影响当前作用域中的值,这个功能的使用场景也有很多,例如可以用来在不同组件中定义不同的权限,具体使用方法如下:

privatestatic final ScopedValue<String> NAME = ScopedValue.newInstance();

ScopedValue.runWhere(NAME, "duke", () -> {
   try (var scope = new StructuredTaskScope<String>()) {

       scope.fork(() -> childTask1());
       scope.fork(() -> childTask2());
       scope.fork(() -> childTask3());

       ...
    }
});

程序的输出为:

Hello, duke!
Hello, world!
Hello, duke!

2.2.3 实现服务权限校验功能

我们看一个完整的例子,在这个例子中,我们定义了三个服务组件,分别是接收服务请求的Server类、处理服务请求的Application类、以及从DB中获取数据的DBAccess类,Server使用ScopedValue存储了请求的权限,作用域为Application的请求处理handle方法,handle使用StructuredTaskScope进行并发调用,其中一个子任务从DBAccess中获取数据,DBAccess则会根据用户的权限来判断是调用DB查询数据还是抛出权限不足的异常。代码如下:

class Server {
    final static ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();

    void serve(Request request, Response response) {
        // 将请求权限记录在PRINCIPAL中,调用Application.handle处理请求
        var level     = (request.isAdmin() ? ADMIN : GUEST);
        var principal = new Principal(level);
        ScopedValue.where(PRINCIPAL, principal)
                   .run(() -> Application.handle(request, response));
    }
}

class Application {
    void handle(Request request, Response response) {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // 开启子线程执行findUser()
            Supplier<String> user  = scope.fork(() -> findUser());
            Supplier<Integer> order = scope.fork(() -> fetchOrder());
            try {
                scope.join().throwIfFailed();
            } catch (ExecutionException | InterruptedException e) {
                throw new RuntimeException(e);
            }
            response.user = user.get();
            response.order = order.get();
        }
    }

    // findUser方法调用了DBAccess.open()
    String findUser() {
        ... DBAccess.open() ...
    }
}

class DBAccess {
    DBConnection open() {
        // 获取PRINCIPAL值,判断请求权限,权限不足抛异常
        var principal = Server.PRINCIPAL.get();
        if (!principal.canOpen()) throw new  InvalidPrincipalException();
        return newConnection(...);
    }
}

总结:Java的官方建议指出,在许多当前使用ThreadLocal的场景中,作用域值可能是有用且更可取的选择。一般而言,当使用ThreadLocal的目的与作用域值的目标一致时,即单向传输不变数据,建议迁移到作用域值。然而由于ThreadLocal的使用方式更不设限,作用域值可能永远无法完全取代ThreadLocal,即使是可迁移的部分,迁移成本还是比较高的。更适合使用作用域值的场景是当我们编写新功能时优先考虑使用,特别是搭配虚拟线程和结构化并发来一起构建的组件。

2.3 变量句柄 Variable Handles

变量句柄是一种对变量的类型化引用,可以支持在多种访问模式下读取和写入变量,并且能提供类似于java.util.concurrent.atomic 和 sun.misc.Unsafe能提供的原子性操作。使用方式如下:

class Foo {
    int i;
}


class Bar {
    static final VarHandle VH_FOO_FIELD_I;

    static {
        try {
            VH_FOO_FIELD_I = MethodHandles.lookup().
                in(Foo.class).
                findVarHandle(Foo.class, "i", int.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

变量句柄可以对变量进行原子性的读-改-写操作,与C/C++的原子操作兼容,不依赖于java内存模型的更新,例如:

Foo f = ...
boolean r = VH_FOO_FIELD_I.compareAndSet(f, 0, 1);
int o = (int) VH_FOO_FIELD_I.getAndSet(f, 2)

相比于其他变量的原子操作方式,变量句柄不会像AtomicInteger等原子类型那样增加空间开销,以及管理间接引用的额外并发问题;也不会像使用原子 FieldUpdaters那样经常花费更大开销;也不像使用sun.misc.Unsafe一样缺乏可移植性和支持性。

正因为这样一些优势,变量句柄已经在JDK中广泛使用,例如StructuredTaskScope中用来管理子任务线程的ThreadFlock,就是使用变量句柄来对线程信息进行原子更新的:

public class ThreadFlock implements AutoCloseable {
    private static final JavaLangAccess JLA = SharedSecrets.getJavaLangAccess();
    private static final VarHandle THREAD_COUNT;
    private static final VarHandle PERMIT;
    static {
        try {
            MethodHandles.Lookup l = MethodHandles.lookup();
            THREAD_COUNT = l.findVarHandle(ThreadFlock.class, "threadCount", int.class);
            PERMIT = l.findVarHandle(ThreadFlock.class, "permit", boolean.class);
        } catch (Exception e) {
            throw new InternalError(e);
        }
    }

    private final Set<Thread> threads = ConcurrentHashMap.newKeySet();

    // thread count, need to re-examine contention once API is stable
    private volatile int threadCount;
    ...
  
    // set by wakeup, cleared by awaitAll
    private volatile boolean permit;
    ...

    private long threadCount() {
        return threadCount;
    }

    private void incrementThreadCount() {
        THREAD_COUNT.getAndAdd(this, 1);
    }
    ...
}

StructuredTaskScope的shutdown策略也使用到了变量句柄,例如ShutdownOnFailure使用变量句柄存储首个子任务执行异常:

public static final class ShutdownOnFailure extends StructuredTaskScope<Object> {
    private static final VarHandle FIRST_EXCEPTION;
    static {
        try {
            MethodHandles.Lookup l = MethodHandles.lookup();
            FIRST_EXCEPTION = l.findVarHandle(ShutdownOnFailure.class, "firstException", Throwable.class);
        } catch (Exception e) {
            throw new ExceptionInInitializerError(e);
        }
    }
    private volatile Throwable firstException;
    ...

    @Override
    protected void handleComplete(Subtask<?> subtask) {
        super.handleComplete(subtask);
        if (subtask.state() == Subtask.State.FAILED
                && firstException == null
                && FIRST_EXCEPTION.compareAndSet(this, null, subtask.exception())) {
            super.shutdown();
        }
    }
    ...
}

对比Java现有的变量原子更新API:atomic、atomicArray、fieldUpdater,变量句柄在性能方面也没有损失。实际上变量句柄是从Java9引入的,已经存在了很久,并且在JDK中也被越来越广地使用,对开发者来说这也是最推荐使用的原子更新方式。

2.4 响应式异步框架 Flow

java.util.concurrent.Flow是一个响应式流(Reactive Streams)发布-订阅(publish-subscribe)框架接口,基于Flow API,我们可以快速构建响应式代码。例如,我们先定义一次消费一条消息的Subscriber:

public class EndSubscriber<T> implements Flow.Subscriber<T> {
    private Flow.Subscription subscription;
    public List<T> consumedElements = new LinkedList<>();

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        this.subscription = subscription;
        subscription.request(1); // 每次消费1个消息
    }

    @Override
    public void onNext(T item) {
        System.out.println("Got : " + item);
        consumedElements.add(item);
        subscription.request(1);
    }

    @Override
    public void onError(Throwable t) {
        t.printStackTrace();
    }

    @Override
    public void onComplete() {
        System.out.println("Done");
    }
}

onSubscribe()方法在注册到Publisher时调用,Subscription代表一个Subscriber注册到Publisher的关系,自定义的Publisher通常也会定义自己的Subscription;onNext()方法负责消费消息。接下来我们用JDK提供的Publisher,SubmissionPublisher来说明使用:

@Test
public void whenSubscribeToIt_thenShouldConsumeAll() 
  throws InterruptedException {
 
    // given
    SubmissionPublisher<String> publisher = new SubmissionPublisher<>();
    EndSubscriber<String> subscriber = new EndSubscriber<>();
    publisher.subscribe(subscriber);
    List<String> items = List.of("1", "x", "2", "x", "3", "x");

    // when
    assertThat(publisher.getNumberOfSubscribers()).isEqualTo(1);
    items.forEach(publisher::submit);
    publisher.close();

    // then
     await().atMost(1000, TimeUnit.MILLISECONDS)
       .until(
         () -> assertThat(subscriber.consumedElements)
         .containsExactlyElementsOf(items)
     );
}

// 输出
|Got : 1
|Got : x
|Got : 2
|Got : x
|Got : 3
|Got : x
|Done

总的来说,基于Flow框架,我们可以不依赖JDK以外的包就能实现响应式编程,这对Java语言机制来说是个补充。

2.5 其他并发接口更新

2.5.1 Thread-Local Handshakes

Thread-Local Handshakes是一种在Java虚拟机中引入的机制,用于在运行时停止或握手单个线程,而无需触发全局安全点。

Thread-Local Handshakes的作用有以下几个方面:

  1. 改进偏向锁撤销:在当前的实现中,偏向锁的撤销需要停止所有线程。通过使用Thread-Local Handshakes,可以只停止需要撤销偏向锁的单个线程,从而降低了撤销偏向锁的成本。
  2. 减少VM延迟:某些服务性能查询,如获取所有线程的堆栈跟踪,可能会对虚拟机的整体性能产生较大影响。Thread-Local Handshakes允许在不影响其他线程的情况下,仅停止需要进行查询的单个线程,从而降低了查询操作的延迟。
  3. 安全的堆栈跟踪采样:传统的堆栈跟踪采样方法依赖于操作系统提供的信号机制,存在一定的不确定性和安全性问题。使用Thread-Local Handshakes可以更安全地进行堆栈跟踪采样,减少对信号的依赖。
  4. 优化内存屏障:通过与Java线程进行握手,可以采用称为非对称Dekker同步技术来省略一些内存屏障操作。这可以优化某些特定场景下的性能,减少全局内存屏障的数量。

通过引入Thread-Local Handshakes机制,可以在需要停止或握手的情况下,只对单个线程进行操作,而无需停止所有线程,从而提高了应用程序的性能和响应性。

2.5.2 线程自旋提示API

Thread.onSpinWait() 是一个新增的API,目的是提示系统当前线程正在执行自旋循环。自旋循环逻辑通常用于等待某个条件满足,例如在多线程应用程序中等待锁定。当自旋等待的时间很短,且线程数不多时,自旋等待可以提高程序的性能,因为它避免了线程切换和上下文切换的开销。但当自旋等待时间过长或线程数很多时,它可能会降低程序的性能,因为它会占用 CPU 时间而阻塞其他线程。

在这种情况下,通过调用 Thread.onSpinWait() 方法可以帮助 JVM 更好地管理自旋等待,并可能提高程序的性能。具体来说,它可以减少线程之间的延迟,并可能减少 CPU 的功耗。

Thread.onSpinWait() 方法是一种纯提示,没有语义行为要求。它可以被实现为一个空方法,而且不会影响程序的正确性。对于服务端开发来说,我们在一些高并发场景,例如秒杀等,常常会使用到一些自旋获取锁逻辑,使用这个API可以帮助我们降低一些CPU负载。

image.png