Java并发编程技术——昨天,今天与可见的未来

本文章简单带大家回顾 Java 并发编程的发展历程,以不同的角度理解 Java 并发编程的发展过程与大致思路。

作者致力于使文章适合所有程序员阅读(包括初学者),如有建议请在评论区留言,非常感谢大家的支持。

并发与并行

直到现在(2024年),我认识的许多 Javaer 依旧试图使用多线程来描述一切与并发与并行有关的行为。而将它们混合在一起的一个主要原因,就是 Java 使用线程模型实现并发和并行。

如今,并发和并行拥了更加明显的定义上的区别,即

  1. 并发是关于正确有效地控制对共享资源的访问,比如某网站实现万人同时在线抢票功能,需要保证发放数量和顺序正确的同时保证用户体验。
  2. 并行是使用额外的资源来更快地产生结果,比如某数据分析需要在一分钟内处理百万条数据,我们可以通过并行处理,充分利用多核优势,缩短计算时间。

Java的线程模型

对于操作系统来说,一个进程通常代表一个应用,而一个应用可以向操作系统内核申请线程来完成任务。对于操作系统来所每个Java应用,就是一个进程,Java应用可以向操作系统申请线程作为自己的线程。

对于Java程序和JVM来说,线程是执行代码的最小单元,每一个线程都拥有自己的操作栈,同时共享整个堆区域。Java虚拟机(JVM)负责线程的调度和执行。

当处于多线程环境中,我们很难保证程序最终运行结果的正确性。在硬件、操作系统和JVM层面,当以下条件满足就可能出现竞态条件:

  1. 当多个线程以不确定的时间间隔对共享数据进行读写操作,导致最终结果依赖于线程执行的时序。
  2. 当多个线程同时访问共享数据时,每个线程都在自己的本地缓存中保存了一份数据的副本。

竞态条件发生时,多个线程或进程可能会争夺同一资源,发生数据竞争,从而导致数据不一致性和不可预测的程序行为。

为了避免竞态条件与缓存带来的数据不一致,我们需要通过同步机制来确保操作的顺序性和一致性。Java通过其内存模型(JMM),对这些机制进行了封装和抽象,确保了线程安全。

什么是同步(Synchronization)

同步是指在确保某一资源同时只允许一个访问者对其进行访问(互斥)的同时,保证对访问者访问资源的顺序控制。具有唯一性和排他性,如果有多个线程或进程试图同时访问同一资源,只有一个能够成功,其他的必须等待直到资源被释放。

注意:线程同步不一定发生阻塞,只有当访问同一资源出现互相等待和互相唤醒会发生阻塞,即一个线程主动等待另一线程释放资源。

什么是顺序一致性(Sequential Consistency)

顺序一致性是一个重要的内存模型概念,用于描述在多处理器系统中,操作的执行顺序如何被保持一致。它要求:

  1. 单个处理器的操作必须按照程序指定的顺序执行。
  2. 操作的结果必须表现得如同所有处理器上的操作都按某一特定顺序执行,而且每个处理器内部的操作顺序与程序定义的顺序一致。

顺序一致性确保了即使在多处理器系统中,程序的行为就像是在单个处理器上顺序执行一样,从而简化了并发编程的复杂性。

JAVA内存模型(JMM)

JMM 的设计目的是为了解决 Java 多线程环境中的可见性和有序性问题,同时提供一种机制来预测和控制多线程程序中变量的读写方式。JMM屏蔽了不同硬件和操作系统之间的内存访问差异,确保Java程序在各种平台上都能获得一致的并发性能。

  1. Java提供的同步原语:volatile,synchronized,final,帮助我们确保线程之间的正确协作和数据一致性
  2. JVM规范 Happens-Before 规则,提供预测和控制多线程程序中变量的读写方式的机制

JSR-133 Happens-Before 规则

单一线程原则,在一个线程内的程序可以被视为按顺序执行。

