news 2026/10/29 16:11:29

死锁从复现到定位治理:并发编程锁顺序与线程转储实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
死锁从复现到定位治理:并发编程锁顺序与线程转储实战

1. 先把死锁这件事说透:它不是玄学,是四把钥匙的排列组合

线上出事的时候,最怕的不是报错,而是"什么都不报"。CPU 不高、内存正常、日志停在某一行不动了,接口全部超时,重启之后又好了,过两天再来一次。这类现象里,死锁(Deadlock)占了相当大的比例——两个或多个线程互相等着对方手里的锁,谁也不肯先松手,最后一起无限期挂在那儿。我第一次遇到这玩意是在一个交易类的服务上,压测跑到第 40 分钟突然整体卡死,线程数一直涨,QPS 掉到 0,当时排查了大半天才反应过来:不是性能问题,是两把锁被两个线程反着拿了。

这篇内容想干的事情很明确:把死锁从"听说过"变成"能复现、能定位、能预防"。前半部分讲清楚它在真实代码里到底怎么发生的,后半部分给可直接抄的复现代码、抓现场的方法和一套工程化的治理手段。适合已经写过多线程但被死锁坑过的同学,也适合刚接触并发编程、想看看到底哪里会翻车的朋友。哪怕你平时只写业务 CRUD,只要代码里出现过synchronized、ReentrantLock、数据库事务里的多行更新,这篇都用得上——因为死锁从来不挑语言,也不挑框架,它只挑"加锁顺序"。

1.1 教科书上那四个条件,在真实代码里长什么样

大家都知道死锁有四个必要条件:互斥、持有并等待、不可剥夺、循环等待。听着抽象,翻译成代码就很好理解了。

互斥说的是资源同一时刻只能被一个线程占用。这个条件在绝大多数场景下你根本消除不掉——共享变量就一份,谁改都得排队,这就是互斥的根源。

持有并等待是说你拿着 A 锁的同时,还想去申请 B 锁。这是最容易出事的一环,也是工程上最值得动刀的地方。只要一个线程在持锁期间不再申请新锁,死锁就无从谈起。

不可剥夺指的是别人手里的锁你抢不走,只能等。Java 的synchronized就是这样,进不去就BLOCKED,一直等下去;ReentrantLock稍好一点,因为你可以用tryLock(timeout)主动放弃。

循环等待是最关键的判据:A 等 B,B 等 A,绕成一个环。真实的死锁现场,你用线程转储把等待关系画出来,一定能看到这个环——可能只有两三个线程,也可能绕了五六层。

注意:这四个条件里,互斥和不可剥夺基本没法消除,真正能动手的是"持有并等待"和"循环等待"。前者靠缩小临界区解决,后者靠固定加锁顺序解决。这条思路后面会反复用到。

1.2 死锁、活锁、饥饿:三个词不能混着用

排查的时候我经常听到有人把三个概念混为一谈,结果方向跑偏。

死锁是线程彻底停住,状态不再变化,转储里能看到明确的BLOCKED或者WAITING,并且互相指向对方。

活锁是线程没停,但一直做无用功。典型场景是两个线程都在"检测到冲突就退让重试",结果节奏完全同步,你退我也退,谁也没往前推进。活锁的 CPU 通常是忙的,这一点跟死锁正好相反——死锁的 CPU 往往很低。

饥饿是某个线程长期拿不到资源。比如读写锁里读请求源源不断,写线程排了一下午也没轮上;或者线程池里低优先级任务总被高优先级插队。饥饿的线程状态是"能跑但一直没轮到",跟前两者的区别在于系统整体还在正常运转。

分清楚这三者的意义在于排查手段完全不同:死锁去看线程转储里的等待环,活锁去看重试逻辑有没有退避和随机化,饥饿去看调度策略和队列优先级。

1.3 死锁真正麻烦的地方,是它"偶发"

如果死锁每次必现,那反而是好事,本地一跑就暴露。麻烦的是它依赖时序:两个线程恰好同时走到某个位置,等待时间恰好重叠,才会咬住。生产环境并发量一大,这个概率被放大,于是变成"一天出一次""一周出一次"。

