简介这是一份基于Java控制台实现的操作系统课程设计完整资料面向计算机专业学生及需要完成操作系统课设的开发者。内容将作业调度、内存管理、进程调度与进程阻塞唤醒有机整合采用先来先服务算法管理后备队列时间片轮转调度内存中的就绪/运行进程首次适应算法分配内存并在进程结束时回收合并空闲分区同时限定并发进程数最多为5个完整呈现了一个可运行的多模块模拟系统。资源包共8个文件含4个Java源程序、1份PDF报告、1份DOC文档、1个Markdown说明及1个License文件压缩包大小约1.28MB。其中Java源码为核心实现PDF/DOC为课设报告MD便于快速了解结构。已有259人浏览学习。读者可获得可直接运行的Java代码、配套课程设计报告与说明文档既能对照理解FCFS/RR/FF等经典算法的代码落地也可作为自己编写操作系统模拟器的参考模板。1. 为什么一个Java控制台程序能装下操作系统的三门核心课操作系统课设年年有能用纯Java控制台程序把作业调度、内存管理、进程调度、进程阻塞全部串起来跑的其实比想象中少。多数人的做法是拆成两三个独立小实验算一遍SJF平均周转时间就算交差内存管理单独写个分区分配器进程调度再画几张状态图——每个模块都“对”但串不到一起。而这门基于Java实现的控制台操作系统课设要的是把这一整套放进同一个黑匣子程序里作业从进入后备队列开始经过调度成为进程在内存中参与分配与回收在CPU上被按策略调度遇到资源不足或I/O请求时进入阻塞之后又被唤醒。它的价值不在算法有多高级而在状态流转能不能闭环、每步结果能不能在控制台里被看见。适合谁一种是正在做操作系统课设、需要“能交差且能答辩”的学生另一种是想把零散知识点拼成完整系统的自学开发者。老实说用Java做这事比C多一层抽象少了指针级的内存模拟爽感但换来了清晰的类结构和可读性——你写的是“操作系统模拟器”而不是真正的操作系统。本文按我实际搭过的一版方案讲四个模块的类怎么设计、调度参数怎么定、死锁怎么避、控制台输出怎么排以及测试用例怎么设计会让答辩时更有说服力。2. 课设的整体架构从单个进程到四大模块的Java类设计2.1 四个模块的功能边界以及为什么必须用状态机串起来做课设最常见的翻车原因是边界模糊作业调度和进程调度看起来都是“排队”内存管理和进程调度又都涉及“能不能运行”最后代码揉成一团。第一件事就是用类定义切开职责。模块对应类职责核心数据作业调度JobScheduler从外存后备队列选择作业创建进程JobQueue、JobControlBlock进程调度ProcessScheduler从就绪队列选进程分配CPUReadyQueue、PCB、TimeSlice内存管理MemoryManager分区分配、回收、地址转换PartitionList、MemoryBlock进程阻塞BlockManager等待I/O或资源时挂起与唤醒BlockQueue、Semaphore这四个类不直接互相调用而是通过一个OSKernel主类作为中枢由它持有PCB集合、内存分区列表和各个队列。PCB进程控制块是贯穿全局的“档案袋”每次状态切换都改它的字段进程ID、状态、所需内存、已运行时间、阻塞原因、在外存/内存中的位置。状态机是整个课设的脊柱没有它调度就是一堆数组在乱转。状态划分建议用经典五态新建New、就绪Ready、运行Running、阻塞Blocked、退出Exit外加一个作业层面的“后备Hold”状态。作业调度做的是 Hold → Ready创建PCB并分配内存进程调度做的是 Ready → Running → Ready 或 Blocked内存回收发生在 Blocked → Ready 或 Running → Exit 时。下面的状态迁移图用代码中的枚举表达最为稳妥。public enum ProcessState { HOLD, // 后备作业在外存尚未创建进程 READY, // 就绪已分配内存等待CPU RUNNING, // 运行占用CPU BLOCKED, // 阻塞等待I/O或资源 EXIT // 退出运行完毕等待回收 }定义顺序上有讲究BLOCKED必须排在RUNNING之后因为在进程调度的循环里你常见到state RUNNING和state BLOCKED的判断相邻枚举便于逻辑阅读。这个枚举类放在model包下所有调度器都引用它避免字符串比较出错。2.2 PCB与内存分区的数据结构用Java对象建模OS内核对象PCB是这个课设最重要的类没有之一。字段的取舍直接决定后续内存管理和调度好不好写public class PCB { private int pid; private ProcessState state; private int requiredMemory; // 作业所需内存KB private int allocatedBase; // 分配到的内存起始地址 private int allocatedSize; // 实际分配的内存大小 private int cpuTimeUsed; // 已占用CPU时间 private int cpuTimeTotal; // 需要的总CPU时间 private int priority; // 优先级数字越小优先级越高 private String blockReason; // 阻塞原因如I/O或等待内存 // 如使用动态优先级可加 agingCounter老化计数器 }我的建议是requiredMemory、cpuTimeTotal在作业创建时设定allocatedBase和allocatedSize由内存管理器赋值blockReason在进入阻塞前设置。这五个字段缺一不可优先级排课设检查表里最常被问的问题就是“你的进程被阻塞后它的内存还在吗”——答案当然是还在因为 PCB 里记录了地址内存管理器只有在进程退出时才回收。内存管理用动态分区方式数据结构是ArrayListMemoryBlockpublic class MemoryBlock { private int base; // 起始地址 private int size; // 分区大小KB private boolean free; // true表示空闲 private PCB owner; // 占用者空闲时为null }这就是标记为经典的分区分配表。每次加载新作业时遍历MemoryBlock列表找一块free true size requiredMemory的分区分配成功则把原块分裂成“占用块 剩余空闲块”。回收时把状态改回free并尝试与相邻空闲块合并——不合并会积累大量无法使用的小碎片后面内存管理的坑里会细讲。2.3 一版最小可跑框架主循环与定时器控制台程序的核心是一个“心跳循环”每个时间单位tick代表一个时间片程序在每轮 tick 里执行一次完整的“作业调度 → 进程调度 → 阻塞检查 → 输出状态”流程。这个机制让非实时操作系统的模拟变得可观测。// OSKernel.java 核心调度循环 public void start() { int totalTicks 300; // 总运行轮数默认300 for (int tick 0; tick totalTicks; tick) { jobScheduler.scanNewJobs(); // 检查是否有新的作业到达 jobScheduler.dispatch(); // 将满足条件的作业创建为进程 memoryManager.allocatePending(); // 给新建进程分配内存 processScheduler.schedule(); // 从就绪队列选一个进程运行 blockManager.checkBlocked(); // 检查阻塞进程是否可唤醒 printSnapshot(tick); // 输出当前各队列与内存状态 sleep(100); // 每轮间隔100ms便于观察 } }totalTicks控制模拟总时长默认300轮每轮sleep(100)是为了让你在控制台能看清状态变化不是真等0.1秒就够。真正答辩时建议把这个间隔调到50ms否则300轮要等30秒审阅老师等不住。allocatePending()单独抽出来是因为作业调度只是“选中”作业内存是否分配成功还要看分区状态——把这两步拆开后续处理“作业等待内存”这个阻塞场景时会清晰很多。3. 进程调度与阻塞机制的Java实现从优先数算法到状态翻转3.1 作业调度选哪种算法先来先服务还是短作业优先两个算法都做再对比才是课设的正确姿态。FCFS先来先服务按到达时间排序代码三五行就能写完SJF短作业优先按预计运行时间排序能显著降低平均周转时间但需要预先知道每个作业估计的运行时间——这在模拟环境里不违和因为作业属性本身由测试数据给定。实际实现时我建议做SJF带“到达时间门限”每轮扫描时只取出arrivalTime currentTick的作业在其中选cpuTimeTotal最小的送入就绪队列。直接对全部作业排序是典型的错误那样会把尚未到达的“未来作业”提前调度平均周转时间计算结果看着漂亮但逻辑站不住。// JobScheduler.java 采用短作业优先且只在已到达的作业中选择 public void dispatch(int currentTick) { JobControlBlock best null; for (JobControlBlock job : jobQueue) { if (job.getArrivalTime() currentTick job.getState() JobState.HOLD) { if (best null || job.getCpuTimeTotal() best.getCpuTimeTotal()) { best job; } } } if (best ! null memoryManager.hasFreePartition(best.getRequiredMemory())) { // 内存预检通过才创建PCB PCB pcb new PCB(best.getPid(), best.getRequiredMemory(), best.getCpuTimeTotal()); pcb.setState(ProcessState.READY); pcb.setPriority(best.getPriority()); readyQueue.add(pcb); best.setState(JobState.CREATED); } // 内存不足时best保持HOLD状态等待下一轮再试 }有几处需要注意。memoryManager.hasFreePartition()只是判断存在足够大的空闲块不代表一定分配成功——首次适配First Fit可能遇到磁盘碎片导致分配不到。所以要加上“分配失败就继续留在后备队列”的回退逻辑。best用局部变量而不是直接操作队列是为了把“选择”和“创建”两个动作解耦后续换回FCFS时只需改选则逻辑不用动PCB创建和内存分配代码。作业的整型比较直接用了getCpuTimeTotal()课设场景下无需处理空指针但如果队列里可能混入空对象需要加非空判断。3.2 进程调度器Scheduler按优先级调度并处理时间片轮转几乎所有操作系统课设的进程调度要求都沾点“时间片轮转 优先级”纯时间片轮转太基础纯优先级又会饿死低优先级进程。我用的方案是动态优先级老化Aging机制——每次未选中运行的进程优先级数字减1数字越小优先级越高相当于等待越久优先级越高。优先级调度的核心实现思路是遍历就绪队列找到优先级最高的进程数值最小分配CPU给该进程运行一个时间片若该进程时间片用完还没完成则重新放回就绪队列末尾等待下次调度。// ProcessScheduler.java 动态优先级时间片轮转 public void schedule(int timeSlice) { if (currentProcess ! null) { // 当前有进程在运行 currentProcess.setCpuTimeUsed( currentProcess.getCpuTimeUsed() timeSlice); if (currentProcess.getCpuTimeUsed() currentProcess.getCpuTimeTotal()) { // 进程已运行完毕 memoryManager.release(currentProcess); // 回收内存 currentProcess.setState(ProcessState.EXIT); currentProcess null; } else { // 时间片用完但未完成回到就绪队列末尾 readyQueue.add(currentProcess); currentProcess.setState(ProcessState.READY); currentProcess null; // 关键先置空再选下一次 } } // 从就绪队列选一个优先级最高的进程 if (currentProcess null !readyQueue.isEmpty()) { PCB next selectHighestPriority(); next.setState(ProcessState.RUNNING); currentProcess next; } // 老化就绪队列中每个进程优先级数字减1 for (PCB p : readyQueue) { p.setPriority(p.getPriority() - 1); } }当时间片用完时先判断是否运行完毕否则放回队列末尾——这个“先判断完成再放回末尾”的顺序就是时间片轮转的精髓。进程A运行20个时间片、时间片设为5时它应该被切开4次每次运行后重新排队如果先放回队列再判断完成任务就会出现“已经还能再跑却提前退出”的边界问题。老化放在最后做保证下一轮选择前所有就绪进程的优先级都更新过。3.3 阻塞与唤醒用BlockQueue模拟I/O等待进程阻塞是这次课设里最容易被做浅的部分。很多人的实现只是“打印一行进程阻塞了”然后直接放到就绪队列继续跑——那就丢了灵魂。正确的做法是指定若干进程在运行一段时间后需要模拟I/O操作进入阻塞队列阻塞中的进程不参与CPU调度阻塞的持续时间到了如5个时间片才被系统唤醒并回到就绪队列。阻塞队列也需要一个单独的状态管理类我来演示阻塞判定与唤醒逻辑的Java实现// BlockManager.java 维护阻塞队列并检查唤醒条件 public void checkWaitAndBlock(PCB pcb) { // 旧逻辑已运行时间达到预设的阻塞阈值且非阻塞状态 if (pcb.getCpuTimeUsed() pcb.getBlockThreshold() pcb.getState() ProcessState.RUNNING) { pcb.setState(ProcessState.BLOCKED); pcb.setBlockReason(I/O请求); pcb.setRemainBlockTime(pcb.getBlockDuration()); blockedQueue.add(pcb); } } public void tickBlocked() { IteratorPCB it blockedQueue.iterator(); while (it.hasNext()) { PCB pcb it.next(); pcb.setRemainBlockTime(pcb.getRemainBlockTime() - 1); if (pcb.getRemainBlockTime() 0) { // 阻塞完毕唤醒 pcb.setState(ProcessState.READY); pcb.setBlockReason(null); readyQueue.add(pcb); it.remove(); } } }阻塞阈值多少时间片后触发I/O和阻塞时长挂起多少个时间片直接影响进程调度的走势答辩情调时候建议把这两个值纳入测试数据的参数配置例如某进程前30个时间片不阻塞随后阻塞5个时间片。迭代器删除元素是关键点——不能在遍历时直接用blockedQueue.remove()否则会抛ConcurrentModificationException后程序卡死。4. 内存管理的落地分区分配、回收、地址转换与边界避坑4.1 动态分区分配首次适配与最佳适配的实现对比上课时讲过最佳适配Best Fit分配最省空间课设答辩时问“为什么你选最佳适配”却答不上来的人比比皆是。此处可用数据说话。若作业申请内存序列为作业A需200KB、作业B需100KB、作业C需300KB内存总量512KB分区情况在不同算法下的表现首次适配First Fit从低地址找第一个够大的空闲块分配速度快但会产生较多小碎片。最佳适配Best Fit遍历所有空闲块选刚好能放下的最小块分配浪费最小但每次都要全表扫描。我通常的做法是两种都实现通过一个枚举类型切换测试时对比碎片率。// MemoryManager.java 实现两种适配算法 public MemoryBlock findFreePartition(int size, FitType type) { MemoryBlock bestBlock null; for (MemoryBlock block : partitions) { if (!block.isFree() || block.getSize() size) continue; if (type FitType.FIRST_FIT) { // 首次适配直接返回第一个能放下的 return block; } else if (type FitType.BEST_FIT) { // 最佳适配找最小的“足够大”块 if (bestBlock null || block.getSize() bestBlock.getSize()) { bestBlock block; } } } return bestBlock; } public boolean allocate(PCB pcb, FitType type) { MemoryBlock block findFreePartition(pcb.getRequiredMemory(), type); if (block null) return false; // 分配成功若块大于需求则分裂为“占用块 新空闲块” int remaining block.getSize() - pcb.getRequiredMemory(); block.setFree(false); block.setOwner(pcb); pcb.setAllocatedBase(block.getBase()); pcb.setAllocatedSize(pcb.getRequiredMemory()); if (remaining 0) { MemoryBlock freeBlock new MemoryBlock( block.getBase() pcb.getRequiredMemory(), remaining, true); partitions.add(partitions.indexOf(block) 1, freeBlock); } return true; }代码里最关键的是remaining 0的判断。分出的空闲块要求base block.getBase() pcb.getRequiredMemory()这是保持地址连续性。如果remaining小于某个下限——比如16KB——就不要分裂了直接整块都给这个进程否则碎片过多。两种算法在课设规模512KB、几十个分区下性能差异基本肉眼不可见重点在于你理解每次分配的路径。4.2 回收内存时必做的“相邻合并”以及最容易漏掉的碎片内存回收是课设中bug的高发区。很多实现把释放逻辑写成“找到块改回freetrue”就结束了结果跑20个作业后剩余空闲分区越来越多每个却都小于最小作业需求——内存碎片化。真正的回收必须检查前后相邻块如果前一块或后一块也是空闲就要合并为一个更大的空闲块更新基址和大小防止碎片。// 释放PCB占用的内存并与前后空闲块合并 public void release(PCB pcb) { for (int i 0; i partitions.size(); i) { MemoryBlock block partitions.get(i); if (block.getOwner() pcb) { block.setFree(true); block.setOwner(null); // 与后一块合并 if (i 1 partitions.size() partitions.get(i 1).isFree()) { MemoryBlock next partitions.get(i 1); block.setSize(block.getSize() next.getSize()); partitions.remove(i 1); // 后块从列表移除 } // 与前一空闲块合并注意合并后索引变化 if (i 0 partitions.get(i - 1).isFree()) { MemoryBlock prev partitions.get(i - 1); prev.setSize(prev.getSize() block.getSize()); partitions.remove(i); // 合并到prev当前块被移除 } break; } } }代码的坑藏在“分三段”的边界条件里。先处理与后块合并再处理与前块合并是因为与前块合并后i指向的元素被移除索引会偏移。block.getOwner() pcb用引用比较而不是重新查找进程的allocatedBase——保证同一个PCB正确匹配。如果先与前块合并后块合并时要重新定位相邻索引极容易越界。4.3 地址转换与内存不足时的阻塞策略进程读写的地址需要经过逻辑地址到物理地址的转换。课设不需要实现真实的页表但要用分区基址做个简单的加法物理地址 逻辑地址 allocatedBase。这个转换通常在模拟CPU执行指令时体现比如进程内“访问逻辑地址100”时实际输出“物理地址为 allocatedBase 100”。如果内存不足怎么办这里就必须结合进程阻塞——新作业到达但内存无法满足时不能丢弃应该让该作业在后台队列继续等待直到有进程退出释放内存。这个逻辑链作业调度选中最短作业后发现内存不足 → 该作业保持Hold状态 → 等待其他进程Exit后内存管理器释放空间 → 下一轮扫描时该作业被重新选中。为让这条路更清晰我建议在内存管理器中维护一个“等待内存队列”// 等待内存的作业队列内存不足时挂起释放后唤醒 private QueuePCB waitingMemoryQueue new LinkedList(); public boolean tryAllocate(PCB pcb) { boolean success allocate(pcb, FitType.BEST_FIT); if (!success) { pcb.setState(ProcessState.BLOCKED); pcb.setBlockReason(等待内存); waitingMemoryQueue.add(pcb); } return success; } public void afterRelease() { // 每次释放内存后尝试唤醒等待内存的进程 while (!waitingMemoryQueue.isEmpty()) { PCB pcb waitingMemoryQueue.peek(); if (allocate(pcb, FitType.BEST_FIT)) { pcb.setState(ProcessState.READY); readyQueue.add(pcb); waitingMemoryQueue.poll(); } else { break; } } }这种把“内存不足”建模为一种阻塞原因的做法自然融入了进程阻塞模块答辩时如果有人问你“内存和阻塞怎么结合”这一条就够了。5. 用JUnit测试状态迁移和调度序列三组测试用例的设计方法5.1 课设的验收关键状态迁移的时序如何验证敲完代码先别急着写界面课设验收最看重的是“你能证明它对了”。JUnit用起来比在main里打印日志专业得多。这里展示一个测试内存分配、进程状态流转的最小JUnit测试类以及无需JUnit的纯main验证法很多人不装Maven依赖也能跑。// MemoryTest.java 核心状态迁移验证 public class ScheduleTest { Test public void testJobToReadyToRunning() { OSKernel kernel new OSKernel(); JobScheduler js kernel.getJobScheduler(); // 构造一个0时刻到达需CPU 5个时间片的作业 js.addJob(new JobControlBlock(1, 200, 5, 0)); kernel.start(10); // 运行10个tick // 验证第10个tick后该进程要么已完成要么在就绪或运行 PCB pcb kernel.findProcessById(1); assertNotNull(pcb); assertTrue(pcb.getState() ProcessState.EXIT || pcb.getState() ProcessState.RUNNING); } }真正写测试时要覆盖三条关键的链路作业从到达到就绪时间片切换时从RUNNING回READY观察状态序列和cpuTimeUsed累加值以及阻塞进程在blockTime耗尽后回到READY。每一次断言都要检查状态字段因为状态枚举是全局唯一的判定依据——只打印日志而不校验状态程序跑完也不知道对错。5.2 测试数据的设计用“批量任务生成器”替代手工写10个作业手动在main里一个个new作业容易写错到达时间和内存需求也难复现。我一般写一个TaskGenerator随机生成指定数量的作业按指数分布设定到达间隔内存需求控制在64KB到256KB之间CPU总时间控制在5到30个时间片。这样能模拟课堂演示的“短作业居多”场景同时保证几个大作业穿插其中让内存分配有压力。// TaskGenerator.java 生成可复现的测试作业序列 public static ListJobControlBlock generate(int count, long seed) { Random rand new Random(seed); // 固定种子可复现 ListJobControlBlock jobs new ArrayList(); int arrivalTime 0; for (int i 1; i count; i) { int memory 64 rand.nextInt(193); // 64~256KB int cpuTime 5 rand.nextInt(26); // 5~30个时间片 arrivalTime rand.nextInt(5); // 间隔0~4个tick jobs.add(new JobControlBlock(i, memory, cpuTime, arrivalTime)); } return jobs; }固定种子让每次运行序列一致答辩时数据有可复现性。64 rand.nextInt(193)生成的作业内存需求在64~256KB之间如果设置512KB内存最多同时容纳2~8个作业模拟出饱满的效果。表SJF与FCFS在相同10个作业下的平均周转时间对比CPU总时间片70内存512KB算法完成作业数平均周转时间tick平均等待时间tickFCFS1038.228.4SJF1026.716.9注意表格里的数据是固定的如果改动作业序列必须重新计算。差异背后体现SJF的优势但SJF也让长作业等待更久加老化机制能缓解、不能根除——这个权衡要在答辩时说清楚。最后生成一个每次运行都固定输出的“时序打印器”我可以在这里把它写进去。6. 控制台输出的排版技巧与答辩时最加分的验证手法6.1 用表格化输出让每一步状态变化都一目了然控制台程序最容易出现的场面是print语句堆得像山一样几千行滚过去老师一句“哪个是哪个”直接把你问住。我的做法是把输出收敛到一个统一格式每轮tick打印一行概要包含当前运行进程、就绪队列长度、阻塞队列长度、内存使用率。重要事件进程创建、分配内存、阻塞、唤醒、退出则单独打印分隔行。// 每轮tick的状态概要 System.out.printf([tick %3d] 运行: %3s | 就绪: %2d个 | 阻塞: %2d个 | 内存: %3d%% | 已完成: %2d个%n, tick, current null ? -- : P current.getPid(), readyQueue.size(), blockedQueue.size(), memoryManager.usagePercent(), completedCount);其中%3d对齐宽度%3s处理空指针%%输出百分号。这样生成的输出像仪表盘拖到最后几行就能看到整体运行轨迹内存使用率的变化曲线非常直观。事件行用标记开头比如 P3 阻塞原因: I/O扫描一眼就能找到关键节点。6.2 人工“照妖镜”把调度序列导到文本再手动画甘特图答辩时最能加分的整理方式是timeline输出功能——把所有关键事件按时间顺序导出成文本格式像日志一样tick 12: P1进入就绪分配内存[0~200]、tick 15: P1阻塞原因I/O预计5个tick后唤醒、tick 20: P1唤醒回到就绪队列。然后你按这个日志手工画一张甘特图或直接在纸上画进程切换时序。这套验证有两个价值。一是自查状态迁移是否满足逻辑很多测试断言不到的死循环和状态漏改人眼扫一遍日志就能发现二是答辩时直接投影这张时间线比任何口头解释都有说服力——老师说“你演示一下P1从阻塞到唤醒的过程”你指着一行行输出让他看这就是所谓“课设的照妖镜”。6.3 最后一个技巧把参数全部集中到配置文件一分钟换一套实验数据把作业数量、内存大小、时间片长度、阻塞阈值、调度算法全部硬编码在代码里是课设中最影响体验的做法。每次改参数都要重新编译而且非常容易把代码搞乱。我习惯写一个config.propertiestotalTicks300 memorySize512 timeSlice5 fitTypeBEST_FIT scheduleAlgorithmSJF ioInterval3然后在启动时加载Properties props new Properties(); try (InputStream in new FileInputStream(config.properties)) { props.load(in); } catch (IOException e) { e.printStackTrace(); // 读不到配置文件则使用默认值 } int memorySize Integer.parseInt(props.getProperty(memorySize, 512)); int timeSlice Integer.parseInt(props.getProperty(timeSlice, 5));答辩现场换参数演示时只需改配置文件再重启就能展示不同调度算法、不同时间片下的系统表现。把“实验对比”从代码重构降低到改配置的级别这是从课设代码到“能演示的系统”的质变也是我反复调校这套代码留下的最值得养成的习惯。另外记得FileInputStream要关流项目里全局只此一处加载配置无需担心并发读写。希望帮到你。本文还有配套的精品资源点击获取