先说结论:这是一道非常典型的多线程场景题。很多同学看到“3 个服务器线程 + 1 个并行管理线程 + 10 个客户线程”就直接写一个 Thread 类丢进循环里跑,结果不是请求全部堆到一个服务器上,就是程序跑完之后卡死退不出来。真正写一遍你就会发现,线程调度、阻塞等待、退出时机、日志乱序,每一个点都能咬你一口。这次我用 Java 完整实现了一个服务器并行访问控制模拟:3 个 ServerWorker 线程负责真正处理请求,1 个 AccessManager 线程负责接收客户请求和分配到空闲服务器,10 个 Customer 线程用随机时间和随机请求类型制造并发压力。整个过程能清楚看到“客户提交 -> 管理线程分配 -> 服务器处理 -> 回到空闲池”的闭环,同时通过有界请求队列实现了最简单的访问控制与限流。适合正在做操作系统、并发编程课程设计,或者想把生产者-消费者模型在服务器场景里落地的人参考。
1. 项目整体设计与并发模型拆解
1.1 3个服务器线程、1个管理线程、10个客户线程分别该干什么
先把角色看清楚。项目里一共有四类线程角色,数量各不一样,职责绝不能混。
| 线程角色 | 数量 | 核心职责 | 模拟含义 |
|---|---|---|---|
| ServerWorker | 3 | 真正执行客户请求,处理完一个后再接收下一个 | 相当于一个服务器集群里的 3 台处理节点 |
| AccessManager | 1 | 接收客户请求,选择一个空闲服务器并派发任务 | 相当于接入层的请求调度器/负载均衡器 |
| Customer | 10 | 随机产生请求,向管理线程提交 | 模拟 10 个并发访问客户 |
| main 主线程 | 1 | 初始化所有线程,并在结束后执行优雅关闭 | 相当于系统启动和停机控制台 |
这里最容易误解的是“并行管理线程”。它并不是替代服务器线程去执行处理的线程,而是只做“接收请求 + 找到空闲服务器 + 把请求派发下去”这三件事。真正干活的还是那 3 个服务器线程。
我习惯把管理线程想象成银行大堂的取号机:客户往取号机里塞号码,取号机再把客户引导到当前空闲的柜员窗口。柜员才是真正办业务的人,取号机只是分发。
1.2 客户请求是怎么“流”到服务器的
整个流程可以简化成五个环节:
- 客户线程随机生成一个请求,向 AccessManager 提交,这个提交动作会放进一个“待处理请求队列”。
- 如果请求队列满了,客户线程阻塞等待,相当于访问控制生效,不允许无限堆积。
- AccessManager 不断从请求队列取出一个请求,然后从“空闲服务器队列”中取一台空闲服务器。
- 如果当前没有空闲服务器,AccessManager 就在空闲队列上阻塞等待,直到某个服务器处理完上一个请求并把自己放回去。
- 服务器线程从自己的任务队列里拿到请求,执行处理逻辑,然后重新注册为空闲服务器,等待下一次调度。
这个流程本质上是一个多生产者多消费者的级联模型:客户线程是请求队列的生产者,AccessManager 是请求队列的消费者;同时 AccessManager 又是服务器任务队列的生产者,服务器线程是消费者。
用生活化的话说,这就是“前台 + 工单池 + 处理工单的人”的经典结构。
1.3 这个调度模型到底解决了什么问题
有人可能会问:如果已经有 3 个服务器线程了,直接让 10 个客户线程分别去调用某个服务器线程的方法不就行了?为什么非要中间再插一个管理线程?
原因是:直接调用会带来两个问题。
第一,客户线程不知道哪台服务器当前是空闲的。如果它随便挑一台,很可能挑中的那台正在处理前一个请求,结果请求全挤到一台机器上,其他两台空转。
第二,如果让客户线程自己加锁去抢服务器,等于把调度逻辑写死在客户线程里,以后想改负载均衡策略就很痛苦。一旦加了管理线程,调度策略就和管理线程绑定,客户线程只负责“把请求提交出去”,完全不关心后面由谁处理、什么时候处理。
所以管理线程的真正价值是“解耦 + 集中调度”。这也是为什么真实系统里一定要有独立的接入层或网关层,而不是让客户端直接连后端节点。一个小型模拟能让你体会这个架构理由,已经值回票价了。
2. 并行访问控制的同步与阻塞原理
2.1 为什么我用 BlockingQueue 而不是普通 List 加锁
如果你自己用 ArrayList 或者 LinkedList 来存请求,那所有线程在读写这个列表时必须自己维护一把锁,还需要手动实现 wait/notify,否则就会出现两个线程同时读写、数组越界、数据错乱的问题。
Java 里最省心也最稳妥的做法是直接用 BlockingQueue 接口的实现类,比如 ArrayBlockingQueue 和 LinkedBlockingQueue。它们内部已经用 ReentrantLock 和 Condition 封装好了“队列满则阻塞写线程、队列空则阻塞读线程”的逻辑。
对应到项目里:
- 请求队列用 ArrayBlockingQueue,容量设成 5,意思是最多只能有 5 个请求等待分配。
- 空闲服务器队列用 ArrayBlockingQueue,容量设成 3,和服务器数量一致,一台服务器最多只能占用一个空闲位。
用队列之后,线程之间的同步问题就从“我要自己写锁”变成了“我只需要决定生产者和消费者的节奏”。这也更接近真实业务代码的写法。
2.2 有界请求队列就是一个天然的“访问控制闸门”
队列容量为什么不能设置成无限大?我在这道题里特意把请求队列容量设成 5,就是想体现“访问控制”这几个字。
当 10 个客户线程同时活跃时,如果所有请求都不加控制地涌进来,管理线程和服务器线程只会在后面越积越多。真实场景中,这种突刺流量很可能直接把后端打垮。有界队列等于一道限流闸门:排队区最多容纳 5 个请求,如果队列满了,客户线程就必须在 put 方法上阻塞,等队列空出位置。
用银行类比,就是大厅里最多允许 5 个人在排队,第 6 个人来了只能在门外等着,而不是直接冲进柜台。
这种方式虽然简单,但已经构成了最基本的访问控制。你只需要调整队列容量,就能模拟不同并发压力下的系统表现:容量越小,客户线程阻塞越频繁;容量越大,系统对突发流量的缓冲能力越强,但同时会带来更高的排队延迟。
2.3 空闲服务器队列的“等待-通知”比轮询省太多资源
管理线程每次要从空闲服务器队列拿一台服务器。如果当前 3 台服务器都在忙,管理线程就不能继续往下分配。
一种偷懒的写法是让管理线程循环检查“有没有空闲服务器”,也就是忙等待。这会让管理线程一直空转占用 CPU,而且服务器线程什么时候完成是不可预测的,轮询间隔也很难设置:间隔太短浪费 CPU,间隔太长延迟分配。
正确做法是使用阻塞队列的 take 方法。如果队列为空,管理线程自动挂起,直到有服务器处理完任务后调用 put 把自己放回空闲队列,才被系统唤醒。这个机制本质是利用队列内部的 Condition 在“等待”和“通知”之间切换,不需要我们自己写任何 wait/notify 代码,也不存在漏掉通知的问题。
项目里的服务器线程也遵循同样逻辑:它空闲时不是自己主动去请求队列抢任务,而是先把自己放进空闲服务器队列,让管理线程来决定什么时候给它派活。这样整个系统的调度权就统一收拢到管理线程手里了。
2.4 退出机制:毒丸和 CountDownLatch 缺一不可
这是整个题目最隐蔽的坑:怎么让一个多线程模拟程序在业务结束后正常退出。
很多初版实现里,客户线程执行完了,主线程也跟着结束,但管理线程和服务器线程还阻塞在 take() 方法上,导致 JVM 一直不退出。如果你把线程设置成守护线程,确实可以让程序强制退出,但这相当于“来不及处理完就杀掉”,非常不优雅,也不是我们要学的正确方式。
我在这里使用了两种组合工具:
- 毒丸对象:一个特殊标记请求。主线程在所有客户线程结束后,把它放入请求队列。AccessManager 从队列里拿到这个对象就明白自己该退出。
- CountDownLatch:用于让主线程等待关键线程完全退出,不会出现“主线程已经结束,管理线程还在分配”的问题。
具体做法是:主线程先等待所有客户线程通过 CountDownLatch 汇报完成,然后向请求队列放入毒丸;管理线程取出毒丸后退出并减一个计数;主线程再向每个服务器线程的独立任务队列放入毒丸,服务器线程处理完当前请求后退出并减轻计数;最后主线程等待三个服务器全部退出,然后关闭线程池。
这套退出方式,比直接调用 System.exit 或者线程 interrupt 都更可控。
3. 完整模拟代码实现
3.1 代码结构总览与初始化顺序
整个项目只有一个 Java 文件,里面通过静态内部类组织四种角色。大家可以直接拷贝运行,不需要额外依赖。
初始化顺序是有讲究的:先创建 3 个服务器线程并注册到空闲队列,再启动管理线程,最后启动 10 个客户线程。这样做的原因是保证管理线程开始消费请求时,空闲服务器池里已经有可用的服务器对象,避免出现“请求到了却没有服务器可分配”的初始空窗期。
代码中的关键数据结构只有四个:
- REQUEST_QUEUE:客户请求的有界队列,容量 5。
- IDLE_SERVER_QUEUE:空闲服务器队列,容量 3。
- POISON_PILL:毒丸对象。
- 三个 CountDownLatch:分别对应客户线程、管理线程、服务器线程的退出信号。
3.2 可直接运行的完整源码:ServerAccessControlDemo.java
import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.CopyOnWriteArrayList; public class ServerAccessControlDemo { static final int SERVER_THREAD_COUNT = 3; static final int CUSTOMER_THREAD_COUNT = 10; static final int REQUEST_QUEUE_CAPACITY = 5; static final AtomicInteger REQUEST_ID = new AtomicInteger(0); static final BlockingQueue<Request> REQUEST_QUEUE = new ArrayBlockingQueue<>(REQUEST_QUEUE_CAPACITY); static final BlockingQueue<ServerWorker> IDLE_SERVER_QUEUE = new ArrayBlockingQueue<>(SERVER_THREAD_COUNT); static final Request POISON_PILL = new Request(-1, "EXIT", "MAIN"); static final CountDownLatch CUSTOMER_DONE = new CountDownLatch(CUSTOMER_THREAD_COUNT); static final CountDownLatch MANAGER_DONE = new CountDownLatch(1); static final CountDownLatch SERVER_DONE = new CountDownLatch(SERVER_THREAD_COUNT); static final List<ServerWorker> SERVER_LIST = new CopyOnWriteArrayList<>(); static class Request { final int id; final String type; final String sender; Request(int id, String type, String sender) { this.id = id; this.type = type; this.sender = sender; } } static class ServerWorker implements Runnable { final int serverNo; final BlockingQueue<Request> requestQueue = new LinkedBlockingQueue<>(); int processedCount = 0; ServerWorker(int serverNo) { this.serverNo = serverNo; } void dispatch(Request request) throws InterruptedException { requestQueue.put(request); } @Override public void run() { log("服务器#" + serverNo, "启动,已注册到空闲服务器池"); try { while (true) { Request request = requestQueue.take(); if (request == POISON_PILL) { log("服务器#" + serverNo, "收到退出信号,停止服务"); break; } long start = System.nanoTime(); log("服务器#" + serverNo, "开始处理 请求#" + request.id + " [" + request.type + "] 来自 " + request.sender); Thread.sleep(ThreadLocalRandom.current().nextInt(200) + 100); long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); processedCount++; log("服务器#" + serverNo, "完成 请求#" + request.id + ",耗时 " + costMs + "ms,累计处理 " + processedCount + " 个"); IDLE_SERVER_QUEUE.put(this); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { log("服务器#" + serverNo, "退出运行,累计处理 " + processedCount + " 个请求"); SERVER_DONE.countDown(); } } } static class AccessManager implements Runnable { @Override public void run() { log("管理线程", "启动,监听客户请求..."); try { while (true) { Request request = REQUEST_QUEUE.take(); if (request == POISON_PILL) { log("管理线程", "收到退出信号,停止分配"); break; } ServerWorker worker = IDLE_SERVER_QUEUE.take(); log("管理线程", "将请求#" + request.id + " 分配给空闲服务器#" + worker.serverNo + ",空闲池剩余容量 " + IDLE_SERVER_QUEUE.remainingCapacity()); worker.dispatch(request); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { log("管理线程", "退出运行"); MANAGER_DONE.countDown(); } } } static class Customer implements Runnable { final int customerNo; Customer(int customerNo) { this.customerNo = customerNo; } @Override public void run() { String[] requestTypes = { "登录请求", "查询订单", "提交订单", "支付请求", "浏览商品" }; int total = ThreadLocalRandom.current().nextInt(3) + 3; int submitCount = 0; log("客户#" + customerNo, "启动,本次会话计划提交 " + total + " 个请求"); try { for (int i = 0; i < total; i++) { Thread.sleep(ThreadLocalRandom.current().nextInt(300) + 100); String type = requestTypes[ThreadLocalRandom.current().nextInt(requestTypes.length)]; Request request = new Request(REQUEST_ID.incrementAndGet(), type, "客户#" + customerNo); REQUEST_QUEUE.put(request); submitCount++; log("客户#" + customerNo, "已提交 请求#" + request.id + " [" + type + "],请求队列剩余容量 " + REQUEST_QUEUE.remainingCapacity()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { log("客户#" + customerNo, "结束,共提交 " + submitCount + " 个请求"); CUSTOMER_DONE.countDown(); } } } static String now() { return new SimpleDateFormat("HH:mm:ss.SSS").format(new Date()); } static void log(String tag, String message) { System.out.printf("[%s] %-18s %s%n", now(), tag, message); } public static void main(String[] args) throws InterruptedException { log("MAIN", "启动服务器并行访问控制模拟"); ExecutorService pool = Executors.newCachedThreadPool(); for (int i = 1; i <= SERVER_THREAD_COUNT; i++) { ServerWorker worker = new ServerWorker(i); SERVER_LIST.add(worker); IDLE_SERVER_QUEUE.offer(worker); pool.execute(worker); } pool.execute(new AccessManager()); for (int i = 1; i <= CUSTOMER_THREAD_COUNT; i++) { pool.execute(new Customer(i)); } CUSTOMER_DONE.await(); log("MAIN", "所有客户线程已结束,等待请求队列排空..."); REQUEST_QUEUE.put(POISON_PILL); MANAGER_DONE.await(); log("MAIN", "管理线程已退出,开始通知服务器线程停止"); for (ServerWorker worker : SERVER_LIST) { worker.dispatch(POISON_PILL); } SERVER_DONE.await(); pool.shutdown(); log("MAIN", "模拟结束,所有线程已平稳关闭"); } }代码里有两个细节值得说明。
一是 LinkedBlockingQueue 作为每个服务器线程的独立任务队列,容量是无限的。这里无限没关系,因为管理线程每次只会向一台“当前空闲”的服务器派发任务,这台服务器在收到任务后就会立刻执行,不会出现一个服务器的积压任务无限增长。
二是毒丸采用同一个静态对象。Java 中引用比较是安全的,所以服务器线程里用request == POISON_PILL判断退出条件。生产环境中更稳妥的做法是给请求类加一个isShutdown()标记,而不是比较引用,但课程模拟里引用比较足够直观。
3.3 运行日志与结果解读
跑起来之后,控制台会输出类似下面的日志:
[12:00:00.101] MAIN 启动服务器并行访问控制模拟 [12:00:00.132] 服务器#1 启动,已注册到空闲服务器池 [12:00:00.136] 服务器#2 启动,已注册到空闲服务器池 [12:00:00.141] 服务器#3 启动,已注册到空闲服务器池 [12:00:00.158] 管理线程 启动,监听客户请求... [12:00:00.202] 客户#3 启动,本次会话计划提交 4 个请求 [12:00:00.321] 客户#3 已提交 请求#1 [登录请求],请求队列剩余容量 4 [12:00:00.329] 管理线程 将请求#1 分配给空闲服务器#1,空闲池剩余容量 2 [12:00:00.330] 服务器#1 开始处理 请求#1 [登录请求] 来自 客户#3 [12:00:00.341] 客户#1 已提交 请求#2 [查询订单],请求队列剩余容量 3 [12:00:00.349] 管理线程 将请求#2 分配给空闲服务器#2,空闲池剩余容量 0 [12:00:00.349] 服务器#2 开始处理 请求#2 [查询订单] 来自 客户#1 [12:00:00.401] 客户#5 已提交 请求#3 [浏览商品],请求队列剩余容量 2 [12:00:00.418] 服务器#1 完成 请求#1,耗时 88ms,累计处理 1 个 [12:00:00.449] 管理线程 将请求#3 分配给空闲服务器#1,空闲池剩余容量 0 [12:00:00.450] 服务器#1 开始处理 请求#3 [浏览商品] 来自 客户#5关注同一批请求的三条日志:客户提交、管理线程分配、服务器开始处理。你会发现:
- 客户提交和分配之间几乎不存在间隙,因为管理线程一直阻塞在请求队列上,请求一到就能立刻取走。
- 服务器的处理耗时远大于调度耗时,所以系统瓶颈在服务器执行逻辑,而不是管理线程。
- 当三个服务器都在忙时,后续请求会留在请求队列里等待,不会出现“一个服务器同时处理两个请求”的情况。
跑完一次后,几个服务器的累计处理数会大致接近,但不会绝对相等,因为每个服务器处理耗时是随机的。这种“谁先完成任务谁先接下一个任务”的分配方式,其实就是一种最简单的动态负载均衡:快服务器多吃活,慢服务器少吃活。
4. 常见问题与排查技巧实录
4.1 请求都处理完了,程序却一直退不出去
这是最常见的翻车现场。原因基本都是管理线程或服务器线程还阻塞在 take() 上,而主线程已经执行完了。
解决方案就是用我在代码里写的三层退出机制:
- 客户线程结束后,主线程用 CountDownLatch 得知“所有请求都不会再新增”。
- 主线程向请求队列放入毒丸,管理线程消费到毒丸后退出。
- 主线程再向所有服务器线程的独立任务队列分发毒丸,服务器线程处理完手里最后一个请求后退出。
这里有一个特别容易踩的坑:毒丸必须放在所有真实请求的后面。不能把毒丸当成独立信号直接发给管理线程,否则管理线程可能在请求队列还有好几个任务的时候提前退出。
我代码里的做法是:先CUSTOMER_DONE.await()等所有客户线程结束,再调用REQUEST_QUEUE.put(POISON_PILL)。由于请求队列是 FIFO,一旦客户线程全部结束,毒丸就会排在所有遗留真实请求之后,管理线程会先把前面的请求都分配完,再处理毒丸退出。
4.2 为什么看起来“某个服务器特别忙,其他服务器在摸鱼”
如果你把请求处理时间固定,理论上分配会很均匀。但你会看到有的服务器连续处理了好几个,有的才处理一两个。这是因为处理耗时是随机生成的,一台服务器可能在交错时间里处理了大任务,另一台正好每个都很快。
另一种情况是你没把服务器线程放入空闲池,而是让每个服务器线程自己循环去请求队列抢任务,这样就会出现抢锁不均衡的现象。我的实现里服务器线程自己不抢队列,只有管理线程会去取空闲服务器然后派发,分配权利完全集中,所以不均衡只源于处理耗时的随机波动。
如果想让结果更均衡,可以把随机处理时间改成固定值,或者调度策略换成轮询,这个在下面扩展部分会讲到。
4.3 日志顺序乱,怎么判断系统是否正常
多线程程序的日志天生是交错的,这是正常现象。不要试图让所有日志严格按时间顺序排列成一条完美流水线,那反而说明你的线程根本没有真正并行。
判断系统是否正常的正确方法是:为每个请求编号,只追踪同一个请求编号的三条日志。例如:
- “客户#3 已提交 请求#5”
- “管理线程 将请求#5 分配...”
- “服务器#1 开始处理 请求#5”
- “服务器#1 完成 请求#5”
只要同一个请求的“提交 -> 分配 -> 开始 -> 完成”顺序正确,并且整个请求队列剩余容量始终在 0 到 5 之间,系统就是正常的。
如果日志单行出现乱码或者半条信息,那通常不是并发问题,而是日志工具本身不是线程安全的。我的代码里用了System.out.printf,虽然每个打印语句内部有锁,但更严谨的做法是引入一个独立的日志队列,让所有线程把日志丢进去,由一个专用日志线程统一输出。真实系统里一般用日志框架,而不是直接打控制台。
4.4 Random 用不好会影响模拟结果的真实性
模拟随机请求时,如果所有线程共同使用一个Random实例,这个实例内部的方法会被多线程争抢,出现锁竞争,随机数的生成速度反而变成瓶颈,而且随机数序列其实并没有真正“独立”。
我的建议是使用ThreadLocalRandom.current(),它让每个线程维护自己的随机数生成器,不需要加锁,性能和随机性都更好。另一个细节是不要在每次 sleep 的时候临时 new 一个 Random,那样浪费对象,而且时间种子接近会让随机数分布看起来不够“随机”。用ThreadLocalRandom是最省事也最正确的方式。
4.5 请求队列容量调大调小会带来什么变化
这里可以做一个很直观的对比实验:
- 容量设为 1:请求队列任何时候最多只有一个请求。客户线程提交完一个请求后,必须等待管理线程取走才能提交下一个,压力全部转移到客户端的阻塞逻辑上。
- 容量设为 5:可以同时缓冲 5 个请求,客户线程阻塞概率明显下降。
- 容量设为 100:客户线程几乎不阻塞,但高峰期 100 个请求堆积在后端等待,服务器启动时可能挨个从队列里取,系统响应延迟反而变高。
有界队列的容量本质上是对“消费能力”和“排队空间”的取舍。容量太小容易让生产者频繁等待,容量太大会增加平均排队时间,还会在系统故障时积压大量未处理请求。这道题里设成 5 只是一个方便观察的中间值,实际生产环境中需要结合请求平均处理时间、每秒吞吐量来估算。
5. 从课程模拟到真实服务器架构的扩展
5.1 用线程池替代手写线程,管理逻辑会怎么变
项目里手写了三个服务器线程和一个管理线程。实际上 Java 自带的ThreadPoolExecutor已经内置了“工作线程池 + 任务队列”的模型。如果用线程池来改这个题目,相当于:
- 核心线程数设为 3,对应 3 台服务器。
- 任务队列对应请求队列。
- 拒绝策略对应“访问控制”:如果任务队列满且所有线程都忙,可以选择丢弃、抛异常或由调用线程执行任务。
但线程池也有一个区别:它只有一个内部队列,没有独立的管理线程做负载分配。如果遇到某些任务比较重、某些任务比较轻,线程池的默认策略是“谁空了谁取下一个任务”,这和我们项目里的“空闲服务器池”模型思路是类似的,都是先完成者先接活。
真实后端不会只有一个线程池,通常会有接入层线程池、业务处理线程池、异步落库线程池。这层嵌套关系里,每一级都相当于我们的 AccessManager,只不过有些已经在框架内部封装好了。
5.2 调度策略从“空闲池先到先得”换成轮询或最少连接
当前实现中,只要服务器处理完任务并放回空闲池,管理线程下次取到的就是它,这是一种“先到先得”的动态分配策略。
你可以在此基础上扩展三种常见策略:
- 轮询:管理线程维护一个计数器,按 1、2、3、1、2、3 循环分配,不关心服务器当前是否空闲。这个策略适合所有服务器性能完全一致的场景。
- 最少连接:管理线程实时统计每个服务器正在处理的请求数,每次都分配给当前连接数最少的服务器。适合处理时长差异较大的场景。
- 响应时间加权:给响应时间短的服务器分配更多请求,响应时间长的服务器少接请求。这个策略更接近真实负载均衡器的“最小响应时间”算法。
在项目里实现轮询其实很简单,给 AccessManager 加一个AtomicInteger roundRobinIndex,每次取到请求后按 index 选择服务器即可。但要处理“选中的服务器正在忙”的情况,最简单的方式还是先从空闲队列取,取不到就等待。
5.3 加上统计与监控,让模拟结果不再靠脑补
目前程序只在日志里打印了每个服务器累计处理数量。想验证调度效果,可以再加几组统计指标:
- 请求提交时间、分配时间、完成时间的差值就是每个请求的排队等待时间。
- 从请求生成到处理完成的全部时间就是系统响应时间。
- 用每秒完成的请求数来衡量吞吐量。
- 统计一段固定时间内每个服务器的活跃占比,判断是否存在负载不均。
实现时可以用ConcurrentHashMap<Integer, Long>按请求 ID 记录时间戳,或者在 Request 类里加两个字段,分别记录创建时间和完成时间。每次处理完成后计算耗时并累加到LongAdder,最后输出平均值和最大最小值。
加了这套统计之后,你再调整请求队列容量、服务器数量、随机处理耗时,就能从数字上看到系统行为变化,而不只是“感觉好像更快了”。
最后再分享一点实际体会
这题做好之后,我最大的感触是:真正决定一个并发程序质量的,往往不是业务逻辑,而是“线程之间的协作规则”。哪个队列该有界,哪个操作会阻塞,谁负责退出信号,这些在设计阶段就要想清楚,不能等代码写崩了再去补锁。
调试步骤上,我建议先把日志系统做好:时间戳、请求 ID、线程角色,缺一不可。我第一次调试时就是因为日志里只有“客户提交”“服务器完成”这种泛泛消息,根本看不出请求卡在哪个环节。后来把所有队列操作的前后日志都加全,问题一眼就暴露了。
另外,如果你们课程设计要求手写锁和条件变量,我建议先用 BlockingQueue 把模型跑通,再对照 ArrayBlockingQueue 的源码去手写一个wait/notify版本。直接手写很容易漏唤醒或者出现死锁,先看到正确运行效果再回头逐行剖析,理解会扎实很多。
这个项目后续可以扩展的方向也很多:改成多管理线程、接入线程池、加可视化监控、把服务器处理逻辑替换成异步 IO。每一步扩展都会让你对“服务器并行访问控制”这九个字有更深的理解。