管程锁定规则,同时间发生解锁与锁定的操作顺序被规定为先解锁。

volatile 变量规则,同时间发生对volatile变量操作顺序被规定为先写后读。

此外还有传递性,线程启动规则,线程加入规则,线程中断规则,对象终结规则。

三种同步原语

  1. synchronized 是一种非公平可重入锁,可以提供最高级别的线程安全:同步,从而保证了有序性,可见性,原子性。它可以应用于方法或代码块,防止多个线程同时访问共享资源,从而避免数据竞争和并发问题。当一个线程进入同步代码块时,其他线程必须等待,直到该线程释放锁,才能继续执行。
  2. volatile修饰的变量可以保证线程之间的可见性、有序性,并防止指令重排序引起的问题(JSR-133 )。Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令,禁止满足特定读写顺序的操作进行重排序。这确保了volatile变量的读写操作的有序性。同时对volatile修饰变量进行操作会强制将当前处理器缓存行的数据写回到系统内存,使各处理器的缓存失效。当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
  3. final 提供了初始化安全保证( JSR-133 )和数据的不可变性。

目前虚拟线程暂不支持synchronized(2024.3.26 Java22)。

Java并发编程工具(JUC)

Concurrency Utilities( JSR-166 )于2004年提出,是 Java 社区对并发编程的规范要求,旨在提供更强大、更丰富的并发编程支持,其中包含了一组并发编程的API和工具类,它引入了大量的并发工具和类,用于处理多线程编程的常见问题:

  1. Lock,以及新的底层锁结构:LockSupport::park,LockSupport::unpark
  2. CAS,利用 CAS 的原子类
  3. 并发集合,利用 JUC 提供更好的性能
  4. 并发执行框架与 Future

Java引入的以线程为核心思想的并发编程工具,在较好保证多线程的安全性的同时使得利用多线程平台的能力成为了可能。其隐藏了底层系统的复杂性,提供了一套相对简单的API来进行并发控制,这使得开发者可以在用户态下进行多线程编程,而无需关心底层的操作系统如何处理这些线程。

除此之外,自 Java 1.2 以来添加的ThreadLocal,使每个线程可以存储线程独有的变量,来防止多个线程对变量竞争导致竞态条件。

换一个视角看问题

这样是否真的能充分利用并发优势,带给我们的性能提升吗?

这当然可以,同步阻塞充分利用了系统底层能力,可以被用来设计非常灵活的并发系统,但这同时需要程序员具备更多专业知识和并发编程的经验。Java的设计师们,相信程序员们可以利用自己的聪明才智来解决多线程带来的复杂性。

最终,多线程编程的复杂性,多线程可能带来的风险,使用这些工具带来的心智负担,全都被暴露给试图使用多线程提高应用性能的程序员们。

虽然大多数编程语言,依旧选择使用这种复杂,但更加自由的模型,来支持自己的并发编程。

同步阻塞带来了其他问题

直接基于多线程模型进行编程,通常是复杂且困难的。为了减轻程序员的负担,使多线程编程带来的复杂性原理远离业务,提高最终产品的稳定性。大部分框架或者规范直接基于同步阻塞的方式简化多线程模型,往同步阻塞的道路上一去不返。

这样来看,框架确实通过封装,大大减少普通程序员编写业务时的心智负担,但这样做依旧存在一些问题:

  1. 在早期的Java企业级网络应用服务器中,大多都采用连接独占线程或进程的模型,线程/进程处理来自绑定连接的消息,在连接断开前不退也不做其他事情。这导致大多服务器存在性能瓶颈和可伸缩性问题,很难处理C10K问题,这样的高并发请求数量。

  2. 虽然阻塞操作虽然使得一切看起来都是那么井然有序和协调,并且线程的个数并不受硬件限制,你的程序可以只有一个线程、也可以有成百上千个,操作系统会默默做好调度,让诸多线程共享有限的 CPU 时间片。

    但这个调度过程是通过上下文切换实现的,操作系统利用软中断机制,把程序从任意位置打断,然后保存当前所有寄存器,整个过程会产生数微秒的开销。这种切换带来了不少的代价,占用宝贵的 CPU 时间,切换的每个线程都会占用至少 1 页内存。

