news 2026/10/20 8:33:47

多线程入门到实战:并发、锁与线程池核心原理深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多线程入门到实战:并发、锁与线程池核心原理深度解析

我学多线程学了三四轮才敢说自己真的入门了。回头看,最大的问题不是理解不了某个API,而是我一直用错误的方式在学。多线程几乎是所有中级开发者的分水岭,背后牵扯的并发、锁、线程池、JMM、CPU缓存这一整条链路,如果靠死记硬背来学,看书的时候觉得自己懂了、一到线上出问题时还是两眼一抹黑。这篇内容不打算按教科书顺序讲,我想换个方式,用我自己踩坑和验证过的路径,把这些年前前后后积累的东西拆开揉碎讲清楚,适合刚接触多线程的新手,也适合学了一半卡在"看得懂但写不对"阶段的同学。

1. 理解多线程的真需求:为什么并发能提速,以及大多数人第一步就理解错了

很多人学多线程的第一反应是"多线程能提高性能"。这个说法不能说错,但它太粗糙了,粗到会直接误导后面的学习方向。日常大家说"快",其实包含了两种完全不同的东西:响应快和吞吐高。

响应快指的是单个请求从发出去到拿回结果的时间更短,吞吐高指的是单位时间内能处理的请求数量更多。多线程在这两者的作用方式完全不一样。如果说响应用的是并行计算,把一个大的计算任务拆成N块同时算;吞吐靠的则是并发处理,比如一个Web服务同时收1000个连接请求,每个请求单独开线程去处理,单看一个请求并没有变快,但整体能接收的请求量上去了。

所以就带出第一个关键问题:什么时候开多线程真的有收益?这里可以套一个处理器领域的加速比公式:加速比 = 1 / ((1-P) + P/N)。P是任务里能被真正并行化的部分占比,N是CPU核数。假设一个任务里能并行的部分占50%、单核耗时100毫秒,四核机器上理论加速比只有1.6倍。但如果P能提到90%,加速比就接近2.9倍。这个公式的价值在于提醒你,多线程优化的天花板取决于并行部分占比,不是线程数越多越好。如果代码里串行部分是瓶颈,加多少线程都没用。

还有一个反直觉的例子:把任务拆成比CPU核数多太多的线程。一个简单的计数任务,10万次累加,在8核机器上分别开8个线程和8000个线程,你会发现8000个线程不仅没有更快,反而明显变慢。原因就是上下文切换。线程调度器要在不同线程间切换执行,切换时保存和恢复现场是有开销的,线程数一旦远远超过核数,CPU大量的时间都花在线程切换而不是真正的计算上。这个开销在IO密集场景可能被掩盖住,但CPU密集型场景直接裸露出来。

所以在动线程之前,先回答三个问题:

  • 这个任务的瓶颈是CPU计算,还是IO等待?
  • 任务里天然能拆成独立子任务的部分到底占比多少?
  • 线程的数量是不是匹配当前的硬件资源?

想清楚这三点,后面的锁和线程池研究起来才心中有数。多线程的核心价值不是"开线程",而是正确地组织并发,让CPU忙起来而不是空转,让IO等待时间里也能干活。

2. 线程的一生:六种状态背后的运行逻辑

线程状态这个问题,几乎每次面试都会考,但大部分教材讲得干巴巴的,背了也不理解。我自己是在排查一次线上卡顿问题、打完jstack日志后,才彻底把状态机这件事刻进脑子的。

Java线程有六个状态,线程被new出来之后处于NEW;调用了start()之后进入RUNNABLE。RUNNABLE是"可运行"的意思,实际意味着"随时可以被调度器选中执行"或者"正在运行中"。它并不代表"正在执行",这个区分在上操作系统课的时候容易模糊,放到Java里得抠清楚。

BLOCKED、WAITING、TIMED_WAITING这三个状态都代表线程"暂时干活不了",但原因完全不同。BLOCKED是线程想进入synchronized同步块或方法,但锁被别人持有着,它被挡在门外干等。WAITING是线程主动放弃CPU,等待另一个线程来通知它,常见触发点是wait()、join()或者LockSupport.park()。TIMED_WAITING是带超时时间的等待,sleep(100)、wait(1000)、join(2000)都会进这种状态。TERMINATED就是线程执行完了run()方法,彻底结束。

这个状态机最容易被坑的地方有三个。

