1. 线程同步到底在解决什么问题先抛一个所有人都会遇到的场景你写了一个秒杀系统库存只剩10件结果同一时刻有30个用户同时下单。如果程序处理得不够严谨最终成交的订单数可能远超10件甚至出现库存变成负数的笑话。这不是段子而是真实生产环境里我再熟悉不过的血泪教训。我们常说的线程并发冲突、数据错乱本质上就是线程同步没有做好。很多人学Java多线程第一步就是记住synchronized、Lock、ThreadLocal这些名词。但我觉得理解“为什么要同步”比会写同步代码重要得多。Java多线程程序跑得好不好几乎全部取决于你对并发底层问题的理解深度。这就像开车没必要会造发动机但你至少得知道油门踩大了会窜出去、刹车踩急了会打滑否则出事只是早晚的问题。线程同步要解决的核心是对共享资源的访问控制。Java内存模型JMM规定了线程和主内存之间的交互它揭示了并发出现问题的根源归纳起来就是三个经典特性原子性、可见性、有序性。原子性指一个操作是不可中断的要么全做要么全不做。典型反例是 i看起来是一行代码实际在字节码层面经历读取、修改、写回三步多个线程同时执行就可能在同一个旧值上叠加导致最终结果远小于预期。可见性指一个线程修改了共享变量另一个线程能不能立刻看到。在JMM里每个线程都有自己独立的工作内存变量读写的都是副本。如果A线程改了变量还没来得及刷回主内存B线程读到的还是旧值这就产生了可见性问题。有序性指指令执行的顺序JVM和CPU为了优化性能会进行指令重排序。单线程下重排序不影响结果但多线程下可能出现类似“先打印后赋值却被另一个线程先看到赋值”的怪现象。一个经典的例子是双重检查锁单例DCL如果实例字段不加volatile另一个线程可能拿到一个“半初始化”的对象程序看起来没报错却莫名空指针。正是因为这三个特性的存在我们才需要线程同步机制来兜底。注意一个关键认知同步的目的不是让线程排队执行而是让共享数据在并发环境下保持一致和正确。这点如果理解偏了很容易写出“为了同步而同步”的烂代码比如把一个高性能的并发系统硬生生压成单线程那还不如不用多线程。2. 锁的核心synchronized的深度用法2.1 三种用法与锁对象辨析synchronized是Java语言层面最基础的同步手段理解它的关键在于“锁住的是对象不是代码”。第一种是同步实例方法锁住的是当前实例对象this。同一个实例的多个线程访问同步方法会互斥但不同实例之间互不影响。很多初学者写了一个带synchronized方法的类然后创建多个实例分别交给不同线程发现同步完全失效就是因为锁对象是this而不是这个类。第二种是同步静态方法锁住的是当前类的Class对象。Class对象在JVM里全局唯一所以静态同步方法天然具备类级别的互斥效果。这里有个容易踩坑的点实例同步方法和静态同步方法用的不是同一把锁两者并发执行互不干扰但如果你期望它们彼此互斥结果会让你怀疑人生。第三种是同步代码块这是实际项目里用得最多的一种。它可以锁定任意对象写法是synchronized(lock) { ... }比较典型的是锁“常量字符串的intern对象”或者用单独的Object实例充当锁。为什么不建议直接用字符串常量做锁这涉及到锁对象的可见范围问题在.class文件里内容相同的字符串可能合并到同一个String实例导致本不相干的线程抢同一把锁万一是用lock这种常量JVM内部常量池会直接复用其他业务代码也用到同样字符串常量的时候就会无端阻塞排查起来非常痛苦。锁对象的选择直接决定同步范围。我习惯用一个原则永远锁住操作的数据所属的对象而不是随便new一个Object去套。比如操作一个ArrayList就synchronized这个list对象本身操作某个Account对象锁账号实例比锁一个外部静态变量更内聚更符合业务直觉。代码评审时看到synchronized(new Object())这种写法基本可以直接打回因为每次new出来的锁对象不同多线程之间根本不存在互斥关系。2.2 可重入性与锁升级机制synchronized是可重入锁即同一个线程已经持有一把锁之后可以再次进入由这把锁保护的代码区域。比如方法A加了synchronizedA内部又调用同一个类的同步方法B线程持有A的锁时进入B不需要重新竞争。底层记录着锁的持有线程和重入次数重入一次计数加一退出一次计数减一归零才真正释放。可重入性极大简化了编程模型否则我们在A里调用B之前还得手动“解开”外层锁或者更糟糕——直接死锁。JDK 1.6之后synchronized不再是一个单纯的重量级锁而是引入了锁升级机制。锁的初始形态是无锁随着并发竞争逐步升级无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁的意思是同一线程第一次获取锁时会在对象头Mark Word里记录线程ID。之后这个线程再次进入同步块时不需要做任何同步操作直接执行性能开销接近零。但如果另一个线程来竞争偏向锁会撤销进入轻量级锁状态。轻量级锁通过CAS自旋尝试获取锁适合线程“等一下就能拿到锁”的场景。如果自旋次数超过阈值默认是10次自适应的版本会动态调整锁就膨胀成重量级锁此时未拿到锁的线程会被挂起阻塞涉及用户态和内核态的切换开销最大。面试时遇到“synchronized的性能”问题很多人还停留在老版本印象里说它慢。其实锁升级机制让synchronized在低竞争下性能很好只有高竞争场景才需要对比ReentrantLock。这几年我用synchronized写业务同步代码绝大多数场景都够用真正需要上ReentrantLock的只有少数对等待中断、超时、公平性有要求的场景。注意synchronized既保证原子性也保证可见性和有序性。它在进入同步块时清空工作内存退出时把修改强制刷新到主内存这就是所谓的“synchronized happens-before规则”。理解这一点非常重要——不仅争用锁的线程被阻塞了数据的一致性也得到保障。3. volatile、原子类与Lock家族谁才是你的选择3.1 volatile的边界在哪里valatile是Java里最轻量的同步机制它保证变量的可见性和禁止指令重排序但不保证原子性。很多人拿它和synchronized对比其实两者的定位完全不同。我用一句话总结volatile是状态标志位和个人使用的“轻量级同步”synchronized是复合操作的“重型保障”。volatile适合的经典场景是boolean stop标志位。一个线程运行while循环另一个线程修改stop状态如果不加volatile循环线程可能永远看不到新值程序死循环跑满CPU。加了volatile后修改立刻对其他线程可见简洁又高效。但如果你试图用volatile修饰int count来实现多线程累加结果一定是错的因为count不是原子操作volatile解决不了多个线程同时读改写带来数据丢失的问题。如何判断一个场景能不能用volatile我的经验是看操作是否满足“单次读写”约束。如果一个线程只写、其他线程只读或者写操作不依赖当前值那么volatile足够如果涉及读改写、复合操作、依赖上个状态的校验老老实实用锁或者原子类。3.2 原子类与CAS的无锁思路JDK的java.util.concurrent.atomic包提供了AtomicInteger、AtomicLong、AtomicBoolean等原子类它们的核心是CASCompare And Swap操作。CAS的流程是拿内存中的值V和期望值A比较相等就用新值B替换不相等就重试或放弃。整个过程是CPU级别的一条原子指令不需要加锁。用AtomicInteger替代synchronized做计数是个典型的无锁优化import java.util.concurrent.atomic.AtomicInteger; public class Counter { private final AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }多个线程同时调用increment方法底层通过CAS不断重试在竞争不高时性能比synchronized好得多。但要注意CAS存在ABA问题某个线程把变量从A改成B又改回A另一个线程CAS时看到的值还是A认为没有变化实际上中间发生了修改。大多数业务场景ABA不构成问题但如果你写的是无锁栈或链表就要使用带版本号的AtomicStampedReference。高并发场景下还有一个更进阶的选手LongAdder。它把热点数据分散到多个Cell里每个线程只更新自己那一个Cell的分值从AtomicInteger的“单点CAS竞争”变成了“分段累加”的设计大大降低冲突概率最终sum时再汇总。LongAdder适合读少写多的统计场景但它的读取不是强一致性的精确度要求极高的场景需要自己权衡。3.3 ReentrantLock与Condition的进阶手法Lock接口是JDK 1.5引入的最常用实现是ReentrantLock。和synchronized相比它有几个独门武器支持公平锁构造参数传true线程按请求顺序获取锁、支持响应中断lockInterruptibly、支持超时获取tryLock(timeout, unit)还支持更灵活的条件变量Condition。import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class LockCounter { private int count 0; private final Lock lock new ReentrantLock(); public void increment() { lock.lock(); try { count; } finally { lock.unlock(); } } }注意ReentrantLock必须在finally块里手动解锁一旦忘记解锁锁永远不会被释放其他线程会卡死到天荒地老。这是和synchronized最大的区别synchronized异常时JVM自动释放锁而Lock不行。我见过不止一次线上事故罪魁祸首就是某个同事try/finally写漏了导致接口请求大面积超时。Condition的作用是替代传统的wait/notify机制。一个Lock可以创建多个Condition每个条件队列独立管理实现更细粒度的等待通知。经典的生产者消费者里可以用两个Condition分别表示缓冲区非空和非满消费者等待notEmpty生产者放完触发notEmpty.signal生产者等待notFull消费者取完触发notFull.signal。而用synchronized的wait/notifyAll每次唤醒的是所有线程再让它们自己抢资源效率低不少。做个直接对比在一个容器容量为10的生产者消费者模型里single Condition 和多个Condition带来的差异在高吞吐场景下非常明显这也是面试里考察Lock用的经典题目。3.4 synchronized与Lock怎么选我给出的不是谁更好而是各自的最适区间。表格整理一下维度synchronizedReentrantLock底层实现Monitor对象头标记AQSAbstractQueuedSynchronizer锁获取方式隐式无感知显式lock/unlock公平性非公平默认非公平可配置公平可中断不支持支持lockInterruptibly超时机制不支持支持tryLock(timeout)条件队列一个锁一个等待集可以创建多个Condition异常释放自动释放必须finally手动释放性能低竞争时有锁升级优化高竞争时可避免一些锁膨胀问题大部分业务场景我的建议是优先用synchronized因为它写起来简单、不容易出错、可读性好。只有出现“需要可中断获取锁”“需要超时避免死等”“需要多个条件队列”“需要公平锁”这四类需求时才切换去用ReentrantLock。4. 线程间的协作等待通知机制与并发工具类4.1 wait/notify的正确打开方式线程同步不只是互相排斥很多场景线程之间还需要协调协作。Java提供的经典协作方式是基于Object.wait()和Object.notify()/notifyAll()的等待通知机制。这里有一个黄金法则wait、notify、notifyAll必须放在synchronized块或方法中调用否则抛出IllegalMonitorStateException。原因是Java设计上要求调用wait前必须持有对象锁这样可以避免丢失通知的竞态条件。同时wait之后线程会释放持有的锁进入等待队列。这一点经常被忽略如果wait不释放锁持有锁的线程永远无法被人唤醒程序直接死锁。被唤醒后wait方法返回线程需要重新去竞争锁拿到锁才继续执行。这里有个极大的坑不能假设被唤醒后条件一定满足了。wait期间可能有多个线程同时被唤醒其中只有一个能抢到锁处理资源另一个线程重新抢到锁时资源已经被别人消费光了。所以规范做法是用while循环检查条件而不是用ifsynchronized (queue) { while (queue.size() 0) { queue.wait(); // 条件不满足就等 } // 条件满足消费一个元素 queue.remove(); }这种写法同时也能规避虚假唤醒问题。所谓虚假唤醒是指线程在没有notify的情况下被唤醒这是操作系统层允许的行为实际比较少见但正规并发代码必须防御它。4.2 CountDownLatch等所有线程都跑完热搜词里专门有个“java线程等待都完成”这对应的正是CountDownLatch。它的场景非常典型主线程启动N个子线程分别执行任务然后主线程需要等待所有子线程都完成之后再做汇总或收尾工作。使用方式很直接import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class WaitAllExample { public static void main(String[] args) throws InterruptedException { int threadCount 5; CountDownLatch latch new CountDownLatch(threadCount); ExecutorService pool Executors.newFixedThreadPool(threadCount); for (int i 1; i threadCount; i) { int taskId i; pool.execute(() - { try { Thread.sleep(500); System.out.println(任务 taskId 执行完毕); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); // 计数减一必须在finally里保证执行 } }); } latch.await(); // 主线程阻塞等待计数归零 System.out.println(所有任务已完成开始汇总结果); pool.shutdown(); } }CountDownLatch是一次性的计数器减到0之后不可重置。如果需要反复等待多批次任务就用CyclicBarrier它支持循环利用。两者一字之差语义完全不同千万别用混。CountDownLatch是“一扇门所有人都到了才开门”CyclicBarrier是“所有线程互相等待到齐后一起出发”合作关系更强。4.3 ThreadLocal不共享就没有同步问题线程同步还有一条逆向思路既然同步是为了解决共享变量冲突那能不能不共享ThreadLocal就能实现每个线程独立持有自己的变量副本线程之间天然隔离不需要加锁。常用的场景是SimpleDateFormat。这是个线程不安全的类并发调用parse会抛出各种诡异异常。低版本下有人用synchronized包一层结果性能暴跌更优的做法是每个线程一个SimpleDateFormat实例private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); public Date parse(String time) throws ParseException { return DATE_FORMAT.get().parse(time); }ThreadLocal的使用也要当心内存泄漏。ThreadLocalMap里的Entry是弱引用但value是强引用。如果线程池里的线程长期存活而ThreadLocal变量被置为nullvalue依然通过线程的Entry被引用无法回收可能触发OOM。规范习惯是使用完调用remove()清理这一点是很多人不重视却实打实会踩的坑。5. 实战场景拆解从数据错乱到DCL单例5.1 案例一模拟售票超卖问题为了讲清楚同步的威力我用一个模拟场景来说明。定义一个TicketSystem类public class TicketSystem { private int tickets 10; public void sell(String window) { if (tickets 0) { tickets--; System.out.println(window 卖出一张票剩余 tickets); } else { System.out.println(window 票已售罄); } } }三个窗口线程同时卖票代码会出现两种情况一是负库存多个线程同时执行到判断语句都认为tickets大于0就进入卖票逻辑二是超卖tickets--被拆成读取-修改-写回多个线程申请的是同一个旧值。解决方式很简单给sell方法加synchronized修饰锁住当前TicketSystem实例判断和扣减就形成原子操作了public synchronized void sell(String window) { // 剩余逻辑不变 }为什么判断和扣减必须同步因为“判断库存是否充足”和“扣减库存”在并发下必须看作一个整体操作。只锁扣减不锁判断两个线程还是能同时进入判断“判断扣减”被拆成两步执行照样出问题。5.2 案例二双重检查锁DCL单例单例模式是Java面试里的常客懒汉式单例在并发场景下必须考虑线程安全。那么经典的“双重检查锁”写法如下public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查避免每次都加锁 synchronized (Singleton.class) { // 加锁保证线程安全 if (instance null) { // 第二次检查防止重复创建 instance new Singleton(); } } } return instance; } }这里instance为什么必须加volatile因为instance new Singleton()不是原子操作它经历三个步骤分配内存、执行构造方法初始化对象、把变量指向该内存地址。JVM和CPU的指令重排序可能把第2步和第3步调转如果线程A执行完第3步后还没执行第2步线程B进入第一次检查发现instance不为null返回了一个尚未完成初始化的对象随后调用其方法就可能触发空指针或莫名其妙的异常。加volatile之后禁止了重排序确保线程B永远只能看到一个完整构造的对象。这个例子把volatile的有序性语义体现得淋漓尽致也是我强烈建议所有Java开发者亲手写一遍并画一下内存时序图的知识点。5.3 案例三生产者消费者模型我们用一个基于ReentrantLockCondition的生产者消费者模型来收束实战部分这是一个高频的面试手写题也是日常业务中消息队列消费场景的简化版import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class ProcessorK { private final int capacity; private final QueueK queue new LinkedList(); private final Lock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); public Processor(int capacity) { this.capacity capacity; } public void put(K element) throws InterruptedException { lock.lock(); try { while (queue.size() capacity) { notFull.await(); // 队列满了就等 } queue.offer(element); notEmpty.signal(); // 通知等待的消费者 } finally { lock.unlock(); } } public K take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); // 队列空了就等 } K element queue.poll(); notFull.signal(); // 通知等待的生产者 return element; } finally { lock.unlock(); } } }这套代码的好处在于notEmpty给到消费者的通知不会误伤生产者反之亦然两个等待队列互不干扰。如果是用synchronized的wait/notifyAll每次唤醒都得把所有线程从等待队列拉出来重新竞争并发高时上下文切换成本会明显上升。5.4 并发工具类的选型速查学习并发工具类最怕背了概念却不知道什么时候用。结合实际场景我总结一个选型逻辑多个线程更新同一个计数AtomicInteger写入竞争大时升级为LongAdder共享状态存在复杂条件判断synchronized代码块或ReentrantLock等待一批任务全部完成CountDownLatch一群线程互相等待、齐步走CyclicBarrier控制同时只能有N个线程访问资源Semaphore只希望线程A写、线程B读且状态简单volatile足矣每个线程独立持有对象副本ThreadLocal这个经验不像官方文档那样面面俱到但足够应对绝大多数生产问题。核心思想永远是先明确并发问题是什么再选择最小代价的解决方式而不是上来就搬一个重量级工具。6. 常见问题与排查技巧实录6.1 死锁最隐蔽的线程杀手死锁是并发编程里最经典也最难排查的问题。线程A持有锁1等待锁2线程B持有锁2等待锁1两个线程就永远互相等待。写个极简演示Object lockA new Object(); Object lockB new Object(); // 线程1 synchronized (lockA) { Thread.sleep(100); synchronized (lockB) { System.out.println(线程1获取到两把锁); } } // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { System.out.println(线程2获取到两把锁); } }排查死锁最有力的工具是线程转储。用jps找到进程PID再执行jstack pid输出里有明确的“Found one Java-level deadlock”字样并列出两个线程互相等待的锁名称和代码行号。面试时如果被问到排查过程完整回答出这一步是明显的加分项。预防死锁的核心手段有三个按固定顺序加锁让所有线程以相同的顺序获取锁尝试获取锁的超时机制比如ReentrantLock的tryLock拿不到锁就释放已持有的锁重试缩小锁范围尽量减少一次持有多个锁的机会。个人经验业务代码里最管用的是按固定顺序加锁从源头上消灭循环依赖。6.2 数据错乱但难复现怎么办多线程Bug最讨厌的地方在于“偶发”。反复运行十次可能只失败一次线上出问题本地怎么都复现不了。遇到这种情况我一般从这几个方向排查第一检查是否所有对共享变量的访问都被同一把锁保护。很多人只给写操作加了锁读操作裸奔或者用了不同的锁对象保护同一个变量这两个都是常见的“看起来有同步实际等于没有”。第二检查共享变量是否加了volatile。不加volatile某线程修改的变量对另一个线程不可见即使没有并发写也可能读到旧值。此类Bug的表现是单线程跑永远正常多线程跑结果随机。第三用jstack看线程状态是否大量Thread.State是BLOCKED、WAITING、TIMED_WAITING。如果大部分线程长时间卡在某个锁的monitor上说明这里有严重的锁竞争大概率存在锁范围过大或死循环持锁的问题。第四在JDK 9的环境下可以使用jcmd或jfr录制一段运行数据查看线程争用情况。jfr是做性能分析的利器能记录锁竞争和阻塞分布的细节值得花时间学。6.3 锁粒度太大性能被拖垮很多团队把同步手段用对了但性能依然很难看问题大多出在锁粒度上。比如一个批量导入方法里本来只是操作某个共享计数器需要同步结果整个方法都加了synchronized。虽然正确性没问题但所有调用者全部串行吞吐量几乎被腰斩。优化思路是把同步块缩小到最小必要范围尽量只锁住真正的临界区代码。还是说计数器你完全可以在方法内部只对count加synchronized块而不锁整个方法。如果方法里还有网络调用、数据库IO这种优化带来的收益会非常恐怖。我见过一个业务方法去掉大范围同步、换成细粒度锁之后接口RT从800ms降到200ms靠的就是“缩小同步范围”这一招。6.4 常见问题速查表现象可能原因排查方向值总是小于预期i复合操作缺同步检查是否加了锁或使用原子类一加锁就StackOverflow可重入锁被误用为不可重入确认synchronized递归调用场景某线程永远不退出共享标志位缺volatile给标志位加volatile程序卡死无响应死锁或永久等待jstack查看线程状态wait/notify抛异常未持有锁调用wait将调用移入synchronized块明明成功了却还阻塞CountDownLatch计数不等于任务数检查是否有分支漏调countDown内存持续增长ThreadLocal未清理使用完调用remove()线程池任务全卡住锁未在finally中释放检查lock/unlock是否配对这份速查表不是万能药但覆盖了我这几年在生产环境里遇到的多线程问题80%以上的表象。遇到问题先对号入座能省下大量排查时间。6.5 几个我反复踩过的坑坑一把this当作锁对象导致锁扩散。如果一个类里有多个不相关的同步方法都用了synchronized修饰等价于锁this那么不同业务场景的同步方法之间也会互相阻塞。比如一个UserService既有login同步方法又有updatePassword同步方法两个方法锁同一把this锁登录请求和改密请求互相影响。改进做法是拆成多个锁对象或改用显式Lock分别加锁。坑二并发量很大时还去用公平锁。公平锁虽然让线程按顺序获取锁但它需要维护一个先进先出的等待队列大量线程排队时开销非常高。业务上如果对“插入顺序”没有强需求老老实实用默认的非公平锁。我见过有团队为了“看起来公平”把性能拖垮的案例实在得不偿失。坑三教科书说用synchronized项目里却不知道锁该放哪。我的习惯是先明确共享变量是谁其次考虑有没有复合操作最后决定用同步还是用原子类或ThreadLocal。不要为了用并发工具而用先有并发问题才有并发方案。坑四以为加了锁就不会有可见性问题。锁确实能解决可见性但前提是所有访问都走同一把锁。如果有人绕过锁直接读共享变量就是所谓的“数据竞争”此时锁的happens-before规则完全不生效数据依然不可见。代码审查时一定要检查读操作是否也被同步。7. 多线程同步的后续扩展线程同步这个话题到这里其实只讲了并发编程的入门三板斧。真正生产环境里我们很少直接new Thread去裸写而是通过线程池管理线程也很少手写Lock队列更多用并发容器如ConcurrentHashMap、BlockingQueue。这些组件内部已经把同步细节封装好了使用门槛降低不少。后续你还可以沿着几条线深入一条是JUC包的核心原理AQS框架、ReentrantReadWriteLock、StampedLock一条是Java内存模型的进阶细节happens-before规则的完整七条、final字段的内存语义还有一条是更高层的并发框架比如CompletableFuture的异步编排、ForkJoinPool的分治计算。如果要做线上调优那么锁竞争分析、JFR录制、JIT编译优化也值得研究。就我个人的体会理解线程同步最简单的方式是把它当成“房屋钥匙管理规则”共享资源就是公共活动室不同线程就是不同的人synchronized是所有人都必须遵守的物理锁。单项原子操作用自动门不共享的资源就一把钥匙一个人用而CountDownLatch就像小区集体活动的集合通知——所有人才可以出门。模型想通了代码怎么写都自然。