这使得JVM与其上运行的大部分Java企业级服务器,一度成为高内存占用底并发量的代名词。

其他语言的进步

时代在变化,除了Java,其他语言也在各自的领域大放异彩,其中函数式思想对现代并发编程影响深远,接下来让我们主要从最近比较主流的解决方案,来谈一谈其它语言的改进与其带来的优点。

使用函数式编程避免副作用

副作用(Side effects)是函数式编程中的一个概念,是指函数或方法在执行过程中对外部环境产生的可观察变化,如修改全局变量、修改状态、进行I/O操作等。命令式编程和面向对象编程中常常包含副作用,这种状态的改变和外部影响使得程序的行为变得复杂,难以理解和预测。

在函数式编程中,纯函数被视为无副作用的数学映射,即给定相同的输入,函数总是产生相同的输出,而不会对外部环境造成任何改变。这种无状态和无副作用的函数可以更容易地进行推理和测试,使程序更加可靠和可维护。同时函数式编程中的不可变数据结构和纯函数特性使得并行执行更加容易,因为没有竞态条件。

面向对象编程中也可以遵循一些原则来减少副作用的发生:确保对象在创建时就是完整的,可以帮助避免程序中出现错误和不一致的状态;使用封装将对象的状态隐藏在类内部,通过方法提供对状态的访问和修改,从而控制对状态的修改;遵循单一职责原则和开闭原则减少对对象的修改。

协程

在现代化编程中,为了突出协程的优点,我们可以将协程看作一种轻量级的并发执行单元,其在代码级别上实现了并发,不需要操作系统的线程,不涉及操作系统级别的上下文切换,从而避免了系统线程切换的开销。

另外一篇文章具体介绍了协程及其在不同语言下的应用

追赶时代的Java

闭包(Java 1.1 & Java 8)

“Closures”(闭包)是一种编程语言概念,它指的是一个函数(或子程序)以及与其相关的引用环境(包括捕获的变量)的组合。当一个函数捕获了其外部环境的变量,并可以在其定义范围之外访问和修改这些变量时,就形成了一个闭包。

通过捕获外部环境的变量,函数可以保持状态,并在不同的调用之间共享状态。这在函数式编程中常用于实现惰性求值、记忆化和缓存等功能。同时闭包可以用来创建不可变的数据结构。通过在闭包中捕获状态并返回不可变的结果,可以确保数据的安全性和线程安全性。

类与对象之间的关系可以类比为函数式编程中的函数与闭包之间的关系。

  1. 类与函数:类可以看作函数式编程中的函数。类定义了一组相关的属性和方法,类似于函数定义了一组相关的操作和逻辑。类可以用于创建对象实例,就像函数可以调用和执行。
  2. 对象与闭包:对象可以看作是闭包的实例化结果。对象存储了类定义的属性的具体值,并且可以通过方法访问和修改这些属性。对象包含了类的行为和状态,类似于闭包包含了函数和捕获的变量。

需要注意的是,函数式编程和面向对象编程是两种不同的编程范式,它们在思维方式和代码组织上有一些差异。函数式编程更加强调函数的纯粹性、不可变性和函数组合,而面向对象编程更加强调对象的封装、继承和多态性。虽然闭包可以与对象类比,但它们之间仍存在一些语义和实现上的差异。

JVM有原生的基于类的对象支持,所以在JVM上实现一种支持闭包的语言只需要让该语言的编译器生成模拟闭包的类即可。Java 1.1及之后的版本中,通过匿名内部类可以捕获外部作用域的变量,并在内部类的方法中访问和修改这些变量,这使得匿名内部类具有类似闭包的特性,可以在内部类中封装行为和状态。

