课堂笔记

教师:AI(DeepSeek → Claude Code) 学员:3 年+经验全栈开发工程师 这里记录每堂课的核心知识点。


第一课:CPU 缓存与 volatile

存储金字塔

层级名称速度大小对应物
L0CPU 寄存器0.3ns几十个局部变量可能被 JIT 编译进寄存器
L1L1 缓存1ns32-64KBCPU 私有
L2L2 缓存3ns256-512KB稍大的私有草稿本
L3L3 缓存10ns几 MB-几十 MB同 CPU 核心簇共享
L4主存 (RAM)60-100ns8-64GB内存条,Java 堆对象所在地
L5硬盘/SSD毫秒级TB 级MySQL 数据文件

核心认知:编程不是操作内存条,而是操作缓存副本。多线程问题的根源 90% 是因为 CPU 各自为政,拿着过期的缓存副本做计算。

volatile 关键字

两件事

  1. 保证可见性:一个线程修改 volatile 变量,新值立即对其他线程可见
    • 底层:Lock 前缀指令 → 强制刷回主存 + 无效化其他 CPU 缓存行
  2. 禁止指令重排序:编译器与 CPU 不会为了优化打乱 volatile 变量读写前后的代码顺序
    • 底层:内存屏障

不能替代 synchronized

  • volatile 只解决”读脏数据”,不解决”读-改-写冲突”
  • count++ 是三条指令:读缓存 → 加 1 → 写缓存,volatile 管不了中间的插队

volatile + 双重检查锁单例

  • instance = new Singleton() 三步:分配内存 → 初始化对象 → 引用指向内存
  • 无 volatile 时 CPU 可能重排为:分配内存 → 引用指向内存(此时对象未初始化!)→ 初始化对象
  • 另一个线程拿到半成品对象直接崩溃

总结口诀

  • 可见性:volatile = 通知全班同学”板书改了”
  • 原子性:synchronized = 把黑板锁起来,改完才开锁

第二课:学习策略 —— “一臂距离”原则

核心原则

只学那些:你伸出手,刚好能摸到、且明天上班调试代码时能用上的底层知识。

正确 vs 错误的学习范围

状态例子
✅ 该学Spring Boot 自动配置原理(写 Starter)
✅ 该学Redis RDB/AOF 持久化机制(生产丢数据)
❌ 先别学Redis zmalloc 内存分配器实现
✅ 该学JVM 类加载机制(排查 ClassNotFoundException)
❌ 先别学HotSpot C2 编译器图着色寄存器分配算法

技能深挖优先级(按面试频率排序)

  1. MySQL:行锁/间隙锁/临键锁、MVCC 与 ReadView、Change Buffer
  2. Redis:底层编码(Ziplist/Skiplist)、IO 多路复用单线程模型、Redlock
  3. JVM:对象内存布局、G1 Region 与 Remember Set、四种引用
  4. Spring Cloud:Nacos AP/CP 切换、Gateway WebFlux 异步非阻塞
  5. Vue:响应式原理(Proxy 与依赖收集)、Diff 双端比较

第三课:B+ 树索引(MySQL)

待补充——由学员提交 SQL 后引出,AI 承诺讲授

核心概念(占位)

  • B+ 树按字段原始值排序
  • DATE_FORMAT 等函数导致索引失效的原因
  • 最左前缀匹配原则

第四课:SQL 性能分析实战

案例 SQL 五大死因

死因 1:对字段使用函数(索引粉碎机)

DATE_FORMAT(EXTENSION_APPLY_CREATE_TIME,'%Y-%m-%d') > EXTENSION_START_DATE

底层:B+ 树按原始值排序,函数相当于把电话本涂改成密码再去找人 → 全表扫描

死因 2:IN (SELECT …) 子查询 底层:MySQL 5.7/8.0 可能优化为相关子查询 → 外层每扫一行,内层跑一遍 → DEPENDENT SUBQUERY

死因 3:重复子查询 相同子查询出现 10+ 次,MySQL 不会缓存,重复计算

死因 4:巨大的 IN 列表 上千个 ID 的 IN 列表 → range_optimizer_max_mem_size 超限 → 退化为全表扫描

死因 5:过多的 UNION ALL 接近 30 次 UNION ALL → 至少 30 个临时表 → 内存/磁盘 IO 爆炸

优化方案速查

病灶手术方案
DATE_FORMAT改用范围查询,或加虚拟列/函数索引(MySQL 8.0+)
IN (SELECT)改写为 EXISTS 或直接 JOIN
重复子查询物化到真实的中间表或 Redis 缓存
长 IN 列表导入临时表后用 INNER JOIN 代替 IN

第五课:Spring Boot Jar 包结构(“三明治”模型)

解压后的目录结构

