时间轮定时器原理与实战:单轮、多轮实现及避坑指南
简介本资源是一套Java时间轮定时器的完整实现代码面向具备一定Java基础、希望深入理解高效定时任务调度机制的开发者。内容围绕单轮与多轮两种时间轮结构展开涵盖槽位划分、指针轮询、任务插入与移除等核心环节可用于心跳检测、超时处理、过期数据清理等场景的学习与二次开发。压缩包共33个文件约41KB以11个java源码和13个class文件为主体另有8个xml配置及1个iml工程文件构成可直接导入IDE运行的Maven项目结构。目前已有1976人学习下载适合对照源码理解时间轮从任务分配到触发执行的完整流程掌握单轮与多轮在长间隔任务调度上的差异与取舍为分布式系统与消息队列中的定时需求提供可复用的实现思路。1. 时间轮定时器到底解决了什么问题做过长连接网关或订单超时关闭的兄弟大概率踩过这个坑用ScheduledExecutorService管理几十万个定时任务堆里全是按到期时间排序的节点每次插入和取消都是 O(log n)任务量一上来调度线程光维护堆结构就吃满了 CPU。更难受的是取消任务——ScheduledFuture.cancel()只是打个标记节点还赖在队列里内存迟迟不释放这就是典型的「定时器把业务拖垮」。时间轮Timing Wheel换了个思路不再按到期时间排序而是把时间切成固定大小的槽位slot任务按「还有几轮 落在哪个槽」直接挂到对应格子的链表上。插入和删除都是 O(1)取消任务就是把它从链表里摘掉内存立刻回收。它特别适合两类场景一是海量短周期任务心跳检测、会话超时二是任务取消频繁连接断开要撤销超时任务。Java 里 Netty 的HashedWheelTimer就是单轮时间轮的经典实现而 Kafka 的SystemTimer走的是分层多轮思路。这篇就把单轮和多轮两种实现从原理到代码讲透参数怎么调、坑在哪一次说清。2. 单轮时间轮一个环形数组怎么转起来单轮时间轮的结构非常朴素一个固定长度的数组当环一个指针指向当前槽指针每隔tickDuration往前走一格走到哪格就把那格链表上的任务全拿出来执行。核心就三个参数tickDuration每格代表多长时间、wheelSize一圈多少格、maxDelay tickDuration * wheelSize能表达的最大延迟。2.1 环形数组 双向链表的数据结构先定义任务节点和槽位。任务节点要能挂到链表上所以带前后指针槽位就是一个链表头。这里用双向链表是为了 O(1) 删除——取消任务时能直接摘节点不用遍历。// 时间轮任务节点双向链表结构支持 O(1) 摘除 public class TimerTaskEntry { long expireMs; // 绝对到期时间戳 int rounds; // 还需要转几圈才执行单轮里恒为 0 Runnable task; // 真正的业务逻辑 TimerTaskEntry prev, next; // 双向链表指针 TimerTaskList list; // 所属槽位取消时用来定位 public TimerTaskEntry(long expireMs, Runnable task) { this.expireMs expireMs; this.task task; } } // 槽位一个带哨兵的双向链表避免边界判空 public class TimerTaskList { TimerTaskEntry head new TimerTaskEntry(-1, null); // 哨兵 public TimerTaskList() { head.prev head; head.next head; } // 头插O(1) public void add(TimerTaskEntry entry) { entry.next head.next; entry.prev head; head.next.prev entry; head.next entry; entry.list this; } // 摘除任意节点O(1)这就是时间轮取消任务快的根本原因 public void remove(TimerTaskEntry entry) { if (entry.list ! this) return; entry.prev.next entry.next; entry.next.prev entry.prev; entry.list null; } }expireMs存绝对时间戳而不是相对延迟是为了让任务在轮转过程中不用反复换算。rounds字段在单轮实现里用不上但多轮实现会复用这个结构所以先留着。哨兵节点head让链表操作不用判断空指针这是写链表的老套路了。2.2 指针推进与任务到期判定时间轮靠一个后台线程驱动指针。每推进一格就把该槽位链表上的任务全部取出逐个判断是否真的到期——因为同一个槽里的任务到期时间可能不完全一样都落在这一格的时间窗口内。public class SingleWheelTimer { private final TimerTaskList[] wheel; private final long tickDurationMs; private final int wheelSize; private long currentTime; // 当前指针指向的时间 private int currentIndex; // 当前槽下标 public SingleWheelTimer(long tickDurationMs, int wheelSize) { this.tickDurationMs tickDurationMs; this.wheelSize wheelSize; this.wheel new TimerTaskList[wheelSize]; for (int i 0; i wheelSize; i) wheel[i] new TimerTaskList(); this.currentTime System.currentTimeMillis(); } // 添加任务算出落在哪个槽 public void addTask(long delayMs, Runnable task) { long expireMs System.currentTimeMillis() delayMs; // 超过一圈能表达的最大延迟单轮直接拒绝多轮实现会处理 if (delayMs tickDurationMs * wheelSize) { throw new IllegalArgumentException(delay exceeds maxDelay); } // 关键用 (expireMs / tickDuration) % wheelSize 定位槽位 int index (int) ((expireMs / tickDurationMs) % wheelSize); wheel[index].add(new TimerTaskEntry(expireMs, task)); } // 指针推进一格处理该槽所有任务 private void advance() { currentIndex (currentIndex 1) % wheelSize; currentTime tickDurationMs; TimerTaskList bucket wheel[currentIndex]; TimerTaskEntry entry bucket.head.next; while (entry ! bucket.head) { TimerTaskEntry next entry.next; // 先存 next任务里可能改链表 if (entry.expireMs currentTime) { bucket.remove(entry); entry.task.run(); // 实际项目里应丢到业务线程池别阻塞指针 } entry next; } } }槽位定位公式(expireMs / tickDurationMs) % wheelSize是单轮时间轮的核心。它把绝对时间戳映射到环上的一个格子同一格里的任务到期时间差不超过一个tickDuration。advance()里先保存next再执行任务是因为任务执行过程中可能触发取消操作改动链表不先存指针会踩空。2.3 参数怎么定tickDuration 与 wheelSize 的取舍这两个参数直接决定精度和内存调错了要么精度不够要么空转浪费。参数含义调大调小tickDuration每格时间跨度精度变粗CPU 空转少精度高指针推进频繁wheelSize一圈格子数能表达的最大延迟变大内存占用上升内存小最大延迟受限经验值如果业务超时精度要求是秒级tickDuration设 100ms 到 500ms 都行wheelSize一般取 512 或 1024这样单轮能覆盖512 * 100ms ≈ 51 秒到1024 * 500ms ≈ 8.5 分钟。超过这个范围的延迟单轮就表达不了必须上多轮。我一般会先算业务里 95% 的延迟落在什么区间再倒推这两个值而不是拍脑袋。注意tickDuration不是越小越好。设成 1ms指针每秒推进 1000 次即使没任务也在空转CPU 白白消耗。精度够用就行。3. 多轮时间轮延迟超过一圈怎么办单轮的死穴是maxDelay有限。订单 30 分钟未支付关闭如果maxDelay只有 1 分钟单轮根本放不下。多轮时间轮分层时间轮就是来解决长延迟的用多个轮子每个轮子负责一个时间量级像时钟的秒针、分针、时针一样逐级进位。3.1 分层结构秒轮、分轮、时轮怎么协作分层时间轮的思想是延迟 3 秒的任务放秒轮延迟 3 分钟的任务放分轮延迟 3 小时的任务放时轮。低层轮子转满一圈就往高层轮子「进位」一个任务。这样每一层的maxDelay都不用很大但组合起来能表达任意长的延迟。// 分层时间轮每层是一个单轮层与层之间按溢出规则降级 public class HierarchicalWheelTimer { private final SingleWheelTimer[] levels; private final long[] levelTickMs; // 每层每格的时间跨度 private final int[] levelSize; // 每层格子数 public HierarchicalWheelTimer(long[] tickMs, int[] sizes) { this.levelTickMs tickMs; this.levelSize sizes; this.levels new SingleWheelTimer[tickMs.length]; for (int i 0; i tickMs.length; i) { levels[i] new SingleWheelTimer(tickMs[i], sizes[i]); } } // 根据延迟选择放哪一层 public void addTask(long delayMs, Runnable task) { for (int i 0; i levels.length; i) { long maxDelay levelTickMs[i] * levelSize[i]; if (delayMs maxDelay) { levels[i].addTask(delayMs, task); return; } } // 超出所有层放最高层并记录剩余轮数这里简化处理 levels[levels.length - 1].addTask(delayMs, task); } }选层逻辑是从最低层开始谁的maxDelay能装下这个延迟就放谁。比如秒轮maxDelay是 60 秒分轮是 60 分钟一个 3 分钟的任务秒轮装不下就落到分轮。这种「就近放低层」的策略能让大部分短任务集中在低层减少高层轮子的负担。3.2 任务降级从高层轮子搬到低层轮子分层时间轮真正的难点在降级。当分轮指针走到某个槽里面的任务可能还没到期它只是「轮到分轮处理了」这时要把任务重新计算搬到秒轮去等秒轮再精确触发。// 分轮指针推进时把槽里任务降级到秒轮 private void cascade(int levelIndex, TimerTaskList bucket) { TimerTaskEntry entry bucket.head.next; while (entry ! bucket.head) { TimerTaskEntry next entry.next; bucket.remove(entry); long remaining entry.expireMs - System.currentTimeMillis(); if (remaining 0) { entry.task.run(); // 已到期直接执行 } else { // 还没到期重新计算放到低一层轮子 addTask(remaining, entry.task); } entry next; } }降级的本质是「重新入轮」把任务从当前层摘下来算出剩余延迟再按addTask的规则放回合适的层。这个过程可能跨多层一个 1 小时的任务会从时轮降到分轮再降到秒轮。每次降级都要重新定位槽位所以分层时间轮的插入成本比单轮高但换来的是能表达任意长延迟。3.3 多轮实现里 rounds 字段的正确用法还有一种更常见的多轮实现不是分层而是「单环 轮数」。它用一个环但每个任务记录还要转几圈rounds才执行。指针每走到一个槽把该槽任务的rounds减一减到 0 才真正执行。// 单环多轮用 rounds 字段表达超过一圈的延迟 public void addTaskWithRounds(long delayMs, Runnable task) { long expireMs System.currentTimeMillis() delayMs; long ticks delayMs / tickDurationMs; // 总共要经过多少格 int index (int) ((expireMs / tickDurationMs) % wheelSize); int rounds (int) (ticks / wheelSize); // 需要转几圈 TimerTaskEntry entry new TimerTaskEntry(expireMs, task); entry.rounds rounds; wheel[index].add(entry); } // 指针推进时对 rounds 做减法 private void advanceWithRounds() { currentIndex (currentIndex 1) % wheelSize; TimerTaskList bucket wheel[currentIndex]; TimerTaskEntry entry bucket.head.next; while (entry ! bucket.head) { TimerTaskEntry next entry.next; if (entry.rounds 0) { entry.rounds--; // 还没到减一圈继续等 } else if (entry.expireMs System.currentTimeMillis()) { bucket.remove(entry); entry.task.run(); } entry next; } }rounds的算法是总格数 / wheelSize。比如tickDuration100ms、wheelSize512一个 60 秒的任务需要经过 600 格rounds 600 / 512 1也就是转一圈多。这种实现比分层简单但有个隐患如果rounds很大任务会在环上「空转」很多圈才执行期间指针每次经过都要做一次减法判断任务量大的时候这部分开销不能忽略。提示单环多轮适合延迟分布相对均匀的场景如果延迟跨度极大几毫秒到几小时混在一起分层时间轮的效率更稳。4. 避坑与排查时间轮上线后最容易翻车的几处时间轮代码看着简单真上生产环境坑一个比一个隐蔽。下面这几条都是血泪经验按「现象 → 原因 → 解决」列出来。4.1 任务执行阻塞指针导致整体延迟现象明明设了 100ms 精度实际任务延迟到几秒甚至几十秒越积越多。原因advance()里直接entry.task.run()如果任务里有耗时操作查库、发 HTTP指针线程被卡住后面的槽位全部推迟处理。解决指针线程只负责「取出到期任务」执行丢给独立业务线程池。时间轮和业务执行彻底解耦。// 指针线程只投递不执行 private final ExecutorService workerPool Executors.newFixedThreadPool(8); // advance 里改成投递 workerPool.execute(entry.task);4.2 取消任务后内存不释放现象连接断开后调了取消但堆内存不降GC 后老年代还是涨。原因取消时只把任务标记为「已取消」没从槽位链表里摘除节点还挂在环上要等指针转到才清理。解决取消时立刻从链表摘除。这就是前面TimerTaskList.remove()存在的意义——O(1) 摘除内存立即回收。public void cancel(TimerTaskEntry entry) { if (entry.list ! null) { entry.list.remove(entry); // 立即摘除不等指针 } }4.3 tickDuration 设太小导致 CPU 空转现象任务量不大但调度线程 CPU 占用稳定在 10% 以上。原因tickDuration设成 1ms指针每秒推进 1000 次大部分槽是空的纯空转。解决按业务精度需求反推。秒级超时用 100ms 到 500ms 足够别为了「看起来精确」设成 1ms。4.4 多轮 rounds 计算溢出现象设置超长延迟比如 24 小时后任务永远不触发或者触发时间完全错乱。原因rounds用int存ticks / wheelSize在某些参数组合下超出int范围或者expireMs / tickDurationMs本身溢出。解决rounds和中间计算全用long并在addTask入口校验delayMs上限超限直接拒绝或走降级逻辑。long ticks delayMs / tickDurationMs; // 用 long别用 int long rounds ticks / wheelSize; if (rounds Integer.MAX_VALUE) { throw new IllegalArgumentException(delay too large); }4.5 系统时间跳变导致任务错乱现象服务器 NTP 校时后一批任务提前触发或迟迟不触发。原因时间轮用System.currentTimeMillis()做基准墙钟时间被回拨或前跳指针和任务到期判断全乱。解决改用System.nanoTime()做单调递增的基准它不受系统时间调整影响。所有时间计算基于相对时间只在需要展示时转成墙钟。5. 把时间轮接进业务一个可复用的封装技巧前面讲的都是骨架真接进业务还得解决几个工程问题怎么统一管理任务句柄、怎么优雅停机、怎么监控积压。我一般会封装一个Timeout句柄业务侧只拿到句柄取消和状态查询都通过它不直接碰内部链表。// 对外的任务句柄屏蔽内部实现 public class Timeout { private final TimerTaskEntry entry; private volatile boolean cancelled false; public Timeout(TimerTaskEntry entry) { this.entry entry; } public boolean cancel() { if (cancelled) return false; cancelled true; if (entry.list ! null) entry.list.remove(entry); return true; } public boolean isCancelled() { return cancelled; } } // 业务侧用法拿到句柄随时可取消 Timeout timeout timer.newTimeout(30_000, () - closeOrder(orderId)); // 支付成功回调里 timeout.cancel();这个封装的价值在于业务代码不关心任务挂在哪个槽、转了几圈只关心「30 秒后关单」和「支付成功就取消」。取消的幂等性由cancelled标志保证重复取消不会出错。验证时间轮是否正确我习惯用两个手段。一是压测插入和取消造 100 万个任务测插入耗时和取消后内存回收情况正常应该是插入接近 O(1)、取消后堆内存明显下降。二是精度测试设一批不同延迟的任务记录实际触发时间和预期时间的偏差偏差应该稳定在一个tickDuration以内如果偏差越来越大说明指针被阻塞了。监控上至少暴露三个指标当前环上任务总数、指针单次推进耗时、业务线程池队列深度。指针推进耗时一旦超过tickDuration说明处理速度跟不上得加线程或调大tickDuration。这几个指标接进现有监控体系时间轮就不再是黑匣子。最后说个我踩过的坑早期图省事把时间轮和业务逻辑写在同一个类里结果测试时想单独验证指针推进逻辑发现根本没法 mock 时间。后来把「时间源」抽成一个接口测试时注入可控的假时钟指针推进就能精确控制单元测试才写得下去。这个习惯我一直保留——凡是依赖系统时间的组件时间源一定要可注入这是给自己留的后悔药。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