更恶心的是重启能解决——运维一看接口不通,重启,恢复了。问题被掩盖,根因一直留在代码里。所以我的习惯是:只要线上出现过一次"无报错、纯卡死、重启恢复"的现象,就当死锁处理,先把线程转储抓下来再说,别等第二次。关于怎么抓,第 3 节会详细写。

2. 典型死锁场景拆解:从业务代码到中间件

死锁不是只在面试题里出现,它在你每天写的业务代码里到处都是。下面几类场景是我这些年在不同系统里反复见到的,按出现频率从高到低排。

2.1 锁顺序不一致:最经典,也最容易复现

转账是讲这个场景最好的例子。账户 A 给账户 B 转钱,代码要同时锁住两个账户对象。如果两笔转账方向相反、同时到达,就会出问题。

线程 T1 处理"A 转 B":先锁 A,再锁 B。 线程 T2 处理"B 转 A":先锁 B,再锁 A。

只要 T1 拿到 A、T2 拿到 B,两边同时去申请对方的锁,环就形成了,谁也进不去。

用 Python 写出来大概是这样:

import threading import time lock_a = threading.Lock() lock_b = threading.Lock() def transfer_ab(): with lock_a: time.sleep(0.1) # 放大时序窗口,真实环境靠并发量 with lock_b: print("A -> B done") def transfer_ba(): with lock_b: time.sleep(0.1) with lock_a: print("B -> A done") t1 = threading.Thread(target=transfer_ab) t2 = threading.Thread(target=transfer_ba) t1.start(); t2.start() t1.join(); t2.join() # 会一直卡在这里

这段代码的意图很清楚:sleep(0.1)是为了让两个线程都先拿到自己的第一把锁,然后再去抢第二把。真实业务里不需要sleep,高并发下的调度抖动足够制造这个窗口。

Java 里用synchronized是同样的道理:

public class TransferDemo { private static final Object LOCK_A = new Object(); private static final Object LOCK_B = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (LOCK_A) { sleepQuietly(100); synchronized (LOCK_B) { System.out.println("A -> B done"); } } }); Thread t2 = new Thread(() -> { synchronized (LOCK_B) { sleepQuietly(100); synchronized (LOCK_A) { System.out.println("B -> A done"); } } }); t1.start(); t2.start(); } private static void sleepQuietly(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }

修复方式很朴素:给账户加一个全局唯一的 ID,加锁时永远按 ID 从小到大排序。不管来的请求是 A 转 B 还是 B 转 A,实际加锁顺序都是"先小 ID、后大 ID",循环等待的条件直接被破坏。

def transfer(from_acc, to_acc, amount): first, second = sorted([from_acc, to_acc], key=lambda x: x.id) with first.lock: with second.lock: from_acc.balance -= amount to_acc.balance += amount

这个改法成本极低,但效果是彻底的。我后来在所有需要同时操作多个对象的场景里都强制走这一套,包括批量更新、批量删除、多资源预约,一律先排序再加锁。

2.2 嵌套锁与回调地狱:看不见的第二把锁

比顺序不一致更隐蔽的是"锁里调别人"。你自己写的代码加锁顺序没问题,但你在持锁状态下调用了一个公共方法,而这个方法内部又去加了另一把锁——第二把锁的获取顺序完全不在你的控制范围内。

典型例子是事件监听。订单状态变更时你持有订单锁,然后触发监听器通知积分服务;积分服务那边加了自己的积分锁;同时另一个链路是先拿积分锁再回调订单状态——环就绕起来了。

这类问题的难点在于,代码和锁的对应关系被跨模块拆散了。我排查过一个系统,A 模块的锁在文件开头三行就加上了,触发死锁的那把锁在 B 模块第 400 行的方法里,中间隔着七层调用。光看 A 模块的代码,怎么都找不到问题。

我的经验是定两条硬规矩:

  • 持锁期间禁止调用外部模块、RPC、数据库、消息队列。要调用就把数据准备好,出临界区之后再调。
  • 锁只能内层获取、不能跨层获取。如果一个方法本身被设计成"可以直接外部调用"的公共方法,那它内部的锁就必须是叶子锁,不允许再往下拿新锁。

这两条写进代码规范,配合静态扫描偶尔查一下,能挡掉大部分问题。

2.3 数据库层面的死锁:行锁、间隙锁与事务边界

