news 2026/10/14 17:14:24

死锁排查与预防实战:从线程快照到锁顺序优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
死锁排查与预防实战:从线程快照到锁顺序优化

前言

死锁这玩意儿,像程序世界里的灵魂绑架。线程们互相攥着对方想要的锁,谁也不撒手,然后一起卡死在那里,既不报错也不退出,CPU 占用看着还挺正常甚至接近 100%,可业务就是一动不动。我见过不少项目,线上半夜挂掉,重启前拉线程快照,满屏都是Thread-12持有锁 A 等待锁 B,Thread-7持有锁 B 等待锁 A——明明白白一对冤家。最气人的是,这种问题在代码 review 阶段根本看不出来,压测环境偶尔复现一两次,你重启完又跑得好好的,真到了高峰期流量一大,瞬间大面积卡死,让人措手不及。

这篇内容不是教科书式的理论复述,而是把我在多个并发项目里踩坑、挖坑、填坑的经验全部倒出来。从死锁产生的四个必要条件讲起,再把真实场景里的经典死锁代码、检测方法、修复策略一项项拆开,最后附上我个人积累的排查清单和速查表。适合正在写并发代码的同学,也适合线上问题排查查到崩溃的兄弟,内容直接照着“抄作业”就行。

目录(简略版,便于整体把握)

  1. 死锁的本质与四个必要条件
  2. 高频死锁场景与典型代码复盘
  3. 死锁检测与线程快照定位实操
  4. 死锁的预防策略与工程化改造方案
  5. 常见问题排查实录与避坑速查表

1. 死锁的本质与四个必要条件

1.1 死锁到底是什么

死锁是两个或两个以上的线程在执行过程中,因为竞争资源而互相等待,在没有外部干预的情况下永远无法继续推进的状态。不用被定义绕晕,举一个特别生活化的例子:两个人面对面过一条独木桥,桥上只能同时过一个人,两个人都不肯退让,都想让对方先退后自己先过,结果谁也别想过桥。线程和锁的关系就是这样,不过线程比人更死板,人急了还能跳水里游过去,线程没有指令让它退让,它就永远堵在那。

死锁在 Java、C++、Go、Python 等几乎所有支持多线程编程的语言里都会出现,只是表现形式略有不同。Java 里面用synchronized和ReentrantLock,C++ 里面用std::mutex,Go 里面用 channel 加 mutex 混着写也照样能挂。可以说,锁与锁之间的关系设计出了问题,和语言本身没多大关系。

1.2 四个条件缺一不可

死锁的诞生需要同时满足四个条件,任何一个不成立,死锁都不会发生。这四个条件是理解一切死锁问题的基础:

条件含义生活类比
互斥资源一次只能被一个线程占用独木桥上只能站一个人
持有并等待线程持有至少一个资源,同时等待获取另一个资源已经占了桥入口,还伸手去抓桥出口
不可剥夺资源只能由持有者主动释放,不能被强行夺走没有管理员能把桥上的人拽下来
循环等待两个或多个线程形成首尾相连的资源等待环A 等 B 的锁,B 又等 A 的锁

很多人背过这四个条件,但是遇到实际代码还是反应不过来,原因是条件太抽象。我习惯在看代码时做一个“资源等待图”的心理映射:把每个线程看成一个圆圈,把每把锁看成一个方框,线程持有锁就画一条从方框指向圆圈的线,线程等待锁就画一条从圆圈指向方框的线。如果这个图里出现了环,那就是死锁的充分必要条件。你可以说这是有向图中检测环的问题,也可以用肉眼看一两个简单场景,但复杂场景还是交给工具,后面第三章节会详细说。

1.3 饿死、活锁与死锁的区别

写并发代码时,还有两个容易混淆的概念:活锁和饥饿。死锁是大家都不动,CPU 空转;活锁是线程虽然没有被阻塞,但一直在重复做无效的操作,比如两个线程检测到冲突后互相谦让,你让一步我让一步,结果让来让去还是没法推进。饥饿则是某个线程一直拿不到锁,长期无法执行,但这并不是死锁,因为其他线程还在正常推进。

