消息传递
消息传递让线程通过发送命令请求修改状态,由持有状态的线程执行操作。本文先比较共享对象与单一所有者,再说明消息怎样交接、结果怎样返回。最后讨论队列过载、消费者异常和服务关闭。
1. 面试核心
先记住这几条,再读正文- 单一状态所有者。 单一所有者独占状态的修改权,其他线程通过不可变消息提交操作,由所有者串行执行。卡 · 共享对象的同步范围
- 消息可变性。 发送者在入队后继续修改消息中的可变对象,会与消费者产生共享数据竞争。
- 队列容量。 有界队列限制待处理消息数量。队列满时必须等待、拒绝或降级,才能把消费者处理不足反馈给发送者。
- 结果回复。 消息携带的
CompletableFuture把成功结果或异常返回给调用方。处理失败也要完成对应 Future,避免调用方一直等待。 - 关闭顺序。 FIFO 队列使用毒丸结束消费时,要先停止所有生产者,再把终止消息排在已接受消息之后。卡 · 消费者异常与关闭协议
2. 共享对象的同步范围
多个线程直接访问同一个业务对象时,要共同遵守一套同步规则。比如,下面的订单簿在取消订单时会修改内部的 HashMap:
public final class OrderBook {
private final Map<Long, Order> orders = new HashMap<>();
public void cancel(long id) {
orders.remove(id);
}
}
多个线程直接调用 cancel 时,需要用共同的锁保护 orders。若还提供创建订单和汇总查询,所有读写路径也要遵守同一套规则。锁还要保护 map 与订单状态之间必须保持一致的关系,这种关系称为不变量。调用路径越多,就越容易漏掉某条读取或修改路径。
另一种方式是让单个消费者独占整个 OrderBook,其他线程只发送命令。这个消费者称为状态的单一所有者:
BlockingQueue<Command> mailbox = new LinkedBlockingQueue<>(1_000);
void eventLoop() throws InterruptedException {
while (!Thread.currentThread().isInterrupted()) {
Command command = mailbox.take();
command.apply(orderBook);
}
}
示例使用容量为 1000 的 FIFO 队列,FIFO 表示先进先出。消费者每次取出一条命令,执行完成后再取下一条,所以状态修改按出队顺序串行进行。
只要 orderBook 及其内部可变对象都只由这个消费者访问,内部方法就无需为多个调用者加互斥锁。其他线程的查询和修改都要通过消息完成。示例只展示消费循环,命令失败与关闭的处理见后文。
3. 消息与状态所有权
消息应带齐完成一次状态转换所需的信息,并尽量使用不可变值。比如,取消订单只需传递订单 ID,再由所有者查询自己管理的订单:
record CancelOrder(long orderId) implements Command {
public void apply(OrderBook book) {
book.cancel(orderId);
}
}
示例用 record 声明命令,字段在构造后固定。orderId 是基本值,接收者可以直接使用。业务若需要取消原因,也可以携带不可变的 String。
集合等可变数据要明确交给谁管理,可以选择以下三种传递方式:
- 传递不可变对象,让发送者和消费者都只能读取。
- 复制一份快照,让消费者读取与发送者后续修改相互独立的数据。
- 只传标识符,由所有者查找自己管理的数据。
集合副本中的元素若仍可变,也需要复制元素或限制访问。record 只固定字段的值,发送者若继续修改字段指向的集合,仍会与消费者竞争。
Java 并发工具包(JUC)中的队列保证,入队前的写入对另一线程取出该消息后的操作可见。这个保证覆盖交接前的动作,所以入队后的修改仍需遵守所有权约定。
4. 结果通道
发送命令后,调用方还需要知道操作是否成功。消息可以携带一个 CompletableFuture,由消费者在处理结束时写入结果:
record CreateOrder(Order order, CompletableFuture<Long> reply) {}
这里的 reply 就是结果通道,用来把处理结果交回调用方。创建成功时,所有者调用 reply.complete(id);命令失败时,调用 reply.completeExceptionally(error)。Order 若可变,也要按上一节的规则交接。
调用方等待结果时要设置截止时间,并处理异常。否则,消费者故障后,已经排队的请求会一直等待,把故障扩散到调用线程。
等待 Future 超时后,已入队的命令仍可继续执行。因此,调用方应使用稳定的订单号等业务标识查询结果,并在重试时避免重复创建。
5. 有界队列与过载
消息传递仍受消费者处理能力限制。消费者每秒处理 1000 条,生产者持续提交 2000 条时,每秒就会多积压 1000 条消息。有界队列用固定容量限制积压,并在容量耗尽时让提交方处理过载。
提交消息时,需要区分以下三种等待方式:
put等待队列腾出空间,适合允许生产者等待的流程。offer立即返回,队列已满时返回false,调用方可以拒绝请求。- 带超时的
offer在期限内等待,把排队时间纳入请求预算。
队列满后的处理还要符合消息的业务含义:
- 订单命令需要明确返回拒绝或超时,让调用方知道提交失败。
- 重复统计消息可以按业务规则合并,减少待处理数量。
- 刷新通知可以只保留最新一次,丢弃已被替代的旧通知。
运行时要监控队列积压和处理延迟,及时发现消费者持续跟不上生产者的情况。
6. 共享状态的适用场景
消息传递适合把一组状态的连续修改交给同一个线程,但会增加排队延迟和异步错误处理。单个所有者处理能力有限,集中到它的请求也会形成热点。选择方案时,可以比较以下三类简单场景:
- 只维护独立计数器时,直接使用原子类更清楚。
- 读多写少、允许读取某一时刻的配置时,可以发布不可变快照。
- 临界区短、竞争低,而且调用方需要同步取得结果时,可以使用普通锁。
需要提高消息处理能力时,可以按互不影响的 key 分片,让每个分片由一个消费者管理。同一 key 要稳定路由到同一所有者,才能保持串行修改。热点 key 仍会使某个分片积压,因此还需观察各分片的负载。
跨分片的状态一致性或事务仍需额外协调。选择设计时,要明确状态归属、吞吐目标、失败后的处理方式,以及怎样观察积压和延迟。
7. 消费者异常与关闭协议
消费者失败或服务关闭时,已经接受的命令仍需要处理结果。BlockingQueue 没有统一的关闭方法,所以业务要自行约定如何停止接收,以及如何处理剩余消息。
毒丸是一种特殊终止消息,消费者读到它后退出。FIFO 队列可以按以下顺序完成有序关闭:
- 停止接收新命令,并协调所有生产者结束提交。
- 把毒丸放到已接受的消息之后,让消费者先处理剩余命令。
- 等待消费者退出。若有多个消费者,要安排足够的终止通知。
协调生产者很关键:若毒丸入队后还有新命令提交,这些命令就会排在终止消息之后,失去消费者。
单条命令抛出异常时,要先完成对应的异常回复,再判断状态是否还能继续使用。状态仍一致时,可以继续消费;若命令只完成了部分修改,则应停止消费并恢复状态。
需要取消消费时,中断可以让 take() 等待结束;正在执行的命令则要主动响应中断。消费者退出后,队列里尚未处理的消息仍需拒绝或交给其他消费者接管,并完成相应回复。
8. 常见问题
8.1 使用队列以后还会有竞态吗?
会,只要交接后仍有多个线程修改同一个对象,就存在竞争。比如,发送者继续增删消息中的列表,消费者就会读到变化中的内容。多个消费者直接修改同一份业务状态时,也需要同步。因此,所有权约定要覆盖消息及其引用到的可变对象。
8.2 FIFO 队列能保证业务操作按顺序完成吗?
FIFO 队列保证按入队顺序取出,业务先后与完成顺序还要另外约定。多个发送者并发提交时,入队顺序由竞争结果决定。若业务要求按事件发生顺序处理,就需要序列号等协议。
多个消费者的执行时长也会不同,所以先出队的消息可以后完成。需要同一订单按顺序修改时,应让它的命令进入同一个串行消费者。
9 · 面试题卡片
本页题目与参考回答
9.1 怎样通过设计减少共享状态
出现公司:阿里巴巴
考察重点
- 说明不可变值、线程封闭和单一所有者怎样减少跨线程修改。
- 解释队列交接前的写入为什么对消费者可见。
- 说明队列满、命令失败和服务关闭时,调用方怎样取得处理结果。
参考回答
可以用不可变值、线程封闭和单一所有者减少共享状态。
线程封闭就是让数据只由一个线程访问,比如把临时数据保留为局部变量。需要连续修改的一组状态,可以交给一个消费者,其他线程通过不可变消息请求操作。
JUC 队列保证交接前的写入对消费者可见,业务还要定义命令顺序和结果回复。队列设置容量上限,满时明确等待、拒绝、合并或丢弃的规则,并监控积压与处理延迟。命令失败和服务关闭时,也要给已接受的命令明确结果。
独立计数器或低竞争的短操作可以直接使用原子类或锁,避免引入额外的排队和异步处理。
9.2 消息循环怎样处理命令失败和服务关闭?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明命令失败后何时可以继续消费,以及关闭时怎样处理已接受的消息和回复。
相关内容:消费者异常与关闭协议。
参考回答
命令失败时要完成异常回复,服务关闭时要为已接受的消息安排执行或取消。
失败后若状态仍一致,可以继续消费;状态已被部分修改时,应先停止并恢复。
有序关闭要先协调全部生产者停止提交,再把毒丸放到已接受消息之后。这样消费者处理完前面的命令才退出。若选择中断退出,还需拒绝或接管剩余消息,并完成它们的结果通道。