这一批练了三轮:Java 的锁与等待(可重入、死锁、wait/notify、join),前端的可迭代协议与移动端适配,数据库的分组能否借索引,以及网络层的 TCP 保活和 DNS 缓存。上一批有两处只答到规则没说到机制,这次是它们的复测。
可重入:同一个线程再要同一把锁为什么不卡死
一个同步方法里调用另一个同步方法,锁住的都是同一个对象,这种情况不会卡死。JVM 允许同一个线程重复获取它已经持有的锁,这叫可重入性。每个对象都有关联的 monitor,里面记着当前持有锁的线程和重入计数:线程第一次拿到锁时计数为 1,再进一次同一把锁变成 2,每退出一层减一,减到 0 才真正释放。
如果没有这个特性,第一个方法拿到锁之后调用第二个方法,就当场把自己等死了。
死锁与 jstack
两个线程各持一把锁,又都在等对方手里的那把,就构成死锁。用 jstack <pid> 抓线程栈,检测到环时输出里会有一段 Found one Java-level deadlock,下面列出两个线程各自持有什么、在等什么。
死锁的四个必要条件里,工程上最好打破的是循环等待:让所有线程按同一个全局顺序去获取锁,A 先 B 后的规则在哪个线程里都一样,环就形成不了。
wait 醒来第一件事是重新检查条件
wait() 返回不代表条件已经成立。JVM 允许虚假唤醒,也就是没有 notify、没有中断、也没有超时,wait 自己就返回了。所以等待的写法必须是 while (!条件) { obj.wait(); },不能写成 if。
另外,被 notify 唤醒之后线程不会立刻往下跑:它要从等待集挪回锁的竞争队列,重新拿到 monitor,wait() 才真正返回。这段时间线程处于 BLOCKED。
notify 与 notifyAll
notify() 随机唤醒等待集里的一个线程,notifyAll() 唤醒全部,让它们一起竞争锁。生产者消费者场景里用 notify 有风险:被唤醒的可能是另一个生产者,它看到队列已满又去 wait,而真正该干活的消费者还在睡,两边一起挂住。
wait 和 notify 必须作用在同一个对象上,因为等待集挂在这个对象的 monitor 上。两个线程各自 wait 在不同对象上,调用 notify 时找不到对方那个等待集,通知就丢了。notify 本身不释放锁,调用者继续持有,直到退出同步块。
join 在等谁
主线程调用 t.join(),停下的是主线程,它进入 WAITING 状态,底层是在 t 这个 Thread 对象上做 wait()。谁来叫醒它:t 的 run() 结束、线程终止时,JVM 会在这个 Thread 对象上执行一次 notify_all。
主线程在 join 期间如果还持有别的锁,比如在 synchronized (bizObj) 块里调用 t.join(),bizObj 的锁不会被释放,join 只释放它正在等待的那个 Thread 对象的监视器。
十个任务要并发跑完再继续,写法是先全部 start(),再逐个 join()。这个顺序下 join 谁先谁后不影响总耗时;如果 start 一个 join 一个,整体就退化成串行。
可迭代协议,以及 Array.from 为什么什么都吃
展开运算符在背后找的是对象上的 [Symbol.iterator] 方法,调用它拿一个迭代器,再不断取 next() 的值。arguments 和 NodeList 身上有这个方法,纯对象 { length: 2, 0: 'a', 1: 'b' } 没有,所以 [...obj] 直接抛 TypeError: obj is not iterable。
Array.from 宽容得多,它按顺序试两种方式:先看有没有 Symbol.iterator,有就按迭代器取值;没有就看 length,按索引逐个取。所以类数组和可迭代对象它都能吃,第二个参数是映射函数,作用类似 map 的回调。
for...of 走的是同一条迭代协议。for...in 不走协议,它枚举的是对象上所有可枚举的字符串键,并沿原型链一路往上找,所以继承来的属性也会被翻出来;用在数组上拿到的是索引字符串。
rem 和 vw 各有哪些麻烦
用脚本动态改 html 的 font-size、全站尺寸用 rem,除了依赖 JS,还有两个实际问题。脚本放在 head 里会阻塞渲染,放在底部则会先按默认字号排一遍版,再重排一次,页面会闪。用户在浏览器里自己调大字号时,根字号被脚本覆盖,用户的选择就不生效了;反过来,如果把字号锁死,无障碍上又吃亏。
全用 vw 也有坑。100vw 包含滚动条的宽度,页面一旦出现纵向滚动条就多出一截,横向滚动条就是这么来的。设计稿上 1px 的边框换算成 vw 是个小数,设备像素比不到 2 的屏幕上会被渲染成忽粗忽细的 1 到 2 个物理像素。100vh 取的是地址栏收起时的那份大视口高度,地址栏一展开内容就跳,现在一般用 dvh、svh 处理。
现在的常见组合是 rem 配 postcss-pxtorem 自动换算,再用 clamp() 限制字号上下限;或者纯 CSS 的 vw 加 clamp()、min()、max(),把 JS 依赖去掉。选哪套看项目里字号要不要跟着用户设置走。
viewport 那行 meta 和 dpr
<meta name="viewport" content="width=device-width, initial-scale=1"> 是给移动端浏览器看的,用来设置布局视口。不写这一行,手机会按一个大约 980px 宽的布局视口排版,再整体缩小塞进屏幕,字看着小得要命。width=device-width 让布局视口等于设备宽度,initial-scale=1 让初始缩放为 1。
设备像素比(dpr)是物理像素数除以 CSS 像素数。CSS 里写的 1px,在 dpr=2 的屏幕上占 2 个物理像素,dpr=3 占 3 个。设计稿要求的“1 物理像素的细线”用 CSS 的 1px 做不到,这就是高分屏上细线偏粗的来源。常见绕法是画一条 1px 的线再用 transform: scaleY(0.5) 压掉一半,或者用伪元素配合 transform-origin 做半像素线;dpr 是 3 时得按实际比例再算一次。
GROUP BY 什么时候能顺着索引走
表上有 idx_user_status (user_id, status)。索引先按 user_id 排,同一个 user_id 内部再按 status 排,所以 status 只在单个 user_id 内部有序,跨越不同的 user_id 就散了。按 status 分组时,存储顺序不满足“相同的值挨在一起”,MySQL 只能建临时表把行搬进去重新归组,再排序。
按 user_id 分组,或者按 (user_id, status) 这两列一起分组,都能直接顺序扫索引:相邻记录天然属于同一组,边扫边计数即可,不需要额外的临时表和排序。
EXPLAIN 的 Extra 里,Using temporary 表示建了内部临时表,Using filesort 表示额外排了序、不是靠索引顺序拿到的。两者可以同时出现,典型就是先建临时表再排序。能借索引分组时,可能看到 Using index for group-by。
TCP 保活为什么靠不住
Linux 上 tcp_keepalive_time 默认 7200 秒,也就是空闲两小时才发第一个探测包,之后每 75 秒一次、连续 9 次不通才判定断开。对一个要快速释放连接的在线服务来说,这个时间尺度没有意义。
更硬的问题是 NAT 和防火墙。客户端被 kill 之后,中间设备那张映射表还在,但通常几分钟就老化删除;表项一没,服务端发出的探测包既到不了对端,也收不到回应,连接在协议层面仍然是 ESTABLISHED,只能靠超时慢慢发现。
所以要在应用层补心跳。心跳必须验证对端的回包,只定时发、从不检查有没有回应,等于什么都没做。间隔要小于链路上所有中间设备的最小空闲超时,NAT、负载均衡、防火墙常见的空闲超时在 60 秒上下,所以心跳一般取 25 到 30 秒留余量;判定超时取间隔的 2 到 3 倍,也就是 50 到 90 秒没回包才算断。服务端既不心跳也不清理,这些假活连接会一直占着文件描述符、内存和连接表项,攒多了就是句柄耗尽。
DNS 的答案缓存在哪,改完为什么不立刻生效
解析出一条 A 记录之后,结果可能同时存在于几层:浏览器自己的 DNS 缓存、操作系统的缓存、本地递归 DNS 服务器的缓存,有些应用和运行时还会再存一层。每一层能留多久,由权威 DNS 服务器在记录上给的 TTL 决定。
把 A 记录从旧 IP 改成新 IP,有用户马上生效,是因为他那条链路上的缓存刚好过期或本来就没有;还有人指向老地址,是某一层的缓存还没到时间。TTL 设得极短,解析频繁回源到权威服务器,延迟和权威侧压力都会上去;设得极长,改完记录要等很久才全网生效。所以常规做法是变更前先把 TTL 调短,等旧值在各层缓存里过期,改完稳定一段时间再调回去。
A 记录指向一个 IPv4 地址,CNAME 记录指向另一个域名,由解析器继续追下去。站点接 CDN 常用 CNAME,因为 CDN 厂商的调度 IP 会变,把 www 指到他给的域名,厂商换 IP 时不用改自己的记录。
这一批里比较值钱的是两条机制:可重入靠的是 monitor 上的重入计数,GROUP BY 能不能免临时表看的是索引里那一列到底全局有序还是局部有序。规则记不住没关系,能推出来就够了。