我见过一个特别典型的活锁代码:线程 A 发现锁被占,就主动等一会儿再重试,线程 B 发现锁被占也主动等一会儿再重试,两边都采用同样的退避时间,结果不断同步错开又不断撞上。解决方法是在退避算法里引入随机性,比如每次等待时间用随机因子扰动,打破共振。

搞清楚这三者的区别,排查问题时才不会定位错方向。死锁看锁的持有等待关系,活锁看重试节奏,饥饿看调度策略。很多新人一看到线程卡住就喊死锁,其实线程快照里如果能看到大量RUNNABLE状态的线程在做重复操作,那更像是活锁,而不是死锁。


2. 高频死锁场景与典型代码复盘

2.1 经典教科书式死锁:嵌套锁反向加锁

先来一段最典型的死锁代码。银行转账场景为什么总被拿来当教学案例,因为它天然涉及两个资源账户,而且不同线程操作两个账户的顺序可能不同:

public class BankAccount { private int id; private double balance; public void transfer(BankAccount target, double amount) { synchronized (this) { synchronized (target) { if (this.balance >= amount) { this.balance -= amount; target.balance += amount; } } } } }

线程 A 执行acc1.transfer(acc2, 100),线程 B 执行acc2.transfer(acc1, 100),这个时候线程 A 持有acc1的锁等待acc2的锁,线程 B 持有acc2的锁等待acc1的锁,完美满足四个条件,死锁当场发生。

修复方案相信大家都能想到:让所有线程都遵循同一个加锁顺序。比如约定先对 id 小的账户加锁,再对 id 大的账户加锁。这样两个线程都会先抢acc1的锁,再抢acc2的锁,就不会出现反向等待。代码改造如下:

public void transfer(BankAccount target, double amount) { BankAccount first = this.id < target.id ? this : target; BankAccount second = this.id < target.id ? target : this; synchronized (first) { synchronized (second) { // 转账逻辑 } } }

这个方法看起来很简单,但实际项目里锁的对象可能不是结构化的账户,而是动态的缓存 key、字符串常量、枚举对象,排序的规则就要根据具体情况来定。我在某个支付系统中处理过类似的场景:多个资源 id 是 UUID 随机生成的,没有大小顺序,最终做法是取两个 id 拼接后的哈希值来排序,哈希碰撞概率虽然极低,但为了保险还加了equals二次校验和超时保护。

2.2 高并发业务里最容易出的死锁:线程池与锁的嵌套

嵌套锁导致的死锁属于最基础的一类,但工程中更常见也更阴险的是线程池排队和锁嵌套的组合。别人转发过一段经典案例:一个任务先获取了某个资源的锁,在锁内部又把后续的子任务提交到了同一个线程池中。如果线程池的队列已满,或者核心线程数已耗尽,这个锁内的子任务就会在队列里排队等待。而排队的线程池中的线程为了执行队列中的下一个任务,可能又需要获取同一把锁。

这里用一个简化模型说明:有一个固定大小为 2 的线程池,同时提交了 3 个任务,每个任务先拿锁 A,然后在锁内提交一个子任务到同一个线程池。第一批两个任务拿到了锁 A,他们的子任务被提交到队列,但线程池已经没有空闲线程去执行这些子任务了。这两个父任务在等待子任务的结果返回(通过Future.get()),子任务却拿不到线程来执行,于是两个父任务永远不释放锁 A,第三个任务也无法获取锁 A,整个线程池被卡死。

我在一个异步任务处理框架里踩过这坑,表现形式是线程池全部线程处于WAITING状态,队列里堆着一大堆子任务,页面上的任务状态全部是“处理中”,重启才能恢复。解决办法有两个方向:一是把线程池隔离,父任务和子任务用不同的线程池,避免子任务排队时卡住父任务持有的锁;二是锁内不要提交异步任务,把子任务先收集起来,出锁后再批量提交。两招我都用过,实际项目里第二招更干净,第一招适合没法改任务拆分逻辑的场合。

2.3 动态锁顺序死锁:数据库行锁也躲不掉

有人说 Java 同步块能管住,数据库行锁总不至于出事。还真能,而且死锁起来更隐蔽。数据库锁的加锁顺序由 SQL 执行计划决定,两条 SQL 同时修改多个记录时,如果加锁顺序不一致,数据库层面同样会产生死锁。

举个例子:事务 1 执行UPDATE account SET balance=balance-100 WHERE id=1,再执行UPDATE account SET balance=balance+100 WHERE id=2;事务 2 执行UPDATE account SET balance=balance-100 WHERE id=2,再执行UPDATE account SET balance=balance+100 WHERE id=1。如果两个事务交错执行,一个先锁了 id=1,另一个先锁了 id=2,后面互相等待,数据库直接报死锁错误,只回滚其中一个事务。

MySQL 里出现死锁时,错误日志会显示Deadlock found when trying to get lock; try restarting transaction。解决思路和 JVM 里的策略一模一样:要么让所有事务都按 id 升序加锁,要么在事务里用SELECT ... FOR UPDATE预先锁定所有需要的记录,提前把锁占好。这里要提醒的是,数据库死锁是可以被数据库检测并自动回滚的,这和 JVM 死锁不一样,JVM 不会自动检测,只能靠jstack人工发现。

2.4 可重入锁引起的“假死锁”与自死锁

还有一种很容易误导人的死锁:同一个线程试图获取自己已经持有的不可重入锁。C++ 的std::mutex默认不可重入,同一线程连续两次lock()就会自己锁死自己。Java 的synchronized是可重入的,所以没这个问题,但ReentrantLock默认也是可重入的,也不会有问题。

真正需要注意的是那些自定义的不可重入锁,或者把锁升级成了读写锁但写锁内部又调用了读锁获取逻辑,这种变种情况我在一个缓存框架里遇到过。某个类用了读写锁保护缓存,在写锁内更新缓存后,又调用了一个内部方法去读取缓存,这个方法又尝试获取读锁,结果写锁持有者去等读锁,而同一个线程在其他地方也持有读锁想升级成写锁,两边纠缠在一起。

这类问题的典型特征是在线程快照里能看到同一个线程持有锁 X,同时又在等待锁 X,方向还是同一个线程。看到这种日志,第一反应就是检查锁的可重入性设计。


3. 死锁检测与线程快照定位实操

3.1 用 jstack 定位 Java 死锁

出现死锁征兆时,第一时间要做的是保留现场。Java 环境里最通用的命令就是jstack,把自己那个线程 id 对应的 Java 进程主线程 dump 下来。

# 先找到Java进程ID jps -l # 导出线程快照 jstack -l <pid> > /tmp/thread_dump_$(date +%Y%m%d%H%M%S).txt

拿到 dump 文件后,只看两处:一是文件末尾是否包含Found one Java-level deadlock或者Found 1 deadlock的字样。如果存在,JVM 已经帮你算好了死锁环,全链路列出每个线程持有的锁和等待的锁;二是搜关键字waiting to lock,把每个线程“持有锁”和“等待锁”的关系抓出来,人工构建资源等待图确认环。

实际排查中我总结过一个套路:用 grep 把"xxx"持有锁的行拉出来,再把waiting to lock的行拉出来,配套at java.lang.Object.wait等行号信息,能快速判断哪些线程卡在锁上,哪些线程卡在Condition的await上。不要一上来就翻整份 dump,文件几千行,翻到后面眼睛都花了。

3.2 ThreadMXBean 程序化检测死锁

在线排查太麻烦时,可以在程序启动参数里加一个检测线程,每 5 秒扫描一次是否有死锁线程存在,一旦发现就主动预警。Java 自带 API 做了大部分工作:

import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; ThreadMXBean tmb = ManagementFactory.getThreadMXBean(); long[] deadlockedThreadIds = tmb.findDeadlockedThreads(); if (deadlockedThreadIds != null) { for (long id : deadlockedThreadIds) { ThreadInfo ti = tmb.getThreadInfo(id); System.err.println("Deadlocked thread: " + ti.getThreadName() + ", state=" + ti.getThreadState()); } }

这里有个容易忽略的细节:findDeadlockedThreads()只检测因为对象监视器锁(synchronized)产生的死锁,如果要检测ReentrantLock导致的死锁,要调用findMonitorDeadlockedThreads()和findDeadlockedThreads()两个方法结果合并判断。JDK 6 之后findDeadlockedThreads()支持java.util.concurrent.locks的死锁检测,但不同的 JDK 版本行为细节有差异,稳妥的做法是两个方法都调用。

3.3 C++/Go 场景的线程栈分析

非 Java 语言排查死锁的姿势略有不同。C++ 用pstack或者gdb attach来看线程栈,核心思路同样是获取线程当前执行位置,然后人工推理锁关系:

pstack <pid>

gdb 操作更灵活:

gdb -p <pid> -batch -ex "thread apply all bt"

Go 程序直接把所有 goroutine 的栈信息输出到文件:

curl http://localhost:6060/debug/pprof/goroutine?debug=2

Go 的go tool pprof或者直接在运行中的程序里注册net/http/pprof可以拿到 goroutine 栈,栈顶会显示 goroutine 正在sync.Mutex.Lock()等待,同时能看到它等待的锁地址。配合go race在开发阶段跑竞态检测,能提前发现很多锁顺序问题。

3.4 线上崩溃时的现场保护注意事项

线上出了问题不要急着重启,这是我在几次事故中得到的惨痛教训。死锁现场如果丢掉,能证明死锁的线索也就没了。正确的操作顺序是:

  1. 先执行 jstack/pstack 等快照采集命令。
  2. 如果有 dump 文件,保留完整 JDK 自带堆转储(jmap -dump)如果确定要分析内存。
  3. 确认现场信息留够后,再触发服务重启或者摘流量。

另外一个细节:很多容器环境里跑的是 JVM 进程,PID 并不是容器外面看到的那个。需要先进入容器内部用jps找 PID,或者直接用docker top <容器名>查对应进程号。这个操作我在 k8s 环境里踩过几次,jstack报 attach 失败或 PID 不存在,多数是容器 PID 映射问题。


4. 死锁的预防策略与工程化改造方案

4.1 策略一:锁顺序一致性

前面提到过转账案例,锁顺序一致性是解决死锁最基础、最有效的方法。所有线程在获取多个锁时,都遵循一个全局一致的顺序,循环等待条件就被破坏了。

实现层面有几个技巧:

  • 为资源定义全局唯一的序号,比如数据库主键、对象 id、哈希值。
  • 加锁前对所有需要加锁的资源按序号排序。
  • 如果资源无法排序,退一步使用一个“元锁”来做全局协调,比如所有线程先获取全局锁再去拿其他锁。

全局锁方案性能损耗大,但实现简单,适合锁竞争不激烈的低并发场景。排序法性能损耗小,但要求每个资源都有可比较的唯一标识。

我在一个分布式锁组件里见过一个更巧妙的做法:每次锁多个 key 时,把所有 key 排序后拼接成字符串作为“锁描述”,然后把锁超时时间统一设置成同一个值,配合 Redis 的 Lua 脚本一次性加锁,顺序天然一致,死锁问题从根上消除了。这个方法很值得参考。

4.2 策略二:使用带超时的锁替代无限等待

让锁带超时是容错性很高的策略。Java 的ReentrantLock提供了tryLock(timeout, TimeUnit)方法,超时后返回false,线程不再无限等待,而是走异常或重试逻辑:

Lock lockA = new ReentrantLock(); Lock lockB = new ReentrantLock(); if (lockA.tryLock(1, TimeUnit.SECONDS)) { try { if (lockB.tryLock(1, TimeUnit.SECONDS)) { try { // 业务操作 } finally { lockB.unlock(); } } else { // 获取lockB超时,回退处理 } } finally { lockA.unlock(); } }

这里有一个很关键的问题:tryLock是用系统时间来计算等待剩余时间的。宿主时钟如果发生大幅跳变(比如 NTP 校时),会直接影响超时判断。虽然现在的操作系统时钟同步越来越精准,但在要求极高的系统里,需要评估是否值得用tryLockNanos结合单调时钟实现自定义超时,而不是直接依赖tryLock(timeout)。

超时时间设置也是门学问。设置太短,高负载下正常的锁竞争也会被误判为超时,导致业务大量重试;设置太长,又失去了超时保护的意义。个人经验是把超时时间设置为该资源正常持有耗时的 5~10 倍,比如资源持有时长通常在 50ms 左右,超时设置为 500ms 就比较合理,太重试的业务可以设置 1 秒。

4.3 策略三:避免在锁内执行耗时操作

死锁的本质是线程占着资源不去干活,如果锁的持有时间极短,就算有循环等待,线程也能快速释放让另一个线程推进,死锁的触发概率就大幅降低。所以工程上有个金科玉律:锁内不做 IO、不调用远程服务、不做大计算、不等待子任务完成。

锁内存取 HashMap 没问题,锁内查数据库就要掂量一下。锁内调用第三方 HTTP 接口更玄学,因为远程服务超时可能导致线程在锁内长期挂起,这时候持有锁的时间变得不可控,死锁概率指数上升。

我在之前维护的一个订单处理系统里,对锁内逻辑做了严格的度量:每次拿到锁之后记录时间,释放锁时统计耗时,一旦锁持有时间超过 300ms 就报警。靠着这个指标淘汰了一大批不合理的锁设计。有了监控数据,你才知道锁内代码是不是真的短、平、快。

4.4 策略四:锁粒度控制与无锁化改造

很多死锁问题都源于锁粒度过大和锁边界不清晰。如果能做到锁粒度尽量小,单把锁覆盖的资源尽量少,多个锁之间的交叉概率也会下降。更进一步的思路是抛弃表面上的锁,用无锁数据结构或者原子变量来替代加锁操作。

Java 中有ConcurrentHashMap、AtomicReference、LongAdder等工具,C++ 中可以用std::atomic和std::shared_mutex。读多写少的场景用读写锁也能大大提升并发度,但读写锁的降级和升级要特别注意,写锁内尝试获取读锁在同一个线程上是允许的,反过来在读锁内获取写锁则可能死锁。

无锁化不是万能药。ABA 问题是 CAS 操作最典型的坑,需要加入版本号或布尔标记来规避。而且无锁算法的正确性证明复杂度远高于锁方案,如果没有多年并发经验,优先还是把锁设计做对,而不是追求花哨。

4.5 策略五:资源分配的“全有或全无”

银行家算法是教科书级的资源分配策略:一个进程要么一次性获得所有需要的资源,要么一个都不拿。放在工程实践里,可以用“抢占资源预分配”实现,也就是一把锁内获取全部资源,比如将多把锁合并成一把“聚合锁”。

最简单的落地做法是把多个账户的锁都放在一个统一的对象上,比如用一个全局锁保护所有账户的转账操作。这牺牲了并发性,但对于账户数量不大、并发转账频率不高的系统来说完全够用,而且死锁问题直接消灭。

分布式系统里更多使用的是“乐观锁”方案:不加锁,只比对版本号或时间戳,更新时检查版本是否正确,不正确就重试。乐观锁没有死锁问题,但会有并发更新的冲突重试问题,这也是业务上可以接受的。


5. 常见问题排查实录与避坑速查表

5.1 案例一:莫名其妙出现的“假死锁”其实是线程泄漏

有次线上某服务不定时卡死,线程 dump 里没有死锁环,一堆线程卡在数据库查询或者 HTTP 调用上。定位了半天,发现是连接池的最大连接数被打满,所有线程都在等待获取连接,而持有连接的线程本身又发生了什么异常,迟迟不归还。从外在看有点像死锁,但本质是资源池耗尽。

处理方式是把线程 dump 里waiting to lock换成waiting to connect或者waiting on condition去搜,能发现大量线程阻塞在连接池的信号量上,而不是在 Java 对象锁上。资源池问题要用池化组件的监控指标去看活跃连接数、空闲连接数、等待线程数。看清对象,不要见到阻塞就喊死锁。

5.2 案例二:因锁升级导致的写锁等待读锁

一个缓存刷新服务,大体逻辑是:外部请求触发缓存刷新,刷新线程先拿写锁,清掉旧缓存,然后回源拉取新数据。逻辑内部有一个公共的方法get(key)每次都先拿读锁。刷新线程在持有写锁状态下调用get,尝试拿读锁,同一线程在持有写锁的前提下获取读锁,这本身就是允许的,但如果另一个线程先持有了读锁,然后这个刷新线程被阻塞在写锁上,刷新线程又持有写锁等待读锁——不对,写锁阻塞时它没有持有写锁。

真正的死锁发生场景是:线程 1 持有写锁,线程 2 持有读锁,线程 1 尝试再拿写锁(因为重入计数)或者某些特殊实现导致内部需求,线程 2 等线程 1 释放写锁,线程 1 等线程 2 释放读锁。这就是读写锁升级引发的经典死锁。解决办法是刷新线程不要复用同时获取读锁的方法,而是内部直接用数据快照访问缓存;或者在允许的框架里获取升级锁。

5.3 案例三:重试机制放大了死锁影响面

某回调处理系统内加了自动重试机制,线程 A 持锁后不满足业务条件,会释放锁然后重新获取锁,循环次数较多。大量线程在同一时刻按相同节奏重试,导致锁竞争极其严重,有效吞吐急剧下降,最后线程全部卡在锁上表现为接近死锁状态。

最终方案是给重试加随机退避,退避窗口取 50ms 到 300ms 之间的随机值,并且控制单线程最大重试次数,超过次数直接失败返回,不继续纠缠。退避的目的是打破多个线程之间的同步共振,类似的技巧在分布式系统中的指数退避算法里也非常有用。

5.4 避坑速查表

下面的表格是我在实际项目中沉淀的排查清单,遇到死锁相关问题时可以按顺序过一遍。

排查维度检查项处理建议
线程快照是否存在Found one Java-level deadlockJVM 自动检测结果直接按 JVM 输出的死锁环分析
线程快照waiting to lock是否成环人工绘制资源等待图,确认环是否存在
资源池线程卡在waiting to connect或waiting on condition居多检查数据库连接池、线程池、HTTP 连接池
锁设计是否有线程持锁后执行 IO、远程调用限流锁内存放时间,超阈值报警
锁设计加锁顺序各线程是否一致统一资源排序规则
锁设计是否存在读写锁升级使用数据快照或分离方法
重试机制重试节奏是否会造成线程共振引入随机退避
工具检测定时调用findDeadlockedThreads()程序化监控自动告警

这张表不是理论推出来的,是排查过线上问题之后实践总结出来的。每次遇到阻塞问题,按这个顺序过一遍,大部分都能定位到真正原因。

5.5 一个小技巧:用日志打印锁获取点辅助分析

死锁定位难,除了工具不够熟,很多时候是因为代码里没有留下足够清晰的锁获取痕迹。我建议在关键锁获取的地方打印日志,至少记录线程名、锁目标对象、持锁时长。实践下来比较实用的是下面的格式:

long start = System.nanoTime(); boolean gotLock = lock.tryLock(1, TimeUnit.SECONDS); long cost = System.nanoTime() - start; if (gotLock) { log.info("lock acquired by thread={}, cost={}ns", Thread.currentThread().getName(), cost); } else { log.error("lock timeout by thread={}, cost={}ns", Thread.currentThread().getName(), cost); }

线上排查时,有日志的锁和没日志的锁完全是两个概念。有日志的时候,锁竞争在哪里、等了多久一目了然;没日志的时候,只能靠线程 dump 做一次长周期内的模糊猜测。


一些自己的体会:死锁问题不是写代码时“注意一下”就能杜绝的,它更像并发设计里的系统性问题。每次排查完死锁,我都会花时间审视一遍整个系统的锁使用惯性:哪些将可以合并以减少锁竞争,哪些可以通过 CAS 替换,哪些锁需要加超时,哪些锁内部要禁止远程调用。把这些规则沉淀成团队内部的代码规范,比临时补救高效得多。多线程这条路上没有一劳永逸的解法,但每踩过一个坑,对并发模型的理解就厚一层。

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

GitHub日榜趋势监测系统:从原始数据到技术信号提取

1. 这不是“榜单截图”&#xff0c;而是一套可复用的 GitHub 日榜趋势观测系统你有没有试过打开 GitHub Trending 页面&#xff0c;刷出一堆陌生项目&#xff0c;点开 README 又看不懂技术栈&#xff1f;或者想跟踪某个新兴框架的爆发节奏&#xff0c;却只能靠手动刷新、截图、…

作者头像 李华
网站建设 2026/10/14 17:13:02

Python爬虫实战:B站动漫数据抓取与可视化分析全流程

简介&#xff1a;这是一份面向Python爬虫初学者与数据分析爱好者的实战项目资源&#xff0c;以B站番剧为切入点&#xff0c;串联爬虫抓取、数据清洗、统计分析与可视化全流程。项目先爬取总榜获取动漫粗略信息与直达链接&#xff0c;再逐条访问详情页补充数据&#xff0c;最终结…

作者头像 李华
网站建设 2026/10/14 17:12:14

信息学竞赛复赛试题解析:从线性枚举到前缀和与约瑟夫环

简介&#xff1a;第18届绍兴市少儿信息学竞赛复赛试题已整理为一份Word文档&#xff0c;面向小学阶段的信息学爱好者与编程初学者&#xff0c;既可用于备赛练习&#xff0c;也可作为课堂或社团的测评资料。卷面包含“好朋友”“统计人口”“卫星”“粉刷匠”四道经典题目&#…

作者头像 李华
网站建设 2026/10/14 17:10:15

OpenCode Go额度又提升了

10 月 12 日 17:14&#xff0c;OpenCode 官方账号在 X 上发了条帖子。Operation Cheepseek 第三阶段&#xff0c;$10的GO套餐&#xff0c;DeepSeek V4.1 Flash 使用额度提升到 $80。 X 上 OpenCode 官方账号的公告帖&#xff0c;两行字&#xff0c;603K 次查看&#xff0c;6.9K…

作者头像 李华
网站建设 2026/10/14 17:10:06

木材科学类论文的图表与排版:从一块板倒推该放哪些数据

木材科学与工程方向的论文&#xff0c;逻辑起点往往是一件具体的产品——一块人造板、一件家具部件。从成品倒推该测什么、该放什么图、该怎么排版&#xff0c;比从概念出发更实用。 说明&#xff1a;木材科学与工程不属于平台官网列明的学科专项方向&#xff08;官网专项为生信…

作者头像 李华
网站建设 2026/10/14 17:10:00

面向对象数据库(OODB):对象即存储的原生持久化方案

1. 为什么今天还要谈“面向对象数据库”——一个被低估的架构选择很多人第一次听到“面向对象数据库”&#xff08;Object-Oriented Database&#xff0c;简称OODB&#xff09;时&#xff0c;下意识反应是&#xff1a;“这东西不是90年代就淘汰了吗&#xff1f;现在不都用关系型…

作者头像 李华