早期Java社区希望引入更简洁的语法来表达闭包和纯函数,这就是“Closures”项目。该项目由Neal Gafter提出,并在2007年至2008年期间推进了一系列Java语言规范的扩展。”Closures”提出了一套语法和语义规则,允许在Java中定义匿名函数和捕获外部变量。这些匿名函数可以作为一等公民,传递给其他函数或直接赋值给变量。

Java 8引入的Lambda表达式和函数式接口则成为了更为通用和主流的函数式编程的方式,其在语法上与”Closures”很相似。真正的闭包允许在函数执行期间引用和修改自由变量,而OpenJDK Closures中的捕获变量是不可变的。Lambda表达式更接近真正的闭包概念,但Lambda依旧需要保证变量在使用时必须是不可修改的(effectively final)。

为什么说 Java 只实现了一半闭包:Java 的闭包只支持值捕获,即 Lambda 在创建闭包的地方把捕获的值拷贝一份到闭包生成的“对象”里,拷贝的值不会被共享,所以没有必要更改且在语法上无法更改(因为只是一份值拷贝,除非使用值为引用的“box”来共享引用)。

而其他语言的闭包大部分是通过把原本在栈上分配的”值“,变成在堆上分配,并传递“引用”实现;或通过其他各种方法使闭包内可以方便的直接传递引用。

函数式编程(Java 8)

Java 8引入函数式编程主要的目的,是通过引入纯函数、不可变数据和高阶函数等概念,为开发人员提供更多的工具和技术来编写高效、简洁、可维护的代码;在并发编程中,函数式编程可以让程序员更专注于问题的本质,而不是纠结于底层的线程同步和通信细节。

举一个例子可以帮助我们更好的理解这一点:

假设我们有一个文本文件,其中包含了大量的单词。我们的目标是计算每个单词在文件中出现的次数,并找出出现次数最多的前 N 个单词。通过并行计算,我们可以同时处理多个文件,从而更快的完成任务。