Arduino BLE FTMS 实战:让老健身车接入 Zwift 等训练 App

Arduino BLE FTMS 实战:让老健身车接入 Zwift 等训练 App

简介:这份资源面向嵌入式开发与室内骑行训练爱好者,提供一套基于Arduino与ESP32的BLE FTMS室内自行车健身机实现方案,用两块ESP32分别模拟功率计与智能骑行台,解决商用骑行台价格高昂、协议封闭的问题。压缩包共30个文件&#xff…

2026/10/15 15:36:28 阅读更多 →
Gradpaper AI 初稿生成实测:项目信息输入质量对论文产出效果的影响研究

Gradpaper AI 初稿生成实测:项目信息输入质量对论文产出效果的影响研究

AI 论文写作工具越来越多,很多学生误以为工具可以凭空生成高质量论文初稿。但在 Gradpaper 实测过程中发现,初稿质量最大的决定因素不是模型能力,而是项目信息卡的填写完整度。本文设置多组对照实验,量化不同项目信息输入条件下&a…

2026/10/15 15:36:28 阅读更多 →
搜索插入位置:二分查找边界条件与lowerBound实现详解

搜索插入位置:二分查找边界条件与lowerBound实现详解

1. 题目场景与核心思路拆解LeetCode热题100里的“搜索插入位置”这道题,我刷了不下三遍,每一遍都有新体会。题目本身不长:给定一个排序数组和一个目标值,在数组中找到目标值并返回下标,如果不存在,则返回它…