应用层锁之外,数据库死锁同样高频,而且往往更隐蔽,因为它发生在服务端,你的代码看起来毫无问题。

最典型的还是顺序不一致。两个事务分别更新 id=1 和 id=2 的行,方向相反,在默认隔离级别下就会互相等待:

-- 会话 A BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT; -- 会话 B(几乎同时) BEGIN; UPDATE account SET balance = balance - 50 WHERE id = 2; UPDATE account SET balance = balance + 50 WHERE id = 1; COMMIT;

数据库的死锁检测能力比应用层强得多,通常会主动挑一个事务回滚,抛出一个死锁错误。所以数据库死锁的表现往往不是"卡死",而是"偶发报错",这反而容易被忽略——业务方看到失败就重试,重试成功了就没人管了。

需要注意的第三个坑是间隙锁。在可重复读隔离级别下,范围查询会锁住间隙,两个不相干的条件如果落在同一个间隙里,也会互相阻塞。这种情况在批量任务和高并发写入混跑时特别容易出。

注意:数据库死锁的重试不是万能的。如果业务逻辑本身就容易形成环,重试只会让它反复撞墙,还可能放大流量。先修顺序,再加重试,顺序不能反。

2.4 线程池与资源池的相互等待

这一类死锁不太符合教科书的定义,但危害一样大:任务 A 在池子里等任务 B 的结果,而任务 B 因为池子满了排不进队列。

举个具体的:一个固定大小为 8 的线程池处理批量任务,每个任务内部又提交一个子任务到同一个线程池并future.get()等待结果。当 8 个父任务都占满线程、都在等子任务时,子任务排队的队列再长也没人消费——整个池子就僵住了。

这种"资源耗尽型死锁"在连接池、信号量、文件句柄上都可能出现。排查的时候看不到BLOCKED,看到的是WAITING在Future.get()或者Semaphore.acquire()上,很容易误判成下游慢。

应对方式有这么几条,我在不同项目里都用过:父任务和子任务用隔离的线程池;或者干脆不嵌套提交,把任务拆成两个阶段串起来;再或者用CompletableFuture的编排能力,避免"提交后原地等"。最简单的兜底是给get()加超时,至少不会永久卡住。

3. 实操:造一个死锁,再把它从现场揪出来

光看代码不动手,遇到真事还是懵。这一节把我自己练手和带新人时用的流程完整写一遍,你可以照着跑一次,把整个链路走通。

3.1 构造最小复现用例

用上一节的 Java 例子,编译跑起来。观察两个现象:进程没退出、CPU 占用接近 0。这就符合死锁的典型特征。

想让它更接近生产,可以加个循环,让两个线程反复抢占,同时把sleep去掉,改为在锁内做一些耗时操作(比如读配置文件、算个哈希),这样更容易在真实调度下咬住。

Python 那边如果想让复现率高一点,可以把sleep换成threading.Barrier,让两个线程在同一时刻同时冲线:

import threading lock_a = threading.Lock() lock_b = threading.Lock() barrier = threading.Barrier(2) def worker(first, second, name): with first: barrier.wait() # 双方都拿到第一把锁,再一起前进 with second: print(f"{name} done") t1 = threading.Thread(target=worker, args=(lock_a, lock_b, "T1")) t2 = threading.Thread(target=worker, args=(lock_b, lock_a, "T2")) t1.start(); t2.start() t1.join(); t2.join()

用Barrier之后基本是 100% 复现,不依赖运气,很适合写进单元测试或者演示脚本里。

3.2 抓现场:线程转储到底怎么看

进程卡住之后,第一件事不是重启,是抓栈。JVM 环境下用 JDK 自带的 jstack,一条命令就够:

jstack -l <pid> > thread_dump.txt

如果卡住的是 Python 进程,可以用faulthandler在超时后自动打印所有线程栈:

import faulthandler faulthandler.dump_traceback_later(30, exit=True)

拿到转储之后,重点看三样东西。第一,找BLOCKED状态的线程,这是最直接的目标。第二,看- waiting to lock <地址>和- locked <地址>这两行,它们明确告诉你这个线程持有哪把锁、又在等哪把锁。第三,把这些地址串起来,如果形成闭环,死锁就确认了。

一份典型的死锁转储片段长这样:

