news 2026/10/3 15:16:33

AQS源码深度拆解:从state与CLH队列掌握JUC核心机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AQS源码深度拆解:从state与CLH队列掌握JUC核心机制

很多人在面试时都能甩出“AQS是java.util.concurrent的核心”“ReentrantLock基于AQS实现”这几句话,但一旦被问到“CLH队列到底是怎么工作的”“非公平锁为什么不公平”“state为什么用int而不是long”,就当场卡壳。我啃JUC源码前后经历了三轮反复阅读,每轮都有新收获。这篇就把我踩过的坑、读懂的源码、以及在生产环境排查过的真实问题完整拆开讲一遍,目标是看完之后能画出一条从acquire到unpark的完整调用链,也能在线上看到线程dump时快速定位AQS相关的阻塞问题。

1. 同步器的统一基座:为什么JUC最终选了AQS这条路

1.1 三个同步问题的本质拆解

先说个本质问题:无论ReentrantLock、Semaphore、CountDownLatch,还是ReentrantReadWriteLock,它们在底层面对的问题其实一模一样,只有三个:

第一,共享变量怎么安全地修改。锁的本质是让线程在临界区互斥访问,但怎么记录“当前有没有线程持有锁”?需要一个所有线程都能看到的共享状态。

第二,资源不可用时线程怎么办。抢不到锁的线程不能一直死循环空转,否则高争用场景下CPU会被打爆,所以必须有一种机制让线程挂起等待。

第三,资源可用时怎么通知。持锁线程释放锁之后,被挂起的线程得能被唤醒并重新竞争,这个唤醒操作不能丢,否则就会发生线程永久阻塞。

这三个问题看似简单,但要把它们做成一套通用的、高性能的、可复用的机制,就是一件很见功力的事。在AQS出现之前,每个同步工具都得自己维护一套等待队列和自己的挂起唤醒逻辑,重复代码多,还容易出并发Bug。Doug Lea设计AQS的初衷就是把这三个问题的公共骨架抽出来,让上层同步器只需要关心“什么时候允许抢锁”这一个策略问题。

我用食堂打饭来类比:state就是窗口剩余的饭菜数量,等待队列就是排队的人,持锁线程就是正在打饭的人。食堂窗口永远只服务队首,队首打完饭离开,下一个才能上前。AQS就是这套食堂排队的管理员,不同的同步器只是改了“什么情况下算饭菜充足、什么情况下算售罄”的规则。

1.2 AQS在JUC中的实际地位

看几个实际例子就能感受到AQS的威力:ReentrantLock的加锁解锁逻辑,Semaphore的许可获取,CountDownLatch的倒计数阻塞,ReentrantReadWriteLock的读写互斥,甚至ThreadPoolExecutor内部的Worker能不能干活,底层都依赖AQS的状态管理和队列挂起机制。

之前有个项目里我用CountDownLatch做多线程任务的汇合,线上偶发子线程全部阻塞,当时第一反应是看线程dump,结果发现阻塞点都停在java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await()上。那一刻才真正意识到,不管上层是什么同步器,最终绕不开的都是AQS这一套机制。理解了AQS,就等于拿到了JUC所有并发工具的通关密码。

2. 两条核心资产:int state与CLH等待队列的变体实现

2.1 state字段:为什么一个volatile int就够了

AQS内部有一个private volatile int state字段,所有同步器的核心逻辑都围绕它展开。这里有两个关键设计点值得我们停下来想清楚。

第一,为什么用volatile修饰?因为state会在多线程之间共享,一个线程修改它,另一个线程要立即可见。volatile保证了可见性和有序性,配合CAS操作就能实现无锁的原子更新,不需要在锁内部再加锁,性能损耗最小。

第二,为什么是int而不是long?我年轻时也纠结过这个问题,后来看Doug Lea的论文才明白:int在32位和64位JVM上都能保证CAS操作的原子性,而且绝大多数同步器的状态值根本不会超过int范围。ReentrantLock用state记录重入次数,CountDownLatch用state记录剩余计数,Semaphore用state记录剩余许可量,这些场景int完全够用。

