1. 从一次线上卡死说起:LockSupport.park 到底把线程挂哪了
先说场景。上周排查一个 Java 服务,现象很典型:接口调用量不大,但线程池里的任务越堆越多,最后整个服务像被冻住一样,日志停在某一行不再往下打。用jstack抓了一份线程 dump,发现一堆线程的状态是WAITING (parking),堆栈顶部就是java.util.concurrent.locks.LockSupport.park。
如果你也遇到过类似情况,那这篇内容就是写给你的。LockSupport是 Java 并发包里最底层的线程阻塞工具,AQS、ReentrantLock、CountDownLatch、ThreadPoolExecutor的 worker 线程,底层都靠它的park和unpark来挂起和唤醒线程。它不像wait/notify那样需要先拿锁,也不像sleep那样必须等够时间,而是直接操作线程的许可(permit),一个线程最多持有一个许可。
park的语义是:如果当前线程的许可可用,就消费掉许可并立即返回;否则阻塞当前线程。unpark(thread)的语义是:把目标线程的许可置为可用。注意这里有个反直觉的点——unpark可以先于park调用,许可会“攒着”,之后那次park会直接返回,不会阻塞。这个特性是很多人踩坑的根源。
适合谁看?正在写并发工具类、调线程池、排查接口莫名卡死的 Java 后端同学。我会从最小复现代码讲起,给出可复制的线程 dump 命令,再顺着调用链把线程状态和排查方法串起来。最后聊一个实际工程里绕不开的问题:当你的服务需要调用外部模型接口时,endpoint 和 Key 的管理同样会带来一类“看起来像卡死、其实是配置错”的报错,比如本地代理失败、401,这时候统一 Key 通道的思路能省不少事。
先把结论放前面:park本身不会“卡死”,它只是把线程挂起,真正的问题往往在唤醒逻辑没走到,或者调用链上游根本没把请求发出去。下面一步步拆。
2. 最小复现:park/unpark 的许可机制与线程状态
先上一段能直接跑的代码,把许可机制看清楚。新建一个ParkDemo.java:
import java.util.concurrent.locks.LockSupport; public class ParkDemo { public static void main(String[] args) throws Exception { Thread worker = new Thread(() -> { System.out.println("worker 准备 park"); LockSupport.park(); System.out.println("worker 被唤醒,继续执行"); }, "worker-thread"); worker.start(); Thread.sleep(1000); System.out.println("main 调用 unpark"); LockSupport.unpark(worker); } }编译运行javac ParkDemo.java && java ParkDemo,输出是:
worker 准备 park main 调用 unpark worker 被唤醒,继续执行现在把unpark提到park之前,改成先unpark(worker)再start,你会发现 worker 里的park直接返回,打印顺序变成“worker 准备 park”紧接着“worker 被唤醒”。这就是许可预支的效果。
再看一个容易误判的场景:park被中断。park响应中断,但不会抛InterruptedException,而是直接返回,你需要自己检查中断标志:
Thread t = new Thread(() -> { LockSupport.park(); System.out.println("interrupted? " + Thread.currentThread().isInterrupted()); }); t.start(); Thread.sleep(500); t.interrupt();输出interrupted? true。很多框架在park返回后会检查中断状态并决定是否继续循环,如果你自己写循环等待,忘了处理中断,线程就会“醒了又睡”,看起来像卡住。
线程状态方面,park之后线程进入WAITING,如果带超时的parkNanos则是TIMED_WAITING。用jstack抓下来长这样:
"worker-thread" #12 prio=5 os_prio=0 tid=0x00007f... nid=0x5a3c waiting on condition [0x00007f...] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x000000076b0a1c40> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) ...关键信息有三处:WAITING (parking)说明是park挂起;parking to wait for <地址>指向具体的同步器对象;下面的堆栈告诉你它是在哪个await或acquire里挂的。拿到这三样,基本能定位到是哪把锁、哪个条件队列。
这里要提醒一句:WAITING (parking)不等于死锁。死锁是多个线程互相持有对方需要的锁,而park只是等待某个条件被满足。真正要区分的是“等不到唤醒”和“唤醒逻辑压根没执行”。下一节讲怎么把 endpoint 配置统一起来,因为很多“卡死”其实是请求根本没发出去。
3. 可复制配置:把 endpoint 统一到 TaoToken 的 settings 片段
排查并发问题的同时,另一个高频报错来自外部接口调用。比如你的服务里用某个 SDK 调模型接口,本地跑得好好的,一上测试环境就报local proxy failed或者401 Unauthorized。这类问题表面看是网络,实际往往是 endpoint 和 Key 散落在多个配置文件里,改了一处漏了另一处。
我的做法是把所有外部调用的 Base URL 和 Key 收敛到一个统一通道。TaoToken 提供的就是这样一个统一 Key 通道,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。下面给一份可直接复制的配置片段,路径按你项目实际情况放。
先看 Java 项目里常见的application.yml写法:
llm: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model-id: claude-sonnet-4-5 connect-timeout: 5000 read-timeout: 60000对应的环境变量在启动脚本里注入,别硬编码进仓库:
export TAOTOKEN_API_KEY="sk-你的key"如果你用的是 Claude Code 这类工具,配置走的是 settings 文件。在项目根目录建.claude/settings.json:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的key", "ANTHROPIC_MODEL": "claude-sonnet-4-5" } }Codex 用户走的是auth.json,路径通常在~/.codex/auth.json:
{ "OPENAI_BASE_URL": "https://taotoken.net/api", "OPENAI_API_KEY": "sk-你的key", "model": "gpt-5" }Cline 或 MCP 场景下,配置里同样要写全三件套:Base URL、Key、Model ID,缺一个都会报错。比如 MCP 的 server 配置:
{ "mcpServers": { "taotoken": { "url": "https://taotoken.net/api", "apiKey": "sk-你的key", "model": "claude-sonnet-4-5" } } }这里有个坑要单独说:401报错不一定是 Key 错,也可能是 Base URL 末尾多了或少了一个/v1。TaoToken 的 API 入口是https://taotoken.net/api,具体路径拼接以接入文档为准,文档在 https://taotoken.net/doc 。配置改完先别急着跑业务,下一节用一条最小请求验证通道是否通。
4. 验证请求:用 curl 和 Java 各跑一次确认通道
配置写完,第一步不是启动整个服务,而是用最小请求验证。先上 curl,把 Key 换成你自己的:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "只回复两个字:通了"}], "max_tokens": 16 }'如果返回里能看到choices数组和内容,说明通道没问题。如果报401,先检查$TAOTOKEN_API_KEY是否真的被 shell 读到了,用echo ${TAOTOKEN_API_KEY:0:8}看前几位。如果报连接超时,检查 Base URL 是否写成了https://taotoken.net/api/带尾斜杠导致路径拼接异常。
Java 侧用HttpClient写个最小验证类:
import java.net.URI; import java.net.http.*; import java.time.Duration; public class ApiCheck { public static void main(String[] args) throws Exception { String key = System.getenv("TAOTOKEN_API_KEY"); String body = "{\"model\":\"claude-sonnet-4-5\"," + "\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]," + "\"max_tokens\":16}"; HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("https://taotoken.net/api/v1/chat/completions")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + key) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> resp = HttpClient.newHttpClient() .send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("status=" + resp.statusCode()); System.out.println(resp.body()); } }跑通之后你会看到status=200和一段 JSON。这一步的意义在于:把“网络通道”和“业务逻辑”分开验证。很多同学一上来就启动整个 Spring 服务,报错了不知道是并发问题还是配置问题,白白浪费半小时。
验证通过后,再回到park的排查。因为一旦通道确认没问题,那些WAITING (parking)的线程就只可能是业务侧的锁竞争或唤醒缺失,排查范围一下子缩小了。想直接在线试模型对话的话,可以走 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite ,不用写代码就能确认模型是否可用。
5. 常见报错排查:401、local proxy failed 与 reading choices
这一节把几个高频报错和park排查对照着讲,因为它们的表象很像,都是“卡住不动”。
先说401 Unauthorized。前面配置章节已经埋了伏笔,这里给排查顺序:第一,确认 Key 是否注入成功,echo一下;第二,确认 Base URL 是否和 Key 匹配,别拿 A 平台的 Key 去请求 B 平台的 endpoint;第三,确认请求头是Authorization: Bearer xxx而不是x-api-key,不同 SDK 默认头不一样。三件套 Base URL、Key、Model ID 任何一个写错都可能报 401 或 404。
再说local proxy failed。这个报错通常出现在你本地配了某个代理,但代理进程没起来,或者端口对不上。排查方法是先curl直连 TaoToken 的 API 入口,如果直连能通、走代理不通,那就是代理配置的问题,把代理关掉或改对端口即可。注意这里说的是本地开发环境的网络配置,不涉及任何绕过网络管理的手段。
然后是reading choices这类报错,完整信息往往是error reading choices: unexpected end of JSON input或类似。这通常意味着响应体不是预期的 JSON,可能是返回了 HTML 错误页,也可能是流式响应被中途截断。排查方法:把max_tokens调小,关掉流式,用 curl 看原始返回。如果 curl 返回的是 HTML,说明请求打到了错误的路径,检查 Base URL 是否漏了/v1。
最后回到park。如果线程 dump 里大量线程是WAITING (parking)且堆栈指向ThreadPoolExecutor.getTask,那说明线程池空闲,不是问题。如果指向你自己的Condition.await,就要看是谁负责signal。常见错误是signal在await之前调用了,而你没用循环检查条件:
// 错误写法:可能永久等待 if (!conditionMet) { lock.lock(); try { cond.await(); } finally { lock.unlock(); } } // 正确写法:循环检查,防止虚假唤醒和信号丢失 lock.lock(); try { while (!conditionMet) { cond.await(); } } finally { lock.unlock(); }park的许可机制和Condition的等待队列是两套东西,但底层都走LockSupport。理解了许可可以预支这一点,你就能明白为什么“先 unpark 后 park”不会丢信号,而Condition的signal如果发生在await之前就会丢——因为Condition维护的是等待队列,不是许可。
6. 把排查思路沉淀成习惯:从线程 dump 到统一通道
聊到这里,把两条线收一下。一条是LockSupport.park/unpark的线程排查线:抓 dump、看状态、读堆栈、定位同步器、检查唤醒逻辑。另一条是外部接口调用的配置线:统一 Base URL、统一 Key、写全三件套、先 curl 验证再跑业务。
这两条线在工程里经常交织。比如你的线程池任务里调用了模型接口,接口因为 401 一直重试,任务线程就一直在park等待重试间隔,表现出来就是线程池被占满、接口超时。这时候如果你只盯着park看,会以为是并发 bug,其实是配置问题。
我自己的习惯是:任何新接入的外部服务,先写一个独立的验证类跑通,再往业务代码里集成。TaoToken 的接入文档在 https://taotoken.net/doc ,里面有各语言和各工具的配置示例,照着改比猜快得多。如果你需要长期跑编码类任务或 Agent,可以考虑 Coding Plan,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,把 Key 和额度统一管理,省得每个项目各配一套。
最后留一个实用技巧:在jstack输出里搜parking to wait for,把后面那个对象地址记下来,然后在同一份 dump 里搜这个地址,能快速找到是哪个锁对象被等待。这个技巧在处理复杂调用链时特别省时间。线程 dump 命令再贴一次,方便你复制:
jstack -l <pid> > dump.txt # 或者用 jcmd jcmd <pid> Thread.print > dump.txt拿到 dump 后,先看WAITING (parking)的线程数量,再看它们的堆栈是否集中在同一个同步器上。如果集中,那就是锁竞争或唤醒缺失;如果分散,那更可能是各自等待不同的外部资源。方向对了,剩下的就是顺着调用链往下读代码。