2026/10/15 15:36:28 阅读更多 →

最新新闻

Pycopy:存储优先的Python方言,让嵌入式与云端部署更轻量

Pycopy:存储优先的Python方言,让嵌入式与云端部署更轻量

简介:Pycopy是一个极简、轻量且可高度扩展的Python方言实现,源自MicroPython,面向桌面端、云端与微控制器等各类受限环境。该源码压缩包适合嵌入式及物联网开发者,也适合想理解精简解释器设计的程序员。项目实现了完整的Python 3.…

2026/10/15 16:25:22 阅读更多 →
Meta开放式机架V3:BBU与机架结构设计深度解析

Meta开放式机架V3:BBU与机架结构设计深度解析

聊聊数据中心里的机架,可能很多人下意识想到的就是机房那一排排黑乎乎的铁柜子,标准19英寸、某个高度、前后通风,设备一层层往上摞。但如果你真的在一线维护过超大规模集群,你一定会对传统机架有一肚子槽点:电源线乱七…

2026/10/15 16:25:22 阅读更多 →
软件模拟ADS1220驱动:GPIO实现24位ADC采样,无需硬件SPI

软件模拟ADS1220驱动:GPIO实现24位ADC采样,无需硬件SPI

简介:面向STM32F103xx系列微控制器的开发者,这份软件模拟ADS1220采样芯片的驱动方案,适用于仪器仪表、工业控制等需要高精度模拟信号采集的各类测控场景。压缩包共172个文件,大小5.22MB,以C语言源码为主体,…