这里有一个细节很多人忽略:volatile只能保证单次读写的原子性,但state += 1这样的复合操作并不安全,所以AQS的所有状态变更都是通过compareAndSetState这类CAS方法完成的。这也是为什么AQS里不会出现state++这种写法。

2.2 CLH队列的变体:双向链表里藏着哪些玄机

AQS的等待队列常被人叫CLH队列,严格来说它是CLH锁的一种变体实现。原始的CLH锁是隐式链表,每个线程自旋检查前驱节点的状态位;而AQS改成了显式的双向链表,并且线程在获取不到锁时会通过LockSupport.park真正挂起,而不是自旋消耗CPU。这个改动是为了在高争用场景下避免无谓的CPU空转。

队列的节点是AQS的内部类Node,每个节点持有线程引用、等待状态waitStatus、前驱指针prev、后继指针next,还有nextWaiter用来区分独占模式和共享模式。线程抢锁失败时会被包装成Node节点追加到队尾。

这里重点说一下为什么AQS要维护一个双向队列,这是面试常考也最容易讲不清的点:在acquireQueued逻辑里,线程被唤醒后需要检查自己的前驱节点是不是head,只有前驱是head的节点才有资格再次尝试获取锁,所以需要prev指针向前回溯;而在unparkSuccessor唤醒后继线程时,又需要从tail往前遍历,找到第一个未被取消的节点,这个过程依赖prev指针。可以说,prev负责资格判定,next负责垃圾回收和协助遍历。

很多源码解析都没讲透的一点:为什么唤醒后继要从tail往前找,而不是直接使用head的next指针?答案是为了处理节点取消竞争。如果一个节点被取消后,它的next指针还可能指向一个已经被取消的节点,而head向后遍历到一半可能遇到next为null(节点还在并发入队中),这时候如果从头往后找,很可能会漏掉真正需要唤醒的线程。从tail往前找则能保证找到的每一个节点都是可靠的。这个坑我在排查线上问题时真正体会过。

2.3 Node的四个等待状态位

Node节点的waitStatus有五个取值,初始值是0,我这里直接列一个表,方便大家对照源码:

状态值含义触发场景
CANCELLED = 1节点已取消线程超时或中断,不再等待锁
SIGNAL = -1后继节点需要被唤醒当前节点释放锁时必须unpark后继
CONDITION = -2节点在条件队列中线程调用了await,从AQS队列转移到条件队列
PROPAGATE = -3共享模式传播唤醒共享锁释放时避免信号丢失
0初始状态新入队的节点

SIGNAL是最核心的状态。它的含义不是“当前节点可以被唤醒”,而是“当前节点有责任唤醒后继节点”。shouldParkAfterFailedAcquire方法里做的就是这件事:如果前驱节点的waitStatus不是SIGNAL,就用CAS把它改成SIGNAL,然后自己才放心挂起。这个状态设计保证了唤醒信号的链条不会断。

3. 模板方法模式:框架定流程,子类定策略

3.1 acquire与release的骨架代码

AQS最精彩的设计是模板方法模式:它把加锁、排队、挂起、唤醒的完整流程在父类里写死,然后把“是否允许获取锁”和“是否允许释放锁”这两个策略点留给子类实现。

以独占模式为例,acquire方法的骨架是固定不变的:

public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }

整个流程只有四步:先尝试立刻获取锁;失败就把当前线程包装成独占节点加入队尾;然后进入acquireQueued循环;如果在这个过程里线程被中断过,最后补一次自我中断。

release方法的骨架同样是固定的:

public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }

锁释放成功后,如果头节点的waitStatus不等于0(说明有后继节点等着被唤醒),就唤醒合适的后继线程。你会发现tryRelease和tryAcquire都是未实现的钩子方法,用protected修饰,抛UnsupportedOperationException。子类如果不打算支持某种模式,可以不实现对应的钩子方法,调用时会直接抛异常。

3.2 两种钩子方法的实现风格差异

AQS把子类需要覆盖的钩子方法分成三组:独占获取、共享获取、条件队列。独占模式的核心钩子是tryAcquire,返回boolean;共享模式的核心钩子则是tryAcquireShared,返回int。这个返回值有三种情况:负数表示获取失败;零表示获取成功但剩余资源为0;正数表示获取成功且有剩余资源。