在 Java 5 中我们需要使用匿名内部类携带外部共享状态来完成任务的定义。同时利用synchronized锁,保证对共享变量wordCount的操作的可见性、有序性和原子性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class WordCountSyncJava5 {
private static final int NUM_THREADS = 4;
public static void main(String[] args) throws IOException, InterruptedException {
String filename = "input.txt";
int topN = 10;
final Map<String, Long> wordCount = new HashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(NUM_THREADS);
BufferedReader reader = new BufferedReader(new FileReader(filename));
try {
String line;
while ((line = reader.readLine()) != null) {
executeCountWordsTask(executor, line, wordCount);
}
} finally {
reader.close();
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
for (Map.Entry<String, Long> entry : wordCount.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
}
private static void executeCountWordsTask(final ExecutorService executor, final String line, final Map<String, Long> wordCount) {
executor.execute(new Runnable() {
public void run() {
countWords(line, wordCount);
}
});
}
private static synchronized void countWords(String line, Map<String, Long> wordCount) {
String[] words = line.split("\\s+");
for (String word : words) {
Long count = wordCount.get(word);
if (count == null) {
count = 1L;
} else {
count++;
}
wordCount.put(word, count);
}
}
}

以上代码看起来在逻辑上并没有什么问题,但是由于全局变量的状态在并行执行中被多个任务共享,可能会出现竞态条件和不一致的结果,这就叫做引入了副作用和可变状态。并行执行的结果将依赖于任务之间的执行顺序,不再具有确定性和可预测性,我们必须对共享变量通过加锁等手段来保证执行的顺序一致性。

现在,让我们使用更函数式的方式来解决同样的问题。

在函数式编程中,我们会使用纯函数,即没有副作用的函数。一个纯函数的特点是,给定相同的输入,它总是返回相同的输出,并且没有任何可观察的副作用。

在Java8后,我们可以在Java中使用Lambda表达式代表一个纯函数或者一个闭包,并使用StreamAPI对大量数据进行处理,Stream中的操作可以使用Lambda表达式或方法引用以及匿名内部类来定义。

这样我们可以以函数式的方式传递代码逻辑,每个操作相互独立,就不会产生竞态条件或数据共享的问题。函数式编程的特性确保了每个任务的独立性和安全性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class WordCountFunctional {
public static void main(String[] args) throws IOException {
String filename = "input.txt";
int topN = 10;
Map<String, Long> wordCount = Files.lines(Paths.get(filename))
.parallel() // 并行流
.flatMap(line -> Stream.of(line.split("\\s+")))
.collect(Collectors.groupingByConcurrent(w -> w, ConcurrentHashMap::new, Collectors.counting()));
wordCount.entrySet().stream()
.sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
.limit(topN)
.forEach(entry -> System.out.println(entry.getKey() + ": " + entry.getValue()));
}
}

通过这个例子我们可以观察到,函数式编程中的不可变数据结构和纯函数特性使得并行执行变得更加容易,同时不需要担心数据的共享和修改的竞态条件问题。同时,函数式具有更加强大的抽象和表达能力,用更简短的代码完成更复杂的工作。

”协程“虚拟线程(Virtual Thread)(Java 21)

Project Loom 是 OpenJDK 社区的一个尝试,其主要目标是支持高吞吐量的轻量级并发模型,社区希望避开无栈协程的染色指针和不支持直接结构化并发带来的其他问题,降低语言负担,这要求 JVM 底层进行有栈协程的支持

Project Loom 通过引入用户模式线程和延续(Continuation),提出了一种替代内核线程的方法。这个项目的核心是虚拟线程(Virtual Threads),它们被设计为比操作系统的内核线程更轻量,可以大幅提升并发应用的资源效率,同时保持与 Java 线程的向后兼容性。

Project Loom 通过引入runtime支持的Continuation结构,重写网络库并且提供java.lang.Thread的子类VitrualThread,做到了只要简单替换线程池实现就可以获得类似于go但是是协作式的用户态线程的能力,同时也给予了旧有代码升级最小化改动的帮助。

Thread::currentThread,LockSupport::park,LockSupport::unpark,Thread::sleep,也对此做了适配,这意味着我们那些基于J.U.C包的并发工具仍旧可以使用。但当前虚拟线程在某些特殊情况下会被”pin“在平台线程上,退化为内核级线程,这需要其他框架或平台为高性能做出某些特殊的牺牲与升级。

接下来我们给出一个使用虚拟线程的例子:

1
2
3
4
5
6
7
8
9
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("start");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("end");
});

作用域值(ScopedValue)(预览)

ScopedValue与ThreadLocal类似,提供了范围内安全的共享变量。(Java JEP 429 还处于孵化器阶段,并没有被正式纳入 Java 语言规范)。

作用域值是一种新的语言结构,它允许声明一个只能在当前范围(extent)内访问的变量。一个范围是一个代码块或一个方法调用栈,它可以包含多个线程。作用域值只能被当前范围内的代码读取,不能被其他范围内的代码读取或修改。作用域值是不可变的,并且可以安全地在线程之间共享。

结构化并发(Java 8 & 预览)

在传统的异步编程中,我们通过异步操作加回调来完成并发操作,线程间随意跳转带来的后果与滥用goto语句类似:

  1. 如果没有一种顺序化的组织方式,大概率会出现回调地狱,使得代码晦涩难懂。
  2. 非结构化并发还使控制流变得非常复杂,并且由于异步操作和调用者具有不同的调用栈,因此异步操作异常后通常无法通知原始的调用者,进而无法以抛出的方式向上传递错误,这些都使得调试的难度大大增加。
  3. 更加严重的是,这些异步任务大部分不可以被直接控制,其生命周期也与调用者的作用域完全无关,很容易出现线程泄露问题。同时异步任务内的变量如果不被及时清理,则会导致内存泄露问题。
  4. 就算异步任务可以被直接控制,我们也很难确定正确取消或释放异步任务的时机。

