CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
计算机科学入门/课时/操作系统概念
RawBlame
课时目录12 节

计算机科学入门

  1. 01计算机如何工作
  2. 02数据结构
  3. 03算法与搜索排序
  4. 04复杂度分析
  5. 05离散数学基础
  6. 06操作系统概念
  7. 07数据库原理
  8. 08计算机网络
  9. 09系统设计入门
  10. 10安全基础
  11. 11设计模式与 UML
  12. 12综合练习

操作系统概念

进程/线程、调度、内存、并发原语。

35 分钟

操作系统概念

这一课你将能做到

  1. 区分进程与线程,解释上下文切换的成本,以及 Java 线程与操作系统线程的关系
  2. 描述 CPU 调度、虚拟内存、分页与缺页中断的基本思想
  3. 理解文件系统的组织方式,以及互斥锁、信号量、条件变量分别解决什么问题

为什么要学

操作系统是「管家」:一套硬件,几百个程序争抢 CPU、内存、磁盘,它负责让大家「看似同时」运行、互不踩踏。Java 后端遇到的大量线上问题,根因都在这个层面:

  • 接口 RT(响应时间)毛刺:可能是线程被调度挂起、上下文切换、缺页中断
  • 机器内存没满但应用报 OOM:虚拟内存与 JVM 内存限制的交互
  • 线程池开多大?开多了反而更慢:调度与切换成本
  • 磁盘写了一半机器掉电:文件系统与日志(journal)如何保证一致性

判题平台更是操作系统知识的密集场景:每份提交都要隔离执行、限制 CPU/内存/时间、回收工作目录。理解进程线程与调度,才能正确设计执行沙箱和线程池。

先建立图像

进程与线程
┌─────────────── 进程 Process(资源分配单位)──────────────┐
│ 独立的地址空间:代码段/数据段/堆                          │
│ ┌线程1(栈+寄存器)┐ ┌线程2(栈+寄存器)┐ ┌线程3┐           │
│ │ 共享进程的堆和文件描述符,各自有栈    │                 │
└───────────────────────────────────────────────────────┘
  进程间内存隔离(通信要靠管道/Socket/共享内存)
  同进程线程共享内存(通信快,但要处理并发安全)

并发「同时」的真相(单核)
  线程A ▌▌    ▌▌▌       CPU 在 A/B/C 间快速切换
  线程B    ▌▌    ▌▌     时间片 time slice(毫秒级)
  线程C      ▌▌    ▌▌

虚拟内存
  进程看到的:0 ........ 连续的虚拟地址空间
       ↓ MMU 查页表翻译 ↓
  物理内存的散页 / 暂存到磁盘的交换区

跟着做

步骤 1:观察进程和线程

public class ProcessThreadDemo {
    public static void main(String[] args) {
        long pid = ProcessHandle.current().pid();
        System.out.println("当前 JVM 进程 PID = " + pid);

        // 一个进程里开两个线程:它们共享 main 里的变量
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("线程A: " + i);
            }
        }, "thread-A");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("线程B: " + i);
            }
        }, "thread-B");

        t1.start();
        t2.start();
        // 输出顺序是交错的、不保证的——这就是并发:调度顺序由操作系统决定
    }
}

运行后再开一个终端,Windows 执行 tasklist | findstr java,Linux/macOS 执行 ps -ef | grep java,能看到这个 JVM 进程;JDK 自带的 jstack <pid> 可以列出进程内所有线程及其栈。关键认知:Java 传统线程(java.lang.Thread)在主流 JDK 上一对一映射操作系统内核线程,创建、阻塞都有内核开销,所以不能为每个请求新建线程,要用线程池(Java 21 的虚拟线程则是另一套轻量模型)。

步骤 2:体会上下文切换的代价

import java.util.concurrent.CountDownLatch;

public class SwitchCost {
    public static void main(String[] args) throws Exception {
        int n = 200_000;

        // 对照组:两个线程各跑各的
        CountDownLatch latch1 = new CountDownLatch(2);
        long t1 = System.nanoTime();
        for (int t = 0; t < 2; t++) {
            new Thread(() -> {
                for (int i = 0; i < n; i++) { Math.sin(i); }
                latch1.countDown();
            }).start();
        }
        latch1.await();
        long t2 = System.nanoTime();

        // 实验组:两个线程用一根锁反复交替唤醒(制造大量切换/争用)
        final Object lock = new Object();
        CountDownLatch latch2 = new CountDownLatch(2);
        for (int t = 0; t < 2; t++) {
            new Thread(() -> {
                for (int i = 0; i < n; i++) {
                    synchronized (lock) { lock.notifyAll(); }  // 频繁让出与争用
                }
                latch2.countDown();
            }).start();
        }
        latch2.await();
        long t3 = System.nanoTime();

        System.out.println("无争用(ms): " + (t2 - t1) / 1_000_000);
        System.out.println("强争用(ms): " + (t3 - t2) / 1_000_000);
    }
}