第一个坑是调用start()之后线程真的立刻执行吗?不是。它只是进入了RUNNABLE等待调度,真正跑起来由操作系统决定,这段等待时长可能非常短,也可能持续一个完整的时间片长度。存在这种调度延迟,所以多线程程序天生就有不确定性,这也是并发Bug难以复现的根源。

第二个坑是sleep不释放锁。假如一个线程持有了锁,然后在synchronized块里执行sleep(5000),其他线程想拿这把锁就得跟着等够五秒。很多刚学多线程的人天真地以为"sleep了别人就能进来了",没有那回事。sleep只是让当前线程休息,它带着锁一起休息。真正释放锁的方式是退出synchronized块,或者调用wait()方法。

第三个坑是用jstack观察线上线程时,你看到的RUNNABLE并不一定在跑代码。因为Java里的RUNNABLE还把IO等待算进去了,比如一个线程正在等待Socket数据到达,在操作系统眼里它是在等待IO、处于休眠状态,但在Java层面它仍然是RUNNABLE。所以排查线上CPU不高但线程大量卡在RUNNABLE的时候,很可能是IO操作阻塞而不是真的在疯狂计算。

实操建议:遇到线程"卡死"问题,先用jps找出Java进程ID,然后jstack <pid>打出线程快照。重点看线程栈顶部的状态和锁信息,什么锁被谁持有、哪些线程在等锁、有没有循环等待,基本一目了然。这是排查死锁和线程池满的常规操作,也是理解状态机最好的实践入口。

线程状态的学习价值不在于记住六个枚举名,而在于建立"线程什么时候会主动让出CPU、什么时候被强制挡在外面、什么时候真正在跑"这种画面感。有了这个画面,后面看并发工具源码就会顺畅很多。

3. 线程协作机制:为什么wait/notify必须放在synchronized里

线程之间的协作比"互相抢锁"复杂得多。抢锁是竞争关系,而协作更像是"你做完我接手"这种上下游关系。Java提供的最原始的协作工具就是wait()和notify(),这套机制看懂了,后面再学CountDownLatch、Semaphore就轻松很多。

wait()做的事情是让当前线程释放掉它持有的锁,然后进入该锁对象的等待集合。注意"释放锁"这三个字,这是wait和sleep最本质的区别。notify()则是随机叫醒一个正在此锁对象上等待的线程;notifyAll()叫醒所有,让它们重新去抢锁。被叫醒并不会立刻执行,它要先和其他线程一起竞争重新拿到锁,拿到之后从wait()调用的下一行代码继续执行。

为什么这套机制必须要求在synchronized块内部使用?直接原因是没有持有锁就会抛IllegalMonitorStateException。但更深层的原因是防止lost wakeup问题。设想两个线程协作,一个负责生产数据、一个负责消费数据。消费者先检查缓冲区是否为空,空了就等待生产者填充。

问题出在这个"检查-等待"不是原子操作。消费者检查发现为空,但还没来得及调用wait(),生产者此时已经把数据放好了并调用了notify()。然后消费者才执行到wait(),它只能继续等下去。可是生产者的通知信号已经发出去了,不会再补一次。消费者就永远等在那里。把"检查-等待"整体放进同步块里,让它在锁的保护下原子完成,就能保证生产者不可能在检查与等待之间插入动作,从而避免信号丢失。

两个经典的生产者消费者写法可以对比着看。用wait/notify手写:

synchronized (queue) { while (queue.size() == MAX_SIZE) { queue.wait(); } queue.add(item); queue.notifyAll(); }

注意这里的判断条件用的是while而不是if。原因是线程被唤醒后还会再次进入循环检查条件,因为可能有多个生产者在wait,一个notifyAll唤醒所有,它们醒来后要重新争抢锁、重新确认缓冲区是否真的有空间。用if的话,被唤醒后不再检查条件,万一其他生产者刚好吃掉了空间,当前线程就会往已满的队列里塞数据,轻则逻辑错误、重则数组越界。

工程上其实很少直接手写wait/notify了,因为BlockingQueue把这个套路封装得很完善。用LinkedBlockingQueue或者ArrayBlockingQueue的put()和take(),底层就是你手写那套逻辑,但加上了中断处理和条件判断的细节。真实项目里写一个生产者消费者,首选就是BlockingQueue,能用工具类就不要重复造轮子。但底层原理一定要懂,不然遇到线上诡异的"程序假死"时,你不知道从哪个方向去查。

