309
社区成员
发帖
与我相关
我的任务
分享项目里存在三种不同粒度的锁:
| 锁对象 | 类型 | 保护的数据 |
|---|---|---|
this(ElevatorCar) | 对象内置锁 | 轿厢全部状态字段 |
this(Dispatcher) | 对象内置锁 | pendingTasks、计数器、inputEnded |
f2Lock(ElevatorShaft) | 专用 Object 锁 | f2Occupied 标志 |
ElevatorCar 几乎所有字段都通过 synchronized(this) 保护,但同步块的粒度经过了精心的拆分。以 normalStep() 为例:
private boolean normalStep() {
boolean shouldOpen;
synchronized (this) { // 块①:只读决策
shouldOpen = strategy.shouldOpenAtCurrentFloor(
onBoard, waitQueue, currentFloor, direction);
}
if (shouldOpen) {
openAndExchange(); // 块外:耗时操作
return true;
}
Direction nextDirection;
synchronized (this) { // 块②:只读决策
nextDirection = strategy.decideMoveDirection(...);
direction = nextDirection;
}
if (nextDirection == Direction.IDLE) {
return false;
}
moveOneFloor(nextDirection); // 块外:sleep 在锁外
return true;
}
锁与语句的关系:同步块只包含对共享状态的读取和轻量赋值,sleep()、TimableOutput.println()、shaft.enterFloor2() 这类耗时或可能阻塞的调用全部在锁外执行。这是一个关键设计决策——如果把 sleep(400ms) 放进同步块,Dispatcher 调用 assignTask() 时就会被阻塞长达 400ms,严重影响调度实时性。
moveOneFloor() 中的锁使用同样体现了这一原则:
private void moveOneFloor(Direction moveDirection) {
// 锁外计算 nextFloor,锁内只做边界检查
synchronized (this) { ... }
sleepMs(speedMs); // sleep 在锁完全释放后
synchronized (this) { // 重新加锁更新状态并打印
currentFloor = nextFloor;
TimableOutput.println(...);
notifyAll();
}
}
两次加锁之间有一个完整的 sleep 窗口,这段时间内 Dispatcher 可以自由调用 getCurrentFloorSnapshot() 或 assignTask(),不会被阻塞。
F2 互斥锁选择了一个专用的 Object 而非复用 ElevatorShaft.this,这有明确的理由。两辆轿厢在等待 F2 时需要互相阻塞对方,如果用 ElevatorShaft.this 作为锁,那么 getState()、setState() 这些频繁被调用的方法也会因为一辆轿厢在 F2 开门而被阻塞,造成不必要的争用。专用锁把"F2 物理互斥"这一职责从"井道状态读写"中完全剥离出来,两把锁互不干扰。
openAndExchange() 中锁的分段private void openAndExchange() {
openDoor(); // 可能阻塞在 enterFloor2()
List<PassengerTask> transferTasks = new ArrayList<>();
List<PassengerTask> completedTasks = new ArrayList<>();
synchronized (this) { // 统一修改 onBoard/waitQueue
// 下客、上客逻辑
notifyAll();
}
for (PassengerTask task : transferTasks) {
dispatcher.requeue(task); // 锁外调用 Dispatcher
}
for (PassengerTask ignored : completedTasks) {
dispatcher.markCompleted(); // 锁外调用 Dispatcher
}
sleepMs(400);
closeDoor();
}
这里有一个重要的设计:下客和上客在同一个同步块内完成,保证了"这次开门的乘客交换"是原子的——不会出现下客完成一半、Dispatcher 看到一个中间状态然后做出错误分配的情况。而 dispatcher.requeue() 则故意放在锁外,因为 Dispatcher 有自己的锁,两个锁嵌套会引入死锁风险。
Dispatcher 是整个系统的中央协调者,但它本身并不拥有任何物理资源,只维护三类信息:
private final List<PassengerTask> pendingTasks; // 待分配队列
private boolean inputEnded; // 输入是否结束
private int totalTasks; // 已提交总数
private int completedTasks; // 已完成总数
totalTasks - completedTasks 就是系统中"在途"的任务数,这是判断能否退出的核心依据。
与 InputThread 的交互:InputThread 是生产者,通过两个方法向 Dispatcher 注入数据:
dispatcher.submit(task) // 新乘客到来
dispatcher.markInputEnded() // EOF,输入结束
两个方法都是 synchronized 的,调用后执行 notifyAll() 唤醒 Dispatcher 主循环。这是一个标准的生产者-消费者通知模式,InputThread 不等待任何返回值,提交即走。
与 ElevatorCar 的交互是双向的,这是设计中最复杂的部分:
Dispatcher → ElevatorCar:
assignTask(task) 分配任务,写入 waitQueue
requestStop() 系统退出时通知
ElevatorCar → Dispatcher:
requeue(task) 中转或被迫下车后重新排队
markCompleted() 乘客到达终点
notifyAvailability() 特殊流程结束,通知重新选车
notifyAvailability() 是一个值得关注的细节。当一辆轿厢从维修/升级/回收中恢复,重新变为 accepting = true,它调用此方法通知 Dispatcher:候选集变大了,之前因为找不到合适轿厢而被搁置在队首的任务现在可能有车接了。没有这个通知,Dispatcher 会一直等到下一次自然唤醒才重试。
while (true) {
PassengerTask task;
synchronized (this) {
while (pendingTasks.isEmpty() && !shouldStopDispatcher()) {
wait();
}
if (shouldStopDispatcher()) break;
task = pendingTasks.remove(0);
}
ElevatorCar chosen = chooseCar(task);
if (chosen == null) { // 暂时没有可用轿厢
synchronized (this) {
pendingTasks.add(0, task); // 放回队首(保持优先级)
wait(); // 等待轿厢状态变化
}
continue;
}
// 分配
}
当 chooseCar 返回 null 时,任务被放回队首而非队尾,这保证了先到的乘客不会因为反复重试而被后来者抢先。这是一个简单但有效的公平性保证。
时间性能指标通常关注两点:乘客平均等待时间和总运行时间。评分函数的三项分别对应不同的优化目标:
距离项 ×10 直接最小化接客前的空跑时间,这是对等待时间最直接的影响因素,因此权重最高。负载项 ×4 防止热点轿厢堆积任务,把任务分散开来,避免某些乘客因为反复排在繁忙轿厢后面而等待过长。中转惩罚 +20 让系统优先选择可以直达的轿厢,减少因中转引入的额外时间。
但这个评分函数是静态快照评分,存在一个固有的局限:getCurrentFloorSnapshot() 拿到的是轿厢当前位置,而不是它完成所有任务后的预期位置。一辆轿厢可能此刻在 F1,但已经有 10 个任务要送到 F7,仍然会因为当前距离近而被优先选中。负载项 ×4 是对这一问题的部分补偿,但不能完全消除。这也是这类贪心调度策略的典型权衡:计算简单、响应实时,但牺牲了对未来状态的预判能力。
第一层:数据安全,确保共享变量不被并发读写撕裂。项目用 synchronized 块而非 volatile 或 Atomic 类来实现,原因是需要保护的往往不是单个变量,而是一组相关变量的复合操作。比如 assignTask() 中同时修改 waitQueue 和 direction,必须原子完成;用 volatile 只能保护单个变量,无法保证复合操作的原子性。
第二层:状态一致性,确保对象在任意时刻暴露给外部的状态都是合法的。isIdleForShutdown() 是一个典型例子:
public synchronized boolean isIdleForShutdown() {
return !inSpecialFlow && waitQueue.isEmpty()
&& onBoard.isEmpty() && pendingMaint == null
&& !pendingUpdate && !pendingRecycle;
}
这六个字段必须在同一个锁的保护下一起检查,否则可能出现"waitQueue 刚清空、onBoard 还没清空"时被 Dispatcher 误判为空闲的情况。
第三层:协调正确性,确保线程间的等待-通知语义不会出现遗漏唤醒或虚假退出。项目中所有 wait() 都套在 while 循环里而非 if 语句里:
while (pendingTasks.isEmpty() && !shouldStopDispatcher()) {
wait();
}
这是 Java 多线程的一个基本要求——wait() 可能因为 notifyAll() 而被唤醒,但唤醒时条件不一定真正满足(其他线程可能先一步拿走了任务),必须重新检查条件。用 if 代替 while 会导致在条件不满足时继续执行,产生难以复现的逻辑错误。
项目在结构上形成了清晰的三层:
InputThread / 外部请求
↓ submit / requestMaint / requestUpdate / requestRecycle
Dispatcher(调度层)
↓ assignTask / requestStop
ElevatorCar(执行层)
↓ enterFloor2 / leaveFloor2
ElevatorShaft(资源层)
每一层只与相邻层通信,跨层调用被设计上隔离了。InputThread 不直接操作 ElevatorCar,ElevatorCar 也不直接解析输入请求。这种分层带来了一个重要的线程安全保证:锁的作用域天然地限制在层内。ElevatorCar 的 this 锁只保护轿厢自身状态,Dispatcher 的 this 锁只保护调度队列,两者没有嵌套,死锁风险被架构层面消除了。
唯一需要跨层调用的地方——ElevatorCar 调用 dispatcher.requeue() 和 dispatcher.markCompleted()——被刻意放在 ElevatorCar 的同步块外部,就是为了避免"持有 Car 锁的同时去争抢 Dispatcher 锁",这是死锁的经典成因。这个细节说明设计者在确定同步块边界时有意识地考虑了锁的获取顺序。
首先就是多线程难度陡升,需要更多的时间去理解线程安全的原理和实现,可以在oopre课程里涉及一点相关的知识,可能能帮助更快上手。然后是给一些多线程debug的知识技巧补充