app.jar (改名 app.zip → 解压)
├── BOOT-INF/          ← 肉馅:业务代码 + 依赖
│   ├── classes/       ← .class 字节码文件
│   └── lib/           ← 第三方依赖 jar 包
├── META-INF/          ← 面包底:启动说明书
│   └── MANIFEST.MF    ← Main-Class + Start-Class
└── org/               ← 面包顶:Spring Boot 引导程序
    └── springframework/boot/loader/JarLauncher.class

MANIFEST.MF 关键内容

Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.yourcompany.YourMainApplication
  • java -jar → 先执行 JarLauncher(第一把钥匙)
  • JarLauncher 启动后 → 去 BOOT-INF 找到真正的启动类(第二把钥匙)

认知升级

一个 Spring Boot 程序 = 一个精心设计的”三明治”:

[引导程序 (org/)]        ← 第一片面包:负责启动和加载
[业务代码 (BOOT-INF)]    ← 中间的肉和菜:你写的代码和第三方依赖
[配置文件 (META-INF)]    ← 第二片面包:告诉系统怎么吃这个三明治

第六课:JVM 类加载机制与 Spring Boot 启动原理

什么是 classpath

classpath 是 JVM 启动时列出的”去哪儿找 .class 文件”的地址清单。

在 IDE 中:IDEA 自动把 target/classes/ + 所有 Maven 依赖 jar 拼成 classpath。 在命令行java -cp myapp.jar:lib/* com.example.Main 中的 -cp 就是 classpath。

JVM 三大类加载器(双亲委派模型)

Bootstrap ClassLoader (C++ 实现)  ← 加载 rt.jar (String、ArrayList 等核心类)
        ↑ 委托
Extension ClassLoader              ← 加载 jre/lib/ext/ 下的扩展包
        ↑ 委托
Application ClassLoader            ← 加载 classpath 下的所有类

双亲委派的含义:加载一个类时,先向上委托给父加载器,父加载器能加载就轮不到子加载器。

为什么要这样? 安全。防止你写一个 java.lang.String 替换 JDK 自带的——Bootstrap 加载器永远优先加载。

Spring Boot 的问题

Spring Boot 的 fat jar 结构里,你的业务代码在 BOOT-INF/classes/,依赖在 BOOT-INF/lib/

Application ClassLoader 不认识 BOOT-INF 这个目录,所以无法用双亲委派正常加载。

Spring Boot 的解决方案

三步改造:

第 1 步:spring-boot-maven-plugin(打包阶段)

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
  • .class 文件挪到 BOOT-INF/classes/
  • 把依赖 jar 包挪到 BOOT-INF/lib/
  • 把 JarLauncher 等引导类放到 org/
  • MANIFEST.MF 写入 Main-Class 和 Start-Class

第 2 步:JarLauncher 引导(启动阶段)

// JarLauncher 是真正的入口(由 MANIFEST.MF 的 Main-Class 指定)
public static void main(String[] args) throws Exception {
    (new JarLauncher()).launch(args);
}
 
// 关键:isNestedArchive 筛选哪些路径需要加入类路径
protected boolean isNestedArchive(Archive.Entry entry) {
    return entry.isDirectory() 
        ? entry.getName().equals("BOOT-INF/classes/") 
        : entry.getName().startsWith("BOOT-INF/lib/");
}

第 3 步:LaunchedURLClassLoader(加载阶段)

  • 继承自 java.net.URLClassLoader
  • 内部维护 URL 列表:BOOT-INF/classes/ + BOOT-INF/lib/*.jar
  • 打破双亲委派:先在自己维护的 URL 列表里找类,找不到才让爸爸找
  • 用这个 ClassLoader 加载 Start-Class,反射调用 main 方法

完整的 Spring Boot 启动链条

java -jar lProject.jar
  → JVM 读 MANIFEST.MF → Main-Class = JarLauncher
    → JarLauncher.main()
      → new JarLauncher().launch(args)
        → 注册 springboot:// 协议处理器 (JarFile.registerUrlProtocolHandler())
        → ExecutableArchiveLauncher.getClassPathArchivesIterator()
          → 调用 isNestedArchive() 筛选路径
        → createClassLoader() → 创建 LaunchedURLClassLoader
        → getMainClass() → 从 MANIFEST.MF 读 Start-Class
        → Thread.currentThread().setContextClassLoader(classLoader)
        → createMainMethodRunner(launchClass, args, classLoader).run()
          → 用 LaunchedURLClassLoader 加载你的启动类
          → 反射调用 main 方法
          → 你的 Spring Boot 项目跑起来了

核心认知

Spring Boot 的 jar 包结构不是 JVM 规定的,而是 Spring Boot 自己在上面包装了一层。JVM 只认 Main-Class,后面的事全是 JarLauncher 自己用自定义 ClassLoader 搞定的。

你之前以为的”程序入口”(你自己的 LProjectApplication.main),其实是 Spring Boot 帮你手动调用的第二道入口。真正的第一道入口是 JarLauncher。