除了wait/notify,日常用得多的协作工具还有CountDownLatch、CyclicBarrier和Semaphore。

CountDownLatch是倒数门栓。设定初始计数N,每次countDown()减一,主线程调await()停在门栓处,直到计数归零才放行。典型场景是启动一个任务要等所有前置模块都初始化完,或者并发压测时让N个线程同时开始。

CyclicBarrier是循环屏障,可以让一组线程互相等待,全部到达屏障点之后才同时放行。和CountDownLatch最大的区别是它可以复用,所有线程到达后屏障重置,下一轮还能继续用。适合分阶段并行计算,比如每轮每个线程算完自己那份数据,等大家都完成后再交换结果继续下一轮。

Semaphore是信号量,维护N个许可,acquire()拿许可、release()还许可。它天然适合限流场景,比如数据库连接池最多放10个连接,超过的线程就等着。

选择建议:

工具适用场景作用维度
wait/notify某个共享状态的条件变化时唤醒等待者条件协作
CountDownLatch一个或N个线程等待其他线程完成一锤子门栓
CyclicBarrier一组线程互相等待到齐后一起继续循环屏障
Semaphore控制同时执行的线程数量上限流量闸门

协作机制这一块是理解线程从"各干各的"到"并行不悖"的桥梁。你不会在日常业务代码里天天写它们,但排查线上问题、阅读中间件源码时,这些工具出现的频率极高。

4. 锁怎么选:synchronized、Lock、volatile、原子类的边界与取舍

锁是并发编程里最容易被过度使用的东西。很多人写多线程代码,不管三七二十一给方法加个synchronized就完事。能用能用,但代价是本可以并行的部分全被串行化了。这一节把几类同步手段放一起对比,讲讲各自的定位和适用边界。

先讲synchronized。JVM层面经过多年优化,现在已经不是当初那个"重锁"了。它的锁升级过程是从无锁到偏向锁,再到轻量级锁,最后才升级为重量级锁。这个设计的思想非常务实:大多数情况下锁只有单个线程在反复进出,那就没必要走内核互斥量。偏向锁就是记一下是哪个线程在用;发生竞争时才升级成CAS自旋形式的轻量级锁;自旋也抢不到、竞争非常激烈,才彻底升级为重量级锁交给操作系统阻塞唤醒。

synchronized的特征是自动。锁的获取和释放由JVM管,即使run方法里抛异常,锁也会自动释放,不会存在街头手写锁忘了finally导致死锁的问题。所以它的定位是"基础款"。

再讲Lock接口。Lock不是用来取代synchronized的,它解决的是synchronized做不到的几件事:

  • 可中断等待。lockInterruptibly()在等待锁的过程中可以响应中断,线程不想等了可以撤。
  • 超时获取。tryLock(3, TimeUnit.SECONDS)最多等三秒,拿不到就放弃,这在防死锁场景下非常实用。
  • 公平锁。ReentrantLock构造参数传true,可以按先来后到的顺序分配锁,避免线程饥饿。synchronized默认非公平,可能导致某些线程一直拿不到锁。
  • 多个条件队列。一个Lock可以创建多个Condition,分别管理不同状态的等待线程,比单个wait/notify灵活得多。

然后是volatile。关键字之所以存在,是因为JMM里的可见性问题。CPU缓存导致一个线程修改了共享变量,另一个线程可能还在用旧缓存值。volatile保证两点:写入后立即对其他线程可见,以及禁止相关指令重排序。但它不能保证复合操作的原子性。比如i++这种read-modify-write操作,用volatile并不能解决并发覆盖,需要用AtomicInteger或者加锁。

原子类则是另一种思路。AtomicInteger的底层是CAS自旋。CAS就是"比较并交换",如果当前内存值等于预期值,才改成新值,否则失败并重试。它无锁化了读改写操作,在低竞争下性能很好,高竞争下因为自旋激烈可能会有性能退化,但通常仍比重量级锁好。

一个通俗的类比:synchronized像公共厕所门上挂的锁,谁进谁上锁,其他人只能干等。volatile像墙上贴的公告,改动大家都看得到,但它没有实体锁。原子类更像是"投币式自助服务",操作本身就是原子的,放进去钱那就一定完成一次出货。

