☰
Java/Swing实现两级调度模拟:作业调度与进程调度可视化
2026/10/15 12:06:05 网站建设 项目流程

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内容即可,否则界面会有明显闪烁,而且会丢失列的拖动状态。

甘特图部分,我维护了一个List<CpuRecord>,每条记录包含进程名、开始时间、结束时间。绘制时根据当前面板宽度换算每单位时间对应的像素宽度,用fillRect画出时间块。这套自定义绘制的代码量不大,但演示效果比纯表格直观得多,也是整个模拟器里“最像教学工具”的一个部分。

4. 核心调度算法的落地:从公式到能跑的Java代码

4.1 作业调度:FCFS、SJF、HRN的代码骨架

作业调度的核心算法我实现了三种:先来先服务(FCFS)、短作业优先(SJF)、最高响应比优先(HRN)。三种算法的输入都是后备队列,输出是“选中的作业”,区别只在选择标准。

FCFS最简单,直接按到达时间遍历,取第一个还在等待的作业即可,相当于LinkedList的队头元素。

SJF要在整个后备队列里找运行时间最短的等待作业:

private Job selectJobBySJF(List<Job> 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(List<Job> 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 两个调度器在一个时间步内的工作顺序

这是我调试中反复调整过的核心逻辑。一个时间步内,如果处理顺序错了,系统行为就会有系统性偏差。我最终采用的处理顺序是:

  1. 时间推进currentTime + 1;
  2. 把“到达时间等于当前时间”的作业加入后备队列;
  3. 调用JobScheduler.schedule(),尝试从后备队列调入尽可能多的作业;
  4. 调用ProcessScheduler.dispatch(),让当前CPU执行一个时间单位;
  5. 检查是否有进程运行完成,执行回收逻辑;
  6. 刷新界面。

步骤3和4的顺序有讲究:先做作业调度再做进程调度,才能让刚调入内存的进程在同一个时间步里参与CPU分配。如果反过来,新调入的进程必须等到下一轮才能上CPU,会把所有周转时间和甘特图都往后推一个单位,首次跑出来的数据就和手算对不上。

步骤5放最后也是刻意的:进程在一个时间单位结束时可能刚好用完剩余时间,这时回收内存,下一个时间步的作业调度就能立刻使用新释放的空间,逻辑上无缝衔接。

5. 用一组测试数据验证:结果对不对、算法差异怎么看

5.1 测试用例设计与预期计算

模拟器写完不代表正确,我用一组可以手算的测试数据做了验证。设计原则是:数据量小、每个作业的内存需求不同、存在“内存装不下导致等待”的场景,这样才能同时检验作业调度、进程调度和内存管理的联动。

测试环境:内存容量40,进程调度方式固定为先来先服务(便于手算预期值)。

作业号到达时间运行时间内存需求
J102520
J231015
J361510
J49525

先手算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(运行5),SJF会选择J4优先调入,此时J4内存需求25恰好等于空闲内存25。J4结束后J3才调入运行。最终平均周转时间为34.25。

5.2 FCFS与SJF的实测结果对比

把两种算法放进模拟器分别跑同一组数据,输出结果和手算完全相同。

指标FCFSSJF
J1周转时间2525
J2周转时间3232
J3周转时间4449
J4周转时间4631
平均周转时间36.7534.25

这个对比特别有意思:SJF牺牲了到达较晚但运行时间长的J3(周转时间从44增加到49),换来了J4的大幅缩短(46降到31),最终平均周转时间更优。这就是短作业优先“让短作业尽快结束、整体周转更短”的直接体现,比单纯读教材结论直观得多。

5.3 从统计指标反推实现是否有Bug

验证不止看“平均周转时间对不对”,我还会检查几个逻辑不变式:

  1. 每个作业的周转时间一定大于等于运行时间,否则说明时间计算有错;
  2. 各作业占用内存的时段之和不会超过内存总容量;
  3. 同一时刻CPU记录里不会出现两个进程重叠的时间段;
  4. 后备队列、就绪队列在任何时刻的作业/进程集合,与日志中记录的状态一致。

我实际遇到的一个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创建、就绪队列插入;那条“进程调度”的箭头,背后是时间片、就绪队列轮转和完成回收。如果你也在做类似的课程项目,建议别急着写界面,先把第二章里那条联动链在纸上画明白,后面的代码就是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询