"Thread-0" #12 prio=5 os_prio=0 tid=0x00007f... nid=0x... waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) at com.example.TransferDemo.lambda$main$0(TransferDemo.java:15) - waiting to lock <0x000000076ab8a0f0> (a java.lang.Object) - locked <0x000000076ab8a0e0> (a java.lang.Object) "Thread-1" #13 prio=5 os_prio=0 tid=0x00007f... nid=0x... waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) at com.example.TransferDemo.lambda$main$1(TransferDemo.java:24) - waiting to lock <0x000000076ab8a0e0> (a java.lang.Object) - locked <0x000000076ab8a0f0> (a java.lang.Object)

读法很简单:Thread-0 抱着0xe0、在等0xf0;Thread-1 抱着0xf0、在等0xe0。两个地址交叉引用,环就闭合了。

顺便说一句,现代 JDK 的 jstack 自己就会输出一行Found one Java-level deadlock:然后把这个环列出来,省了不少事。但别完全依赖它——有些场景(比如等待线程池资源、等待Future)它识别不出来,还是得人工看。

3.3 线上环境的定位套路

生产环境不允许随便 jstack 怎么办?我的做法是提前埋好三样东西。

第一是定时转储。用一个看门狗线程,检测到某个关键接口的耗时超过阈值(比如 30 秒)就自动触发一次转储并归档,带时间戳。这样等发现的时候现场还在。

第二是锁等待监控。ReentrantLock提供了getQueueLength()和getOwner(),可以定时采样打点;JMX 里也有线程争用的统计。把这些指标接到监控上,锁等待数突增就是预警信号。

第三是业务侧的埋点。在关键的加锁点前后记录耗时,超阈值就打一条日志,带上当前线程名和业务单号。这条日志在事后复盘时价值极高,可以直接定位到是哪条业务链路卡住的。

有一次我们就是这么定位的:监控发现某个线程池的活跃线程数一直不降,捞出来看全是WAITING在一个锁上,再看持锁的那个线程卡在一个外部调用上。整个过程没重启,二十分钟定位到根因。

4. 预防与治理:从编码习惯到架构兜底

死锁这种事,事后修的代价远大于事前防。这一节按"成本从低到高"排,前面的都是写代码时顺手就能做的。

4.1 锁排序:最简单也最有效的第一条

前面已经说过,这里补充几个落地细节。

排序的依据要全局唯一且稳定。用对象 ID、主键、哈希值都行,但别用hashCode()——它可能冲突,也可能在不同 JVM 里不一致。用业务主键最稳妥。

多个对象的批量操作,一定先排序再逐个加锁。批量更新 100 条记录,如果按传入顺序加锁,两个请求传进来的顺序不同就可能成环:

List<Long> ids = new ArrayList<>(request.getIds()); Collections.sort(ids); // 排序是必须的,不能省 for (Long id : ids) { lockManager.lock(id); }

还有一点容易忽略:同一把锁在不同代码路径里必须保持一致的角色。我见过一个系统,A 路径把锁 X 当外层锁、B 路径把锁 X 当内层锁,虽然都用了排序,但因为层级关系反了,照样死。所以排序要配合"锁层级"一起用,给每类锁标一个层级号,只允许低层级等高层级,反方向直接抛异常——这种在开发阶段就能暴露问题,比上线再发现好太多。

4.2 超时和可中断锁:给自己留条后路

ReentrantLock的tryLock(timeout, unit)是保命符。等待超时之后抛异常、记日志、快速失败,总比无限期挂在那里强。

超时时间定多少?我的算法是:先测出这段临界区在 P99 情况下的执行耗时,乘以 5 到 10 倍,再向上取整到一个好记的值。比如临界区 P99 是 60 毫秒,那超时设 500 毫秒到 1 秒比较合适。设太短会误杀正常请求,设太长就失去了兜底的意义。

if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { try { doBusiness(); } finally { lock.unlock(); } } else { log.warn("acquire lock timeout, orderId={}", orderId); throw new LockTimeoutException(orderId); }

需要提醒的是,tryLock只能解决"无限期等待",不能解决"逻辑上必然死锁"。如果两个线程真的在等对方,超时之后两边都会失败,业务全挂。所以它是兜底,不是解法,顺序问题还是得靠 4.1 修。

