diff --git a/notes/学习路线 - Java 后端.md b/notes/学习路线 - Java 后端.md index 4cfee70b..405a72b3 100644 --- a/notes/学习路线 - Java 后端.md +++ b/notes/学习路线 - Java 后端.md @@ -92,7 +92,7 @@ 适合会 Java 语法、准备 Java 后端面试的读者,每天约 1~1.5 小时;没有计算机基础的先学[零基础](学习路线%20-%20零基础.md)。 -每天先读完阅读范围,再不看笔记回答自测题,答完展开参考要点核对;动手练习对照预期的结果;算法题先独立做 20~30 分钟,做不出来再看题解。自测答得上来、动手结果与预期一致、算法题提交通过,这一天就完成了。选读可以跳过。 +每天先读完阅读范围,再不看笔记回答自测题,答完展开参考要点核对;动手练习对照预期的结果;算法题先独立做 20~30 分钟,做不出来再看题解。自测答得上来、动手结果与预期一致、算法题提交通过,这一天就完成了。选读可以跳过;正文中指向后面章节的链接可以先不点开,学到那一天时会读到。 ## 二、开始之前 @@ -294,7 +294,7 @@ static 一节的初始化顺序可以对照代码自己推一遍。equals() 和
参考要点 -1. 设计上抽象类是 IS-A 关系,接口是 LIKE-A 关系,只提供方法契约;一个类可以实现多个接口,只能继承一个抽象类;接口的字段只能是 static final,成员默认 public,抽象类没有这些限制;相关类共享代码、需要非 public 成员或非常量字段时用抽象类,让不相关的类具备同一行为(例如 Comparable)时用接口,多数情况接口优先。 +1. 设计上抽象类是 IS-A 关系,接口是 LIKE-A 关系,只提供方法契约(实现类在类型上仍是接口的子类型);一个类可以实现多个接口,只能继承一个抽象类;接口的字段只能是 static final,成员默认 public,抽象类没有这些限制;相关类共享代码、需要非 public 成员或非常量字段时用抽象类,让不相关的类具备同一行为(例如 Comparable)时用接口,多数情况接口优先。 2. 重写发生在子类与父类之间,方法声明相同,访问权限不能更小,返回类型与父类相同或为其子类型(基本类型必须相同),抛出的受检异常只能是父类方法声明的异常或其子类型;重载指一个类中(包括从父类继承来的方法)方法名相同,参数类型、个数、顺序至少一个不同,只有返回值不同不算重载;重载在编译时按参数的静态类型选定,重写在运行时按对象的实际类型选定。 3. 运行时多态需要继承或实现接口、重写和向上转型;通过类类型的引用调用编译成 invokevirtual 加符号引用,通过接口类型的引用调用用 invokeinterface,运行时按对象的实际类型选择方法,称为动态分派;HotSpot 为每个类维护虚方法表(vtable),按固定槽位取方法入口,接口调用用 itable;重载在编译时按静态类型确定,称为静态分派。 @@ -366,7 +366,7 @@ static 一节的初始化顺序可以对照代码自己推一遍。equals() 和
参考要点 -1. ArrayList 基于动态数组,支持随机访问,插入删除要移动元素;LinkedList 基于双向链表,插入删除只改指针,但要先遍历到插入位置;10 万个元素中间插入 1 万次,ArrayList 约 50 ms,LinkedList 约 0.8~1 秒,只有头部插入 LinkedList 明显更快;多数场景用 ArrayList,头尾频繁增删用 ArrayDeque 或 LinkedList。 +1. ArrayList 基于动态数组,支持随机访问,插入删除要移动元素;LinkedList 基于双向链表,插入删除只改指针,但要先遍历到插入位置;10 万个元素中间插入 1 万次,ArrayList 约 50 ms,LinkedList 约 0.8~1 秒,在头部插入时 LinkedList 明显更快(已用迭代器走到位置后插入删除也只要 O(1));多数场景用 ArrayList,头尾频繁增删用 ArrayDeque 或 LinkedList。 2. 添加元素时容量不够就调用 grow(),新容量为 oldCapacity + (oldCapacity >> 1),约 1.5 倍;无参构造的容量依次为 0、10、15、22、33;扩容用 Arrays.copyOf() 复制整个数组,能预估大小时创建时就指定容量。 3. 线程安全的 List 有 Vector、Collections.synchronizedList() 和 CopyOnWriteArrayList;CopyOnWriteArrayList 写操作加锁并在复制的新数组上进行,写完再替换原数组,读不加锁;适合读多写少的场景,缺点是写时内存约翻倍,写入期间读到的可能是旧数组,迭代器也看不到创建后的修改。 @@ -397,7 +397,7 @@ HashMap 是 Java 面试中问得最多的容器。按存储结构、put、桶下 1. 用 hash() 计算哈希值,table 为空时先 resize() 创建数组;用 (n - 1) & hash 求桶下标,桶为空直接放入;头节点是 TreeNode 就在红黑树中插入,否则遍历链表,键相同则替换旧值,否则接到链表尾部,已有 8 个节点时调用 treeifyBin();size 大于 threshold 时扩容。 2. 插入后 size 大于 threshold(capacity × 0.75)时容量翻倍,默认 16 时放入第 13 个键后变为 32;扩容时按 hash & oldCap 是否为 0 把节点分到 j 和 j + oldCap 两个位置,保持相对顺序;容量是 2 的幂时,hash & (capacity - 1) 只保留 hash 的低位,hash 非负时与 hash % capacity 相同(hash 为负时 % 可能得到负数,与运算仍是合法下标),位运算比取模快,扩容也只需看一位;长度不是 2 的幂时 length - 1 中有为 0 的位,一部分桶永远用不到。 -3. 两个线程同时向同一个空桶插入,后写的覆盖先写的,size++ 也不是原子操作,8 个线程各插 10 万个键 size 远少于 80 万;JDK 1.7 并发扩容时头插法可能让链表成环,get 死循环;JDK 1.8 改为尾插法,但仍会丢数据,转红黑树时还可能破坏树结构;多线程用 ConcurrentHashMap。 +3. 两个线程同时向同一个空桶插入,后写的覆盖先写的,size++ 也不是原子操作,8 个线程各插 10 万个键 size 远少于 80 万;JDK 1.7 并发扩容时头插法可能让链表成环,get 死循环;JDK 1.8 改为尾插法,但仍会丢数据,转红黑树时还可能破坏树结构;只有一个线程写时,读线程在扩容期间也可能对已有的键读到 null;多线程用 ConcurrentHashMap。
@@ -525,7 +525,7 @@ wait() 和 notify() 必须在 synchronized 中调用,读时想一想为什么
参考要点 -1. wait() 是 Object 的方法,sleep() 是 Thread 的静态方法;wait() 会释放锁,sleep() 不会;wait() 只能在同步方法或同步块中调用,否则抛出 IllegalMonitorStateException;不带超时的 wait() 要等 notify()、notifyAll() 唤醒(也可能虚假唤醒),sleep() 到时间自动返回,两者都可以被 interrupt() 打断。 +1. wait() 是 Object 的方法,sleep() 是 Thread 的静态方法;wait() 释放调用对象的锁(同时持有的其它锁不释放),sleep() 不释放任何锁;wait() 只能在同步方法或同步块中调用,否则抛出 IllegalMonitorStateException;不带超时的 wait() 要等 notify()、notifyAll() 唤醒(也可能虚假唤醒),sleep() 到时间自动返回,两者都可以被 interrupt() 打断。 2. NEW 是创建后还没有启动,start() 后进入 RUNNABLE,可能正在运行,也可能在等 CPU;等待进入 synchronized 时为 BLOCKED,拿到锁后回到 RUNNABLE;无参的 wait()、join() 和 park() 进入 WAITING,等其它线程唤醒(被 notify() 唤醒后要先重新竞争锁,这时为 BLOCKED),sleep() 和带超时的 wait()、join() 进入 TIMED_WAITING,时间到自动唤醒;执行完或因异常结束为 TERMINATED,等待 ReentrantLock 的线程是 WAITING 而不是 BLOCKED。 3. 用共享变量记录轮到哪个线程,没轮到的线程 wait(),轮到的线程打印后改变轮次并 notifyAll();等待条件用 while 而不是 if,被唤醒时要重新检查是否轮到自己;也可以用两个 Semaphore 传递许可,第一个初始为 1、第二个为 0,打印后释放对方的许可。 @@ -548,7 +548,7 @@ AQS 一节先看 state 和等待队列怎样实现 ReentrantLock 的加锁、重 自测: 1. CountDownLatch 和 CyclicBarrier 有什么区别?(见[CyclicBarrier](Java%20并发.md#cyclicbarrier)) -2. CompletableFuture 解决了 Future 的什么问题?(见[CompletableFuture](Java%20并发.md#completablefuture)) +2. CompletableFuture 和 Future 有什么区别?(见[CompletableFuture](Java%20并发.md#completablefuture)) 3. 怎样用 BlockingQueue 实现生产者消费者?(见[BlockingQueue](Java%20并发.md#blockingqueue))
@@ -613,7 +613,7 @@ AQS 一节先看 state 和等待队列怎样实现 ReentrantLock 的加锁、重 参考要点 1. 多个线程不管以何种方式访问一个类,调用方不需要额外同步都能表现正确;根源是多个线程读写共享的可变数据;做法:不可变对象,互斥同步(synchronized、ReentrantLock),非阻塞同步(CAS、原子类),无同步方案(栈封闭、ThreadLocal)。 -2. CAS 有内存地址 V、预期值 A、新值 B 三个操作数,V 等于 A 时才更新为 B,由处理器指令(x86 上带 lock 前缀的 cmpxchg)保证原子性,失败时不阻塞线程、只返回 false,由调用方决定重试还是放弃,incrementAndGet() 等方法在内部循环重试;ABA 指值从 A 改为 B 又改回 A,CAS 误以为没变,会让无锁栈这类结构出错;用 AtomicStampedReference 给引用加版本号,每次修改加 1,持有旧版本号的 CAS 会失败。 +2. CAS 有内存地址 V、预期值 A、新值 B 三个操作数,V 等于 A 时才更新为 B,由处理器指令(x86 上带 lock 前缀的 cmpxchg)保证原子性,失败时不阻塞线程、只返回 false,由调用方决定重试还是放弃,incrementAndGet() 的 Java 层实现在内部循环重试,即时编译后通常是一条原子加法指令,不需要重试;ABA 指值从 A 改为 B 又改回 A,CAS 误以为没变,会让无锁栈这类结构出错;用 AtomicStampedReference 给引用加版本号,每次修改加 1,持有旧版本号的 CAS 会失败。 3. 悲观锁访问前先加锁,例如 synchronized、ReentrantLock、SELECT ... FOR UPDATE;乐观锁不加锁直接操作,提交时检查是否被改过,改过就重试,例如 CAS 和数据库版本号(UPDATE ... WHERE version = 1 影响 0 行时重试);读多写少、冲突少时用乐观锁,写多、冲突频繁时用悲观锁。
@@ -641,7 +641,7 @@ ThreadLocal 重点看它的数据结构、内存泄漏的原因,以及线程
参考要点 -1. ThreadLocalMap 的 Entry 对 key(ThreadLocal 对象)是弱引用,对 value 是强引用;ThreadLocal 没有其它强引用时 key 在 GC 后变为 null,value 仍被线程的 Map 引用;这样的陈旧条目要等这个线程之后调用 set()、remove() 或 get() 未命中时才可能被清理,不保证及时,static final 的 ThreadLocal 一直强可达,弱引用不起作用;复用线程还会让后一个请求读到前一个请求的数据;用完在 finally 中调用 remove()。 +1. ThreadLocalMap 的 Entry 对 key(ThreadLocal 对象)是弱引用,对 value 是强引用;ThreadLocal 没有其它强引用时 key 在 GC 后变为 null,value 仍被线程的 Map 引用;这样的陈旧条目要等这个线程之后的 get()、set() 在查找途中遇到它,或者新增条目、remove() 删除已有条目时顺带清理,不保证及时,static final 的 ThreadLocal 一直强可达,弱引用不起作用;复用线程还会让后一个请求读到前一个请求的数据;用完在 finally 中调用 remove()。 2. 同步块编译为 monitorenter 和 monitorexit,另有异常处理器保证抛出异常时也释放锁,同步方法用 ACC_SYNCHRONIZED 标志;没有竞争时是轻量级锁,用 CAS 把 Mark Word 换成指向栈中 Lock Record 的指针;有竞争时膨胀为 ObjectMonitor,_owner 指向持有锁的线程,_recursions 记录重入次数,抢不到锁的线程进入 _cxq、_EntryList 阻塞,调用 wait() 的线程进入 _WaitSet。 3. JDK 21 中依次为无锁(Mark Word 末尾 01)、轻量级锁(CAS 换成 Lock Record 指针,末尾 00)、重量级锁(其它线程持有锁时 CAS 失败,膨胀为 ObjectMonitor,末尾 10,先自适应自旋,抢不到再阻塞);偏向锁 JDK 15 起默认禁用、之后被移除;空闲的 ObjectMonitor 会被回收,对象头恢复为无锁,所以“只能升级不能降级”不准确。 @@ -764,14 +764,14 @@ ThreadLocal 重点看它的数据结构、内存泄漏的原因,以及线程 自测: -1. 三色标记中的漏标是怎样发生的?CMS 和 G1 分别怎样解决?(见[三色标记](Java%20虚拟机.md#三色标记)) +1. 三色标记是怎么回事?漏标是怎样发生的,CMS 和 G1 分别怎样解决?(见[三色标记](Java%20虚拟机.md#三色标记)) 2. 对象在什么情况下进入老年代?(见[内存分配策略](Java%20虚拟机.md#内存分配策略)) 3. Full GC 在什么情况下触发?(见[Full GC 的触发条件](Java%20虚拟机.md#full-gc-的触发条件))
参考要点 -1. 并发标记时,用户线程让已经是黑色的对象引用了白色对象,同时删除了从灰色对象到它的所有引用,这个白色对象不会再被访问,标记结束后被当成垃圾回收;CMS 用增量更新,写入引用时写屏障把卡标记为脏,重新标记时重新扫描;G1 用原始快照(SATB),删除或覆盖引用前写屏障记录旧值并当作存活。 +1. 白色是还没访问到的对象,标记结束时仍为白色的就是垃圾,灰色是已访问但引用的对象还没检查完,黑色是引用都已检查过;从 GC Roots 开始把对象置灰,不断取出灰色对象扫描它的引用,直到没有灰色对象;漏标:并发标记时,用户线程让已经是黑色的对象引用了白色对象,同时删除了从灰色对象到它的所有引用,这个白色对象不会再被访问,标记结束后被当成垃圾回收;CMS 用增量更新,写入引用时写屏障把卡标记为脏,重新标记时重新扫描;G1 用原始快照(SATB),删除或覆盖引用前写屏障记录旧值并当作存活。 2. 经过多次 Minor GC 后年龄达到 MaxTenuringThreshold;动态年龄判定(以 Serial 为例,G1 和 Parallel 的计算方式不同):Minor GC 结束时从小到大累加 Survivor 中各年龄对象的大小,超过 Survivor 一半时,取这个年龄和 MaxTenuringThreshold 中较小的值作为新阈值,下一次 Minor GC 时大于或等于它的对象晋升;Minor GC 时 Survivor 放不下的存活对象直接进入老年代;大对象直接进入老年代(Serial 的 PretenureSizeThreshold,G1 中超过 Region 一半的 Humongous 对象)。 3. 调用 System.gc(),规范上只是建议,HotSpot 默认会执行一次 Full GC,-XX:+DisableExplicitGC 可以禁用;老年代空间不足,例如大对象、长期存活的对象不断进入,或者空间分配担保失败,G1 中是混合回收来不及释放、复制时找不到空闲 Region,或 Humongous 对象找不到连续的 Region;JDK 7 及以前永久代不足,JDK 8 起元空间达到阈值(日志原因为 Metadata GC Threshold)时 Serial 和 Parallel 进行 Full GC,G1 先扩大元空间并启动并发标记卸载类,扩大失败才 Full GC;CMS 发生 Concurrent Mode Failure。 @@ -931,15 +931,15 @@ Next-Key Lock 是 InnoDB 在可重复读下防止幻读的方法,加锁范围
参考要点 -1. 快照读靠 MVCC:可重复读复用同一个 ReadView,看不到其它事务新插入的行;当前读靠 Next-Key Lock:锁住扫描到的记录和记录之间的间隙,其它事务无法在范围内插入;同一事务中快照读和当前读混用时仍会出现幻读。 +1. 快照读靠 MVCC:可重复读复用同一个 ReadView,看不到其它事务新插入的行;当前读靠 Next-Key Lock:锁住扫描到的记录和记录之间的间隙,其它事务无法在范围内插入;同一事务中快照读和当前读混用时仍可能出现幻读。 2. 间隙锁锁住索引记录之间的开区间,不包含记录本身,只阻止插入,间隙锁之间互不冲突;Next-Key Lock 是记录锁加前面的间隙,为前开后闭区间,例如 c 有 10、11、13、20 时为 (10, 11]、(11, 13] 等;c 为普通索引时 c = 11 FOR UPDATE 锁住 (10, 11] 和 (11, 13),唯一索引并且记录存在时只锁这一条记录。 -3. 常见原因:两个事务按相反的顺序更新同一组行,或者“先查有没有,没有再插入”时拿到了同一个间隙锁,插入意向锁与对方的间隙锁冲突;InnoDB 检测到死锁后回滚其中一个事务,应用要重试;排查:看 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK,打开 innodb_print_all_deadlocks 记录每次死锁,锁等待时查 performance_schema.data_locks 和 data_lock_waits;避免:按相同顺序访问行、缩短事务、给条件列建索引、靠唯一索引判重。 +3. 常见原因:两个事务按相反的顺序更新同一组行,或者“先查有没有,没有再插入”时拿到了同一个间隙锁,插入意向锁与对方的间隙锁冲突;InnoDB 检测到死锁后回滚其中一个事务,应用要重试;排查:看 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK(只显示各事务正在执行的语句,持有的锁可能是更早的语句加的),打开 innodb_print_all_deadlocks 记录每次死锁,锁等待时查 performance_schema.data_locks 和 data_lock_waits;避免:按相同顺序访问行、缩短事务、给条件列建索引、靠唯一索引判重。
动手:按[附录 A5](数据库系统原理.md#a5-next-key-locks-的加锁范围)的步骤,用 performance_schema.data_locks 查看一条范围查询加了哪些锁。 -预期:c = 11 FOR UPDATE 时 idx_c 上有 11 的 Next-Key Lock(X)、13 前的间隙锁(X,GAP)和主键 2 的记录锁,只有插入 c = 12 被阻塞;BETWEEN 10 AND 20 时锁到 supremum,插入 9、12、14、21 都被阻塞;改为唯一索引后 c = 11 只锁一条记录(查询不存在的值时仍加间隙锁)。 +预期:c = 11 FOR UPDATE 时 idx_c 上有 11 的 Next-Key Lock(X)、13 前的间隙锁(X,GAP)和主键 2 的记录锁,测试的 c = 9、12、14、21 中只有 c = 12 被阻塞(c 等于边界值 10、13 时是否阻塞取决于主键);BETWEEN 10 AND 20 时锁到 supremum,插入 9、12、14、21 都被阻塞;改为唯一索引后 c = 11 只锁一条记录(查询不存在的值时仍加间隙锁)。 算法(栈和队列,简单):[20. 有效的括号](https://leetcode.cn/problems/valid-parentheses/),题解见[用栈实现括号匹配](Leetcode%20题解%20-%20栈和队列.md#4-用栈实现括号匹配)。 @@ -1018,7 +1018,7 @@ Next-Key Lock 是 InnoDB 在可重复读下防止幻读的方法,加锁范围
参考要点 -1. 找出:开启慢查询日志,long_query_time 线上调到 1 秒或更低,对比 Rows_examined 和 Rows_sent,用 mysqldumpslow 或 sys.statement_analysis 按语句归类;分析:用 EXPLAIN 和 EXPLAIN ANALYZE 看执行计划,常见原因有索引失效、回表太多、filesort 和临时表、连接列没有索引、等锁(Lock_time 大)、统计信息不准;修改:建合适的索引或改写语句,再对比执行计划和耗时,例如加覆盖索引后从 237 ms 降到 20 ms。 +1. 找出:开启慢查询日志,long_query_time 线上常调到 1 秒左右(更小的值只在线上短时间使用),对比 Rows_examined 和 Rows_sent,用 mysqldumpslow 或 sys.statement_analysis 按语句归类;分析:用 EXPLAIN 和 EXPLAIN ANALYZE 看执行计划,常见原因有索引失效、回表太多、filesort 和临时表、连接列没有索引、等锁(Lock_time 大)、统计信息不准;修改:建合适的索引或改写语句,再对比执行计划和耗时,例如加覆盖索引后从 237 ms 降到 20 ms。 2. type(const、eq_ref、ref、range、index、ALL,从好到差)、key 和 possible_keys、key_len(用到了联合索引的前几列)、rows(估算扫描行数)、Extra;Using filesort 表示无法利用索引的顺序,需要额外排序;Using temporary 表示用了临时表,常见于 GROUP BY 的列无法利用索引的顺序,或者 GROUP BY 和 ORDER BY 的列不一致。 3. LIMIT 1000000, 10 要先找出前 1000010 行再丢弃前 100 万行,走二级索引要回表上百万次,优化器改为全表扫描加排序,200 万行约 1.5 秒;延迟关联先在覆盖索引上查出这一页的主键,只回表 10 次,约 81 ms;游标分页记住上一页最后一行的排序值和主键,从它之后取 10 行,约 0.2 ms,但不能跳页;也可以限制翻页深度。 @@ -1080,7 +1080,7 @@ redo log、undo log 和 binlog 分别用于崩溃恢复、回滚和复制。最 动手:执行 SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit' 和 SHOW VARIABLES LIKE 'sync_binlog',结合[redo log](MySQL.md#redo-log)和[两阶段提交](MySQL.md#两阶段提交)两节说明两个参数都为 1 时每次提交会把哪些日志刷到磁盘,改成 0 或 2 有什么风险。 -预期:两个参数默认都是 1,每次提交时 redo log 和 binlog 都刷盘;innodb_flush_log_at_trx_commit 为 2 时操作系统崩溃或断电可能丢失约 1 秒的事务,为 0 时 MySQL 进程崩溃也会丢失;sync_binlog 为 0 或大于 1 时 binlog 不保证每次提交都刷盘,操作系统崩溃或断电时可能丢失已提交事务的 binlog,从库和按时间点恢复都缺少这些事务。 +预期:两个参数默认都是 1,每次提交时 redo log 和 binlog 都刷盘;innodb_flush_log_at_trx_commit 为 2 时操作系统崩溃或断电可能丢失约 1 秒的事务,为 0 时 MySQL 进程崩溃也可能丢失,binlog 中还可能多出主库没有的事务;sync_binlog 为 0 或大于 1 时 binlog 不保证每次提交都刷盘,操作系统崩溃或断电时可能丢失已提交事务的 binlog,从库和按时间点恢复都缺少这些事务。 算法(二分查找,简单):[704. 二分查找](https://leetcode.cn/problems/binary-search/),题解见[正常实现](Leetcode%20题解%20-%20二分查找.md#正常实现)。 @@ -1147,7 +1147,7 @@ redo log、undo log 和 binlog 分别用于崩溃恢复、回滚和复制。最 1. 用 ZSET,成员是用户或文章 ID,分值是分数或点赞数;ZINCRBY 加分,ZREVRANGE key 0 9 WITHSCORES 取前 10 名,ZREVRANK 查名次(从 0 开始),都是 O(logN);分值相同时按成员名的字典序排,要先达到的排前面可以把时间编进分值;前 10 名是热点键时,可以在本地缓存中保存 1 秒。 2. 跳表在有序链表上加多层索引,每个节点以 1/4 的概率升一层,查找、插入、删除的期望复杂度都是 O(logN);与平衡树相比,插入删除只改前驱指针,不需要旋转,范围查询找到起点后沿底层链表遍历,实现也更简单;数据在内存中,不需要 B+ 树那样减少磁盘 I/O;每个指针记录跨度,ZRANK 也是 O(logN)。 -3. 字典是拉链法的哈希表,桶数为 2 的幂;键数达到桶数时扩容(有子进程执行 BGSAVE 或 AOF 重写时要达到 4 倍),新表大小为不小于键数加 1 的 2 的幂;采用渐进式 rehash:同时保留新旧两张表,之后每次增删改查顺便迁移一个桶,serverCron 也定期迁移,期间新键只写入新表,查找和删除时两张表都可能要查,迁移完后新表替换旧表。 +3. 字典是拉链法的哈希表,桶数为 2 的幂;键数达到桶数时扩容(有子进程执行 BGSAVE 或 AOF 重写时,Redis 7.4 要达到 4 倍,7.2 及以前要达到 6 倍),新表大小为不小于键数加 1 的 2 的幂;采用渐进式 rehash:同时保留新旧两张表,之后每次增删改查顺便迁移一个桶,serverCron 也定期迁移,期间新键只写入新表,查找和删除时两张表都可能要查,迁移完后新表替换旧表。
@@ -1263,7 +1263,7 @@ redo log、undo log 和 binlog 分别用于崩溃恢复、回滚和复制。最 1. 用 Cache Aside:读未命中时查数据库并写回缓存,写时先更新数据库(事务提交之后)再删除缓存;删除而不是更新缓存,避免并发写到达缓存的顺序与数据库不同;删除失败或慢读写回旧值时仍会不一致,用过期时间兜底,加上延迟双删、消息队列重试或订阅 binlog 删除;只能最终一致,余额、库存直接读写数据库。 2. 大 Key 是值很大的键(几 MB 的字符串、几十万元素的集合),会阻塞单线程、占带宽、造成数据倾斜,用 --bigkeys 发现,拆成小键,删除用 UNLINK、分批用 HSCAN 读;热 Key 是访问量远高于其它的键,请求集中到一个节点,淘汰策略为 LFU 时用 --hotkeys 发现,否则在客户端或代理中统计,用本地缓存或复制成多个键分散读取。 -3. 取模分布在节点数变化时几乎所有键都要迁移,一致性哈希把节点和键放在哈希环上,键存到顺时针第一个节点,增删节点只影响相邻节点,4 个节点扩到 5 个约 18% 的键迁移;节点少时分布不均(最多 44.7%、最少 9.0%),每个节点放 150 个虚拟节点后占比在 22.8%~27.0%。 +3. 取模分布在节点数变化时几乎所有键都要迁移,一致性哈希把节点和键放在哈希环上,键存到顺时针第一个节点,增删节点只影响相邻节点,4 个节点扩到 5 个理论上约 1/5 的键迁移(实测约 18%);节点少时分布不均(最多 44.7%、最少 9.0%),每个节点放 150 个虚拟节点后占比在 22.8%~27.0%。
@@ -1306,7 +1306,7 @@ redo log、undo log 和 binlog 分别用于崩溃恢复、回滚和复制。最 1. 客户端发 SYN(序号 x),服务器回 SYN+ACK(序号 y、确认号 x+1),客户端回 ACK(确认号 y+1),双方进入 ESTABLISHED;双方的初始序号都要得到对方确认,两次握手时服务器的序号得不到确认;第三次握手还能让客户端对滞留的旧连接请求回 RST,避免服务器多打开一个连接。 2. 保证最后一个 ACK 能到达:ACK 丢失时对方会重传 FIN,2MSL 正好覆盖 ACK 的 1 MSL 加重传 FIN 的 1 MSL;让本连接的旧报文都从网络中消失,不干扰同一四元组的新连接;Linux 中固定为 60 秒。 -3. TIME_WAIT 在主动关闭方,多由短连接引起;发起连接的一方每个 TIME_WAIT 占用本地端口 60 秒,连接同一目标时每秒新建约 470 个连接就会耗尽默认的 28232 个端口(服务器一侧的 TIME_WAIT 共用监听端口,不消耗本地端口),用长连接或连接池、把 tcp_tw_reuse 设为 1(默认值 2 只对回环地址生效)、扩大端口范围解决;CLOSE_WAIT 在被动关闭方,是应用没有调用 close(),多为异常分支没关连接或连接池借出未还,要改代码。 +3. TIME_WAIT 在主动关闭方,多由短连接引起;发起连接的一方每个 TIME_WAIT 占用本地端口 60 秒,连接同一目标时每秒新建约 470 个连接就会耗尽默认的 28232 个端口(服务器一侧的 TIME_WAIT 共用监听端口,不消耗本地端口),用长连接或连接池、把 tcp_tw_reuse 设为 1(默认值 2 只对回环地址生效)、扩大端口范围解决;CLOSE_WAIT 在被动关闭方,是应用没有调用 close(),多为异常分支没关连接、连接池借出未还或处理线程被慢调用阻塞,要改代码,调 tcp_fin_timeout 没有用。
@@ -1363,7 +1363,7 @@ redo log、undo log 和 binlog 分别用于崩溃恢复、回滚和复制。最 参考要点 1. 请求报文:请求行(方法、URL、协议版本)、请求首部、空行、请求主体;响应报文:状态行(协议版本、状态码、原因短语)、响应首部、空行、响应主体。 -2. 301 永久重定向,没有缓存指令时默认可被浏览器缓存,适合域名迁移;302 临时重定向,默认不缓存;304 表示条件请求(If-None-Match、If-Modified-Since)的资源没有变化,不带响应体;502 是网关从上游收到无效响应(后端出错或不可达),504 是网关等待上游超时(后端太慢)。 +2. 301 永久重定向,没有缓存指令时默认可被浏览器缓存,适合域名迁移;302 临时重定向,默认不缓存;304 表示条件请求(If-None-Match、If-Modified-Since)的资源没有变化,不带响应体;502 是网关访问上游时连接被拒绝或收到无效的响应(例如完整的响应首部之前就断开),504 是网关连接或读取上游超时。 3. PUT 创建或完全替换目标资源;PATCH 只对资源做部分修改。
@@ -1478,7 +1478,7 @@ HTTP/2 和 HTTP/3 分别解决了 HTTP/1.1 的哪些问题是常见的追问。G
参考要点 -1. 二进制分帧:报文拆成 HEADERS 帧和 DATA 帧,一个 TCP 连接承载多个流,帧交错发送,不再需要多个连接;首部压缩:双方维护首部字段表并用 Huffman 编码;服务端推送(Chrome 106 起默认禁用);TCP 层的队头阻塞仍在,HTTP/3 改用基于 UDP 的 QUIC 解决。 +1. 二进制分帧:报文拆成 HEADERS 帧和 DATA 帧,一个 TCP 连接承载多个流,帧交错发送,不再需要多个连接;首部压缩:双方维护首部字段表并用 Huffman 编码;服务端推送(Chrome 106 起默认禁用,Firefox 132 已移除,常用 103 Early Hints 或 preload 代替);TCP 层的队头阻塞仍在,HTTP/3 改用基于 UDP 的 QUIC,丢包时只有包中带有数据的流暂停交付。 2. 语义:GET 获取资源,POST 传输实体主体;GET 安全且幂等,POST 会改变服务器状态,不幂等;参数放在哪里只是惯例,GET 参数通常在 URL 中(要编码,长度受浏览器和服务器的限制,例如 Nginx 默认请求行不超过 8 KB),POST 在主体中,但抓包同样能看到,并不更安全;GET 响应可缓存,POST 多数不可缓存。 3. 先查浏览器缓存、系统缓存和 hosts,未命中则向本地 DNS 服务器递归查询;本地 DNS 服务器依次迭代询问根服务器、顶级域服务器,它们只返回下一级服务器的地址,最后由权威服务器返回 IP;各级结果按 TTL 缓存;一般用 UDP 53 端口,响应超过 UDP 报文的长度上限(不用 EDNS 时为 512 字节)被截断,或做全量区域传送(AXFR)时用 TCP。 @@ -1548,7 +1548,7 @@ Web 页面请求过程把前几天的内容串了起来。攻击技术中 XSS、 参考要点 1. 进程是资源分配的基本单位,线程是调度的基本单位,同一进程的线程共享地址空间和文件描述符;创建进程的开销远大于线程,切换进程还要切换地址空间,开销更大;线程间直接读写共享数据,进程间要借助 IPC;进程相互隔离,一个线程段错误会终止整个进程。 -2. 保存当前线程的寄存器、程序计数器、栈指针,恢复另一个线程的值;切换到另一个进程还要切换页表,TLB 和 CPU 缓存中原来的映射和数据大多用不上、要重新加载(有 PCID 时 TLB 不必全部清空),这部分间接开销通常更大,工作集很小时两者几乎没有差别;实测一次切换约 1.1 微秒(其中还包含一次 write 和一次 read)。 +2. 保存当前线程的寄存器、程序计数器、栈指针,恢复另一个线程的值;切换到另一个进程还要切换页表,没有 PCID、ASID 这类标记时要清空 TLB 中的大部分表项;切换后 TLB 和缓存的未命中会增加执行时间,增加多少取决于硬件和访问的数据量,数据量很小时两者几乎没有差别;实测一次切换约 1.1 微秒(其中还包含一次 write 和一次 read)。 3. 协程在用户态由运行时调度,Go 和 Java 虚拟线程把大量协程映射到少量线程上(M:N),Python asyncio 则在一个线程的事件循环中执行,切换不陷入内核、内存占用小,可以大量创建,但同一线程上的协程不能并行;虚拟线程是 JVM 实现的有栈协程,由 ForkJoinPool 的平台线程(默认等于 CPU 核数)执行,阻塞 I/O 时卸载,栈帧以栈块对象存入堆,就绪后再挂载到某个平台线程继续执行;JDK 21 中在 synchronized 内阻塞无法卸载,JDK 24 起基本消除。
@@ -1571,7 +1571,7 @@ Web 页面请求过程把前几天的内容串了起来。攻击技术中 XSS、 1. 什么是用户态和内核态?什么时候会切换?(见[系统调用](计算机操作系统%20-%20概述.md#系统调用)) 2. 进程间通信有哪些方式?(见[进程通信](计算机操作系统%20-%20进程管理.md#进程通信)) -3. 信号量是怎样实现同步与互斥的?(见[信号量](计算机操作系统%20-%20进程管理.md#3-信号量)) +3. 信号量和互斥锁有什么区别?信号量怎样实现同步与互斥?(见[信号量](计算机操作系统%20-%20进程管理.md#3-信号量))
参考要点 @@ -1664,8 +1664,8 @@ Web 页面请求过程把前几天的内容串了起来。攻击技术中 XSS、 参考要点 1. select 每次调用都要把描述符集合复制到内核并逐个检查,开销与描述符数成正比,只能监听编号小于 1024 的描述符;poll 用 pollfd 数组,没有 1024 限制,但同样每次复制和遍历;epoll 用 epoll_ctl 把描述符注册到内核的红黑树中只复制一次,就绪时由回调加入就绪链表,epoll_wait 只返回就绪的事件;监听 1 万个描述符、只有 1 个就绪时,poll 每次约 1.6 ms,epoll_wait 约 0.21 µs,但 epoll 只能在 Linux 上使用。 -2. LT(默认)只要描述符仍可读,每次 epoll_wait 都会报告;ET 只在有新数据到达时报告一次,必须用非阻塞描述符并循环读到 EAGAIN,否则剩下的数据不会再触发事件,连接停住;ET 减少了重复通知,但要防止一个连接长时间占住线程。 -3. BIO 对应阻塞式 I/O,线程阻塞在 read 上,每个连接要一个线程;NIO 对应 I/O 复用,Channel 设为非阻塞,一个线程在 Selector.select() 上等待多个连接中任意一个就绪(Linux 上用 epoll 实现);AIO 对应异步 I/O 的编程方式,数据复制到应用的缓冲区后才通知应用,但 Linux 上的 JDK 实际用 epoll 加线程池实现;BIO、NIO 在复制数据时仍会阻塞,属于同步 I/O。 +2. LT(默认)只要描述符仍可读,每次 epoll_wait 都会报告;ET 只在有新数据到达时报告一次,要用非阻塞描述符并循环读到 EAGAIN,否则剩下的数据不会再触发事件,连接停住;ET 减少了重复通知,但要防止一个连接长时间占住线程。 +3. BIO 对应阻塞式 I/O,线程阻塞在 read 上,每个连接要一个线程;NIO 对应 I/O 复用,Channel 设为非阻塞,一个线程在 Selector.select() 上等待多个连接中任意一个就绪(Linux 上用 epoll 实现);AIO 对应异步 I/O 的编程方式,数据复制到应用的缓冲区后才通知应用,但 Linux 上的 JDK 中异步套接字通道实际用 epoll 加线程池实现,异步文件通道由线程池执行阻塞读写;BIO、NIO 在复制数据时仍会阻塞,属于同步 I/O。
@@ -1764,7 +1764,7 @@ AOP 基于动态代理,自调用时切面不生效的原因也在这里。事
参考要点 -1. @SpringBootApplication 包含 @EnableAutoConfiguration;启动时读取各 jar 中 META-INF/spring/...AutoConfiguration.imports(2.7 之前为 spring.factories)列出的自动配置类;大多数自动配置类带 @ConditionalOnClass、@ConditionalOnMissingBean 等条件注解,条件满足才生效;自动配置在用户的 Bean 注册之后处理,用户定义的 Bean 优先。 +1. @SpringBootApplication 包含 @EnableAutoConfiguration;启动时读取各 jar 中 META-INF/spring/...AutoConfiguration.imports(2.7 之前为 spring.factories,3.0 起不再读其中的自动配置类)列出的自动配置类;大多数自动配置类带 @ConditionalOnClass、@ConditionalOnMissingBean 等条件注解,条件满足才生效;自动配置在用户的 Bean 注册之后处理,带 @ConditionalOnMissingBean 的自动配置让位于用户定义的 Bean。 2. DispatcherServlet 统一接收请求,HandlerMapping 找到处理方法和拦截器;依次执行拦截器的 preHandle,返回 false 就停止;HandlerAdapter 转换参数、调用 Controller,再把返回值转换为响应(如 JSON);倒序执行 postHandle(Controller 抛出异常时跳过);返回视图名时由 ViewResolver 渲染;最后倒序执行 preHandle 返回 true 的拦截器的 afterCompletion,异常时也执行。 3. #{} 被替换为占位符 ?,参数通过 PreparedStatement 单独传给数据库,不会改变 SQL 结构;${} 把参数直接拼进 SQL,传入 1024 OR 1=1 就让条件恒为真,造成 SQL 注入;${} 只用于表名、列名、ORDER BY 字段,并用白名单校验。 @@ -2107,7 +2107,7 @@ RPC 一节对比了它和 HTTP 的取舍。雪花算法要注意时钟回拨; 1. 按 hash(url) % 100 切成 100 个约 1 GB 的小文件,相同的 URL 一定在同一个文件中;逐个文件用 HashMap 统计次数,用小顶堆取出前 10 个;再用小顶堆从 100 × 10 个候选中选出全局前 10 个;某个文件中不同的 URL 仍然太多、HashMap 放不下时,换哈希函数对它再切分。 2. 301 永久跳转,没有缓存指令时也可能被浏览器缓存,之后不再请求短链服务,已缓存的跳转无法撤回;302 临时跳转,默认不缓存,统计点击时再加 Cache-Control: private, no-store;需要统计点击、修改目标地址或设置有效期时用 302,只想减轻服务端压力时用 301。 -3. 拉模式(读扩散)发帖只写一次,读时合并所有关注者的发件箱,关注越多读越慢;推模式(写扩散)发帖写入每个粉丝的收件箱,读很快,但粉丝越多写越多(1000 万粉丝单节点约 50 秒);实际推拉结合:普通用户推,大 V 由粉丝读时拉取合并,只推给活跃粉丝,收件箱只保留最近若干条。 +3. 拉模式(读扩散)发帖只写一次,读时合并自己关注的所有用户的发件箱,关注越多读越慢;推模式(写扩散)发帖写入每个粉丝的收件箱,读很快,但粉丝越多写越多(1000 万粉丝单节点约 50 秒);粉丝数差别大时推拉结合:普通用户推,大 V 由粉丝读时拉取合并(阈值取决于发帖与刷首页的频率之比和能接受的扇出时间),只推给活跃粉丝,收件箱只保留最近若干条。
@@ -2135,7 +2135,7 @@ RPC 一节对比了它和 HTTP 的取舍。雪花算法要注意时钟回拨; 参考要点 1. 先把日流量换算成高峰时的 QPS:日 PV 6 亿平均约 6944 QPS,高峰一小时占 6% 时这一小时平均约 1 万 QPS,大促按 10 倍设计为 10 万,小时内的秒级突发还要留余量;机器数 = 峰值 QPS ÷(单实例拐点 QPS × 目标利用率),再按可用区取整,一个可用区故障时其余实例仍要低于拐点;存储按每天的增量乘以保存时间估算,例如日下单 1200 万、一年约 44 亿行,分库分表主要由数据量决定。 -2. 先确认流量性质:看来源 IP、User-Agent 和接口分布,区分真实增长、爬虫和重试风暴;先保护再扩容:网关限流、关闭非核心功能、熔断慢依赖;逐层查看 QPS、CPU、连接池、慢 SQL、Redis 延迟,找出瓶颈;扩容无状态的应用实例,事后复盘更新容量模型。 +2. 先确认流量性质:看来源 IP、User-Agent 和接口分布,区分真实增长、爬虫和重试风暴;先保护再扩容:网关限流、关闭非核心功能、熔断慢依赖;逐层查看 QPS、CPU、连接池、慢 SQL、Redis 延迟,找出瓶颈;扩容无状态的应用实例,同时核对各实例连接池的上限之和不超过数据库能承受的连接数(例如 36 台各 10 个连接已超过 MySQL 默认的 151),事后复盘更新容量模型。 3. 逐步增加并发,记录吞吐量、P99、错误率和 CPU,找到吞吐不再增长、延迟陡升的拐点,重复多次取中位数;环境、数据量和请求参数的分布要接近生产,读写先分开压再混合,大促前做带标记、写影子库的全链路压测;结论要说明哪一层先到拐点、原因是什么,饱和的资源不一定是根因,例如连接池占满可能是 SQL 在等行锁;实例数 = 峰值 QPS ÷(单实例拐点 × 目标利用率),再用 CPU 使用率校准。
@@ -2206,7 +2206,7 @@ Kubernetes 重点看 Pod 的创建过程、Service 和 Ingress 的作用,以 自测: -1. 面试中遇到“设计一个点赞系统”这类开放题,应该按什么顺序回答?(见[系统设计题的思路](系统设计基础.md#十三系统设计题的思路)) +1. 怎样设计一个点赞系统?(见[系统设计题的思路](系统设计基础.md#十三系统设计题的思路)) 2. 聊天消息怎样做到不丢失、不重复、不乱序?离线期间的消息怎样补上?(见[消息的可靠投递](系统设计基础.md#消息的可靠投递)) 3. 一台服务器能支撑多少个长连接?瓶颈在哪里?(见[单机能支撑多少连接](系统设计基础.md#单机能支撑多少连接)) @@ -2235,14 +2235,14 @@ Kubernetes 重点看 Pod 的创建过程、Service 和 Ingress 的作用,以 自测: -1. 订单超时未支付要自动关闭,关单时用户恰好付了款,怎样保证两边一致?(见[超时关单](系统设计基础.md#超时关单)) +1. 订单超时未支付怎样自动关闭?关单时用户恰好付了款怎么办?(见[超时关单](系统设计基础.md#超时关单)) 2. 支付回调重复到达或并发到达时,怎样保证只发一次货?(见[支付回调与重复支付](系统设计基础.md#支付回调与重复支付)) 3. A 给 B 转账的同时 B 给 A 转账,怎样避免死锁,又不让余额扣成负数?(见[转账与余额扣减](系统设计基础.md#转账与余额扣减))
参考要点 -1. 关单以支付平台为准,分三步:先用条件更新把订单从待支付改为关单中,影响 0 行就按当前状态处理;再调用平台的关单接口,成功或订单已关闭时改为已关闭,在同一个事务中回补库存;关单失败时查单,已支付就按支付成功处理(与回调走同一段代码),订单不存在可以关闭,结果不明保持关单中,由扫描任务稍后重做;只改本地状态的话,用户打开旧的支付页面仍能付款;下单时把同一个截止时间作为 time_expire 传给平台,否则平台默认 7 天内都能支付;回调在关单中到达时仍然有效。 +1. 到期由延迟消息或定时扫描触发关单;关单以支付平台为准,分三步:先用条件更新把订单从待支付改为关单中,影响 0 行就按当前状态处理;再调用平台的关单接口,成功或订单已关闭时改为已关闭,在同一个事务中回补库存;关单失败时查单,已支付就按支付成功处理(与回调走同一段代码),订单不存在可以关闭,结果不明保持关单中,由扫描任务稍后重做;只改本地状态的话,用户打开旧的支付页面仍能付款;下单时把同一个截止时间作为 time_expire 传给平台,否则平台默认 7 天内都能支付;回调在关单中到达时仍然有效。 2. 先验签,核对商户号、订单号、金额与本地订单一致,并确认交易状态是支付成功,再把三件事放在同一个事务中:以交易号为主键插入去重表,重复的通知报 1062 后回滚;用 UPDATE ... WHERE status IN ('UNPAID', 'CLOSING') 改为已支付,影响 1 行才继续;写入发货消息;重复的通知也要应答成功,否则平台会继续重发;先查状态再更新的写法,同一笔通知并发到达 10 次时会发 10 次货。 3. 按账户 ID 从小到大加锁,两个方向的转账都先锁 id 小的行,就不会互相等待(实验中每 480 笔的死锁从约 70~100 次降为 0),只有涉及同一账户的转账在这一行的锁上排队,不是串行化;余额判断写在条件中:UPDATE account SET balance = balance - 100 WHERE id = 1 AND balance >= 100,影响 0 行说明余额不足、回滚,先读出余额在应用中算好再写回会互相覆盖;客户端生成的请求号作主键,死锁回滚或超时重试也只成功一次;每次变动写流水,用期初余额加流水合计核对当前余额。