2026/10/15 16:25:22 阅读更多 →
软件模拟SPI驱动ADS1220:GPIO手搓24位ADC采样方案

软件模拟SPI驱动ADS1220:GPIO手搓24位ADC采样方案

简介:面向STM32F103xx开发者的ADS1220软件模拟驱动包,以纯软件方式模拟SPI时序,完成高精度ADC的初始化、工作模式配置、数据转换与结果读取,解决无硬件SPI或低成本采集场景下的模拟量输入需求。ADS1220为24位低功耗模数转换器&…

2026/10/15 16:25:22 阅读更多 →
HTML转PDF实战:无头浏览器选型、打印样式与避坑指南

HTML转PDF实战:无头浏览器选型、打印样式与避坑指南

简介:这是一套基于Aspose.Pdf库实现HTML转PDF的.NET示例工程,面向Web开发、文档生成等领域需要稳定生成PDF的技术人员。压缩包共32个文件,大小约10.62MB,涵盖C#源码、可直接引用的DLL、PDF演示文件、Web.config等配置,…

2026/10/15 16:25:22 阅读更多 →
Binder通信机制深度解析:从内存映射到线程模型,一次讲透IPC性能优化

Binder通信机制深度解析:从内存映射到线程模型,一次讲透IPC性能优化

1. 从一个"为什么调用这么慢"的疑问说起如果你做过一段时间的系统层开发,或者维护过跨进程通信频繁的模块,大概率遇到过这样的场景:一个看似简单的接口调用,耗时却比预期高出一个数量级;日志里明明只打了几行…

2026/10/15 16:24:20 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →