Java并发基石:synchronized原理、用法与实战避坑全解析
1. 从线程安全说起为什么我们绕不开synchronized聊到Java多线程synchronized绝对是绕不过去的一个关键字。我接触Java并发编程那会儿最先搞明白的就是它但说句实话真正敢说自己用明白了还是在踩了不少坑之后。先把问题说清楚多线程环境下多个线程同时访问共享变量或代码块时会出现竞争条件。比如一个简单的计数器两个线程各自加10000次理论上结果是20000但实际运行往往会得到小于20000的数——这就是典型的线程安全问题。synchronized的作用就是给共享资源加把锁保证同一时刻只有一个线程能执行被锁住的代码段。它是Java内置的、最基础的线程同步机制也是理解后续Lock、原子类、并发容器等高级工具的基石。这篇博文适合谁看刚接触Java并发、被线程安全问题折磨过的初学者以及写了好一阵子多线程代码但没系统梳理过synchronized细节的同学。我会从原理讲到使用姿势再讲到我实际项目中遇到的坑尽量让大家少走弯路。需要先说清楚一点本文讨论的是最常见的使用场景和底层机制不同JDK版本对synchronized的实现细节会有差异但不影响我们对它核心思想的理解。2. synchronized的三副面孔方法锁、代码块锁和类锁synchronized可以作用在三个地方锁定对象完全不同。很多初学者只知道语法不清楚锁的到底是什么导致写出了看似同步、实则仍然线程不安全的代码。2.1 实例方法锁锁住的是当前对象thispublic class Counter { private int count 0; public synchronized void increment() { count; } }当synchronized修饰实例方法时锁的是调用该方法的对象实例。也就是说如果两个线程分别操作同一个Counter对象它们的increment调用会被串行化但如果两个线程操作的是两个不同的Counter对象那互不干扰各加各的。这一点非常重要。我在给A同学看代码时他写过这样的场景全局只有一个共享计数器但他在方法里new了一个新对象去调用同步方法结果锁了个寂寞数据照常错乱。锁的对象都没找对同步自然失效。2.2 静态方法锁锁住的是Class对象public class Counter { private static int staticCount 0; public static synchronized void staticIncrement() { staticCount; } }静态方法加synchronized锁的是当前类的Class对象。注意类锁和实例锁不是同一把锁。假设有个类名为Counter它既有同步的静态方法也有同步的实例方法那么一个线程持有类锁执行静态方法时另一个线程依然可以持有实例锁执行实例方法两者互不阻塞。这一点极容易忽略。我在实战中遇到过有人用静态方法和实例方法同时操作同一个static变量结果静态方法有锁、实例方法也有锁但两把锁互不相干数据依然被并发修改。锁的粒度完全不同必须单独控制。2.3 代码块锁真正灵活的锁定方式public class Counter { private final Object lock new Object(); private int count 0; public void increment() { // 非同步区域 synchronized (lock) { count; } // 非同步区域 } }代码块锁可以指定任意对象作为锁常用方式有三种锁this、锁一个专用的Object成员变量、锁类的Class对象。相较方法锁代码块锁的好处是能缩小同步范围只锁住必要的几行代码减少持锁时间提升并发性能。我个人的实践原则是能用代码块锁的地方尽量不用方法锁。如果一个方法里只有一小段代码涉及共享变量其他部分都很耗时那让整段方法上锁会让其他线程无辜等待吞吐量直接被拉低。3. 锁到底在锁什么对象头、Monitor与偏向锁升级路径如果想真正理解synchronized的语义不能只看语法还得知道它底层是怎么实现的。JDK 6之后对synchronized做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级路径网上讲这个的很多我把核心逻辑捋一遍。3.1 对象头里的Mark Word是锁状态的家Java对象在内存中分为三块区域对象头、实例数据、对齐填充。对象头里的Mark Word记录了对象的哈希码、GC分代年龄、锁状态标志位等信息。在64位JVM中Mark Word占64位它的内容会随锁状态的变化而改变。简单说Mark Word就像一把锁的状态面板锁处于什么状态都记录在这个面板上。锁的升级过程就是多次更新这个面板内容的过程。3.2 四种锁状态的演进逻辑无锁状态没有任何线程竞争对象处于普通可用状态。偏向锁只有一个线程反复进入同步块时为了避免每次都用CAS比较并交换竞争锁JVM会在Mark Word里记录持有该锁的线程ID。之后该线程再次进入时直接判断线程ID是否一致一致就不需要任何CAS操作效率最高。轻量级锁当第二个线程也开始尝试获取该锁时偏向锁会撤销并升级为轻量级锁。轻量级锁通过自旋不断循环尝试的方式获取锁不阻塞线程适合锁持有的时间很短的场景。重量级锁如果自旋等待超过一定次数或等待线程数较多锁状态升级为重量级锁。此时未获取锁的线程会被阻塞挂起涉及用户态与内核态的切换开销较大。可以类比成一间会议室偏向锁就像只有固定一个人用的自习室来了直接进轻量级锁像有人在门口排队但不用登记轮流能进重量级锁像会议室需要前台登记进不去的人只能在外边等着叫号。3.3 从代码到锁状态的对应关系synchronized编译后会在同步代码块前后生成monitorenter和monitorexit两条字节码指令。执行monitorenter时尝试获取锁执行monitorexit时释放锁。JVM会根据当前竞争情况自动决定锁处于哪种状态这个过程对开发者是透明的。但有个常见误区重量级锁是最终的、不可逆的这个说法不完全对。锁可以升级但通常不会降级。至少在HotSpot的当前实现里一旦升级为重量级锁即使后续不再有竞争JVM也不会主动把锁状态降回轻量级。这一点我在排查问题时确认过多次。4. synchronized的语义保证可见性、原子性与重排序控制锁的作用不只是互斥这么简单。深入看的话synchronized还保证了内存可见性和一定的有序性这才是它能真正解决线程安全问题的本质原因。4.1 原子性代码段不可分割synchronized保证被修饰的代码段在任意时刻只有一个线程执行因此代码段内的操作是原子的。比如count这条语句看起来是一步操作实际上在字节码层面是读取、加一、写回三步。没有锁保护时两个线程可能同时读到同一个值分别加一再写回导致结果少了一次递增。synchronized通过互斥把这三步捆绑成了一个整体其他线程要么在开始前等待要么在结束后继续不会看到中间状态。这是它解决竞态条件最直接的保障。4.2 可见性锁释放时刷新工作内存Java内存模型规定每个线程都有自己的工作内存线程对变量的操作先发生在工作内存中再同步回主内存。这个同步时机不受控的话一个线程修改了变量另一个线程可能读到的还是旧值。synchronized的可见性语义是线程释放锁时会把工作内存中修改过的共享变量刷新到主内存线程获取锁时会重新从主内存加载共享变量到工作内存。这相当于在锁的边界上建立了一道刷新关卡保证一个线程的修改对后续获取锁的线程可见。4.3 有序性通过内存屏障防止重排序编译器和CPU为了优化性能可能对指令顺序进行调整。在单线程内重排序不影响程序语义但多线程共享变量时重排序可能导致意外结果。synchronized通过内存屏障Memory Barrier限制了重排序的边界。进入同步块时插入合适的屏障禁止相关指令越过边界退出同步块时同理。虽然synchronized不能显式地定义可见性范围、也不能像volatile那样直接禁止指令重排序的完整语义但它在锁边界的控制已经能保证同步块内的操作在宏观上按序执行。这里有个细节值得注意synchronized和volatile的选择。volatile只保证可见性和有序性不保证原子性synchronized三者都保证但代价是串行执行。对于简单的状态标志位用volatile就够了如果是复合操作比如先检查再修改那就必须用synchronized或原子类。5. 锁的重入机制与等待通知架构线程不是一个人跳舞synchronized的锁是支持重入的同一个线程可以多次获取同一把锁而不会把自己锁死。这是Java设计上的一个明智决策避免了很多死锁陷阱。5.1 重入是怎么实现的public synchronized void outer() { inner(); // 同一个线程再次进入inner方法 } public synchronized void inner() { // do something }当线程持有outer方法的锁时再调用同对象上的同步方法inner不需要再次竞争锁直接进入即可。JVM在对象头的锁记录中会维护一个计数器每重入一次加1每退出同步块减1直到减为0才真正释放锁。我见过最经典的坑某个线程在递归方法里加synchronized担心自己把自己锁死这是完全不必担心的。synchronized的重入性保证线程在已经持有锁的情况下后续对同一把锁的请求都会成功。5.2 wait/notify的经典协作模型synchronized与wait、notify配套使用是Java内置的线程间协作机制。wait让当前线程释放锁并进入等待状态notify唤醒一个在该锁上等待的线程notifyAll唤醒全部等待线程。有一个代码范式大家应该都见过synchronized (lock) { while (!condition) { lock.wait(); } // 条件满足后执行 }这里两个细节必须强调第一wait、notify必须放在synchronized代码块内否则会抛出IllegalMonitorStateException第二条件判断要用while而不是if因为被唤醒后条件可能再次不成立需要用while重新检查避免虚假唤醒spurious wakeup导致的逻辑错误。虚假唤醒是真实存在的。虽然实践环境中较少发生但JVM规范并没有禁止实现时发生虚假唤醒所以正确姿势是永远在循环中检查条件。5.3 等待与锁的关系当线程调用wait后它会释放当前持有的锁让出CPU其他线程才有机会获取锁并执行notify。如果线程不释放锁就等待那所有线程都会被堵死形成另一种活死锁。这是wait和sleep的最大区别sleep不释放锁wait释放锁。这个区别在实际调试中非常重要。我用一个小例子说明生产者-消费者模型中如果用sleep代替wait做条件等待生产者睡眠时还握着锁不放消费者根本无法进入消费代码整个程序就是死局。必须用wait让出锁才能让另一方工作起来。6. 真假锁的对比synchronized、ReentrantLock和锁的选择决策既然深入学了synchronized那就绕不开和ReentrantLock、原子类的对比。不把这些工具的边界理清楚实际项目里很容易选错。6.1 synchronized与ReentrantLock的关键差异ReentrantLock是JDK 5引入的显式锁功能上和synchronized有很大部分重叠但也提供了synchronized不具备的扩展能力。对比维度synchronizedReentrantLock锁的获取与释放自动由JVM管理手动lock/unlock配对响应中断不支持支持lockInterruptibly公平性非公平默认非公平可指定公平超时获取锁不支持支持tryLock(timeout)绑定多个条件一个锁只能有一个wait set一个锁可以有多个Condition底层实现偏向锁/轻量级锁/重量级锁AQS队列同步器如果只是要保证一段代码的互斥性优先用synchronized理由很简单代码简洁不会因为忘记unlock而泄漏锁JVM持续优化性能已经不输给ReentrantLock。ReentrantLock的价值在于特殊场景需要一个锁关联多个等待条件队列时、需要超时避免无限等待时、需要响应中断让线程可以取消等待时。这些场景用synchronized会写得很别扭。6.2 原子类在锁之外的解决方案如果共享资源的竞争操作只是简单的计数、累加、更新那可以考虑AtomicInteger、AtomicLong等原子类。它们基于CAS实现无锁化操作在低竞争场景下性能优于锁。private AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); }注意适用范围原子类适合单条语句的原子更新。如果业务逻辑需要先读多个变量再计算后写回且中间不允许其他线程修改那就不是原子类能覆盖的需要回退到synchronized或显式锁。6.3 我常用的选型判断标准说下我在实际项目里的选择习惯第一默认使用synchronized因为可读性好、不容易出错。第二一个方法里超过两个共享变量要组合更新时也还是用synchronized。第三需要可中断、可定时获取锁或者一个锁要关联多个等待条件时换ReentrantLock。第四计数、状态累计这类简单操作优先原子类配volatile做状态发布。这套标准并不复杂但它帮我少踩了很多用错了工具导致性能不好的坑。性能问题的前提是先保证正确锁的选择更多是工程取舍而不是技术秀技。7. 常见踩坑场景同步失效、死锁与性能误判写了几年并发代码我积累了一些synchronized相关的实战教训。这些坑几乎每个都会在团队里再次出现写出来希望大家能一眼识别。7.1 字符串常量或Integer缓存对象作为锁锁到别人头上了有人喜欢用字符串做锁例如private final String LOCK LOCK;看起来没问题但字符串常量在JVM中是intern的全局只有一个实例。也就是说任何代码拿同一个字符串常量加锁用的都是同一把锁。如果这段代码是公共库的一部分可能与调用方无意中形成锁竞争甚至造成难以排查的阻塞。同理Integer在-128到127之间是缓存对象用这个范围的Integer做锁也可能和其他使用相同Integer值的代码共享锁。稳妥做法是使用私有的、不会被外部引用的Object实例做锁。7.2 锁对象被重新赋值等于是换了一把锁private Object lock new Object(); public void setLock(Object newLock) { this.lock newLock; // 大忌 } public synchronized void doSomething() { synchronized (lock) { // ... } }如果某个方法里对lock字段重新赋值那么后续线程进入同步块时拿到的就是新对象上的锁。如果一个线程正在旧对象的锁上执行另一个线程在新对象的锁上执行二者不但不互斥而且所有依赖旧锁的阻塞逻辑都会失效。锁对象必须是final或者至少在同步执行期间不可变。7.3 锁的粒度太大导致性能雪崩一次线上问题让我印象很深某个服务把整个大方法都加上了synchronized方法内部包括耗时很长的外部接口调用和大量耗时操作。结果单个请求执行期间所有其他请求都压在锁上排队接口整体响应时间暴涨。这个问题的排查很快但根治需要有意识地缩小同步范围。把大锁拆成小锁把不需要同步的计算挪出同步块把耗时IO操作放到锁外性能立刻回升。7.4 死锁多个锁的获取顺序不一致经典死锁场景是两个线程分别持有锁A、等待锁B同时另一个线程持有锁B、等待锁A。synchronized本身没有提供破解死锁的机制只能从编码规范上预防。我的做法是多个锁必须按固定全局顺序获取比如先锁小的、再锁大的编号顺序或者尽量使用tryLock配合超时和ReentrantLock避免无限等待。工程上用静态代码分析工具扫描锁顺序也是一个有效手段。7.5 装作没事的可见性问题没有正确使用锁有些代码在部分路径上加了锁部分路径没加锁。比如读取方法不加synchronized只给写方法加。读者会发现即使读者没有并发写它也可能因为JMM可见性问题读到过期数据。严格做法是读写都要通过同一个锁来同步或者引入volatile。这个问题在标记位模式里最常见一个boolean标志控制循环退出写线程修改它读线程检查它。不给标志加volatile的话读线程可能永远看不到修改后的值程序就看起来卡住了。排查这类问题最费时间因为现象像是死循环其实是可见性问题。8. 从面试到实战怎么把synchronized讲清楚、用明白学完synchronized以后无论是面试还是实际写作代码都需要一个清晰的表达框架。我自己整理了一套消化方式分享出来供参考。8.1 搞清楚三个层面第一层是语法层面知道synchronized可以修饰实例方法、静态方法、代码块明白每种方式锁住的对象是什么。第二层是语义层面理解互斥、原子性、可见性、有序性和可重入性能说清楚synchronized为什么能保证线程安全。第三层是原理层面了解Mark Word、Monitor对象、偏向锁/轻量级锁/重量级锁的升级机制能解释JDK对synchronized的优化演进。8.2 动手验证你学到的东西我建议初学者写几个小实验两个线程各加10000次不加锁看结果加synchronized看结果把锁加在两个不同对象上看结果用JVisualVM或命令行工具观察线程状态BLOCKED、WAITING等。做过一轮实验后很多只可意会的东西会变成亲眼所见。线程状态工具是我在排查线程问题时最常用的手段之一能直接看出哪些线程卡在锁上、哪些线程处于等待状态。8.3 项目里的落地清单写代码时我会在脑内过一遍清单被并发访问的共享变量是否加了锁这个锁的粒度是否过大锁对象是否可能被外部拿到或修改锁的获取顺序是否一致等待条件是否用while循环释放锁的路径是否覆盖异常场景synchronized不会自动处理异常释放的问题但它不像显式锁那样需要手动unlock所以普通场景下容错更好。但即便是自动锁代码块内的异常也会导致锁被JVM自动释放不会像显式锁那样出现死锁这点是synchronized的加分项。8.4 后续还能往哪里延伸学完synchronized之后可以按这个顺序继续深入volatile的原理和使用场景、Atomic类与CAS机制、ThreadLocal的隔离思想、并发容器如ConcurrentHashMap、线程池与阻塞队列、显式锁和AQS框架。synchronized是第一个台阶但绝不是最后一个台阶。最后分享一点个人经验synchronized学起来容易真正用对很难。我见过太多项目因为锁的对象没选对、锁的粒度没控制好、或者读写路径不同步而出现诡异问题。每当遇到这种问题先别急着怀疑JVM和框架回头看看锁加的位置和对象十有八九问题就在那里。如果这篇文章能给正在学Java并发的你一点启发那就值了。多看、多写、多排查线程转储文件是掌握synchronized最快的三条路。