
1. 为什么做这个模拟器两级调度在课堂上究竟难在哪1.1 两级比一级多出来的那一层做操作系统课程项目时我选了“多道批处理的两级调度模拟”这个方向用Java/Swing把作业调度和进程调度两层机制做成了可视化的桌面程序。老实说刚拿到题目时我对“两级”并没有什么直观感受。教材上画得很清楚外存有一个后备队列内存里有一组就绪进程两条粗箭头分别指向“作业调度”和“进程调度”。可一旦进入细节让人卡壳的地方就全冒出来了作业调度到底在什么时刻触发内存不够时后备队列里的作业要等多久进程调度每切换一次会不会反过来影响作业调度的节奏这些疑问不是看两遍书就能解决的。我自己试过用纸笔演算教材例题作业一多、状态一复杂两张纸就画不开了。后来下定决心写一个模拟器用离散时间步驱动整个系统跑起来让每个队列的变化、每次内存分配、每次CPU切换都变成屏幕上看得见的状态。做完之后整个两级调度的逻辑才算真正在脑子里立住了。1.2 模拟器要做到什么程度才算“讲清楚了”我给自己定的验收标准不是“界面好看”或“代码能跑”而是能不能回答下面几个问题新作业到达后备队列时系统如何决定“现在调还是等会儿调”内存中同时驻留多道作业时CPU该给谁一个进程运行完毕释放内存后作业调度如何被“唤醒”同一组作业换一种调度算法平均周转时间到底差多少为了回答这些问题模拟器必须支持自定义作业参数到达时间、运行时间、内存需求、切换作业调度算法FCFS、SJF、HRN、切换进程调度方式先来先服务、时间片轮转、实时显示后备队列/就绪队列/内存占用/CPU执行记录最后还要自动统计平均周转时间和平均带权周转时间。把这些都做到位模拟器才算真正具备教学参考价值。选Java/Swing理由很朴素JDK装好就能跑Swing做桌面原型足够快不需要额外引第三方UI框架而且源码交给别人打开就能看逻辑不依赖复杂工程环境。对于操作系统课程设计来说这套组合性价比很高。2. 先把机制想明白作业调度、进程调度与内存之间怎么配合2.1 作业调度高级调度到底干了什么批处理系统里的作业提交之后先落在外存的后备队列。作业调度也叫高级调度、长程调度负责从后备队列里挑作业把它们调入内存并创建对应的进程控制块PCB。它的决策频率远低于进程调度因为“把作业从外存搬进内存”是一个开销比较大的动作。这里有个关键点作业调度不是想调就能调它必须同时满足两个条件。第一内存空闲空间要足够装下这个作业第二如果系统设置了最大多道程序道数内存中的作业数量不能超过这个道数。两个条件都满足作业才有资格被调入。我实现时额外做了一层判断如果某个作业的内存需求量比系统总内存还大直接标记为“无法调入”并在日志里给出提示。这类边界情况教材里很少写但实际写代码时一定会遇到不处理会在模拟运行中途产生诡异结果。2.2 进程调度低级调度与作业调度的衔接点进程调度低级调度、短程调度决定的是“内存中哪个进程上CPU”。它的触发频率高得多时间片到点要触发进程主动让出CPU要触发高优先级进程到达也可能触发。在多道批处理的模拟里它和作业调度之间有一条隐形的联动链作业调入内存 → 创建PCB → PCB进入就绪队列 → 进程调度选中它上CPU → 运行完成/时间片到 → 释放CPU若进程结束则回收内存 → 内存变多 → 作业调度发现新空间 → 从后备队列再调入下一个。这条链里的每一环都很重要。教材通常把它们拆开讲但模拟器必须把整条链串在一个时间循环里。我写代码时最大的体悟是如果没有“进程结束释放内存”这个动作作业调度永远等不到新空间系统就会出现“后备队列有作业、内存也有地方但就是没人调”的假死状态。这种串联逻辑光看静态图示很容易忽略。2.3 状态流转与资源回收的一整套闭环作业和进程在模拟器里有各自的状态机。作业的状态简单一些提交 → 后备 → 运行调入内存后→ 完成。进程的状态稍细就绪 → 运行 → 阻塞/就绪时间片到→ 终止。为了让这个闭环可验证我在日志里把每次状态迁移都打出来。举个例子作业J3到达后备队列此时内存剩余空间不足作业调度不会强行调入等到某个运行中的进程结束内存回收后作业调度才在下一次调度中把J3调入创建PCB放入就绪队列进程调度再根据算法决定J3何时真正上CPU。这一整套动作在模拟器的日志里就是三四行连续记录一眼能看懂前因后果。资源回收是很容易被新手忽略的部分。一个进程运行完成不止要把它从就绪队列移除还要把占用的内存归还给系统并且把对应的作业状态改为“完成”累计周转时间。我把“回收”封装成一个独立方法任何路径下进程结束都必须走它避免出现内存泄漏式的模拟Bug。3. Java/Swing的整体架构把教学逻辑落成可运行的系统3.1 模块划分与职责边界这个项目算不上大但我不建议把所有代码塞进几个类里硬写。按职责拆开后面调算法、加功能都省事得多。我的目录结构大致是入口Main负责启动Swing界面。模型层Job作业实体、PCB进程控制块、GlobalState系统全局状态包含各队列和内存占用。调度层JobScheduler作业调度器、ProcessScheduler进程调度器。核心控制SimulatorEngine驱动离散时间步统筹两个调度器。界面层MainFrame主窗口、表模型、甘特图绘制面板。这样拆的好处是界面只负责展示和收集参数不碰调度逻辑调度器只关心算法决策不关心按钮事件SimulatorEngine是唯一掌握“当前时间”的地方。想换一种进程调度算法时只需要改ProcessScheduler内部实现界面和作业调度完全不用动。3.2 时间推进模型为什么选离散时间步模拟系统时间推进有两种主流思路事件驱动和离散时间步。事件驱动适合精确模拟中断、IO请求这类异步事件但对课堂演示来说逻辑会绕很多。我最终选了离散时间步整个系统按固定单位时间推进每个时间步内处理“新作业到达”“作业调度”“进程执行”“进程完成回收”这几件事。实现上我直接用javax.swing.Timer充当时钟源。这个选择非常关键Swing的界面更新必须在事件分发线程EDT上执行而javax.swing.Timer的回调正好就在EDT中天然规避了跨线程更新界面的问题。如果用Thread.sleep加后台线程驱动我必须时刻记得SwingUtilities.invokeLater稍不留神界面就会闪烁甚至出现不确定行为。Timer的间隔由用户调节模拟器里叫“速度”快档每个模拟时间单位只留几十毫秒方便看整体节奏慢档留几百毫秒方便观察每一步调度决策。3.3 Swing界面布局与实时刷新的设计界面布局我用了典型的上下三段式顶部控制区作业调度算法下拉框、进程调度方式下拉框、内存容量输入框、开始/暂停/重置按钮。中部数据区左侧是“后备作业队列”表格中间是“内存占用情况”表格右侧是“就绪队列”表格。每张表都有独立的变更时间方便对照。底部展示区上半部分是日志字符串区记录每次调度动作下半部分是自定义绘制的CPU执行甘特图用不同颜色区分不同进程的时间段。表格刷新我用的是AbstractTableModel每次时间步结束调用fireTableDataChanged()。这里有个实用经验不要每次刷新重建整个JTable只更新TableModel内容即可否则界面会有明显闪烁而且会丢失列的拖动状态。甘特图部分我维护了一个ListCpuRecord每条记录包含进程名、开始时间、结束时间。绘制时根据当前面板宽度换算每单位时间对应的像素宽度用fillRect画出时间块。这套自定义绘制的代码量不大但演示效果比纯表格直观得多也是整个模拟器里“最像教学工具”的一个部分。4. 核心调度算法的落地从公式到能跑的Java代码4.1 作业调度FCFS、SJF、HRN的代码骨架作业调度的核心算法我实现了三种先来先服务FCFS、短作业优先SJF、最高响应比优先HRN。三种算法的输入都是后备队列输出是“选中的作业”区别只在选择标准。FCFS最简单直接按到达时间遍历取第一个还在等待的作业即可相当于LinkedList的队头元素。SJF要在整个后备队列里找运行时间最短的等待作业private Job selectJobBySJF(ListJob backQueue) { Job best null; for (Job job : backQueue) { if (job.getState() ! Job.STATE_WAITING) { continue; } if (best null || job.getNeedTime() best.getNeedTime()) { best job; } } return best; }HRN的公式是响应比 (等待时间 运行时间) / 运行时间。等待时间通过当前时间 - 作业到达时间计算。这个算法照顾了长作业又兼顾短作业优先的思想代码上只是把比较规则换成响应比大小private Job selectJobByHRN(ListJob backQueue, int currentTime) { Job best null; double maxRatio -1; for (Job job : backQueue) { if (job.getState() ! Job.STATE_WAITING) { continue; } int waitTime currentTime - job.getArriveTime(); double ratio (waitTime job.getNeedTime()) / (double) job.getNeedTime(); if (ratio maxRatio) { maxRatio ratio; best job; } } return best; }这里有个细节值得说明SJF比较运行时间时用的是作业体里声明的“预估运行时间”不是实际运行时间。在模拟器里二者是一致的但真实系统中预估时间来自作业说明书往往和实际有偏差。我会在注释里特别标注这一点避免看代码的人误以为调度器“开天眼”。选完作业只是第一步。真正调入时还要判断内存是否足够以及是否达到最大道数。我封装了一个tryLoadJob(Job job)方法内部先做内存检查再创建PCB、扣减内存、加入就绪队列并把这次操作写入日志。4.2 进程调度时间片轮转与优先级队列进程调度我实现了先来先服务和时间片轮转两种。时间片轮转是最有代表性的它的核心动作是从就绪队列队头取出进程让它运行一个时间片如果没运行完放回队尾。private void dispatchRoundRobin() { if (readyQueue.isEmpty()) { return; } PCB current readyQueue.remove(0); current.setRunTime(current.getRunTime() TIME_SLICE); if (current.getRemainTime() 0) { readyQueue.add(current); } else { finishProcess(current); } }这里的finishProcess就是前文说的统一回收入口负责释放内存、标记作业完成、记录完成时间。时间片长度我设计成可配置的默认值从界面传入如果时间片远大于作业运行时间时间片轮转就退化成FCFS这个现象在实测中很容易观察到特别适合用来给初学者解释“时间片大小对调度效果的影响”。优先级调度我留了接口但没放进主界面原因是多道批处理场景下优先级抢占需要额外定义“什么情况会打断当前进程”容易把演示重点带偏。如果读者想扩展直接在ProcessScheduler里新增一个dispatchByPriority()方法即可调度时机要么是当前进程主动让出要么是新进程优先级高于当前进程且当前时间片到点。4.3 两个调度器在一个时间步内的工作顺序这是我调试中反复调整过的核心逻辑。一个时间步内如果处理顺序错了系统行为就会有系统性偏差。我最终采用的处理顺序是时间推进currentTime 1把“到达时间等于当前时间”的作业加入后备队列调用JobScheduler.schedule()尝试从后备队列调入尽可能多的作业调用ProcessScheduler.dispatch()让当前CPU执行一个时间单位检查是否有进程运行完成执行回收逻辑刷新界面。步骤3和4的顺序有讲究先做作业调度再做进程调度才能让刚调入内存的进程在同一个时间步里参与CPU分配。如果反过来新调入的进程必须等到下一轮才能上CPU会把所有周转时间和甘特图都往后推一个单位首次跑出来的数据就和手算对不上。步骤5放最后也是刻意的进程在一个时间单位结束时可能刚好用完剩余时间这时回收内存下一个时间步的作业调度就能立刻使用新释放的空间逻辑上无缝衔接。5. 用一组测试数据验证结果对不对、算法差异怎么看5.1 测试用例设计与预期计算模拟器写完不代表正确我用一组可以手算的测试数据做了验证。设计原则是数据量小、每个作业的内存需求不同、存在“内存装不下导致等待”的场景这样才能同时检验作业调度、进程调度和内存管理的联动。测试环境内存容量40进程调度方式固定为先来先服务便于手算预期值。作业号到达时间运行时间内存需求J102520J231015J361510J49525先手算FCFS作业调度下的过程J1在时间0到达并调入运行25个单位期间J2在时间3到达内存剩余20够用调入等待运行J3到达时内存只剩5装不下J4到达时内存同样不足。J1在时间25结束释放内存作业调度按FCFS把J3调入再之后J2运行完J4调入。最终J1、J2、J3、J4的周转时间分别是25、32、44、46平均周转时间是36.75。再手算SJF作业调度J1先调入运行J2在时间3进入内存等待。J1结束后后备队列剩J3运行15和J4运行5SJF会选择J4优先调入此时J4内存需求25恰好等于空闲内存25。J4结束后J3才调入运行。最终平均周转时间为34.25。5.2 FCFS与SJF的实测结果对比把两种算法放进模拟器分别跑同一组数据输出结果和手算完全相同。指标FCFSSJFJ1周转时间2525J2周转时间3232J3周转时间4449J4周转时间4631平均周转时间36.7534.25这个对比特别有意思SJF牺牲了到达较晚但运行时间长的J3周转时间从44增加到49换来了J4的大幅缩短46降到31最终平均周转时间更优。这就是短作业优先“让短作业尽快结束、整体周转更短”的直接体现比单纯读教材结论直观得多。5.3 从统计指标反推实现是否有Bug验证不止看“平均周转时间对不对”我还会检查几个逻辑不变式每个作业的周转时间一定大于等于运行时间否则说明时间计算有错各作业占用内存的时段之和不会超过内存总容量同一时刻CPU记录里不会出现两个进程重叠的时间段后备队列、就绪队列在任何时刻的作业/进程集合与日志中记录的状态一致。我实际遇到的一个Bug就是在检查第3条时发现的甘特图里两个进程存在瞬间重叠原因是进程调度在时间片到点时先画了当前进程的时间块又立刻画下一个进程而两个时间块都包含了同一个边界单位。修正方法是在时间推进时统一用“左闭右开”的区间划分每个时间单位属于且只属于一个进程。这个经验也值得分享模拟器里的时间边界不统一迟早会在统计逻辑里冒出看似随机、实则固定的偏差。6. 源码组织、打包运行与扩展思路6.1 项目目录说明整个项目是标准Maven目录结构源码用Java 8语法编写Swing在Java 8下兼容性最稳换到更新的JDK也都能直接编译运行。主要文件如下src/main/java/ ├── Main.java // 程序入口启动主窗口 ├── model/ │ ├── Job.java // 作业实体编号、到达时间、运行时间、内存需求、状态 │ ├── PCB.java // 进程控制块关联作业、剩余时间、已运行时间 │ └── GlobalState.java // 全局状态内存容量、后备队列、就绪队列、CPU记录 ├── scheduler/ │ ├── JobScheduler.java // 作业调度FCFS / SJF / HRN │ └── ProcessScheduler.java // 进程调度FCFS / 时间片轮转 ├── core/ │ └── SimulatorEngine.java // 时间步驱动统筹两个调度器 └── ui/ ├── MainFrame.java // 主窗口组织所有Swing组件 ├── QueueTableModel.java // 表格数据模型 └── GanttPanel.java // 自定义甘特图绘制模型层的三个类非常纯粹只存数据不写逻辑调度层只依赖模型层的公共接口界面层只通过SimulatorEngine暴露的方法获取状态。这样的分层让阅读源码的人能顺着“入口 → 界面 → 控制 → 调度 → 模型”的顺序逐层理解不用在海量代码里翻找某个变量从哪来。6.2 编译、运行与打包可执行程序本地开发时直接在集成开发环境里运行Main.java就能起界面。命令行方式也很简单mvn clean package java -jar target/batch-scheduler-simulator.jar如果没有Maven也可以用javac手动编译但依赖较多不推荐。打包可执行程序时我用maven-jar-plugin指定了Main-Class为Main再把Swing相关依赖打进jar。因为项目没引第三方库最终就是一个独立的jar文件双击或在命令行运行均可。打包后我通常会额外写一个readme.txt放在压缩包里说明三件事需要JDK 8及以上版本运行命令是java -jar 多道批处理两级调度模拟.jar界面里先设置内存容量和算法再点“开始”。别小看这个说明文件很多同学拿到程序跑不起来十有八九是Java版本不对或者没看日志区的提示。6.3 还能怎么扩展算法、可视化、参数化这个模拟器做完之后我一直在想它的扩展空间下面几个方向是我自己觉得最有价值的增加多级反馈队列。这是进程调度里理论分量很重的内容把不同优先级的就绪队列做成一个数组时间片随队列层级翻倍实现起来不算难但对理解“兼顾短作业与长作业”非常有用。增加内存分配策略。目前内存分配是“需要多少就分配多少”的连续分配可以扩展为首次适应、最佳适应等算法直观展示碎片问题。作业序列随机生成。写一个生成器批量产生随机作业参数让模拟器连续跑多轮自动统计不同算法下的平均周转时间形成对比表格。甘特图导出。把CpuRecord列表绘制成PNG图片做实验报告时直接插入比手画省力太多。我个人做完这个项目之后最大的体会是调度算法本身并不复杂复杂的是把“两个调度器、一组队列、一份内存资源”在一个统一的时间轴上协调起来。模拟器一旦跑通你再回头读教材上关于两级调度的章节会发现每个字都变得具体了——导航栏里那条“作业调度”的箭头背后是内存检查、PCB创建、就绪队列插入那条“进程调度”的箭头背后是时间片、就绪队列轮转和完成回收。如果你也在做类似的课程项目建议别急着写界面先把第二章里那条联动链在纸上画明白后面的代码就是水到渠成的事。