反应式(响应式)编程是一种关注于异步数据流和变化传播的编程范式,用于处理异步数据流和事件驱动的应用程序。这种编程风格非常适合构建高并发、低延迟的应用程序。它允许以声明式的方式来描述数据流动以及相应的变化关系,简化异步编程,提升系统的响应性和可伸缩性。Java 中的反应式编程通常用于提高应用程序的响应能力、弹性和可扩展性。在 Java 中,反应式编程主要通过 Reactor 和 RxJava 等库实现。
早期,为使得软件系统支持更大并发量,需要使用多线程进行并行处理请求、异步处理复杂业务逻辑等,这些大部分需要手动进行编码使得线程能够良好配合以达到完成处理请求的目的。对于有资本的企业来说选择采用分布式系统架构(譬如: 熟知的微服务SpringCloud)来增加公司所产出的软件系统的并发量,引入消息队列使得系统减少请求阻塞最终目的是使得系统成为响应式系统。
现今,随着技术的迭代,JDK、Reactor、Spring官方仿照 .NET 推出了Java语言层面的响应式开发模型及相关组件,这些响应式开发模型的特点为: 天然异步、订阅发布(消息队列思想)、背压(限流)控制。使用JDK、Reactor、Spring提供的这些组件可使得我们轻松构建一个高性能的、响应式的软件系统。
Java中的反应式编程库
Reactor
Reactor 是 Spring 生态系统的一部分,用于构建非阻塞应用程序,主要类包括 Flux 和 Mono 。它支持"少即是多"的设计,通过相对简单的API实现强大的并发特性。
pom:
xml
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.5.1</version>
</dependency>
此框架基于Reactive Stream规范及JDK底层封装的一些接口与实现,可帮助开发者轻松构建一个响应式(全链路非阻塞)应用。
-
消息模型,其原理与MQ基本一致。
-
采用多线程,所写逻辑代码天生具有异步的特性,可自定义线程池自定义异步逻辑。
-
采用 函数式接口、流式编程思想,业务逻辑代码基本都属于链式(流水线)编程。
-
编程方式与传统命令式编程大不相同,需要定义发布者与消费者,且业务逻辑是在函数式接口中进行编写。
- 命令式(阻塞式)编程:A方法调B方法,只有B方法返回响应,A方法才可执行下一行代码。
- 在Web应用中服务器默认为发布者,前端默认为消费者。
此框架核心API主要有如下两种:
-
发布者与消费者通过 subscribe 函数绑定。
- 当没有消费者绑定发布者时,消息不会被处理。
- 在测试 Reactor 提供的各种API时注意要让主线程阻塞,因为默认逻辑都是走的异步。
-
Publisher: 消息发布者
-
Flux:
表示0到N个元素的异步序列,即可以表示无限或有限数量的数据流。
适合用于需要处理多个元素的场景,如流式数据处理或批量数据处理。
提供丰富的操作符来支持对数据流的变换、过滤、组合等操作。
-
Mono:
表示0到1个元素的异步序列,即最多只能包含一个元素或是空的。
适合用于处理仅需要单个结果或可能没有结果的场景,如单个API调用的返回值。
提供的操作符用于对可能只有一个的元素进行操作。
iniimport reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @Slf4j public class ReactorDemo { public static void main(String[] args) { Flux<String> flux = Flux.just("Hello", "Reactive", "World"); flux.subscribe(log::info); Mono<String> mono = Mono.just("Single Item"); mono.subscribe(log::info); // 事件感知API:当流发生什么事的时候,触发一个回调,系统调用提前定义好的钩子函数(Hook【钩子函数】);doOnXxx; Flux<Integer> flux = Flux.range(1, 7) .delayElements(Duration.ofSeconds(1)) .doOnComplete(() -> { System.out.println("流正常结束..."); }) .doOnCancel(() -> { System.out.println("流已被取消..."); }) .doOnError(throwable -> { System.out.println("流出错..." + throwable); }) .doOnNext(integer -> { System.out.println("doOnNext..." + integer); }); //有一个信号:此时代表完成信号 } }
-
-
Subscriber:消息消费者
- 这里主要是学习一些 onXXX 事件回调函数,譬如消息发送异常、消息处理完毕、消息全部处理完成、与订阅者成功绑定等。
- 利用 request 函数控制一次向发布者索要多少数据,背压模式的核心就是 request 函数.
typescript
flux.subscribe(new BaseSubscriber<Integer>() {
@Override
protected void hookOnSubscribe(Subscription subscription) {
System.out.println("订阅者和发布者绑定好了:" + subscription);
request(1); //背压
}
@Override
protected void hookOnNext(Integer value) {
System.out.println("元素到达:" + value);
if (value < 5) {
request(1);
if (value == 3) {
int i = 10 / 0;
}
} else {
cancel();//取消订阅
}
; //继续要元素
}
@Override
protected void hookOnComplete() {
System.out.println("数据流结束");
}
@Override
protected void hookOnError(Throwable throwable) {
System.out.println("数据流异常");
}
@Override
protected void hookOnCancel() {
System.out.println("数据流被取消");
}
@Override
protected void hookFinally(SignalType type) {
System.out.println("结束信号:" + type);
// 正常、异常
// try {
// //业务
// }catch (Exception e){
//
// }finally {
// //结束
// }
}
});
RxJava
RxJava是另一个流行的反应式编程库,受到ReactiveX项目的启发。其主要组成部分是Observable和Flowable。
pom:
xml
<dependency>
<groupId>io.reactivex.rxjava3</groupId>
<artifactId>rxjava</artifactId>
<version>3.1.5</version>
</dependency>
- Observable:
-
- 用于处理一般的异步数据流,适合于不会因生产速度过快(相对于消费速度)而导致背压问题的场景。
- 默认不支持背压,因此在数据生产速度过快时,可能会导致内存消耗过大或数据丢失。
- 适合用于轻量级的数据流任务,如简单的事件流处理。
- Flowable:
-
- 设计用于处理背压问题的数据流,它可以在生产速度超过消费速度时提供控制机制。
- 适合用于处理可能产生大量数据的场景,如大型数据集或高吞吐量的流式处理任务。
typescript
import io.reactivex.rxjava3.core.Observable;
@Slf4j
public class RxJavaDemo {
public static void main(String[] args) {
Observable<String> observable = Observable.just("Hello", "Reactive", "World");
observable.subscribe(log::info);
Flowable<String> flowable = Flowable.just("Backpressure", "Support");
flowable.subscribe(log::info);
}
}
基本概念
- 异步数据流: 无需阻塞即可处理数据。一旦数据准备好,就会使用相应的数据流处理器进行处理。
arduino
import reactor.core.publisher.Flux;
public class AsyncFlowExample {
public static void main(String[] args) {
Flux<String> dataFlow = Flux.just("data1", "data2", "data3")
.map(data -> data.toUpperCase());
dataFlow.subscribe(System.out::println);
}
}
- 非阻塞: 非阻塞意味着操作(例如I/O)不会暂停应用程序的执行,通过使用非阻塞I/O来提升性能。
arduino
import reactor.core.publisher.Mono;
public class NonBlockingExample {
public static void main(String[] args) {
Mono<String> asyncResult = Mono.fromSupplier(() -> {
// 模拟非阻塞I/O操作
return "Non-blocking result";
});
asyncResult.subscribe(System.out::println);
}
}
- 背压(Backpressure): 用于控制数据生产和消费的速率,以防止数据生产者压倒消费者。Reactor和RxJava都支持多种背压策略。onBackpressureDrop 方法概述
java
import io.reactivex.Flowable;
import java.util.concurrent.TimeUnit;
public class BackpressureExample {
public static void main(String[] args) throws InterruptedException {
Flowable.interval(1, TimeUnit.MILLISECONDS)
.onBackpressureDrop(item -> System.out.println("Dropped: " + item))
.observeOn(Schedulers.computation())
.subscribe(
item -> {
// Simulate slow consumer
Thread.sleep(5);
System.out.println("Handled: " + item);
},
Throwable::printStackTrace
);
// Let it run for a while
Thread.sleep(1000);
}
}
-
- 功能:onBackpressureDrop用于在消费者无法及时处理生产者产生的数据时,直接丢弃新的数据项。这种策略不对生产速度进行减缓,仅在生产和消费不匹配时处理数据项的累积问题。
- 适用场景:当数据流中有些数据可以被忽略,或者系统的一部分无法限制生产者速度,而消费者只能处理有限的数据量,且丢弃部分数据不会影响整体应用程序的逻辑时,可以使用此方法。
- 操作机制:
当下游消费者无法及时处理数据项时,onBackpressureDrop直接丢弃那些不能被立即处理的数据。 1. 可以结合回调函数使用,以便对被丢弃的数据项进行记录或日志记录(例如,调试或监控目的)。
- 在这个例子中,数据项产生的速度远远超过了消费者的处理速度,因此使用onBackpressureDrop来丢弃多余的数据项,并打印出哪些项被丢弃。
背压和线程池中的拒绝策略的相同点和不同点
- 相同点
应对过载问题:
- 两者都用于处理生产者(任务或数据流)与消费者(处理逻辑或线程)之间的速率不匹配问题。 - 目标是防止系统因为过载而崩溃。
限制处理能力:
- 背压通过控制生产者的生产速率或缓冲数据来减轻消费者的压力。 - 拒绝策略通过拒绝、丢弃或重新分配任务来限制线程池的负载。
保护消费者/处理资源:
- 背压保护订阅者(消费者)不被数据洪流淹没。 - 拒绝策略保护线程池不被任务洪流压垮。
- 相同点
特性 | 反应式编程中的背压 | 线程池中的拒绝策略 |
---|---|---|
适用场景 | 数据流处理,生产者与消费者速率不匹配问题 | 多线程任务处理,线程池任务过载时的应对机制 |
触发条件 | 消费者无法及时处理生产者的数据 | 线程池的任务队列满或线程池达到最大线程数量 |
应对方式 | 暂停或减缓生产者的数据发送,使用缓冲区,丢弃过多的数据 | 拒绝任务(抛异常)丢弃任务 使用自定义策略 |
实现方式 | 基于 Publisher 和 Subscriber 的协商(如 Flowable 的背压策略) |
通过 RejectedExecutionHandler 接口实现自定义策略 |
粒度 | 更细粒度的数据流控制,面向数据块 | 面向线程任务的整体管理 |
使用的线程池:自带的 Schedulers
Reactor 框架中使用的默认线程池配置通常依赖于 Schedulers
。具体来说:
开发者可以根据具体需求选择合适的 Scheduler 进行线程管理和调度。默认情况下,某些操作可以发生在 "当前线程" 中,具体取决于上下文和调用的方法链。
Schedulers.parallel() :
- 用于并行任务执行的线程池,适合 CPU 密集型操作。 - 默认线程数是处理器核数,即
Runtime.getRuntime().availableProcessors()
。
Schedulers.elastic() :
- 提供一个弹性可伸缩的线程池,适合 I/O 阻塞操作。 - 线程是按需创建的,如果一个线程在 60 秒内闲置没有被使用,就会被回收。
Schedulers.boundedElastic() :
- 设计用于长时间并发阻塞操作,有上限的弹性线程池。 - 默认最大线程数通常是 10 倍的处理器核数,最大队列大小为 100000。
Schedulers.single() :
- 提供了一个单线程的执行上下文,适用于需要顺序执行且不想并行的任务。
Schedulers.immediate() :
- 直接在当前线程执行。
关于自定义Schedulers
这些方法允许根据应用的特定需求提供自定义的线程池配置,帮助更好地管理资源和优化性能。
自定义固定大小的线程池 : 你可以使用 Schedulers.newParallel()
创建一个自定义的固定大小线程池 Scheduler
。
ini
Scheduler customScheduler = Schedulers.newParallel("custom-parallel-scheduler", numberOfThreads);
自定义弹性线程池 : 使用 Schedulers.newElastic()
可以创建一个自定义的弹性线程池 Scheduler
。
ini
Scheduler customElasticScheduler = Schedulers.newElastic("custom-elastic-scheduler", ttlMillis);
自定义具有边界的弹性线程池 : 自定义 Schedulers.newBoundedElastic()
可以创建一个具有线程和队列限制的弹性线程池。
ini
Scheduler customBoundedElasticScheduler = Schedulers.newBoundedElastic(maxThreads, maxTaskQueued, "custom-bounded-elastic-scheduler");
自定义单线程 Scheduler
: 可以使用 Schedulers.newSingle()
创建一个自定义的单线程 Scheduler
。
ini
Scheduler customSingleScheduler = Schedulers.newSingle("custom-single-scheduler");
注意事项
- 数据丢失:由于其策略直接丢弃过多的数据项,因此需要确保丢弃的数据不会对业务逻辑产生负面影响。
- 非阻塞:该策略不会对数据生产者施加任何限制或施压,其只是单纯的丢弃未被处理的数据。
- 监控:在生产环境中,建议对被丢弃的数据进行监控,以便及时了解数据丢失情况。
应用场景
反应式编程非常适合以下场景:
- 高并发场景: 比如实时数据处理、高频率请求处理。
- 资源受限环境: 例如有限的线程池场景,因为其非阻塞I/O特性,无需为每个请求分配单独的线程。
- 数据流场景: 包括实时变化的数据流、事件流处理。
举个例子
在这个例子中会处理用户的订单请求,包括从数据库中获取用户信息、处理订单并返回处理结果。
typescript
package com.reactor.demo;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class LargeOrderProcessingService {
public static void main(String[] args) {
// 使用 Collectors.toList() 收集订单
List<String> orderIds = IntStream.rangeClosed(1, 10000)
.mapToObj(i -> "order" + i)
.collect(Collectors.toList());
long startTime = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(1); // 用来等待流的完成
//创建一个 Flux 对象,该对象从一个包含订单 ID 的集合中生成数据流
Flux.fromIterable(orderIds)
//将 Flux 转换为并行流,以便对每个订单 ID 进行并发处理
.parallel()
//指定调度器(boundedElastic),用于处理 I/O 密集型任务的并发操作,这可以提升处理效率。
.runOn(Schedulers.boundedElastic())
//为每个订单 ID 异步处理订单,processOrder 是一个返回 Publisher(如 Mono 或 Flux)的操作,表示处理的异步工作
.flatMap(LargeOrderProcessingService::processOrder)
//将并行流转换回顺序流。发出的结果会按收到的顺序输出。
.sequential()
//在所有订单处理完成后执行一些操作,计算并打印处理所有订单所用的时间
.doOnComplete(() -> {
long endTime = System.currentTimeMillis();
System.out.println("All orders processed in " + (endTime - startTime) + " ms");
latch.countDown(); // 流完成后倒计数
})
//启动整个流的处理。指定两个 lambda 函数,以在每个成功处理的订单时打印结果,以及在发生错误时打印错误信息
.subscribe(
result -> System.out.println("Processed: " + result),
error -> {
System.err.println("Error: " + error);
latch.countDown(); // 发生错误时也停止等待
}
);
// 阻塞主线程以便观察输出
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static Flux<String> processOrder(String orderId) {
//调用此方法获取与订单相关的用户信息。假定该方法返回一个 Mono<String>,其包含用户信息。
return getUserInformation("user" + orderId)
//绑定用户信息并处理订单批次,使用 flatMapMany 方法,将获取的用户信息与 validateAndProcessOrderBatch 方法绑定
.flatMapMany(userInfo -> validateAndProcessOrderBatch(orderId, userInfo))
//指定在一个弹性线程池中执行订阅和操作(该弹性池适用于 I/O 密集型操作,允许 Reactor 在需要时扩展线程池大小)
.subscribeOn(Schedulers.boundedElastic());
}
private static Mono<String> getUserInformation(String userId) {
return Mono.fromCallable(() -> {
Thread.sleep(100);
return "User Info for " + userId;
});
}
private static Flux<String> validateAndProcessOrderBatch(String orderId, String userInfo) {
List<String> batchOrderIds = IntStream.rangeClosed(1, 100)
.mapToObj(i -> orderId + "-item" + i)
.collect(Collectors.toList());
//将订单项列表转换为一个 Flux,用于异步处理每个订单项
return Flux.fromIterable(batchOrderIds)
//验证订单
.flatMap(orderItem -> validateOrder(orderItem, userInfo)
//在订单验证后,调用 processPayment(validatedOrder) 处理付款。这也是一个异步操作,返回一个 Mono<String>,表示付款确认信息
.flatMap(validatedOrder -> processPayment(validatedOrder)
//在付款成功后,调用 shipOrder(paymentConfirmation) 进行发货。这同样是一个异步操作,返回一个 Mono<String>,表示订单发货的确认信息
.flatMap(paymentConfirmation -> shipOrder(paymentConfirmation)))
);
}
private static Mono<String> validateOrder(String orderId, String userInfo) {
return Mono.fromCallable(() -> {
Thread.sleep(100);
return "Validated Order " + orderId + " for " + userInfo;
});
}
private static Mono<String> processPayment(String validatedOrder) {
return Mono.fromCallable(() -> {
Thread.sleep(200);
return "Payment Confirmed for " + validatedOrder;
});
}
private static Mono<String> shipOrder(String paymentConfirmation) {
return Mono.fromCallable(() -> {
Thread.sleep(150);
return "Order Shipped: " + paymentConfirmation;
});
}
}
- 大数据量:增加了订单的数量,通过处理1万个订单模拟大规模数据处理场景。
- 批量处理:每个订单包含多个子项目(如100个),模拟批量操作场景。
- 并行处理:使用Flux.parallel()分配多线程资源,提高处理大量数据的吞吐率。
- 扩大处理延迟:通过延长每个步骤的延迟时间,以便在实际测试中更清晰地观察Reactor的非阻塞特性。
优点
- 高效利用资源: 非阻塞I/O意味着更少的资源消耗和更高的系统利用率。
- 高响应性: 系统能快速响应用户请求和其他外部事件。
- 可扩展性: 能够自然地对应用程序进行跨越多个节点的扩展。
缺点
- 学习曲线陡峭: 反应式编程需要更改思维方式。
- 复杂性增加: 对于简单应用,可能过度设计。
- 调试困难: 异步代码调试较为复杂。
结论
Java反应式编程为我们提供了一种新的编程模型,使得构建异步、事件驱动的应用程序更加简洁和高效。虽然反应式编程带来了许多好处,但也需谨慎应用,确保其复杂性为项目带来正向的价值。
参考文献
- Project Reactor Documentation
- RxJava: Reactive Extensions for JVM