如果我们使用 Java 5 非结构化并发的 FutureTask(Java 5)

1
2
3
4
5
Response handle() throws ExecutionException, InterruptedException {
Future<String> user = executorService.submit(() -> findUser());
Future<Integer> order = executorService.submit(() -> fetchOrder());
return new Response(user.get(), order.get());
}

ExecutorService 中的子任务独立运行,可能成功或失败。

  1. 即使父任务被中断,中断也不会被传播到子任务,因此会造成泄漏。
  2. 它没有父子关系。由于父任务和子任务将出现在线程转储不相关的线程调用堆栈上,因此调试也变得困难。

尽管代码看起来具有逻辑结构,但这种结构只停留在开发人员的头脑中,而不是在执行过程中。所以,它们是非结构化的并发代码。

而解决这一点的方式,就是使用结构化并发。

结构化并发的核心是在并发模型下,也要保证控制流的单一入口和单一出口。程序可以产生多个控制流来实现并发,但是所有并发控制流在出口时都应该处于完成或取消状态,控制流最终在出口处完成合并。

结构化并发的优点

  1. 它为调用者方法及其子任务创建了一种父子关系。整个代码块变成了原子代码。
  2. 它通过线程转储中的任务层次结构来提供可观察性。
  3. 它还可以在错误处理中实现短路,如果其中一个子任务失败,其他未完成的任务将被取消。如果父任务的线程在 join()调用之前或期间被中断,两个分支将在作用域退出时自动取消。

这让并发代码的结构变得更加清晰,开发人员现在可以推理和跟踪代码,就好像它们是在单线程环境中运行。

大多数情况下,结构化并发的实现技术栈从上层到底层可以分为五个部分,分别是:作用域,异步函数,协程,计算续体与内核态线程,由此可以看出 Java 8并不具备支持完整结构化并发的条件。

CompletableFutrue(Java 8)

1
2
3
4
5
6
Response handle() throws ExecutionException, InterruptedException {
CompletableFuture<Void> userFuture = CompletableFuture.supplyAsync(() -> findUser());
CompletableFuture<Void> orderFuture = CompletableFuture.supplyAsync(() -> fetchOrder());
CompletableFuture.allOf(userFuture, orderFuture).join();
return new Response(user.get(), order.get());
}

所以,虽然 Java 8 提供了一些以结构化组织代码执行的顺序,但严格意义上来讲它们依旧是非结构化的并发代码。

真正的结构化并发 StructuredTaskScope(预览)

1
2
3
4
5
6
7
8
Response handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<String> user = scope.fork(() -> findUser());
Supplier<Integer> order = scope.fork(() -> fetchOrder());
scope.join().throwIfFailed();
return new Response(user.get(), order.get());
}
}

StructuredTaskScope 提供了更易于使用的结构化并发构造,并支持直接派生子任务,保证了堆栈连续,且更加直观强大。

Java并发系统现代化

从1996 年 Java1.0 诞生至今的 28 年,Java 一直是企业软件开发中极为流行的编程语言。全世界有无数使用这种广为人知的编程语言构建的系统,Java1.0 编写的代码与编译成的字节码文件至今仍可与 Java21兼容,并运行在最新的虚拟机上。

通过使用虚拟线程,Java应用可以使用更小的线程数与内存,提供更高的并发量和性能。引入函数式编程使得并发编码更加易于书写和维护,在降低程序员编写异步代码心智负担的同时,提供更好的性能。虚拟线程支持 JVM 生态使用更小的更改,带来最大的收益,为整个 JVM 生态的繁荣做出了努力。

值得注意的是,语言平台提供的底层能力,并不能撼动现代主流高性能框架与架构的地位,如响应式编程和事件驱动架构,依旧是未来发展的主要趋势之一。