切换时操作系统要保存当前线程的寄存器、程序计数器、栈指针,装入另一个线程的全套现场,还要刷掉部分 CPU 缓存——纳秒到微秒级,单次不起眼,高并发下累积可观。工程推论:**线程数 ≈ 核数 ×(1 + 等待时间/计算时间)**只是起点,I/O 密集可多开,CPU 密集开到核数附近即可,盲目「开 1000 个线程」会让切换吞掉算力。

步骤 3:并发原语——锁、信号量、条件变量

import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class ConcurrencyPrimitives {

    // 信号量:判题平台同时只允许 4 个任务执行(许可数=空闲判题机数)
    static Semaphore judgeSlots = new Semaphore(4);

    // 互斥锁 + 条件变量:任务必须等到有空闲机才能开始
    static ReentrantLock lock = new ReentrantLock();
    static Condition hasSlot = lock.newCondition();
    static int freeMachines = 2;

    public static void main(String[] args) throws Exception {
        Runnable judge = () -> {
            try {
                judgeSlots.acquire();
                System.out.println(Thread.currentThread().getName() + " 开始判题");
                Thread.sleep(100);
                judgeSlots.release();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };

        for (int i = 0; i < 10; i++) {
            new Thread(judge, "task-" + i).start();
        }
        Thread.sleep(1000);
    }
}

三者分工要记牢:互斥锁保证同一时刻只有一个线程进入临界区(保护共享数据);信号量维护 N 个许可,用来限流和资源池管理;条件变量让线程「等待某个条件成立并被唤醒」,配合锁实现生产者-消费者(判题任务入队、工作线程领取执行就是这个模式,JDK 里对应 BlockingQueue)。

步骤 4:虚拟内存、缺页与文件系统直觉

# Linux 下观察内存与进程
free -h                 # 物理内存与交换区 swap 使用情况
top                     # 按 1 看各核;RES 是进程实际占用物理内存
jcmd <pid> VM.native_memory summary   # JVM 各区域内存(需开启 NativeMemoryTracking)

# 观察文件:文件系统把「路径名」映射到 inode(元数据+数据块索引)
ls -i                   # 每个文件前面的数字就是 inode 号
df -i                   # 看 inode 是否耗尽(小文件海量时会先没 inode)

直觉串讲:每个进程以为自己独占整个内存,实际访问的是虚拟地址,MMU 查页表翻译成物理页;访问的数据不在物理内存(被换到磁盘或从未加载)时触发缺页中断,操作系统从磁盘搬一页进来——这是访问变慢上千倍的典型原因。文件系统则用 inode + 数据块管理磁盘文件,写入不是立刻落盘(先有页缓存),崩溃一致性靠日志(journaling,如 ext4)或写时复制(如 ZFS)保证;MySQL 的 redo log、Redis 的 AOF 是同样思想在应用层的重现。

讲清楚要点

  1. 进程是资源分配单位,线程是 CPU 调度单位:进程有独立地址空间,崩溃一般不互相影响;同进程线程共享堆,一个线程出错可能拖垮整个进程,所以判题沙箱必须按进程/容器隔离用户代码。
  2. 并发 ≠ 并行:并发是「处理多个任务的能力」(单核切换即可),并行是「同时执行多个任务」(需要多核);协程/虚拟线程解决的是线程创建与阻塞的开销,不改变 CPU 核数。
  3. 调度算法的演进直觉:时间片轮转保证公平,优先级应对重要任务,多级反馈队列兼顾「短任务快响应、长任务高吞吐」;对 Java 程序员体现为线程优先级几乎不可靠、要靠线程池与队列设计。
  4. 虚拟内存的三个收益:进程隔离与安全、用磁盘扩展内存、让每个进程拥有简单连续的地址空间;代价是翻译开销(TLB 缓存页表项)和缺页中断。
  5. 文件系统 = 文件的组织与块管理:目录也是文件,路径经逐层查找定位 inode;顺序写远快于随机写,这是日志型存储(追加写)和 LSM 树性能优势的物理根源。

你可能会懵的地方

  • 线程数设成 CPU 核数就一定最优吗? 只对 CPU 密集型成立。Tomcat 这类服务大量时间在等数据库和网络(I/O 期间线程挂起不占 CPU),所以工作线程要多于核数;现代做法是用带界线程池配合压测找到拐点,或直接用虚拟线程。

  • synchronized 等待时线程占着 CPU 吗? 不占。竞争失败的线程会被挂起(进入阻塞态),操作系统把 CPU 分给别的线程;而 CAS 自旋锁会短暂占 CPU 空转,适合等待极短的场景,各有适用面。

  • 虚拟内存「用磁盘当内存」是不是等于免费扩容? 不是。换入换出(swap)走磁盘,频繁换页会让机器慢到像死机(抖动 thrashing)。生产服务器通常限制甚至关闭 swap,应用内存要靠容量规划而不是透支。

  • 为什么写文件后还要 flush/fsync? write 先进操作系统页缓存就返回了,掉电会丢;flush 把应用缓冲刷给 OS,fsync 才强制落盘。数据库的 WAL 必须 fsync 后才应答客户端,才能在崩溃后恢复。

  • 进程间通信为什么这么「麻烦」? 隔离是安全与稳定的前提,通信必须经过操作系统允许的通道:管道、消息队列、Socket(网络栈)、共享内存(最快但要自己加锁)。微服务之间本质就是走 Socket 的进程间通信。

错法对照

错法问题正解
每个请求 new Thread()内核线程创建贵、数量失控可能拖垮机器用有界线程池,队列与拒绝策略明确
锁范围包整个大方法串行化,并发收益归零只保护共享数据的最小临界区
wait/notify 不放在循环条件里虚假唤醒导致逻辑错误永远 while(条件不满足) wait();优先用 Condition/BlockingQueue
写文件不 flush 就假设已持久化崩溃丢数据关键路径 fsync,或交给数据库事务
用 Runtime.exec 执行用户命令却不回收进程进程/文件描述符泄漏读取输出流、waitFor 超时、destroyForcibly 兜底

卡点急救

  1. CPU 不高但接口很慢:优先排查锁等待(jstack 看 BLOCKED/WAITING 线程)、I/O 等待、调度延迟,而不是去优化算法。
  2. 容器内应用莫名被 OOM Kill:检查 JVM 堆外内存(元空间、线程栈、直接内存、Native 库)总和是否超过容器限制;-XX:MaxRAMPercentage 给整体留余量。
  3. 判题机器逐渐卡死:检查用户进程是否全部被回收(僵尸进程)、工作目录文件是否清理(inode/磁盘)、超时任务是否被真正 kill 掉进程组。

面试常问

  • 进程和线程的区别?什么时候用多进程、什么时候用多线程? 进程独立地址空间、隔离好、开销大、通信要走 IPC;线程共享内存、轻量、通信快但要处理并发安全。需要强隔离与容错(如执行用户代码、Chrome 标签页)用多进程/容器;高并发共享业务数据用多线程。

  • 什么是上下文切换?怎么减少? 切换线程时保存/恢复寄存器、PC、栈指针并可能冷掉缓存的过程。减少方法:控制线程数量、缩小锁粒度减少争用、用无锁结构(CAS/ConcurrentHashMap)、避免频繁阻塞唤醒、I/O 批量化。

  • 虚拟内存解决什么问题?缺页中断为什么影响性能? 解决内存隔离、容量扩展与地址空间简化。访问页不在内存时陷入内核、从磁盘读页,耗时毫秒级(比内存慢数万倍);程序工作集大于物理内存时频繁缺页即抖动,表现为整机卡顿。

自测三问

  1. 一张图讲清「进程—线程—栈—堆—文件描述符」的归属关系。
  2. 判题平台执行用户提交,分别在哪一步用到了进程隔离、信号量思想和文件系统?
  3. 为什么 fsync 很慢却不能省?哪些系统组件必须依赖它?

小结一句话

操作系统用进程隔离资源、用线程调度 CPU、用虚拟内存制造「独占且无限」的内存假象、用文件系统管理持久数据——理解这四件事,线上性能与并发问题就有了操作系统层面的诊断坐标。

延伸

  • 《操作系统导论》(OSTEP)主页——免费、现代、以直觉驱动的权威教材,重点读虚拟化与并发篇
  • 《深入理解计算机系统》(CSAPP)第 8-10 章——异常控制流、虚拟内存与系统级 I/O
  • Java 17 并发工具包 API
  • 下一课:数据库原理——文件系统之上,数据库如何用关系模型、事务与索引,把「存取数据」变成安全高效的查询能力。
加载本节练习…
← 上一课离散数学基础下一课 →数据库原理