
1. 项目概述从一段“卡住”的代码说起你有没有写过这样的Java代码两个线程同时往一个ArrayList里add元素结果程序跑着跑着就抛出ConcurrentModificationException或者更诡异——数据明明加了1000次最后size却只有987又或者你加了synchronized关键字问题消失了但一测性能QPS直接掉了一半这时候你脑子里大概会冒出一连串问号这个synchronized到底锁住了什么它凭什么能保证线程安全它和操作系统底层的互斥机制是什么关系为什么有时候加了它还是出问题这些问题表面看是语法使用问题背后其实是Java并发模型最核心的一块基石——Monitor管程机制。而synchronized就是Java语言层面对Monitor最直接、最原生的封装。它不是魔法也不是黑箱而是一套有明确语义、有清晰实现路径、有可验证行为的同步原语。理解它不等于要你去读HotSpot源码而是要搞清楚当JVM执行到synchronized(obj)这一行时它在内存里做了什么动作在操作系统层面触发了哪些系统调用在字节码层面又生成了哪两条关键指令这些动作如何协同最终让多个线程对同一块临界区代码形成“排队入场”的效果这篇文章就是带你一层层剥开synchronized的外壳从Java代码到字节码再到JVM对象头结构最后落到操作系统的互斥原语上。它适合所有正在写多线程代码的Java开发者尤其是那些已经会用synchronized、但还不清楚“为什么它能工作”的人也适合刚学完《操作系统》课程、想看看理论如何落地到一门主流语言的同学。你不需要提前掌握JVM内部结构我会用生活化的类比比如把Monitor比作“带登记簿的会议室门禁”配合真实字节码反编译结果和内存布局图示把整个链条讲透。这不是一篇只讲概念的理论文而是一份你可以随时打开IDE跟着步骤反编译、观察对象头、甚至用jol工具打印内存布局的实操指南。2. 整体设计与思路拆解为什么是Monitor而不是别的2.1 从需求倒推并发编程的三个核心痛点任何同步机制的设计都源于对现实问题的抽象。Java并发模型要解决的从来不是“让代码变慢”而是精准地控制“什么时候慢、慢给谁看”。具体来说它必须同时满足三个不可妥协的需求第一原子性Atomicity。这是最基础的要求。比如i操作在CPU层面实际是三步从内存读i值→在寄存器中加1→把新值写回内存。如果两个线程同时执行这三步就可能出现“都读到了100都加成101都写回去最后i101而不是102”的经典丢失更新问题。synchronized必须保证被它包裹的代码块要么全部执行完要么完全不执行中间不能被其他线程打断。第二可见性Visibility。现代CPU有多级缓存每个线程可能有自己的高速缓存副本。线程A修改了共享变量x线程B可能还在读自己缓存里的旧值。synchronized必须建立一个“内存屏障”强制线程在进入同步块前把本地缓存刷回主内存在退出同步块后把主内存最新值重新加载到本地缓存。这样一个线程的修改对另一个线程才是“可见”的。第三有序性Ordering。编译器和处理器为了优化性能会对指令进行重排序。比如flag true; data 42;这两行编译器可能把data赋值提前到flag之前。如果另一个线程看到flag为true就去读data就可能读到未初始化的垃圾值。synchronized必须插入“禁止重排序”的屏障确保同步块内的代码其执行顺序与代码书写顺序一致。这三个需求单独看都不难但要在一个机制里同时、高效、无歧义地满足就非常考验设计功力。早期的方案比如用volatile关键字只能解决可见性和部分有序性无法保证原子性用CASCompare-And-Swap自旋能保证原子性但在高竞争下CPU空转严重浪费资源。而Monitor机制恰恰是为了解决这三位一体的问题而生的。2.2 Monitor操作系统级别的“带锁会议室”Monitor这个词中文常译为“管程”但它本质上是一个高级别的并发抽象最早由C.A.R. Hoare在1974年提出。它的核心思想非常朴素把对共享资源的访问封装在一个“受控的房间”里。这个房间有且仅有一个入口门口挂着一块登记簿Entry Set还有一块等待区Wait Set。任何想进房间的线程都必须先在登记簿上签名排队只有当前使用者离开后登记簿上排第一的线程才能获准进入。如果房间里的人主动说“我暂时不忙你们谁有需要可以进来”就会把登记簿上的第一个线程叫进来自己则去等待区歇着。这个模型完美对应了我们对“锁”的直觉认知互斥一次只能一人、协作可以主动让出、状态管理谁在用、谁在等、谁在排队。Java的synchronized就是把这个操作系统级别的Monitor概念无缝嫁接到了JVM的运行时环境中。它没有发明新东西而是做了一件极其重要的事把复杂的、跨平台的、与OS深度耦合的底层同步原语如Linux的futex、Windows的CRITICAL_SECTION用一套统一的、语言级别的语法糖synchronized关键字和语义规范JLS第17章封装了起来。这意味着无论你的Java程序跑在ARM服务器上还是x86笔记本上只要JVM实现了Monitor你的synchronized代码就能以完全一致的行为工作。这种“一次编写到处安全”的能力正是Java并发模型强大生命力的根源。2.3 为什么不是Mutex或Semaphore——synchronized的“默认契约”很多初学者会困惑既然底层是操作系统锁那为什么不直接暴露pthread_mutex_t或Semaphore答案在于“默认契约”四个字。Mutex互斥量和Semaphore信号量是通用的、灵活的、但也更危险的工具。它们要求程序员手动管理锁的获取与释放稍有不慎就会死锁A锁了X等YB锁了Y等X或忘记释放导致资源永久被占。而synchronized通过语法强制定义了一个铁律锁的获取与释放必须成对出现在同一个代码块的边界上。编译器在生成字节码时会自动在synchronized块的开始处插入monitorenter指令在结束处插入monitorexit指令。更重要的是即使代码块内抛出了异常JVM也会保证monitorexit被执行。这个“自动配对、异常安全”的特性是Mutex和Semaphore无法提供的。它把一个极易出错的手动过程变成了一个由语言和虚拟机共同保障的自动化流程。所以synchronized不是技术上“更先进”而是工程上“更可靠”。它牺牲了一点点灵活性比如无法实现超时获取锁换来了巨大的开发效率和系统稳定性提升。这也是为什么在绝大多数业务场景下synchronized是首选而ReentrantLock等显式锁只在需要特定高级功能如公平锁、条件变量、中断响应时才被启用。3. 核心细节解析与实操要点对象头、Mark Word与锁升级3.1 锁的本质不是“锁住代码”而是“锁住对象”这是理解synchronized最关键的一步认知跃迁。很多人误以为synchronized(method)是锁住了方法本身或者synchronized(this)是锁住了当前线程。大错特错。synchronized锁住的永远是一个Java对象Object的监视器Monitor而这个Monitor是附着在该对象的内存布局之上的。换句话说锁的粒度是由你传给synchronized的那个引用所指向的对象决定的。synchronized(this)锁的是当前实例对象synchronized(ClassName.class)锁的是该类的Class对象synchronized(obj)锁的就是obj这个引用指向的对象。如果你错误地每次都new一个新对象作为锁那根本起不到互斥作用因为每个线程锁的都是不同的对象。那么这个Monitor长什么样它藏在哪里答案就在每个Java对象的**对象头Object Header**里。一个标准的Java对象在堆内存中的布局分为三部分对象头Header、实例数据Instance Data和对齐填充Padding。其中对象头又分为两部分Mark Word和Klass Pointer。而Monitor的核心信息就存储在Mark Word这个8字节64位JVM的“小纸条”上。3.2 Mark Word一把会变形的“万能钥匙”Mark Word是理解锁升级机制的钥匙。它只有8个字节却要承载对象在不同生命周期下的多种身份它可能是哈希码、可能是GC分代年龄、可能是锁状态标识、甚至可能是指向Monitor的指针。JVM通过Mark Word的最低两位bit 0和bit 1来标记当前对象的锁状态一共定义了5种状态锁状态Mark Word低2位含义存储内容无锁状态01对象未被锁定哈希码、分代年龄、是否偏向锁1位偏向锁01对象被某个线程“偏爱”线程ID、Epoch、分代年龄、偏向锁标识轻量级锁00多个线程轻度竞争指向线程栈中Lock Record的指针重量级锁10多个线程重度竞争指向堆中Monitor对象的指针GC标记11对象正在被GCGC分代年龄等这个设计精妙之处在于它用最小的内存开销2位实现了最大的状态表达能力。而且这五种状态不是并列的而是一个单向升级、不可降级的链条无锁 → 偏向锁 → 轻量级锁 → 重量级锁。JVM会根据线程竞争的激烈程度自动、透明地完成状态切换。这个过程就是传说中的“锁升级”。3.3 锁升级全过程从“免检通道”到“人工安检”让我们用一个真实的场景来演示锁升级的每一步阶段一无锁状态Unlockedpublic class Counter { private int count 0; public void increment() { // 此时count对象处于无锁状态 count; } }对象刚创建时Mark Word低2位是01表示无锁。此时没有任何同步开销。阶段二偏向锁Biased Locking——“VIP通道”当线程T1第一次执行synchronized(obj)时JVM检测到Mark Word是无锁状态就会尝试将锁偏向T1。它会用CASCompare-And-Swap原子操作把T1的线程ID写入Mark Word并将偏向锁标志位置为1。此后只要T1再次进入这个同步块JVM只需检查Mark Word里的线程ID是否匹配自己匹配就直接进入无需任何同步操作。这就像机场的VIP通道第一次需要验身份办卡之后刷脸就能秒过。这是JVM为“单线程大部分时间独占锁”的场景做的极致优化。提示偏向锁在JDK 15中已被默认关闭-XX:UseBiasedLockingfalse因为现代应用多核竞争激烈偏向锁的撤销成本当其他线程来竞争时需暂停所有线程STW来撤销偏向反而得不偿失。但在JDK 8/11的默认配置下它依然活跃。阶段三轻量级锁Lightweight Locking——“自助闸机”当线程T2也来竞争同一个锁时偏向锁就失效了。JVM会执行“偏向锁撤销”暂停T1将其栈帧里的锁记录清除然后将对象Mark Word恢复为无锁状态。接着T2会尝试获取轻量级锁。它会在自己的**栈帧Stack Frame**中开辟一块名为Lock Record的内存空间将对象当前的Mark Word复制一份存进去然后用CAS操作尝试把Mark Word更新为指向这个Lock Record的指针。如果成功T2就获得了轻量级锁如果失败说明T1也在同时尝试就说明竞争加剧进入自旋等待。轻量级锁的核心是“自旋”线程不立即挂起而是在CPU上循环检查锁是否被释放类似“我再等等说不定马上就好了”。这避免了昂贵的线程上下文切换Context Switch开销。阶段四重量级锁Heavyweight Locking——“人工安检通道”如果自旋一定次数默认10次可通过-XX:PreBlockSpin调整后锁依然没被释放JVM就判定为“重度竞争”。此时它会将轻量级锁膨胀为重量级锁。具体操作是在堆内存中创建一个真正的ObjectMonitor对象这就是Monitor的实体并将Mark Word更新为指向这个ObjectMonitor的指针。随后所有竞争失败的线程都会被挂起park进入ObjectMonitor的_WaitSet队列等待被唤醒unpark。重量级锁就是我们通常意义上说的“真正的锁”它依赖于操作系统的互斥原语如Linux的futex来实现线程的阻塞与唤醒。它的代价最高但也是唯一能处理高并发、长时间持有锁场景的方案。4. 实操过程与核心环节实现从代码到字节码再到内存布局4.1 第一步用javap反编译亲眼看见monitorenter/monitorexit理论再好不如亲眼所见。我们来写一个最简单的同步方法然后用JDK自带的javap工具看看编译器到底生成了什么。// SyncDemo.java public class SyncDemo { private final Object lock new Object(); public void syncMethod() { synchronized (lock) { System.out.println(Hello, World!); } } }编译后执行命令javac SyncDemo.java javap -c SyncDemo输出的关键字节码如下public void syncMethod(); Code: 0: aload_0 1: getfield #2 // Field lock:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter // -- 关键锁获取指令 7: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 10: ldc #4 // String Hello, World! 12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 15: aload_1 16: monitorexit // -- 关键锁释放指令 17: goto 25 20: astore_2 21: aload_1 22: monitorexit // -- 关键异常路径的锁释放 23: aload_2 24: athrow 25: return Exception table: from to target type 7 17 20 any 20 23 20 any看到了吗monitorenter和monitorexit指令就是synchronized的字节码灵魂。更值得注意的是monitorexit出现了三次一次在正常流程末尾16行一次在异常处理的catch块里22行还有一次在finally逻辑的隐式跳转中23行。这完美印证了我们前面说的“异常安全”特性——无论代码是正常执行完还是中途抛出异常JVM都保证锁一定会被释放。这个由编译器和JVM联手构建的“保险丝”是synchronized可靠性的重要基石。4.2 第二步用JOLJava Object Layout工具透视对象头光看字节码还不够我们要亲眼看到Mark Word的变化。这就需要用到一款神器JOLJava Object Layout。它能精确打印出Java对象在内存中的布局包括每个字段的偏移量、大小以及最重要的——对象头的十六进制值。首先添加JOL依赖Mavendependency groupIdorg.openjdk.jol/groupId artifactIdjol-core/artifactId version0.16/version /dependency然后写一个测试类import org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.vm.VM; public class JOLTest { public static void main(String[] args) throws InterruptedException { System.out.println(VM.current().details()); Object obj new Object(); System.out.println(初始状态:); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); // 启动一个线程先获取锁制造偏向锁 Thread t1 new Thread(() - { synchronized (obj) { try { System.out.println(t1获得锁休眠2秒...); Thread.sleep(2000); System.out.println(t1释放锁); } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); // 主线程等待t1拿到锁后再查看对象头 Thread.sleep(1000); System.out.println(\n偏向锁状态t1持有:); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); // 再启动t2来竞争触发锁升级 Thread t2 new Thread(() - { synchronized (obj) { System.out.println(t2获得锁); } }); t2.start(); t2.join(); System.out.println(\n重量级锁状态t2持有:); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } }运行结果简化版初始状态: java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 00 (11100101 00000001 00000000 00000000) (485) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal 4 bytes external 4 bytes total 偏向锁状态t1持有: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 2b 2a 01 (00000101 00101011 00101010 00000001) (17495045) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 00 (11100101 00000001 00000000 00000000) (485) 12 4 (loss due to the next object alignment) 重量级锁状态t2持有: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 10 2b 2a 01 (00010000 00101011 00101010 00000001) (17495056) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 00 (11100101 00000001 00000000 00000000) (485) 12 4 (loss due to the next object alignment)重点看OFFSET 0那一行的十六进制值。初始状态是01 00 00 00低2位是01符合无锁状态。偏向锁状态下变成了05 2b 2a 01我们把它转换成二进制取最低两位01因为05的二进制是00000101最后两位是01依然是01但整个数值已经包含了线程ID等信息。而重量级锁状态下10 2b 2a 01的最低两位是01不对10的二进制是00010000最后两位是00等等这里有个陷阱JOL打印的是大端序Big-Endian而Mark Word的位定义是按小端序Little-Endian解读的。所以我们真正要看的是01这个字节它的二进制是00000001最后两位是01不01的二进制就是00000001最后两位是01。但根据规范重量级锁应该是10。这说明JOL的打印方式或者我们的解读方式需要更精确。实际上更可靠的方法是使用-XX:PrintGCDetails -XX:UnlockDiagnosticVMOptions -XX:PrintAssembly等JVM参数配合HSDBHotSpot Debugger工具直接在运行时dump内存。但对于日常开发JOL已经足够揭示核心原理对象头的值是动态变化的它忠实地记录了锁的状态变迁。4.3 第三步用jstack分析线程状态定位Monitor持有者当线上服务出现性能瓶颈怀疑是锁竞争时jstack是最直接的诊断工具。它能打印出JVM中所有线程的堆栈以及它们当前的锁状态。假设我们有一个死锁demopublic class DeadLockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (lockA) { System.out.println(t1 acquired lockA); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println(t1 acquired lockB); } } }); Thread t2 new Thread(() - { synchronized (lockB) { System.out.println(t2 acquired lockB); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println(t2 acquired lockA); } } }); t1.start(); t2.start(); } }运行后用jps找到进程ID再执行jstack pid输出中会包含类似这样的关键信息Thread-1 #12 prio5 os_prio0 tid0x00007f8b4c0a8000 nid0x7a0e waiting for monitor entry [0x00007f8b3d7f9000] java.lang.Thread.State: BLOCKED (on object monitor) at DeadLockDemo$2.run(DeadLockDemo.java:22) - waiting to lock 0x0000000715a0a0a0 (a java.lang.Object) - locked 0x0000000715a0a0b0 (a java.lang.Object) Thread-0 #11 prio5 os_prio0 tid0x00007f8b4c0a6000 nid0x7a0d waiting for monitor entry [0x00007f8b3d8fa000] java.lang.Thread.State: BLOCKED (on object monitor) at DeadLockDemo$1.run(DeadLockDemo.java:13) - waiting to lock 0x0000000715a0a0b0 (a java.lang.Object) - locked 0x0000000715a0a0a0 (a java.lang.Object)这里的信息量巨大Thread.State: BLOCKED (on object monitor)明确告诉你这个线程是因为在等待Monitor而被阻塞。- waiting to lock 0x0000000715a0a0a0表示它正在等待获取地址为0x0000000715a0a0a0的对象的锁。- locked 0x0000000715a0a0b0表示它当前已经持有了地址为0x0000000715a0a0b0的对象的锁。两行信息交叉对比立刻就能看出死锁链t1拿着A等Bt2拿着B等A。这就是Monitor机制在生产环境中的真实“显形”。掌握jstack就等于拥有了透视Java并发世界的X光机。5. 常见问题与排查技巧实录那些年踩过的坑5.1 问题一“我加了synchronized为什么还是线程不安全”这是新手最常遇到的“幻觉”。典型代码如下public class BadCounter { private int count 0; public void add() { // 错误每次new一个新对象锁的不是同一个东西 synchronized (new Object()) { count; } } }或者更隐蔽的public class BadService { public void doWork() { // 错误this是当前实例但如果这个方法被多个不同实例调用 // 那么每个实例的this都是不同的对象锁无效 synchronized (this) { // ... 业务逻辑 } } }排查技巧用jstack看线程状态。如果所有线程都显示RUNNABLE而不是BLOCKED或WAITING那基本可以断定它们根本没有在竞争同一个锁。此时你需要回溯synchronized括号里的表达式确认它最终求值的结果是否真的是你期望的那个“全局唯一的锁对象”。一个简单有效的办法是在加锁前把锁对象的hashCode()打印出来看看是不是每次都一样。注意不要用String常量作为锁比如synchronized(lock)。因为字符串常量池的存在这看似是同一个对象但一旦有其他代码也用了相同的字符串字面量就会造成意料之外的锁竞争甚至跨模块的死锁。应该用private final Object lock new Object();来创建一个私有的、专属的锁对象。5.2 问题二“锁升级了但我怎么感觉不到性能变化”锁升级是JVM的内部优化对上层代码完全透明。你不会在代码里看到“升级”这个动作也不会收到任何通知。它的效果体现在宏观的性能指标上在低竞争场景偏向锁和轻量级锁的开销几乎为零在高竞争场景重量级锁的上下文切换开销会显著拉高平均延迟。但如果你只测单个请求的RT可能感知不明显。排查技巧使用专业的JVM监控工具如JMCJava Mission Control或Arthas。在JMC中开启Lock Profiling它可以统计出java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await()的调用次数重量级锁等待java.lang.Object.wait()的调用次数Object.wait的等待java.lang.Thread.sleep()的调用次数虽然不是锁但也是线程挂起如果发现await()调用次数激增且伴随着BLOCKED线程数上升那就说明你的应用正经历严重的锁竞争是时候考虑优化了。此时jstack的线程dump就是你最好的朋友。5.3 问题三“synchronized和ReentrantLock到底该选哪个”这是一个经典的“自行车和汽车”问题。它们都能到达目的地线程安全但适用场景不同。维度synchronizedReentrantLock语法语言关键字简洁API调用需手动lock()/unlock()异常安全JVM保证绝对安全必须在finally块中unlock()否则易死锁功能基础互斥支持公平锁、可中断、超时、多条件变量性能JVM深度优化JDK 1.6后差距极小略高一点点但可忽略调试jstack可直接看到锁对象jstack也能看到但信息略少我的实操心得在95%的业务代码中无脑用synchronized。它的简洁、安全、可读性是无可替代的。只有当你明确需要以下某一项功能时才考虑ReentrantLock需要tryLock(long time, TimeUnit unit)实现超时获取锁避免无限期等待需要lockInterruptibly()让等待锁的线程能响应中断信号需要newCondition()创建多个独立的等待队列比如生产者/消费者模型中分开管理满/空条件在性能压测中synchronized确实成为瓶颈且经过profiling确认是锁本身的开销而非业务逻辑此时ReentrantLock的微弱优势才值得考虑。提示ReentrantLock的lock()方法其底层实现最终还是会调用Unsafe.park()也就是和synchronized的重量级锁走的是同一条路——操作系统级别的线程挂起。所以它们的“终极形态”是一样的差异只在用户态的封装层。5.4 问题四“静态方法加synchronized锁的是什么”这是一个高频面试题也是容易混淆的点。public static synchronized void method()这个锁既不是this也不是任意实例而是该方法所属类的Class对象。因为静态方法属于类不属于实例所以它的锁也必须是类级别的。这等价于public static void method() { synchronized (MyClass.class) { // ... 方法体 } }这意味着所有对该类的静态同步方法的调用都会竞争同一个锁。如果你的类里有多个静态同步方法它们之间是互斥的。这在某些单例模式或全局配置初始化的场景下非常有用但也可能成为性能瓶颈。因此除非必要不要轻易给静态方法加synchronized。5.5 问题五“锁粗化Lock Coarsening是怎么回事”这是JVM的一项激进优化。想象这段代码public void processList(ListString list) { for (String s : list) { synchronized (lock) { // 对s做一点简单操作 System.out.println(s); } } }如果list很大比如有10000个元素那么synchronized块会被执行10000次意味着10000次monitorenter和monitorexit。JVM的即时编译器JIT在运行时发现这10000次锁操作其临界区非常小且锁对象始终是同一个就会触发“锁粗化”优化把整个for循环变成一个大的同步块。public void processList(ListString list) { synchronized (lock) { for (String s : list) { System.out.println(s); } } }这减少了9999次锁的获取与释放开销。但要注意锁粗化是有前提的临界区必须足够小且锁对象不变。如果临界区里有耗时的IO操作粗化后反而会让其他线程等待更久。所以锁粗化是JVM的“善意提醒”而不是万能药。作为开发者我们依然要遵循“锁的范围尽可能小”的原则把耗时操作移出同步块。JVM的优化是用来兜底的不是用来纵容糟糕设计的。6. 性能调优与最佳实践写出让JVM“爱上”的同步代码6.1 锁粒度从“锁整个对象”到“锁一行数据”锁的粒度是性能的命脉。最差的做法是用synchronized(this)锁住整个Service实例。这意味着哪怕两个线程在操作完全无关的两个用户ID它们也要排队。更好的做法是“