为什么共享模式返回值要设计成int而不是boolean?因为共享锁的语义是“资源可以被多个线程同时持有”。比如Semaphore剩余2个许可,一个线程获取后还剩余1个,返回值就变成1(正数),这样后续线程还能继续尝试获取。CountDownLatch的countDown递减state时也是类似逻辑。返回int才能表达“剩余量”这个信息。

条件队列这一组钩子则更特殊:ConditionObject不需要子类重写tryXxx方法,它直接调用AQS的await和signal机制,但依赖子类正确重写isHeldExclusively来判断当前持有锁的线程是不是当前线程。这个方法是条件队列能够工作的前提,ReentrantLock通过判断Thread.currentThread() == getExclusiveOwnerThread()来实现。

4. 从acquire到unparkSuccessor:走一遍核心调用链

4.1 tryAcquire失败之后发生了什么

我们顺着acquire方法把完整链路走一遍。要理解这条链路,重点是记住一个原则:AQS把“快速路径”和“慢速路径”分得很清楚。第一次获取锁时,绝大多数情况下锁是空闲的,这时候最快的方式就是直接CAS改状态。只有CAS失败,线程才需要进入队列排队。

addWaiter方法负责把线程包装成Node并追加到队尾。它的做法是先尝试一次快速入队:把当前线程包装成node,把node的前驱指向tail,然后通过compareAndSetTail原子更新队尾。如果这一步成功,就直接返回node。如果tail为空(说明队列还没初始化)或者CAS竞争失败,就进入enq方法,在死循环里不断CAS直到成功为止。

这里有一个容易忽略的细节:enq里采用了惰性初始化。AQS初始状态head和tail都是null,第一个入队的线程需要创建一个空的哨兵节点,再由它作为head和tail。这个设计避免了队列初始化对每次加锁路径的额外开销,是性能优化的经典案例。

4.2 acquireQueued:死循环里的三段式流程

入队只是开始,真正的核心逻辑在acquireQueued里。这个方法以自旋加挂起的方式反复尝试获取锁,骨架可以用伪代码表达:

for (;;) { 如果前驱是head,尝试获取锁 成功则把自己设为新head,退出循环 检查前驱状态,决定是否应该挂起 挂起,等待被unpark }

这里有两个关键点我要特别强调。

第一,为什么只有前驱是head的节点才尝试获取锁?这保证了队列的FIFO公平性:任何时候只有排在最前面的节点能抢锁。非公平锁看似“插队”,但插队只发生在入队之前,一旦进入队列,就必须按顺序来,队列内部的纪律是绝对的。

第二,shouldParkAfterFailedAcquire的CAS操作是整条链路的点睛之笔。它检查前驱节点的waitStatus,如果已经是SIGNAL就直接返回true;如果不是,就尝试把它改成SIGNAL。这样设计的好处是,节点在真正挂起之前,先把“你将来要唤醒我”的责任写进前驱的状态里。前驱释放锁的时候,看到自己的waitStatus是SIGNAL,就知道必须去唤醒后继。

4.3 中断信号的延迟处理机制

很多人在读acquireQueued时会对interrupted标志感到疑惑:明明线程被parkAndCheckInterrupt唤醒了,也检测到中断了,为什么不立刻响应?答案我在调试过真实场景后才真正理解:AQS采用的是“延迟响应中断”策略。线程被中断后,它不会立即抛异常或者立即退出,而是把中断标志记录在局部变量里,继续执行循环,等到最终成功获取锁之后,再由selfInterrupt补一次中断。

这样设计的目的是保证中断不破坏同步队列的一致性。如果线程在等待中被中断就立刻退出队列,那负责唤醒它的前驱节点就会失去目标,后续的唤醒链条也容易被打断。把中断信号留到拿到锁之后再处理,能确保整个队列的唤醒机制不会被低频的中断事件干扰。

这里要顺便踩一个解析源码常见误区:有人会误以为selfInterrupt是“自己中断自己”的荒谬操作,但它的真实含义是“补交中断状态”。因为等待过程中parkAndCheckInterrupt通过Thread.interrupted()检查中断时,会清空线程的中断标志,如果不补一下,上层代码通过Thread.currentThread().isInterrupted()就看不到中断状态了。这段逻辑不读源码是真的会漏掉的。