数据库那边同理,可以设置锁等待超时(比如 3 秒),超时就回滚重试。但重试次数要限制住,一般 2 到 3 次封顶,避免雪崩。

4.3 缩小临界区:能不加锁就别加

最彻底的防死锁方式是减少锁的数量。持有并等待这个条件之所以成立,是因为你在持锁期间还去申请新锁。如果把持锁期间的逻辑压到最短,成环的概率就大幅下降。

具体做法有这么几个。把 IO 操作、RPC 调用、日志落盘全部挪到锁外面,锁内只做内存里的赋值和判断。用不可变对象替掉可变共享状态,数据一旦构造完成就不再修改,天然无锁。用ConcurrentHashMap的computeIfAbsent这类原子操作替代"先查再放"的复合动作,很多地方根本不需要显式加锁。

还有一个很实用的小技巧:读写分离。读多写少的场景用ReentrantReadWriteLock或者StampedLock,读操作之间不互斥,锁争用立刻降一个量级。但要注意写锁的公平性问题,写得频繁的时候读线程可能被饿死,这时候开公平模式,代价是吞吐会降一些,需要实测权衡。

4.4 死锁检测与自动恢复:工程化兜底

再小心也难免有漏网之鱼,所以线上得有检测机制。

最直接的是周期性线程转储分析。用一个低优先级线程每隔一段时间(比如 5 分钟)抓一次栈,用脚本解析里面的waiting to lock和locked关系,建图找环,发现环就告警。这个方案我落地过,实现不复杂,几百行代码,能覆盖住绝大多数应用层死锁。

再往上一层是自动恢复。检测到某个线程卡死超过阈值,可以考虑中断它。Thread.interrupt()对于处在lockInterruptibly()或者Condition.await()的线程是有效的,但对synchronized无效——这也是为什么我在新项目里更倾向用ReentrantLock,可控性强太多。

对于数据库死锁,恢复手段就是重试。但要区分:如果是同一业务反复触发死锁,说明顺序问题没解决,加多少重试都没用;如果是偶发的一次性冲突,重试一次基本就过了。

最后还有一条兜底:关键链路做超时熔断。接口层面设置总超时,超过就返回失败,别让请求无限期挂着。这样即使底层真的死锁了,用户至少能看到明确的失败,而不是页面转圈转到天荒地老,运维也能从错误率上第一时间发现问题。

5. 常见问题速查与避坑清单

5.1 排查速查表

现象最可能的原因第一步动作
接口全部超时,CPU 很低,日志停在某行应用层死锁立刻抓线程转储,找BLOCKED并核对锁地址
偶发报错,提示事务被回滚数据库死锁查数据库死锁日志,看两个事务的加锁顺序
线程池活跃数持续不降,任务排队资源耗尽型等待看栈是不是卡在Future.get()或acquire()
CPU 打满但业务没进展活锁或自旋重试检查重试逻辑有没有退避和随机化
单个线程长期拿不到锁饥饿检查锁是否公平、队列是否有优先级插队
重启后恢复,过几天复发时序相关的死锁加锁等待监控和定时转储,等它再出现

5.2 那些只有踩过才知道的坑

坑一:只 jstack 了一次就下结论。单次转储只能看到一瞬间的状态,如果那一刻恰好没有成环,你就什么都发现不了。我的做法是连续抓 3 到 5 次,间隔 5 秒,对比着看——真正卡死的线程,这几次的快照里位置是不会变的。

坑二:以为锁对象只要不是同一个就不会冲突。字符串常量、装箱后的Integer、缓存的连接对象,这些东西做了池化,看起来是两个变量,实际指向同一份内存。我见过有人用synchronized("order")加锁,结果全站订单串行,性能直接崩掉,还以为是自己业务写错了。锁对象一定要用new Object()或者业务实体的唯一实例。

坑三:锁释放写在 try 外面。lock()之后如果代码抛异常,unlock()没执行,锁就永久泄漏了。正确写法一定是lock()紧跟try,unlock()放在finally里,中间不能有任何其他语句。

坑四:把synchronized加到方法上。等价于用this当锁,任何人只要拿到这个对象引用,就能干扰你的锁。改成同步代码块加私有锁对象,安全边界清晰很多。

