今天三轮分别是 Java 的 GC、移动端触摸、复制与 HTTP 压缩。第一轮在 Survivor 轮转上留了缺口,第三轮漏了一个等待耗时指标,还有一句关于 JS 引擎的话说错了。
引用计数为什么在 Java 里用不了
最直觉的判活办法是给每个对象记一个被引用次数,归零就当垃圾。这招在 Java 里行不通,因为两个对象互相指着对方时,两边的计数永远不归零,实际上它们已经不参与任何业务了,回收器却动不了。
顺带纠正我自己的一句。答题时我说“JS 的垃圾回收机制就是这样的,所以诞生了闭包问题”,这句不对。V8 这类主流引擎用的是标记清除,跟 Java 一样从根往下走。闭包里的变量不被回收,是因为它还被内部函数的作用域引用着、是可达的,跟引用计数的循环误判不是一回事。
GC Root 从哪儿出发
Java 实际用的是可达性分析,从一批叫 GC Root 的起点往下找引用链,够不着的就是垃圾。常见的几类:正在跑的线程栈里的局部变量和参数、类的静态变量、字符串常量池里的引用、JNI 这类本地方法栈的引用、还有被 synchronized 锁住的对象。
被判成不可达也不是立刻消失。覆写了 finalize 的对象会先进队列,由专门的线程执行一次,之后才回收。finalize 这条路现在是历史包袱:Java 9 起标了 Deprecated,JDK 18(JEP 421)起 finalization 已废弃,可以用 --finalization=disabled 关掉。
四种引用,回收时机各不相同
强引用是 new 出来或者普通赋值得到的,只要还在,GC 就不动它,宁可 OOM。软引用在内存充足时留着,快 OOM 了才清,适合做内存紧张就自动丢的缓存,比如图片。弱引用撑不过一次 GC,只要没强引用指着,下次回收就没了。虚引用最彻底,get() 永远返回 null,拿不到对象本身,唯一用途是配合 ReferenceQueue,等对象真被回收时收个通知,去清理关联的堆外资源。
弱引用最熟的例子是 ThreadLocalMap 的 Entry,key 指向 ThreadLocal,用的是弱引用。ThreadLocal 对象在外面没有强引用之后,key 能被 GC 清掉;但 value 还是强引用,所以还是要 remove(),不然线程池里的线程活着,value 就一直挂着。虚引用的现成用例是 DirectByteBuffer 的 Cleaner,靠它知道堆外内存什么时候该释放。
三种回收算法与分代选择
标记清除先标可达对象再清掉没标的,快是快,代价是内存碎片,大对象可能找不到连续空间。复制把内存分成两块,只用一块,回收时把活着的复制到另一块再整块清空,毛病是永远浪费一半,存活对象多的时候搬运成本很高。标记整理先标记,再把存活对象往一端推紧凑,再清边界外的,搬得慢,但没有碎片。
新生代敢用复制,是因为绝大多数对象朝生夕灭,活下来的很少,搬运量小,浪费那一小块也不心疼。老年代相反,存活率高,复制就要搬一大半,又占空间,所以用标记整理或者标记清除。
这里我漏了一问,两个 Survivor 到底怎么轮。Eden 满触发 Minor GC 时,Eden 和一个 Survivor 里的存活对象会被复制到另一个空的 Survivor,然后 Eden 和刚才那个 Survivor 整块清掉。两个 Survivor 轮着当收集目标和交付目标,必须始终留一个空的,不然存活对象没有落脚的对方。对象在这个过程里每熬过一次就从年龄加一,到阈值才晋升老年代。
点击穿透这条链路
昨天我把成因说成“中间那层被阻止”,这轮重讲了一遍。手指按在 mask 上,touchstart 和 touchend 的事件目标都是 mask,关闭浮层的回调就在 touchend 里执行。之后浏览器还会补发模拟的鼠标事件和 click,补发时按坐标重新做命中测试找最上层元素,可那时候 mask 已经没了,命中的就是它底下的按钮。
关键是“关闭”和“命中测试”的先后,不是冒泡串到下层。补发 click 的命中测试发生在 touchend 之后那一小段延迟里,不是在 touchend 当时。
两种修法分别挡在不同位置。在 touchend 里调 preventDefault(),是把后面补发的那串模拟鼠标事件连同 click 一起掐掉。把关闭动作改成监听 click 或者 pointerdown,是等这次 click 先命中 mask 自己、回调里才关,命中测试已经做完了,不会再补发一次打到下层。这个坑现在比以前少见,一是 300ms 延迟取消后补发链路变短,二是组件库基本不用 touchend 收尾,touch-action 和 pointer-events 也更普及了,但它没有完全消失。
触摸事件里的三组坐标和一次判断
touches 是此刻屏幕上所有触摸点,不管落在哪个元素上。targetTouches 限定在事件目标相同的那些点,两根手指分别按在两个按钮上时,各自的事件里只看到自己那个目标上的手指。changedTouches 是这次事件里状态变了的那几根,按下、移动、抬起变化的都在这。
做“横移超过 50px 才算滑动”的判断,记 touchstart 时的起点,touchmove 里算 |currentX - startX| 跟 50 比。坐标用 clientX 和 pageX 都行,前者相对视口不含滚动,后者相对页面含滚动,同一视口内比位移用 clientX 就够。这类手势要 preventDefault 或者给容器写 touch-action: none,不拦的话浏览器会把横向移动当成滚动或手势导航自己接管,并且派发 touchcancel 告诉你手势被抢走了,之后 touchmove 就不来了。
软键盘弹起动的是哪一块
100vh 跟的是布局视口,键盘弹起来它不变。100dvh 跟动态可视区域,键盘一弹就变小。平台差异在这块一直存在:Android Chrome 默认是缩视口,iOS 老版本是把整个页面往上推,所以同一个页面在两边的表现不一样。visualViewport 盯的是用户实际能看到的那块区域,键盘遮挡、pinch 缩放、地址栏收起都会变它,而 CSS 布局用的 layout viewport 不跟着动。
底部固定的提交栏被键盘顶上去挡住输入框,两种解法:聚焦时先把底栏藏了,失焦再放回来;或者监听 visualViewport 的 resize,按可视高度自己算底栏位置。
复制追平看哪两个值
GTID 复制看 Retrieved_Gtid_Set 和 Executed_Gtid_Set,前者是 I/O 线程已经拉到哪儿,后者是 SQL 线程已经执行到哪儿,两个相等才算追上了。传统位点复制对应看 Read_Master_Log_Pos 和 Exec_Master_Log_Pos。
对齐了不等于数据一定和主库一样,位点只说明该重放的日志都重放了,内容对不对得靠校验另说。这一问上次我没答上,这轮补了。
真追不上时先看两个线程状态:Replica_IO_Running 和 Replica_SQL_Running,前者停了是拉不到,后者停了是重放停了。再看 Last_IO_Error 和 Last_SQL_Error 的具体报错,网络、权限、主库 binlog 被清掉、主键冲突都会在这里露出来。延迟一直涨的时候,两个 Pos 都不动就先查 I/O 这头;Read 在涨、Exec 跟不上,那是 SQL 线程重放慢,往从库负载、大事务、锁等待、并行复制配置上找。
半同步退化怎么看
主库等 ACK 等超时会退化回异步,状态变量上能看出来:Rpl_semi_sync_master_status 是 ON 就是半同步生效,OFF 说明已经退化了。再看两个计数,Rpl_semi_sync_master_yes_tx 是成功等到至少一个从库 ACK 的次数,no_tx 是没等到就返回的次数,两个一比就是这一段时间的成功率和退化率。
监控上盯退化的占比和 no_tx 的增长速率,能提前发现退化在变多。这问里还有一个指标我没答上,就是每次等 ACK 花了多久,对应的是 Rpl_semi_sync_master_net_avg_wait_time 和累计的 net_wait_time。半同步配上的从库本来就慢的话,主库的写会被拖住,因为提交的会话线程要等 ACK 才能返回,等待期间它占着的锁和 undo 也不放。
传之前先压一遍
请求头 Accept-Encoding: gzip, br 是客户端在说“我支持这些压缩算法”,响应头 Content-Encoding: br 是服务端在说“这个响应体是用 br 压的”。用哪种由服务端定,看客户端支持列表、自身配置、内容类型和压缩成本,br 压缩率比 gzip 好一点,不支持就降级。
JSON 和 HTML 压完能小一大截,因为文本里重复的标签、字段名、空白和常见词很多,算法找得到重复串。JPEG、PNG 本身已经是压缩格式,数据熵高,再 gzip 一遍几乎不瘦,有时候因为加了头反而变大,还白烧 CPU。
压缩之后 Content-Length 要写压缩后的字节数,不是原始大小。它和 Transfer-Encoding: chunked 不能一起出现,chunked 本身就是自定界,两个一起给属于消息定界冲突,代理和客户端处理起来各家不一致。边压边发不知道总长的时候就只写 chunked。
图片和视频不靠应用层现压,原因是每次请求压一遍要烧服务器的 CPU,图片有自己的编码(WebP、AVIF),视频有 H.264、H.265、AV1,都不是 gzip 能比的;构建时或者 CDN 回源时压好一次就能缓存复用,现压还会把首字节拖到压缩完成之后。
Vary: Accept-Encoding 是告诉缓存“同一个 URL 按这个头分开存”,不加的话缓存会把压缩版和没压缩版混着发,会把 br 版本发给不支持的客户端。