5. 两个落地案例:ReentrantLock与CountDownLatch的正反对比

5.1 ReentrantLock怎么把state变成重入计数

把AQS的理论放到ReentrantLock里,一切都变得具体了。ReentrantLock内部有一个继承AQS的抽象内部类Sync,非公平锁NonfairSync和公平锁FairSync都继承它。加锁时调用sync.lock(),而lock()最终都会走到acquire(1)这条模板方法上。

重入逻辑就藏在tryAcquire里:先读当前state,如果为0说明锁空闲,尝试CAS把它改成1;如果state不为0但持有线程是当前线程,说明是重入,就把state加1继续执行。释放锁时对应把state减1,减到0才真正解锁。这就是为什么ReentrantLock的lock()和unlock()必须严格配对:每次锁重入都要对应一次释放,否则state永远无法回到0。

我实际写代码时见过一个很隐晦的Bug:有人在一个方法里lock了两次,却只在finally里unlock了一次,程序看起来能正常运行,因为state从2减到1,锁仍然被持有。等到另一个线程尝试获取同一把锁时就被卡住了。从现象上完全看不出来,线程dump里只有parking to wait for,一时半会根本定位不到是锁没释放干净的问题。所以用ReentrantLock,我强烈建议所有lock的调用都用try/finally包住,而且unlock必须放在finally的第一行。

5.2 非公平锁与公平锁在tryAcquire上的差异

我把两个锁的tryAcquire关键差异放在一起对比:

维度NonfairSyncFairSync
快速路径acquire前先CAS抢一次没有快速路径
入队前检查直接CAS改state先查hasQueuedPredecessors
队列空时新线程可与队首竞争队首线程优先
适用典型默认ReentrantLock需要准入公平的系统

非公平锁的lock方法上来就直接compareAndSetState(0, 1),成功了就独占锁,根本不看队列里有没有人等。公平锁则必须先检查hasQueuedPredecessors:只要有其他线程排在当前线程之前,就不允许抢锁,哪怕锁此刻是空闲的。

这个做法直接解释了“为什么默认用非公平锁”:非公平锁省去了“排队-挂起-唤醒”整套流程,当一个新线程到达时锁恰好被释放,它可以直接获取而不需要唤醒等待线程,减少了线程上下文切换的开销。在低争用场景下,两者的吞吐相差不太大,但在高争用场景下,非公平锁能减少大量无谓的线程挂起和唤醒,整体吞吐有明显提升。代价是新线程可能反复插队,让先来的线程饿到更晚才能获取锁,不过因为每个等待线程最终都会成为队首,所以不会被永久饿死。

5.3 CountDownLatch的反向用法:state代表剩余资源

ReentrantLock让state越来越大表示锁重入,CountDownLatch则相反:state是剩余计数,初始化时设置为N,每调用一次countDown就通过tryReleaseShared把state减1,减到0的时候自动唤醒所有等待线程,而且这是一次性的。

对比着看这两个类最有意思。ReentrantLock是独占的、可重入的、需要手动释放;CountDownLatch是共享的、不可重入的、一次归零就永久失效。两者状态机完全不同,但都复用了AQS的同一套队列和唤醒机制,这正体现了AQS模板方法的抽象能力。

共享模式里要特别提醒一个源码细节:doReleaseShared中的PROPAGATE状态是为了解决一个信号丢失的问题。假设有两个线程同时调用countDown,其中一个线程看到head的waitStatus是0,而另一个线程刚好把waitStatus从SIGNAL改成了0准备唤醒后继,这时候如果没有PROPAGATE设置,一个通知信号可能就被悄悄吞掉了。把waitStatus从0改成PROPAGATE,目的就是确保共享模式的释放信号能够继续传播下去。这个Bug场景非常罕见,但一旦遇到就是线上灵异事件,理解了这个机制排查起来会快很多。

6. 条件队列与实用选型:await/signal机制和锁的选择

6.1 ConditionObject与AQS主队列的关系