从工程实践的角度,我个人的选型顺序是:

  1. 能用ThreadLocal避免共享,就不需要并发控制;
  2. 能用原子类解决的问题不加大锁;
  3. 能用一个volatile就满足可见性要求,不引入互斥;
  4. 需要多个步骤联动、需要公平、需要可超时,才上Lock;
  5. 默认无脑基础款还是synchronized,优先用它的自动释放和锁升级机制。

很多人纠结锁粒度的问题,这里补充一句。锁不是越大越安全,锁的粒度一定意义上决定了并发度。常见的做法是尽量缩小同步块的范围,只把真正需要互斥的代码包进去。比如一个方法里先做网络请求再更新共享计数器,那就要把网络请求放在同步块外面,只锁计数器更新那几行。因为网络请求可能耗时几百毫秒甚至一秒,锁在外面等于把所有人都堵死。

5. 线程池:为什么生产环境禁止手动new Thread

我刚入行的时候写过一个批处理程序,每天固定时间给几千个用户发通知。当时图省事直接在循环里new Thread,每来一个任务开一条线程。小规模跑着没问题,直到某天数据量涨了几倍,直接触发系统拒绝服务。原因很简单:JVM能创建的线程数是有限的,每开一个线程默认栈内存就是几百KB到1MB,几千个线程上去内存直接撑爆,调度开销也全部失控。

从那时候起我就记住了那句规矩:线程资源不能由业务代码随便创建,应该统一交给线程池管理。

ThreadPoolExecutor的核心参数有七个:

  • corePoolSize,核心线程数,即使空闲也保持存活。
  • maximumPoolSize,最大线程数,任务多时最多能扩到多少。
  • keepAliveTime和unit,临时线程的空闲存活时间。
  • workQueue,任务队列,核心线程全部忙时新任务先排队。
  • threadFactory,线程工厂,可以自定义线程名,排查日志时超级有用。
  • handler,拒绝策略,队列也满了怎么办。

线程池的工作流程是:任务进来,先看核心线程是否都在忙。有空闲就直接干;没空闲就把任务扔进队列排队。队列也满了才创建临时线程去处理;临时线程也建满、队列也满了,触发拒绝策略。

这个流程很多人理解反了,以为任务来了先创建一堆线程。其实是反过来的,线程池倾向于用最少的线程去处理任务,让任务排队而不是疯狂创建线程。

队列的选择决定线程池表现的形态。LinkedBlockingQueue无界队列会让maximumPoolSize变成一个摆设,因为队列永远不会满,所以永远不会创建临时线程。ArrayBlockingQueue有界队列配合合理的容量,才让拒绝策略有意义。SynchronousQueue不缓存任务,直接转给临时线程,适合吞吐要求高、任务执行极快的场景。

拒绝策略有四种:

  • AbortPolicy,直接抛异常。默认策略,适合不允许丢任务的场景。
  • CallerRunsPolicy,让提交任务的线程自己去执行这个任务。这种策略有一种很好的副作用:提交速度会被执行速度反向拖慢,起到了天然限流的作用。
  • DiscardPolicy,静默丢弃。适合可以容忍丢消息的场景。
  • DiscardOldestPolicy,丢弃队列里最老的任务,再尝试提交新任务。

实际项目里怎么定参数?有个粗略但实用的经验公式:

  • CPU密集型任务,corePoolSize设置为CPU核数+1,maximumPoolSize不要超过核数两倍。
  • IO密集型任务,corePoolSize设置为CPU核数乘以2到4,因为IO等待时CPU是闲着的,多放点线程才能把CPU用满。

但这只是起点,真正确定参数必须靠压测和监控。线程池有没有打满、队列堆积了多少、有没有触发拒绝策略,这些要埋点盯起来。我习惯给线程工厂设置好有意义的名字,比如"order-async-worker-",这样线程池出了异常或者CPU飙高,一眼就能从线程dump中锁定是哪个池出了问题。

为什么要手动new一个Thread简单直接,却非要用线程池?三个理由:

  1. 线程创建和销毁的成本高,频繁创建大量短命线程会给GC和调度器带来很大压力。
  2. 并发线程数不可控,没有上限,系统资源容易被耗尽。
  3. 无法统一管理,线程状态无法监控,出了故障只能干瞪眼。

提交任务的时候也有讲究。execute()只接收Runnable,发出去就行,不关心结果。submit()可以接收Callable并返回Future,能拿到任务结果和异常。如果业务需要等一批异步任务都结束后再继续,可以配合CountDownLatch或者Future.get()来汇总。

