资讯详情 Java操作系统课设:控制台模拟作业调度与内存管理实战指南
📅 2026/10/8 12:49:37
简介基于Java实现的操作系统课程设计资料包以控制台程序完整呈现作业调度、内存管理、进程调度与进程阻塞唤醒的联动过程适合计算机相关专业学生完成操作系统课程设计或复习核心调度机制时参考。项目围绕10个随机初始化作业展开先以先来先服务FCFS从后备队列调入作业再以时间片轮转RR调度非阻塞进程用首次适应FF算法完成内存分配并在进程结束时回收、合并空闲分区同时限定内存中就绪、运行、阻塞三类进程总数不超过5个较好地模拟了并发数受限时的调度约束。压缩包共8个文件包括4个Java源文件、Word和PDF两种格式的设计报告、Markdown说明及许可证文件整体约1.28MB结构简明。已有259人浏览学习通过源码与报告配合既能看清FCFS、RR、FF等算法在完整项目中的落地方式也能把握进程调度与内存管理相互关联的课设要点适合借鉴或二次扩展便于在此基础上快速形成自己的完整方案。1. 从控制台看操作系统课设这个Java项目到底在模拟什么打开操作系统课设的验收现场最常看到的场景是学生敲下 java -jar os-course-design.jar一个黑底白字的控制台窗口开始滚动输出调度日志——作业进入内存、进程抢到CPU、页面被换出。这就是基于Java实现的《控制台》操作系统课设的标准形态无UI框架、不连数据库用最朴素的Java集合把作业调度、内存管理、进程调度、进程阻塞四大经典主题在一个命令行窗口里跑通。做这个项目不是去实现一个能跑Linux程序的模拟内核而是用一段能跑通的Java代码把课上背的调度算法变成可见的时间轴和指标。它适合两类人一类是调度算法倒背如流、却写不出百行可运行代码的计算机学生另一类是Java基础扎实但缺操作系统体系课的开发正好补上状态机设计与资源分配两课。2. 作业调度模块先来先服务与短作业优先的Java实现2.1 作业调度和进程调度先分清这两层很多课设一开始就翻车不是代码写不出来而是把作业调度和进程调度混在一锅。作业调度解决的是哪个作业从外存进入内存操作系统课里叫长程调度进程调度解决的是哪个进程从就绪态占用CPU是短程调度。这两个调度器的触发时机完全不同作业调度在系统有内存余量时触发进程调度在每个时间片结束时触发。在Java课设里我的做法是拆成两个独立类JobScheduler 负责把 Job 变成 PCBProcessScheduler 负责调度 PCB。Job 里装的是作业元数据PCB 里装的是运行时状态两者通过内存分配关联起来。先分清这一层后面的代码才不会写成粥。2.2 用 Java 定义作业控制块JCB的五个字段JCB是作业的身份证。常见做法是定义成纯数据类字段越少越不易出错。五个必填字段作业编号、到达时间、所需CPU时间、所需内存大小、是否已调入内存。注意课设里的时间全是相对时间以时间片为最小单位不是秒。/** * 作业控制块 JCB * 字段刻意只保留调度需要的不掺运行时信息 */ public class Job { private final int jobId; // 作业编号 private final int arrivalTime; // 到达时间单位时间片 private final int needCpuTime; // 需要的CPU总时间单位时间片 private final int needMemory; // 需要的内存分区大小单位KB private boolean inMemory; // 是否已调入内存默认false public Job(int jobId, int arrivalTime, int needCpuTime, int needMemory) { this.jobId jobId; this.arrivalTime arrivalTime; this.needCpuTime needCpuTime; this.needMemory needMemory; this.inMemory false; } public boolean canDispatch(int currentTime) { return !inMemory arrivalTime currentTime; } public void markLoaded() { this.inMemory true; } // getter 略 }为什么需要一个canDispatch(int currentTime)方法因为它把需求里的两个判断条件收拢到一处了作业是否已到达、是否还没被调入内存。后续调试时断点打在这个方法上就能看到每次作业调度的判定结果不用在调度器里到处找逻辑。字段用private final修饰避免运行中被意外改写。到达时间、CPU时间、内存大小三个量决定了调度对比的全部维度缺一个作业调度就无从谈起。2.3 FCFS 与 SJF 算法非抢占式的两种选法作业调度的经典算法里课设跑得最多的是 FCFS先来先服务和 SJF短作业优先两者都是非抢占式的。FCFS 按到达时间排队代码量最少SJF 按所需CPU时间排队能显著拉低平均周转时间但会饿死长作业。课设文档一般要求两个都写所以先抽一个策略接口再写两个实现是最省力的结构。/** * 作业调度策略接口 * 返回当前时刻应该被调入内存的作业没有就返回null */ public interface JobStrategy { Job pick(ListJob jobs, int currentTime); } // FCFS选择已到达且最早到达的作业 public class FcfsStrategy implements JobStrategy { Override public Job pick(ListJob jobs, int currentTime) { return jobs.stream() .filter(j - j.canDispatch(currentTime)) .min(Comparator.comparingInt(Job::getArrivalTime)) .orElse(null); } } // SJF选择已到达且所需CPU时间最短的作业 public class SjfStrategy implements JobStrategy { Override public Job pick(ListJob jobs, int currentTime) { return jobs.stream() .filter(j - j.canDispatch(currentTime)) .min(Comparator.comparingInt(Job::getNeedCpuTime)) .orElse(null); } }两个策略都只用一行 Stream 完成选人逻辑filter排除未到达和已在内存的作业min按不同维度取最小值。没有可选作业时流返回Optional.emptyorElse(null)拆掉空壳调度器拿到 null 就知道当前时刻没有可调度的作业。这里藏着一个常见误用如果只依赖 filter 判断到达时间调度器主循环拿到 null 后必须继续推模拟时钟否则所有晚到的作业永远不会被调度。这个时钟向前推进的动作是作业调度最容易漏掉的一行。2.4 调度主循环模拟时钟与参数设定调度主循环是整个课设的中枢。模拟时钟从 0 开始每次 tick 代表一个时间片。JobScheduler 每次先查内存是否有余量有余量就调用策略挑作业挑到就扣内存、调进内存。参数方面我一般这样设内存总量 256KB时间片长度 2 个时钟单位作业到达时间放在 0 到 10 之间CPU 需求在 4 到 20 之间。这些值不是随手写的——内存总量要大于最大作业的内存需求否则永远调不进去时间片长度要远小于 CPU 总时间否则进程调度直接退化成 FCFS。public class JobScheduler { private final ListJob jobs new ArrayList(); private final JobStrategy strategy; private final MemoryManager memoryManager; // 见第4章 private int clock; public JobScheduler(JobStrategy strategy, MemoryManager memoryManager) { this.strategy strategy; this.memoryManager memoryManager; } /** 推进一个时间片返回本次是否发生了作业调度 */ public boolean tick() { clock; Job job strategy.pick(jobs, clock); if (job null) { return false; } MemoryPartition partition memoryManager.allocate(job.getNeedMemory()); if (partition null) { return false; // 内存不足等释放后再试 } job.markLoaded(); System.out.printf([%d] 作业 %d 调入内存起始地址%d大小%dKB%n, clock, job.getJobId(), partition.getStartAddr(), partition.getSize()); return true; } }注意tick()返回布尔值的用意上层循环可以统计调度次数答辩时老师问你这调度器一共调度了多少次你打一条日志就能答上来。另一个细节是内存不足时不要无限重试返回 false 后继续推时钟等前面的进程结束后释放内存再把等待中的作业调入——这样就把作业调度与后面的内存管理串成了完整链路。加载完 Job 后还没结束下一个动作是创建对应的 PCB 并放进就绪队列这通常是课设里连接第2章与第3章的那座桥别漏。3. 进程调度与阻塞时间片轮转背后的状态机设计3.1 PCB 三态模型就绪、运行与阻塞的 Java 表达进程调度的地基是进程控制块PCB。课设里的 PCB 通常携带这些字段进程ID、优先级、已运行时间、剩余CPU时间、阻塞剩余时间、当前状态、内存基址。状态用枚举表达最清晰public enum ProcessState { NEW, READY, RUNNING, BLOCKED, TERMINATED }NEW 和 TERMINATED 是给完整生命周期用的三态模型就绪、运行、阻塞的迁移规则是就绪被调度到运行运行因时间片用完回就绪运行遇 IO 进阻塞阻塞 IO 完成后回就绪。Java 课设最容易翻车的点是把状态存成 int 然后到处写 if 判断状态码。用枚举之后状态迁移至少不会因为数字写错而出现死都没人知道的进程。3.2 时间片轮转调度就绪队列的核心循环时间片轮转是课设要求里出现频率最高的进程调度算法。规则一句话就能说清就绪队列是一个 FIFO每次从队头取一个进程运行一个时间片运行完没结束也没阻塞就放到队尾。用 Java 的LinkedListPCB做就绪队列最顺——poll()取队头add()进队尾。public class ProcessScheduler { private final QueuePCB readyQueue new LinkedList(); private final ListPCB blockedList new ArrayList(); private final ListPCB finishedList new ArrayList(); private final int timeSlice 2; // 每个进程一次最多运行的时间片数 private int clock; // 与作业调度共用同一个模拟时钟 /** 推进一个模拟时间片 */ public void tick() { clock; // 1. 先唤醒阻塞到期的进程 wakeUpBlocked(); // 2. 取就绪队列队头 PCB current readyQueue.poll(); if (current null) { return; } current.setState(ProcessState.RUNNING); // 3. 运行一个时间片 current.useCpu(timeSlice); // 4. 状态迁移 if (current.getRemainingTime() 0) { current.setState(ProcessState.TERMINATED); finishedList.add(current); System.out.printf([%d] 进程 %d 结束周转时间%d%n, clock, current.getPid(), clock - current.getArriveTime()); } else if (current.isBlocked()) { current.setState(ProcessState.BLOCKED); blockedList.add(current); System.out.printf([%d] 进程 %d 阻塞剩余时间%d%n, clock, current.getPid(), current.getRemainingTime()); } else { current.setState(ProcessState.READY); readyQueue.add(current); // 时间片用完回队尾 } } }这段代码就是时间片轮转的骨架。注意tick()的顺序不能乱唤醒必须在取队头之前否则一个刚刚解除阻塞的进程必须多等一个完整的时间片表现上就是不太明显的优先级饥饿。useCpu(timeSlice)内部做的事是cpuTime timeSlice; remainingTime - timeSlice;这两个操作必须成对出现漏了任何一个进程的已运行时间和剩余时间就对不上最后的周转时间指标会失真。isBlocked()这一步在课设里通常用随机数模拟 IO 请求或者用预先写好的 IO 时间表来判断。3.3 阻塞与唤醒机制别用 wait/notify用计数器很多 Java 基础不错的同学一看到进程阻塞四个字第一反应是用Thread.wait()和notify()。这在课设里是灾难来源。操作系统的阻塞是模拟概念不是真正的线程睡眠——PCB 进了阻塞队列后等待的东西是IO操作完成而这个 IO 操作本身也是你模拟出来的。做完这套课设后的血泪经验是给 PCB 加一个blockingTimeLeft字段每次 tick 递减它减到 0 就自动回到就绪队列。这样整个课设是单线程驱动的所有状态迁移都发生在同一个tick()里可调试性远高于多线程。public class Pcb { private int blockingTimeLeft; // 剩余阻塞时间单位时间片 public void decBlockingTime() { if (blockingTimeLeft 0) { blockingTimeLeft--; } } public boolean isBlockedDone() { return blockingTimeLeft 0; } public void block(int ioDuration) { this.blockingTimeLeft ioDuration; } public void useCpu(int slice) { this.cpuTime slice; this.remainingTime - slice; } } private void wakeUpBlocked() { IteratorPCB it blockedList.iterator(); while (it.hasNext()) { PCB pcb it.next(); pcb.decBlockingTime(); if (pcb.isBlockedDone()) { pcb.setState(ProcessState.READY); readyQueue.add(pcb); it.remove(); // 从阻塞队列摘下 System.out.printf([%d] 进程 %d 阻塞结束回到就绪队列%n, clock, pcb.getPid()); } } }为什么要用Iterator.remove()而不是在 for-each 里 remove阻塞队列是 ArrayList在遍历过程中直接调用list.remove()会触发ConcurrentModificationException——这就是课设程序运行到一半莫名抛异常的高频元凶。用 Iterator 的 remove 方法才安全。当初我第一次跑阻塞唤醒逻辑时在for (PCB p : blockedList)里删元素程序稳定翻车改成 Iterator 后立刻就好了。这不是玄学是集合类的快速失败机制任何在遍历中改集合的操作都要小心。3.4 多种进程调度算法的切换复制粘贴不如抽个接口如果课设要求同时实现时间片轮转和优先级调度第二个算法千万不要复制上一段代码再改。正确做法是把从就绪队列选人和运行完的处置分开。优先级调度可以做到调度器内部用一个PriorityQueuePCB按 priority 排序而不是用 LinkedList。时间复杂度与选择理由就绪队列用 LinkedList 的 poll/offer 是 O(1)用 PriorityQueue 的 offer/poll 是 O(log n)两者在几百个进程的课设规模下差异感知不到。但当你在文档里写出这条权衡老师会认为你真的理解数据结构而不是只会背算法。这是课设答辩的一个加分细节。4. 内存管理模块可变分区分配与页面置换的Java落地4.1 可变分区分配空闲分区表的结构设计内存管理在课设里常见两种形态一种是配合作业调度做可变分区分配另一种是独立的页面置换。先说前者。可变分区分配要求内存被动态分割成若干块空闲块用一张表维护每次分配找一个足够大的块切出一块给作业剩下的零头继续留在空闲表里。Java 里用一个ArrayListMemoryPartition即可每个分区是起始地址加大小两个字段。public class MemoryPartition { private final int startAddr; // 起始地址 private final int size; // 分区大小 public MemoryPartition(int startAddr, int size) { this.startAddr startAddr; this.size size; } // getter 略 } public class MemoryManager { private final ListMemoryPartition freeList new ArrayList(); private final int totalMemory; public MemoryManager(int totalMemory) { this.totalMemory totalMemory; freeList.add(new MemoryPartition(0, totalMemory)); // 初始是一个整块 } }为什么用 ArrayList 而不是链表课设规模下内存分区数量通常不超过几十个ArrayList 的随机访问和遍历比每次都 new 节点的 LinkedList 清爽代码也更短。真正容易被忽略的点是MemoryPartition 是不可变的分配操作不修改原分区对象而是把原分区拆成被占用区 剩余区两个对象然后更新 freeList。这样做的理由是防止多个地方持有同一分区的引用导致状态错乱——内存管理里最毒的就是共享可变状态。4.2 首次适应与最佳适应算法选择与碎片坑首次适应First Fit从空闲表头部开始找到第一个 size 大于等于需求的分区就分配。最佳适应Best Fit遍历全表选那个 size 最接近需求的分区尽量减少内部碎片但会让大分配请求更难满足。两者实际实现里只是选分区的标准不同分配后的动作完全相同。把比较逻辑抽成一个接口同一组测试跑两遍就能对比碎片率。public MemoryPartition allocate(int size) { MemoryPartition best null; for (MemoryPartition part : freeList) { if (part.getSize() size (best null || prefer(part, best))) { best part; } } if (best null) { return null; // 内存不足 } freeList.remove(best); int remaining best.getSize() - size; if (remaining 0) { // 把剩余部分重新加入空闲表 freeList.add(new MemoryPartition(best.getStartAddr() size, remaining)); } return new MemoryPartition(best.getStartAddr(), size); }首次适应和最佳适应的唯一区别在prefer(part, best)这个布尔方法里。首次适应要求 part 的起始地址比 best 更靠前最佳适应要求 part 的 size 比 best 更小。把这段抽出来就避免了在两个算法里复制 20 行分配代码。分配只是前半场释放同样关键——释放时要检查与相邻空闲分区的合并规则否则跑几十个作业后空闲表里全是零碎小分区大作业永远分配不到内存看起来就像内存泄漏。合并逻辑是释放后检查新分区的前驱与后继地址相邻就拼接同时保持空闲表按起始地址排序。这一步不做课设演示跑不到一半内存就会被碎片占满。4.3 页面置换FIFO 与 LRU 的 Java 落地如果课设选的是页面置换这部分内存管理会独立成章。它在 Java 里的实现反而更瘦内存被等分成长度相同的页框进程被切成同样大小的页页表记录逻辑页号到物理页框号的映射。FIFO 用一个队列记录进入内存的页号顺序缺页时淘汰队头LRU 需要感知最近最少使用常见做法是用LinkedHashMap的 accessOrder 模式每次 get 一个页键就会把它移动到链表尾部头部自然就是最久未使用的页。public class PageTable { private final int frameCount; // 物理页框数量 private final MapInteger, Integer pageMap; // 逻辑页号 - 物理页框号 public PageTable(int frameCount) { this.frameCount frameCount; // accessOrdertrue 表示按访问顺序排序尾部是最新访问的页 this.pageMap new LinkedHashMap(frameCount, 0.75f, true); } public int accessPage(int pageNo) { Integer frame pageMap.get(pageNo); if (frame ! null) { return frame; // 命中get 自动把该页移到尾部 } // 缺页淘汰最久未使用的页头部 if (pageMap.size() frameCount) { int lruPage pageMap.keySet().iterator().next(); pageMap.remove(lruPage); } int newFrame allocateFrame(); pageMap.put(pageNo, newFrame); return newFrame; } }这里的关键是 LinkedHashMap 构造器的第三个参数 accessOrder。设为 true 后每次map.get(key)都会把该键值对移动到内部链表的尾部所以链表头部永远是最久没被访问过的键缺页时直接取keySet().iterator().next()就是 LRU 候选页。这个实现简洁但有一个隐蔽坑如果某个逻辑页号在缺页后又被访问get之后键的位置变了原来的头部也变了所以淘汰逻辑必须每次从头算不能把 head 缓存下来复用。我就被这个缓存的 head 坑过一次输出和手算结果差一个页框调了一下午才定位到。4.4 内存管理的参数约定与边界条件内存参数直接影响课设演示效果。建议总内存 256KB作业内存需求区间 32KB 到 96KB作业大小尽量控制在总内存的一半以内确保至少有两个作业能同时驻留内存这样调度器才能展示出作业等待内存和进程结束后释放内存两段完整过程。页式管理时页框大小取 4KB总内存 256KB 对应 64 个页框页框数太少显示不出 LRU 的优势太多又让 FIFO 和 LRU 的缺页率差异不明显。边界条件要专门测三种请求内存等于 0、请求内存大于剩余空闲、释放的分区与两块相邻空闲分区都相邻。第三个不测答辩时老师随手一跑就露馅。5. 课设避坑指南五个真实场景和排查思路5.1 阻塞队列里的进程永远不回来现象日志停在进程 X 阻塞之后时钟继续走但 X 再没出现过。原因wakeUpBlocked()里对阻塞剩余时间的递减逻辑放错了分支或者阻塞时间设成了固定值却没执行递减进程永远等不到阻塞结束。解决每次 tick 无条件对阻塞队列里所有 PCB 调用decBlockingTime()然后在isBlockedDone()成立时摘回就绪队列。我在单测里专门写了一个只阻塞一个进程的用例断言阻塞结束后立刻回到就绪队列这一招能挡住九成实现错误。5.2 进程调度完了内存里却没有它现象日志显示作业调入内存成功但进程调度时找不到该作业对应的 PCB。原因作业调度只调用了memoryManager.allocate拿到分区没把分区信息写入新 PCB或者 PCB 入就绪队列前没有被正确创建。解决把分配内存和创建PCB合并成同一个事务操作要么两个都成功要么都回滚。代码里用一个简单的 guard 断言内存分配完、PCB 没建成就把分区释放回空闲表保证状态一致。这个坑是状态分散导致的PCB 和内存表各自维护自己的状态却没有任何一个方法保证它们同步。5.3 遍历集合时删除元素抛 ConcurrentModificationException现象程序跑着跑着突然抛异常控制台打出一大堆堆栈位置总在遍历集合的方法里。原因在 for-each 循环里直接调用list.remove()。ArrayList 和 LinkedList 的迭代器一旦检测到结构性修改就快速失败。解决遍历删除统一用Iterator.remove()或者先用 filter 收集后一次性removeAll。交作业前全局搜索一遍凡是for (...) { list.remove(...) }的地方全部重写。这种翻车一旦被老师复现印象分扣得很重。5.4 每次运行结果都不一样没法对答案现象同一份输入跑两次时间轴不同指标数据对不上。原因HashMap/HashSet 的遍历顺序不固定一旦调度器依赖了哈希序结果每次都可能不同。解决确定性数据结构优先用 ArrayList/ArrayDeque/LinkedHashMap真需要按 key 排序就用 TreeMap。验证算法正确性时给 Random 加固定种子。这样课设演示时第二次跑也能复现第一遍的时间轴。答辩时你复现一次是高频操作不可复现基本等于没做。5.5 进程无限运行就绪队列永远空转现象同一个进程一直占着 CPU就绪队列里的进程全部等待里程碑进度迟迟不出。原因useCpu(timeSlice)里只累计了 cpuTime忘了递减 remainingTime。进程的剩余时间永远不变自然永不结束。解决CPU 时间记账两个变量必须一对一更新。给 PCB 写一个方法useCpu(int slice) { this.cpuTime slice; this.remainingTime - slice; }把两个操作收在一个方法里从此不会再出现只减一半的情况。6. 验证调度器用时间轴日志和指标证明你的课设做对了6.1 时间轴日志把每个调度事件打出来课设做了不少最难的是怎么让老师一眼看懂你做了什么。我的习惯是所有调度事件统一用 printf 输出一行带时间戳的日志格式固定为[时间] 实体 事件 附加参数。从第一行看到最后一行就能完整还原进程的一生。public class TimelineLogger { public static void log(int clock, String entity, String event, String detail) { System.out.printf([%4d] %-10s %-20s %s%n, clock, entity, event, detail); } } // 典型输出 // [ 2] 作业 调入内存 起始地址32大小64KB // [ 3] 进程 阻塞 剩余时间5 // [ 8] 进程 阻塞结束 回到就绪队列 // [ 10] 进程 结束 周转时间8这种日志格式最大的好处是可 grep。跑完一遍后grep 进程.*阻塞能立刻拉出所有阻塞事件的时间点和手算结果做比对。出 bug 时我一般先看第一条阻塞和对应的阻塞结束之间隔了多少个 tick偏差往往一猜一个准。6.2 用平均周转时间验证调度算法单看日志还不足以证明算法正确。对一组作业多次运行后计算平均周转时间平均周转时间 (完成时刻 - 到达时刻) 的平均值和平均带权周转时间带权周转时间 周转时间 / 实际运行时间FCFS 和 SJF 的结果差异就是算法是否真的生效的度量。我把计算逻辑抽成一个指标类每次进程结束时把数据喂进去全部跑完输出一张汇总表。同一组作业FCFS 的平均周转时间通常明显大于 SJF这个信号一旦出现说明作业调度和进程调度已经串起来了。策略到达时间序列完成时刻序列平均周转时间FCFS0, 2, 48, 14, 2012.0SJF0, 2, 46, 12, 2210.7表格里的数值是我随手写的示例重点不是数值而是答辩时拿同一份输入跑两个策略把两张表摆在一起证明你的调度器真的能按策略做出不同取舍。这比口头讲十分钟我的调度器很完善有说服力得多。6.3 让课设再进一步把调度策略接成策略模式如果你不想让这个课设看起来像网上抄的模板一个低成本加分工期是把 FCFS/SJF、RR/优先级、首次/最佳适应全部实现成策略接口调度器只依赖接口不依赖具体类。这样演示时用一个命令行参数在同一个主函数里切换策略跑同一份作业输出两张不同的对比表。答辩时老师问为什么两边周转时间不一样你直接说这是两个策略对同一份输入的选择差异一句话就解释清了。这个设计模式不一定在课设要求里但写上它代码的结构分和可扩展性分会一起拿。这套课设做完我自己最大的收获不是背熟了算法而是学会了用一个统一的模拟时钟把资源分配、进程状态、时间记账全部串在一个主循环里。很多同学遇到 bug 就急着加日志、加断点我喜欢先问一句这个状态转换发生在哪个 tick答案往往就是 bug 本身。如果你也在做 Java 课设建议先把阻塞唤醒写好再去调算法——只要状态机是干净的FCFS 换成 SJF 不过是换一个比较器的事。希望帮到你。本文还有配套的精品资源点击获取