如果说AQS的主队列是管理“抢锁排队”的,那么条件队列就是管理“等待某个条件成立”的。每个ConditionObject对象都有自己独立的单向等待队列,使用nextWaiter指针连接。当线程调用condition.await()时,它必须已经持有锁,然后线程会被包装成一个waitStatus等于CONDITION的节点,加入到条件队列的尾部,同时释放全部锁状态。

这里有一个容易误解的点:signal并不会直接唤醒线程让它运行,而是把条件队列中的节点“转移”回AQS的主同步队列。transferForSignal内部先CAS把节点的CONDITION改成0,然后通过enq重新把它插入到同步队列尾部。所以被signal的线程不会立即执行,而是要等它重新抢到锁之后才从await中返回。想通了这一点,就不会犯“signal之后马上unlock,结果另一个线程还没有真正苏醒”之类的错误。

在实际项目里我一直强调条件队列的使用范式:await调用永远放在一个循环里,不能简单用if判断条件。因为线程被唤醒后可能发现条件并不成立,或者是因为中断被唤醒的,必须循环重新检查条件。我曾经在消息队列消费线程里写过类似的等待逻辑,只判断了一次就往下执行,结果在极端场景下处理了脏数据。改成while检查之后问题立刻消失。

6.2 公平锁与非公平锁的选型经验

日常开发中,大部分情况下直接用默认的非公平锁就够了,包括synchronized本身也是非公平的。不过有两种场景我会主动选择公平锁:第一种是任务对顺序有严格要求的场景,比如按提交顺序处理任务、定时任务需要严格按调度顺序执行;第二种是持有锁的时间相对较长、且等待线程数量较大的场景,这时候非公平锁的插队代价会让后到线程频繁“占便宜”,先到线程的等待时间被人为拉长。

我做过一个简单对比实验:8个线程争抢同一把锁执行短任务,公平锁的TPS大约只有非公平锁的60%左右,而且线程切换次数明显更高。反过来,如果每个线程持锁时间拉到几百毫秒,公平锁和非公平锁的吞吐差距就缩小到10%以内了。这个现象背后的逻辑也不复杂:持锁时间越短,非公平的插队成本越低;持锁时间越长,插队带来的效益越明显。

还有一个值得提的细节:tryLock和lockInterruptibly的区别。tryLock不响应中断,获取不到就立刻返回false;lockInterruptibly获取锁的过程中中断会直接抛出InterruptedException。在线程池优雅关闭、希望任务能响应停止信号的场景里,用lockInterruptibly比用lock更安全,因为shutdownNow本质是通过interrupt来中断工作线程的。

7. 生产环境下与AQS相关的排查经验和调试技巧

7.1 线程dump里怎么快速识别AQS阻塞点

线上出现了线程卡死,第一步一定是打线程dump,用jstack或者jcmd Thread.print都行。AQS相关的阻塞在dump里有两个标志性线索:一是parking to wait for字样,这通常出现在LockSupport.park的调用栈,意味着线程正挂在同步队列或者条件队列上;二是waiting on java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject,这表示线程正在某个Condition上等待。

看到这两类信息,我的排查顺序是:先找到持有锁的那个线程,看它停在了哪里;然后用锁对象的monitor来判断是否存在交叉持有。有一次线上故障我排查了很久,最后发现是两个线程各持有一把锁,又在等待对方释放另一把锁,形成了典型的循环等待。dump文件中两个线程的栈恰好互为对方的下游,这个交叉指向非常直观。

7.2 锁释放不匹配导致线程卡死的处置路径

前文提到的重复加锁释放不匹配,是ReentrantLock使用中最隐蔽的坑。状态为2时释放一次锁,程序仍然可以正常运行,因为当前线程依然持有锁,只是后续另一个线程永远获取不到。等到排查时,jstack里看不到任何异常异常,只能看到“parking”的等待线程和持有锁的线程。如果持锁线程后续又卡在别的地方,这个问题就会纹丝不动地挂在那里。

排查这个问题的办法我一般这样操作:先用jstack找到持有锁的线程,再在怀疑的锁上加一段lockedMonitors输出看持锁次数;如果代码是自研框架,我会在unlock的地方加上断言if (getHoldCount() == 0)提前暴露问题。getHoldCount是ReentrantLock提供的方法,能直接返回当前线程的重入次数,排查重复加锁问题非常有用。