还有一个很容易被忽视的点:线程池千万不要随手拿一个static全局的Executors往项目里怼。Executors.newFixedThreadPool用的是无界队列,很容易导致内存堆积;newCachedThreadPool的maximumPoolSize是Integer.MAX_VALUE,极端情况下会创建无数线程。Java内置的快捷方法适合写Demo和各种临时脚本,生产环境一定要用ThreadPoolExecutor并显式指定参数。

6. 多线程的经典故障:死锁、伪共享和上下文切换的真实成因

多线程的故障分两类。一类是你一眼就能看到的报错,比如线程卡死、OOM;另一类是性能类问题,代码功能上不出错,就是莫名变慢、CPU忽高忽低。这两类问题背后的根源,基本上是死锁、伪共享、上下文切换这老三样。

死锁,教科书会告诉你它需要四个必要条件:互斥、持有并等待、不可剥夺、循环等待。四者同时满足就死锁。但书上的表述太理论,现实里的死锁往往悄无声息。比如线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。两边就僵住了。要说更隐蔽的情况,还可以是加锁的顺序不一致导致的循环等待。

写代码规避死锁有几条务实手段:

  • 加锁顺序强制统一。需要多个锁时,所有线程都按相同的顺序拿锁。
  • 用tryLock带超时。拿不到锁就放弃重试,而不是无限等下去,直接把死锁变成可恢复的失败。
  • 缩小锁范围,别持着锁做耗时操作,因为持锁时间越长,形成循环等待的概率越大。
  • 死锁问题线上出现时,用jstack快速定位,看哪些线程是BLOCKED状态、waiting to lock哪把锁、那把锁被哪个线程持有,就能还原出循环链条。

伪共享是比死锁更隐蔽的性能杀手。它跟CPU缓存行有关。CPU从内存读数据,不是一点点读,而是按缓存行一整块读,常见的缓存行大小是64字节。两个不同的变量如果恰好落在同一个缓存行里,而它们又分别被两个线程频繁修改,那么每次任何一个线程写入,都会导致整条缓存行被标记为失效,另个线程想读就必须重新从内存加载。两个线程明明操作的是完全不同的变量,却因此互相拖累,这种情况就叫伪共享。

治本的办法是让两个变量不要待在同一个缓存行。Java里可以用@Contended注解强制为字段增加填充,把变量推到独立缓存行上。但这是JVM层面的注解,默认可能不生效,需要通过-XX:-RestrictContended参数开启。实际代码里更轻量的做法,是把变量拆到不同的对象里,或者在不同对象中间用一些安全填充。

上下文切换,前面已经提过一次。每个线程执行一段时间后会被调度器挂起,换另一个线程上CPU,这个换人动作就是上下文切换。切换一次只有几微秒,但高并发系统里每秒钟可能要切换到数十万次,累积起来就是可观的浪费。Linux上的vmstat命令可以看cs(context switch)列,如果数值巨大且系统负载和CPU使用率不成正比,就要怀疑是切换过于频繁。

减少上下文切换的办法,总结起来就两类。一类是减少线程数,让线程数量和CPU核数匹配相近,避免过多线程互相抢时间片。另一类是减少锁竞争,因为锁竞争失败会让线程进入阻塞再唤醒,两次切换就搭在里面。用CAS替代锁是一种方式,用无锁数据结构、或用读写锁代替全互斥锁,让读操作不再阻塞,都是减少切换的有效手段。

处理多线程故障有一点非常重要自己的体会:务必保留现场。线程dump、GC日志、CPU采样这些第一手资料,是事后分析最可靠的依据,没有现场资料只能靠猜,效率极低。

7. 一条亲测有效的多线程学习路径

最后聊聊方法。多线程我自己走过弯路,最开始蹲在书前啃底层原理,又是想弄懂操作系统调度又是看CPU缓存,反而越学越虚。后来换了路子,主线任务变成了在代码里跑步过关,效率立刻不一样了。

给自己设定的第一个阶段是跑通"同步原语工具箱"。不用深入底层,把synchronized、ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier、BlockingQueue、AtomicInteger、volatile、ThreadLocal这九大件,各花一两天写一个Demo,强制自己用三种不同的同步手段解决同一个并发问题,比如"多线程累加1万次保证结果正确"。这个阶段的目的不是挑选最佳方案,而是体会每一种工具的行为特征。

