这一批练了三轮:Java 线程的状态和等待机制,前端的 grid 复测、数组排序与路由守卫,数据库的子查询、死锁排查和网络连接状态。昨天 grid 那两处没说明白,今天第一题就是它的复测,从那里开始。
线程的六个状态
Thread.getState() 返回 Thread.State 枚举,一共六个值:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。NEW 是对象建好还没调用 start();RUNNABLE 是已经交给调度器,包含正在运行和排队等 CPU 两种情况,从外部看不出差别;TERMINATED 是 run() 执行结束。
容易混的是中间三个。BLOCKED 是等监视器锁:线程想进入 synchronized 块或方法,锁在别人手里,它就停在这里。WAITING 是没有超时的等待,来源不止 wait(),join() 和 LockSupport.park() 同样让线程进入这个状态。TIMED_WAITING 是带超时的那一类,sleep(ms)、wait(ms)、join(ms)、parkNanos 都在其中。
sleep 与 wait:锁属于谁,醒来之后做什么
Thread.sleep(1000) 让线程进入 TIMED_WAITING,时间到了由调度器重新放回 RUNNABLE。它不释放已经持有的锁。
synchronized 块里的 obj.wait() 进入 WAITING,唤醒它的是另一个持有同一个 obj 监视器的线程,调用 obj.notify() 或 obj.notifyAll()。被唤醒不等于立刻往下执行:线程要从等待集挪回锁的竞争队列,重新拿到 monitor 之后 wait() 才返回,这段时间它处于 BLOCKED。
三个角度对照:sleep 是 Thread 的静态方法,wait 是 Object 的实例方法;sleep 不释放锁,wait 释放;醒来之后 sleep 直接继续,wait 要先抢回锁。两者都能被 interrupt 打断,都抛 InterruptedException,区别是 wait 被打断时会先把锁还回去。
obj.wait() 写在 synchronized 外面直接调用会抛 IllegalMonitorStateException。wait 的语义是释放当前线程持有的该对象监视器并挂起等待,手里没有 monitor 就没有可释放的东西,线程也无处登记到该对象的等待集里。
Callable 与 Future
Runnable 的 run() 没有返回值,也不能抛受检异常。需要结果时用 Callable<V>,方法是 V call() throws Exception。Callable 不能直接交给 Thread,只能提交给线程池,或者包装成 FutureTask。
submit 返回 Future,get() 在任务未完成时阻塞当前线程,直到任务结束或抛出异常。任务内部抛出的异常不会原样抛出,会被包装成 ExecutionException,原始异常通过 getCause() 取。任务被取消时 get() 抛 CancellationException;带超时的 get(timeout, unit) 超时后抛 TimeoutException,任务本身不会因此停止。
grid 的重复轨道和 fr 的份额
repeat(3, 1fr) 是把 1fr 这条轨道定义重复三次,等价于 1fr 1fr 1fr。1fr 的份额来自容器扣掉固定列宽和 gap 之后的剩余空间,三列各占一份就是等分。
内容把网格撑破,根源在 fr 的最小值。1fr 实际等价于 minmax(auto, 1fr),这个 auto 最小值取的是网格项的内容尺寸,遇到长单词或不换行的 URL,轨道的下限就被顶到那里。改法有两种:轨道写成 repeat(3, minmax(0, 1fr)),直接把最小值设为 0;或者给网格项设 min-width: 0(overflow 不是 visible 时自动最小尺寸同样失效),让它对轨道的最小贡献变成 0。前者改轨道,后者改项目,两个层面都能解决,具体用哪个看是全局约束还是个别卡片。
自动折行写 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)),容器宽度能放几列就排几列,每列不小于 200px。auto-fit 和它的区别在空轨道:元素不够铺满时 auto-fit 会折叠空轨道,剩下的项目被拉宽;auto-fill 保留空轨道,项目维持原来的宽度。
跨列:span 和网格线
让第一张卡片横跨两列,属性写在卡片自己身上:grid-column: span 2。span 2 表示从自动放置分配给它的位置开始占两列。grid-column: 1 / 3 用的是网格线编号,从第 1 条线到第 3 条线,同样是两列,但它把起点固定在第一条线上,后续项目要从它后面重新排,插入位置一变就可能挤走别的元素。
sort 默认按字符串排序
[10, 9, 100, 1].sort() 得到 [1, 10, 100, 9]。不传比较函数时,sort 把元素转成字符串,按 UTF-16 码元顺序比较,所以 10 排在 100 前面,9 落到最后。
按数值排序要传比较函数 (a, b) => a - b。比较函数的返回值必须是数字:负数表示 a 在前,正数表示 b 在前,0 表示两者顺序不变。返回布尔值不行,因为 true 会被转成 1、false 转成 0,永远不会出现负数,所有元素都被判定为相等,结果等于没有排序。
sort 原地修改数组并返回同一个引用。不想动原数组可以先用展开运算符或 slice() 复制,ES2023 起有 toSorted(),直接返回新数组。对象数组按属性排序同理,写 (a, b) => a.age - b.age。
路由守卫拦得住页面,拦不住请求
没登录就直接输 /admin,拦截要放在导航真正发生之前。Vue Router 里这类钩子统称导航守卫,最常注册的是全局前置守卫 router.beforeEach。
回调参数是 (to, from, next),to 是目标路由,from 是来源路由。放行调用 next(),改道去登录页用 next('/login')。Vue Router 4 里也可以直接返回 false 取消导航,或返回一个路由地址做重定向,效果和 next 的写法一致。
它只能防用户手上的误操作,不能当权限。前端代码和路由都运行在浏览器里,攻击者不必经过页面,拿 token 直接请求接口,或者改掉本地状态就能绕过去。页面拦得住人,拦不住请求,真正的权限判断要在服务端每个接口上做。
IN 子查询和 JOIN 的结果差在哪
查下过单的用户,WHERE id IN (SELECT user_id FROM orders) 和 JOIN 之后加 DISTINCT 的结果一致。MySQL 5.6 之后,优化器一般不会逐行执行子查询,而是把 IN 改写成半连接(semi-join),再在 FirstMatch、物化、LooseScan 这些策略里挑一个执行。
更早的版本对 IN 子查询处理得不好,常常退化成逐行的相关子查询,性能问题明显,实践里普遍改写成 EXISTS 或 JOIN。这部分我没有旧版本的实测经验,只能说到当年被广泛规避这个程度。
NOT IN 遇到 NULL 会一行都查不出来
WHERE id NOT IN (SELECT user_id FROM orders) 只要子查询结果里有一个 NULL,整条语句就返回空结果,而且不报错。原因是 NOT IN 等价于 id 与子查询里的每一个值都不相等,而 id <> NULL 的结果是 UNKNOWN,不是 TRUE,与其它条件 AND 之后整行的判断不成立。
改写用 NOT EXISTS,或者 LEFT JOIN 之后筛 WHERE o.id IS NULL,这两种写法都不受 NULL 影响。
死锁:谁被牺牲,怎么看出来
InnoDB 遇到死锁会自己处理,不会让事务无限等待。它在内部维护锁的等待关系,等待关系成环,也就是两个事务各持有一把锁又都在等对方那把时,就选一个事务回滚,让另一个继续。
被回滚的事务在应用层表现为 Error 1213,消息是 Deadlock found when trying to get lock; try restarting transaction。这类异常可以重试,但要区分频率:偶发重试一次通常能过去,同一业务反复死锁说明加锁顺序或语句本身有问题,重试只是把问题推迟。
看现场用 SHOW ENGINE INNODB STATUS\G,LATEST DETECTED DEADLOCK 段里能读到每个事务的 ID、状态和正在执行的 SQL,HOLDS THE LOCK(S) 是它当前持有哪些锁,WAITING FOR THIS LOCK TO BE GRANTED 是它在等哪把锁,最后的 WE ROLL BACK TRANSACTION 指出被牺牲的是哪一个。
写法上减少死锁有两条:跨表或多行更新时固定加锁顺序,不能一个事务先锁订单再锁用户、另一个反过来;确保 WHERE 能走索引,扫描的行越少,锁的范围越小,间隙锁被放大的机会也越少。
TIME_WAIT 和 CLOSE_WAIT 各说明什么
TIME_WAIT 是主动关闭连接的一方留下的状态,要等 2MSL 才消失。短连接加高并发最容易堆这个:每个请求新建连接,服务端作为主动关闭方,每关一条就留一个 TIME_WAIT 占着端口。除了调内核参数,应用层更有效的办法是复用连接,HTTP keep-alive、数据库连接池、RPC 长连接都是同一个思路。
CLOSE_WAIT 是另一回事。对端已经发了 FIN,本端回了 ACK,但本端的应用还没有调用 close(),连接就停在这个状态。它不会自己消失,越积越多说明代码里有地方没释放资源:连接用完没关、异常分支漏了 close、或者在等一个不会返回的读写。每个连接占一个文件描述符,堆积起来就会撞上 Too many open files。
排查先定位哪个进程的 CLOSE_WAIT 最多,按进程统计之后去看它的连接管理代码,而不是先去调内核参数。
这一批留下的两个细节值得再往下走一层:Future.get() 抛出异常之后原始异常从哪里取,以及前端拦截为什么不能代替后端校验。两个问题都有一句标准答案,但只说到那一句,落地时还是会卡住。