7.3 走读AQS源码的三个建议

最后聊一点我个人反复啃AQS源码后的学习建议,按阶段分。

第一阶段不要纠结编码细节,先看主流程:如果锁空闲,tryAcquire直接成功,不走队列;如果锁被占用,线程入队后自旋检查前驱,挂起等待被unpark。把这个主干先记住,再往里填细节。

第二阶段围绕状态位转:把CANCELLED、SIGNAL、CONDITION、PROPAGATE这四个状态各自的出现位置和CAS转换路径梳理一遍,就基本掌握AQS的骨架了。我第二次读源码时就是用状态位列表对照代码逐行标注的,比通读全文高效得多。

第三阶段再去看共享模式和条件队列的差异:为什么共享模式的head切换要用setHeadAndPropagate,为什么条件队列节点需要转移到主队列才能重新竞争锁,这些问题的答案都藏在共享与独占的语义差异中。

读完这三遍,再回头看ReentrantLock、Semaphore、CountDownLatch的源码,会发现它们都变成了AQS某个钩子方法的具体实现。能在AQS之上如此轻量地扩展出这么多种同步器,这才是它作为JUC基石的核心价值所在。

我个人在实际项目中还有一个非常受用的习惯:遇到任何“线程卡死但看不出来哪里卡住”的问题,先打一份线程dump看一眼再做其他操作。很多看似神秘的并发Bug,在看到parking to wait for和锁持有线程的交错关系时就已经水落石出了。AQS并不神秘,它只是把一个复杂问题拆成了几个可组合的简单问题,掌握它确实需要一个又一个真实的调试经历来沉淀。希望这篇文章能帮你在下一次遇到并发问题时少走一些弯路。

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

查找方法论全解析:从二分查找到设备识别排查实战

上个月我接了个内部专项,项目代号KY198,任务名字就俩字:查找。起初我根本没当回事,想着无非是写个二分查找函数交差。结果真做起来才发现,这个“查找”牵扯到的场景远比我预想的多——从银河麒麟v10里定位侵占磁盘的巨…

作者头像 李华
网站建设 2026/10/3 15:16:10

张量从入门到实践:多维数组、自动微分与深度学习核心概念解析

1. 从“多维数组”说起:张量到底是个什么东西很多人第一次听到“张量”这个词,脑子里浮现的画面大概是数学课本里密密麻麻的公式和上下标。我当初也是这么想的,直到后来做图像处理和推荐系统,天天跟各种维度的数据打交道&#xff…

作者头像 李华
网站建设 2026/10/3 15:12:29

ROS机器人强化学习路径规划实战:从Gym环境到PPO部署

简介:本资源是一套基于深度强化学习(DRL)实现多智能体动态避障路径规划的完整实践方案,面向机器人导航、自动驾驶仿真及AI算法研究领域的Python开发者与高校科研人员,聚焦解决高密度行人环境中真实交互建模难、协作策略…

作者头像 李华
网站建设 2026/10/3 15:12:08

dsh-waker 插件实战:唤醒 AI 员工,打通 IM 与文件监听自动化

1. 从“AI 员工”这个概念说起:dsh-waker 到底在解决什么问题 第一次看到“dsh-waker”这个名字,我脑子里蹦出来的画面是闹钟——waker,唤醒者。后来把 dsh 这套东西摸了一遍才反应过来,这个命名其实非常精准:它要干的…

作者头像 李华
网站建设 2026/10/3 15:12:04

OpenShell:统一Shell配置管理与跨平台命令行增强实战

在终端里泡了十几年,shell 始终是每天点击量最高的窗口。从最早的 Bash 一路用到 Zsh、Fish,再到各种框架和插件,说实话,工具越装越多,真正能沉淀下来的配置和经验反而越来越少。这几年我一直在用一个叫 OpenShell 的开…

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

VS Code 中基于 MCP 协议与 Seedream 批量生成中文海报实战

1. 为什么要在 VS Code 里折腾海报生成第一次听到“在 VS Code 里生成中文海报”这个说法,我脑子里冒出来的第一个念头是:这不是设计师的活儿吗?但真上手用了一段时间之后,我发现这个组合解决的是一个非常具体的痛点——批量、可复…

作者头像 李华