第二个阶段是动手写一个小型生产者消费者框架。之前看感觉没难度的逻辑,自己真正动手从头写一遍,越是考虑中断、异常、队列满了怎么办,越会发现有太多细节没想透。尤其注意用while检查条件这条规则,只能靠实战中出现对错来加深印象。这个阶段能过,wait/notify和BlockingQueue基本融会贯通。

第三个阶段上一课真实的多线程排错。构造几个典型场景,比如故意制造死锁观察jstack输出,把线程池参数调爆观察拒绝策略触发,开太多线程观察CPU飘忽和切换开销。没有亲手复现这些故障,你永远只能是"听说过"而不是"见过"。

第四个阶段阅读开源框架里的并发源码。Java的线程池源码、阻塞队列源码、还有常用的中间件客户端里的线程池配置和并发工具用法,都是很好的学习素材。阅读目的不是背源码,而是看成熟项目是如何做出选型决策的。配合一些写多线程程序的底部实践(减少锁粒度、避免持锁做IO、使用不可变对象、优先组合并发工具而不是堆synchronized),整个知识体系才会变得立体。

行的路上,我的体会是:多线程别拿"记住多少API"来衡量,能解释清楚一个并发问题为什么这样写、为什么会有这个坑、换一种方案会引入什么新问题,那才算真正掌握。学完一轮后回过来重新看之前写的代码,几乎每次找出能改写的并发缺陷,这就是进步的方向。

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

基于SpringBoot3+Vue3的经方药食两用服务平台设计与实现

最近做了个让我自己挺满意的项目&#xff1a;一套经方药食两用服务平台&#xff0c;技术栈选了 SpringBoot3 Vue3 MyBatisPlus MySQL&#xff0c;前后端完全分离。做这个系统的起因其实很简单——身边不少朋友开始研究药食同源&#xff0c;想知道哪些普通食材能入日常膳食&a…

作者头像 李华
网站建设 2026/10/19 2:49:38

PyTorch底层原理与生产级实战:动态图、内存管理与部署避坑

1. 这不是“学个框架”那么简单&#xff1a;PyTorch到底在解决什么真实问题&#xff1f;你点开这个标题&#xff0c;大概率不是想看又一篇“PyTorch安装教程”或“张量基础入门”。如果你已经写过几行import torch&#xff0c;甚至跑通过MNIST分类&#xff0c;那咱们就跳过那些…

作者头像 李华
网站建设 2026/10/19 6:35:15

PyTorch图像数据增强实战:用transforms提升模型泛化能力

做图像训练&#xff0c;最亏的不是模型选小了、数据不够多&#xff0c;而是把数据加载器里只写了一个ToTensor()就送去训练。我见过太多人在小数据集上反复调模型结构、调学习率&#xff0c;却忽略了数据集本身这张牌。图集图片变换与增强&#xff0c;说白了就是花小钱办大事。…

作者头像 李华
网站建设 2026/10/19 5:46:34

Vue.js安全攻防:从v-html到SSR的XSS漏洞与加固方案

想写这篇 Vue.js 安全的文章&#xff0c;是因为前阵子帮人看一个线上问题。一个后台管理系统&#xff0c;Vue 单页应用&#xff0c;服务端渲染也用上了&#xff0c;结果某天用户反馈“点开详情页就弹窗”。排查到最后&#xff0c;问题出在v-html上&#xff0c;而且那条数据居然…

作者头像 李华
网站建设 2026/10/18 15:11:58

显存不够也能跑大模型:airllm CPU卸载推理实战

前阵子我在一台只有24GB显存的机器上&#xff0c;硬生生把一个70B量级参数的模型跑了起来。不是量化到4bit勉强塞进显存&#xff0c;而是让它以接近显存内推理的速度稳定输出。当时用到的就是这个叫airllm的开源推理方案。这篇文章我把从原理到实测、从踩坑到调优的完整经过整理…

作者头像 李华
网站建设 2026/10/19 7:45:26

PyTorch核心解析:从张量、自动求导到模型部署的实战指南

PyTorch——深度学习领域绕不开的一个名字。如果你已经翻过几篇教程&#xff0c;大概率见过那几行import torch的经典开头&#xff0c;也大概率被它看似简洁的 API 骗过&#xff0c;以为自己懂了&#xff0c;真到了自己动手写网络、调 bug、上 GPU 的时候&#xff0c;才发现坑一…

作者头像 李华