坑五:忽略wait()的循环判断。标准的等待写法必须是while (!condition) { lock.wait(); },不能用if。因为存在虚假唤醒,被唤醒不代表条件真的成立了,用if会导致在条件不满足的情况下继续往下跑,产生各种诡异的数据问题。

坑六:以为加了分布式锁就万事大吉。分布式锁解决的是跨进程互斥,进程内部的多线程问题一点没少。而且分布式锁本身还会带来新的死锁风险——锁超时时间设置不当、加锁和解锁不在同一线程、网络分区导致锁状态不一致,这些都会制造出比单机更难查的问题。

坑七:在锁里做日志输出。看着无害,实际日志框架内部经常有自己的锁和缓冲队列,高并发下锁里写日志会显著拉长临界区,等于人为放大了死锁窗口。真要在锁内记录关键信息,尽量攒到出临界区之后再打。

我个人在后来的项目里养成了一个习惯:每次写完一段带锁的代码,先自己回答三个问题——这段代码持锁期间还会申请别的锁吗?如果有,两把锁的获取顺序在所有路径上都一致吗?等待有没有超时?三个问题的答案都是肯定或者明确的"没有",这段代码才算过。听起来有点啰嗦,但比起半夜被叫起来处理线上卡死,这点时间花得太值了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 16:11:26

WAF防护与SQL注入攻防实战解析

1. WAF防护与SQL注入攻防背景在当今的Web应用安全防护体系中&#xff0c;WAF(Web Application Firewall)已经成为抵御SQL注入攻击的第一道防线。不同于传统防火墙基于IP和端口的过滤&#xff0c;WAF能够深度解析HTTP/HTTPS流量&#xff0c;通过预定义的规则集识别和阻断恶意请求…

作者头像 李华
网站建设 2026/10/29 16:10:45

马尔可夫决策过程:强化学习的数学基础与工程实践

1. 马尔可夫决策过程&#xff1a;强化学习的数学语言第一次接触强化学习&#xff08;RL&#xff09;时&#xff0c;很多人会被各种算法和代码实现吸引&#xff0c;却忽略了背后的数学基础。就像建房子不打地基&#xff0c;后期总会遇到结构性问题。马尔可夫决策过程&#xff08…

作者头像 李华
网站建设 2026/10/29 16:10:41

OCI Data Science与LlamaIndex的企业级AI集成实践

1. 项目概述Oracle Cloud Infrastructure (OCI) Data Science 与 LlamaIndex 的集成代表了当前企业级AI解决方案中最前沿的技术融合。作为一名长期从事数据科学平台架构的从业者&#xff0c;我见证了从传统机器学习流水线到现代LLM应用架构的演进过程。这次集成本质上解决了大语…

作者头像 李华
网站建设 2026/10/29 16:07:42

Windows恢复分区删除指南:diskpart命令详解与风险规避

1. 项目概述&#xff1a;删除"状态良好&#xff08;恢复分区&#xff09;"磁盘的必要性与风险在Windows系统磁盘管理中&#xff0c;我们经常会遇到标有"状态良好&#xff08;恢复分区&#xff09;"的特殊分区。这类分区通常由OEM厂商预装&#xff0c;包含了…

作者头像 李华
网站建设 2026/10/29 16:07:24

用Python实现词达人半自动答题:Selenium抓题与人工确认全流程

简介&#xff1a;一套用Python实现的词达人半自动答题工具源码&#xff0c;既适合对文字游戏感兴趣、想借助脚本提升答题效率的玩家&#xff0c;也可作为Python初学者阅读项目结构和拆分功能的练习素材。压缩包内共有十个文件&#xff0c;类型上以七个功能脚本为主体&#xff0…

作者头像 李华
网站建设 2026/10/29 16:06:20

YOLOv10实战:MNIST手写数字实时检测系统开发

1. 项目概述&#xff1a;当YOLOv10遇上数字识别去年在工业质检项目中第一次接触YOLO系列算法时&#xff0c;我就被这种"看一眼就定位"的检测方式震撼了。如今YOLOv10的发布&#xff0c;让实时检测的精度和效率又上了一个台阶。这次我们尝试用这个前沿框架&#xff0c…

作者头像 李华