ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java并发编程核心:从synchronized到分布式锁的完整机制解析

2026/9/19 5:47:25 拓冰建站 浏览量
Java并发编程核心:从synchronized到分布式锁的完整机制解析 每次面试问到 Java 并发绕不开的总是那句“你说说Java里的锁到底怎么工作的”你会发现线程安全这个看似基础的话题一旦深挖起来能牵扯出对象头、Monitor、CAS、AQS、锁升级、分布式锁一整条链路。而这篇文章我想把这些年在项目里真正用锁、调锁、排查锁问题的经验整理一遍从一个实际写代码的视角把 synchronized、JUC 锁、无锁方案、分布式锁这些内容掰开揉碎讲清楚。无论你是准备面试还是在线上遇到过并发问题这篇都值得你耐心读完。1. 先搞清楚锁到底在解决什么问题1.1 线程安全问题的源头线程安全这个概念很多人背得滚瓜烂熟但真要问一句“为什么需要锁”反而不一定能说到点子上。我习惯用一个生活化的场景来解释假设你和室友共用一个冰箱你在冰箱里放了一盒酸奶室友不知道又放了一盒进去最后两盒都过期了也没人喝。这就是典型的并发访问共享资源导致的“互相干扰”。放到 Java 程序里所谓共享资源就是多个线程都能访问到的变量或对象比如 Redis 客户端、数据库连接池、全局计数器。多个线程同时去读写这些资源如果没有做保护就会出现三类经典问题原子性问题比如 i 不是一步完成的、可见性问题一个线程改了值另一个线程看不到、有序性问题编译器为了性能做了指令重排。锁要解决的核心就是这三件事让“检查-修改-写入”变成一个不可分割的整体同时保证一个线程修改后的结果对其他线程可见。1.2 锁到底是什么从操作系统层面看锁其实是一个内存标志位代表“资源正在被占用”。当一个线程拿到锁之后其他线程想要同一把锁就必须停下来等待。等待的方式有两种一种是“死等”也就是不断循环尝试获取锁叫自旋另一种是“睡等”线程挂起等锁释放后由系统唤醒叫阻塞。这两种等待方式在 Java 里都有体现synchronized在竞争不激烈时会用自旋竞争激烈时就升级为阻塞ReentrantLock底层也大量使用了自旋和 LockSupport 的 park 机制。理解锁的本质后你会发现所谓的锁优化说到底就是在“自旋消耗 CPU”和“挂起唤醒消耗时间”之间做权衡。1.3 重入是锁的基本修养还有一个概念必须提就是重入性。什么叫重入一个线程已经拿到了某把锁当它再次访问同一个锁保护的代码块时能不能直接进去我觉得绝大多数锁都应该支持重入否则自己调用自己的方法都会死锁。synchronized和ReentrantLock都支持重入而且重入的次数都有记录。synchronized是在对象头的 Mark Word 里记录线程 ID 和重入计数ReentrantLock则用 AQS 里的 state 字段记录重入次数。为什么要设计重入因为很多业务方法会链式调用A 方法加锁A 内部又调用 B 方法B 也加了同一把锁如果不支持重入A 调用 B 的瞬间就死锁了。注意重入是有代价的。每次重入都会增加计数释放时需要递减到 0 才能完全释放。虽然现代的 JVM 做了大量优化但设计接口时还是建议尽量把加锁逻辑收敛在一个方法里避免无谓的重入链。2. synchronizedJava 内置锁的完整运行机制2.1 synchronized 的三种用法synchronized是 Java 语法层面的内置锁也是绝大多数开发者最先接触的锁。它有三种用法修饰实例方法、修饰静态方法、修饰代码块。很多人只知道怎么写不知道底层有什么区别。先说结论修饰实例方法锁的是 this 对象。修饰静态方法锁的是 Class 对象也就是整个类的类对象。修饰代码块锁的是括号里指定的任意对象。这里有个经典面试题两个 synchronized 实例方法一个线程访问对象 A 的 m1另一个线程访问对象 B 的 m2会互相阻塞吗答案是不会。因为锁的是不同的 this 对象。反过来如果两个线程访问同一个对象的两个 synchronized 实例方法一定会互相阻塞因为锁的是同一个 this。这个细节在业务里经常踩坑特别是做缓存失效时如果不小心把不同服务的锁对象混用会出现要么锁不住、要么锁太多的问题。2.2 对象头里的秘密Mark Word想知道 synchronized 为什么能这么强大就必须看懂 Java 对象在内存里的布局。每个 Java 对象在堆内存中由对象头Header、实例数据Instance Data、对齐填充Padding三部分组成。对象头里又分为两部分Mark Word 和 Klass Pointer。如果你用 JOLJava Object Layout工具去观察一个刚创建的对象会发现它的 Mark Word 有一段二进制数据这段数据就是锁状态的核心。Mark Word 的设计非常精妙它是一块可以复用的内存区域。在没有锁竞争时它存储对象的 hashCode、分代年龄当对象被当作锁时它存储锁记录指针、重量级锁的 Monitor 指针等。JVM 通过 Mark Word 最后两位的锁标志位来区分当前锁状态01 表示无锁或偏向锁00 表示轻量级锁10 表示重量级锁11 表示 GC 标记。2.3 锁升级偏向锁、轻量级锁、重量级锁Java 1.6 之后synchronized 的性能大幅提升靠的就是锁升级机制。很多人背过“偏向锁→轻量级锁→重量级锁”这条链路但不知道每个阶段具体发生了什么也不理解 JVM 为什么要设计这三个阶段。第一阶段是偏向锁。如果一把锁自始至终只有一个线程访问JVM 干脆在 Mark Word 里记录这个线程的 ID后续这个线程再来时就不需要做任何同步操作直接进入这叫偏向。好比办公室的饮水机如果只有一个人用没必要给饮水机上锁。偏向锁在竞争出现时才会撤销撤销过程涉及安全点其实有一定代价。第二阶段是轻量级锁。当有第二个线程来竞争时偏向锁升级为轻量级锁。轻量级锁的思路不是让线程阻塞而是在栈帧里创建锁记录Lock Record然后通过 CAS 尝试把 Mark Word 替换成指向锁记录的指针。如果替换成功线程就持有了锁如果失败说明竞争存在线程会自旋等待。自旋会占用 CPU但避免了线程阻塞和唤醒的开销适合临界区执行时间很短的场景。第三阶段是重量级锁。当自旋超过一定次数JVM 可配置或等待线程数量过多锁会升级为重量级锁。重量级锁依赖操作系统底层的互斥量Mutex一旦竞争失败线程会进入阻塞队列等待被唤醒。这种锁的优点是稳定缺点是线程切换开销大适合临界区执行时间较长、竞争激烈的场景。2.4 synchronized 的常见误区用 synchronized 这么多年我总结出几个容易出错的地方。第一个误区把锁加在 String 常量上。比如synchronized (lock)这种方式在极端情况下会导致持有同一个字符串字面量的不同业务被同一把锁串行化性能瞬间崩盘。因为字符串常量是全局共享的不同类加载器里的lock可能指向同一个对象。第二个误区锁对象被修改。如果你加锁的对象是一个可变对象比如synchronized (user)然后业务代码又对 user 做了重新赋值锁就失效了因为后来的线程锁的是一个新的对象。我见过一个真实案例团队把锁对象定义成private Object lock new Object()结果某天代码重构时有人不小心lock new Object()所有并发保护瞬间崩塌。正确的做法是用private final Object lock new Object()保证对象引用不可变。第三个误区误以为 synchronized 修饰多个方法就等于多个方法串行。其实并非如此只有访问同一个锁对象的多个线程才会互斥。如果你在 A 方法上锁 this在 B 静态方法上锁 Class这两个锁互不相干可以并行执行。3. JUC 锁家族当内置锁不够用的时候3.1 ReentrantLock 与 synchronized 该怎么选synchronized虽好但功能上有几个硬伤不支持中断等待锁、不支持超时获取锁、无法实现公平锁、只能通过 synchronized 代码块隐式获取和释放。为了弥补这些不足JDK 1.5 引入了ReentrantLock它需要显式lock()和unlock()但提供了超时获取、可中断、公平锁、多个 Condition 等高级功能。在实际项目里我有个大致的选型标准如果只是简单保证原子性优先用 synchronized代码更简洁且 JVM 后续优化的空间更大。如果需要获取锁的超时时间tryLock(3, TimeUnit.SECONDS)或者需要公平队列用 ReentrantLock。如果读多写少优先考虑读写锁。如果追求极致性能和更细粒度控制再考虑 StampedLock 或者无锁方案。3.2 ReentrantLock 的公平锁与非公平锁ReentrantLock 的构造方法里有一个fair参数传入true表示公平锁false表示非公平锁。很多人以为公平锁更好但实际场景里非公平锁的性能反而更高。为什么因为公平锁要求线程严格按照先来后到的顺序获取锁新来的线程即使锁处于空闲也不能插队必须进入队列末尾排队。这看似公平却增加了上下文切换和线程唤醒的开销。非公平锁允许新来的线程尝试插队插队成功就直接执行省去了排队和唤醒的过程整体吞吐量更高。当然非公平锁可能造成饥饿问题但实际业务中概率极低所以默认的非公平锁方案是经过权衡的。3.3 读写锁读写分离的智慧ReentrantReadWriteLock的核心思想是读锁之间不互斥读锁与写锁互斥写锁与写锁互斥。这意味着多个线程可以同时读共享资源但写操作独占。这种锁在读多写少的场景下非常合适比如缓存系统、配置中心的数据加载。但读写锁有个隐蔽的问题写锁可能被不断涌入的读锁饿死。因为读锁之间不互斥读线程可以持续进入写线程迟迟拿不到锁。解决方式是使用ReentrantReadWriteLock的公平模式构造方法里true或使用 StampedLock 提供的乐观读。3.4 StampedLock乐观读的利器StampedLock 是 JDK 8 引入的一个比 ReentrantReadWriteLock 更灵活的锁。它提供三种模式写锁、悲观读、乐观读。乐观读不是一个真正的锁它是先读取一个版本号然后读取数据最后检查版本号有没有变化没有变化就说明读取期间没有写操作数据是有效的有变化则需要重新读取必要时升级为悲观读。这个机制非常像数据库的乐观锁。我说一个适合用它做缓存的场景共享配置对象频繁被读、偶尔被写用乐观读时读线程几乎不需要加锁性能极高。代价是 StampedLock 不可重入而且不支持 Condition所以不是所有场景都适用。3.5 Condition精确唤醒不再是梦Object.wait/notify有一个痛点无法精确唤醒某一个线程。你只能显式使用notifyAll唤醒所有等待线程再由它们自己重新争抢资源这样既浪费又容易产生惊群效应。ReentrantLock的Condition解决了这个问题它可以把等待线程分到不同条件队列中按条件精确唤醒。我用它写过有界队列一个notEmpty条件用于唤醒消费者一个notFull条件用于唤醒生产者。消费者取队列时发现为空就notEmpty.await()生产者放入元素后notEmpty.signal()唤醒。这样比synchronized wait/notifyAll高效得多因为生产者不会重复唤醒不相关的消费者。3.6 AQS 是什么所有锁的基石不管是 ReentrantLock、ReentrantReadWriteLock还是 CountDownLatch、Semaphore底层都依赖同一个框架AbstractQueuedSynchronizerAQS。一句话概括 AQS它用状态值表示锁的状态用双向队列管理等待线程。AQS 里有三样关键东西volatile int state表示锁的状态。对 ReentrantLock 来说0 表示无锁1 表示已经被一个线程持有大于 1 表示重入次数。CLH 队列变体当一个线程获取锁失败时会被包装成 Node 挂到双向队列尾部然后阻塞或自旋等待。CAS 操作所有对 state 的修改都通过 CAS 保证原子性。理解了 AQS再去看 JUC 包的很多类都会觉得豁然开朗。比如CountDownLatch是 state 为 N每次countDown就减 1Semaphore是 state 表示许可证数量ReentrantReadWriteLock把 state 拆成高 16 位和低 16 位分别表示读锁和写锁的占用情况。4. 无锁与锁粒度AtomicInteger、ConcurrentHashMap 的线程安全方案4.1 AtomicInteger 线程安全吗CAS 原理热词里有用户问atomicinteger线程安全吗答案是它针对单变量的多线程更新是线程安全的但不等于你把多个 Atomic 变量组合成一个复合操作也是安全的。AtomicInteger 线程安全的原理是 CASCompare And Swap。CAS 是一个 CPU 级别的指令核心逻辑是比较某个内存位置的当前值是否等于预期值如果等于就更新为新值否则不更新。这个过程是原子的不存在中间状态。举个例子atomicInteger.incrementAndGet()底层执行的操作就是循环读取当前值计算加 1 的结果然后 CAS 尝试赋新值。如果并发冲突导致失败就重新读取、重新计算、重试。这种无锁方案在高并发下通常比 synchronized 性能好因为避免了线程挂起和唤醒的开销。但有个经典陷阱如果你想用 AtomicInteger 实现一个余额检查并扣减的操作单独的get()和decrementAndGet()都是原子操作但先检查余额再扣减这个组合动作不是原子的两个线程可能同时通过检查导致余额变负数。要解决这个问题要么使用synchronized把整个方法锁住要么使用AtomicInteger的updateAndGet/accumulateAndGet把整个操作封装成一个 CAS 循环。4.2 HashMap 线程安全吗怎么处理才安全直接回答HashMap不是线程安全的。并发写入时可能造成数据覆盖甚至在 JDK 1.7 及之前版本扩容时可能形成循环链表导致get时死循环。JDK 1.8 改进了扩容算法用高低位链表把节点拆到新数组解决了死循环问题但并发覆盖的问题依然存在。有三个替代方案Collections.synchronizedMap、Hashtable、ConcurrentHashMap。前两者本质上都是给整个 Map 加同一把锁并发能力很差。ConcurrentHashMap才是正解它用 CAS 局部锁的设计把锁粒度降到单个 bin桶位级别并发写不同桶位时可以并行执行。4.3 ConcurrentHashMap 的分段思想JDK 1.7 的 ConcurrentHashMap 采用 Segment 分段锁默认分成 16 个分段每个分段是一把小型的 HashTable读写时只对对应分段加锁。JDK 1.8 之后放弃了 Segment改为 CAS synchronized 锁单个桶位锁的粒度更细而且当链表长度超过阈值默认 8时会转换成红黑树查询效率从 O(n) 降为 O(log n)。这里值得一提的设计细节是ConcurrentHashMap 读操作基本不需要加锁。它依赖volatile关键字确保数组引用和节点 next 指针的可见性再配合 CAS 保证写操作的原子性。所以读线程能看到什么状态有可能读到正在扩容的中间状态但内部通过 ForwardingNode 和 sizeCtl 机制保证了读操作的正确性。4.4 锁粗化与锁消除JVM 偷偷做的优化很多人不知道JVM 在没有我们干预的情况下也在优化锁。锁粗化是指 JVM 检测到同一个对象有连续加锁解锁的代码块时会把锁的范围扩大减少加解锁次数。锁消除是指 JVM 分析到某些对象只被一个线程访问不会发生竞争就干脆把 synchronized 指令消除掉。这些都是基于逃逸分析的优化。比如你在一个方法里创建了一个局部对象这个对象没有逃逸出方法那么 JVM 会认为它不可能被其他线程共享于是synchronized (localObj)这种代码会被直接编译成普通代码。JVM 的优化能力很强所以我在写代码时不会刻意避免 synchronized 的微小开销而是先写出语义正确的代码再通过性能测试决定是否需要换成更复杂的锁。4.5 无锁队列与无锁编程热词里提到无锁队列我想简单提一下。无锁队列的核心是利用 CAS 实现“先预留位置再写入数据”的机制。经典的实现比如ConcurrentLinkedQueue和Disruptor都是通过 CAS 维护头尾指针避免加锁。无锁编程最大的难点不是写代码而是保证内存可见性和指令顺序。通常需要结合volatile和内存屏障。对我们大多数后端开发者来说直接使用成熟的并发容器就够了不建议在生产环境自己实现无锁队列很容易在极端并发下出现难以排查的偶发问题。5. 分布式锁从单机锁到全局锁的跨越5.1 为什么单机锁解决不了集群问题前几章讨论的锁无论是 synchronized 还是 ReentrantLock本质上都是 JVM 进程内的锁。单机部署时它们完全够用但线上服务基本都是多节点部署用户请求经过负载均衡分发到不同机器每个节点都有自己独立的 JVMJVM 里的锁彼此看不到。这时候要保证整个集群只有一个线程能操作共享资源就需要分布式锁。典型的业务场景包括定时任务重复执行多台机器同时触发、库存扣减、防止用户重复提交、分布式环境下的幂等控制。热词里提到的“redistemplate分布式锁定时任务重复执行”就是这类问题。5.2 Redis 分布式锁的基本实现与隐藏坑用 Redis 实现分布式锁最基础的方法是SET key value NX EX seconds。NX 表示只有当 key 不存在时才能设置成功EX 表示过期时间。线程 A 执行这个命令成功说明拿到了锁线程 B 执行时 key 已存在说明没拿到锁只能等待或返回失败。但这里面有几个大坑我不止一次在代码评审里看到第一个坑锁过期时间太短业务还没执行完锁就自动释放了。解决思路是给锁设置一个合理的超时时间并配合看门狗机制自动续期。Redisson 的lock()方法默认就有看门狗逻辑每隔一段时间自动续期直到业务执行完释放锁。第二个坑释放锁时没有校验持有者。线程 A 的锁过期被自动释放线程 B 拿到锁开始执行这时候线程 A 执行完了调用del命令释放锁结果把线程 B 的锁删了。正确做法是使用 Lua 脚本先比较 value 是否一致一致才删除。if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end第三个坑主节点锁丢失。Redis 主从架构下线程 A 在主节点加锁成功但主节点还没来得及同步到从节点就宕机了从节点升级为主节点后发现锁不存在线程 B 就能加锁成功。Redisson 提供的 RedLock 方案能缓解这个问题但 RedLock 本身也有争议真实项目中我更推荐在锁里存入业务唯一标识在业务层面做幂等兜底而不是完全依赖锁。5.3 定时任务重复执行问题实战如果你用 Spring 的Scheduled做定时任务并且部署了两台机器默认情况下两台机器都会执行任务。我之前遇到过一个需求每天凌晨要同步一次数据但只能有一个实例执行。最简单的方案是Scheduled配合一个分布式锁注解在方法执行前尝试获取 Redis 锁拿不到就跳过。我用 RedisTemplate 写过一个非常轻量的实现public boolean tryLock(String lockKey, String requestId, long expireTime) { Boolean result redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireTime, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(result); } public void unlock(String lockKey, String requestId) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), requestId); }调用时每个实例生成的requestId可以是UUID.randomUUID().toString()释放锁时通过 Lua 确保只能删除自己持有的锁。这里有一个经验锁的 value 不要用固定字符串比如 locked否则无法区分持有者删除时极易误删。5.4 MySQL 分布式锁与 Redis 锁的对比实现分布式锁的另一个常见方案是用 MySQL 数据库。比如创建一张锁表CREATE TABLE distributed_lock ( lock_key VARCHAR(64) PRIMARY KEY, owner_id VARCHAR(64) NOT NULL, expire_time TIMESTAMP NOT NULL );获取锁就是尝试插入一行lock_key固定的记录插入成功则拿到锁释放锁就是删除这行记录。这种方案的优点是实现简单、可靠不需要引入 Redis而且天然符合事务语义。缺点也很明显性能只能达到数据库并发能力的天花板而且如果某个持有锁的实例宕机锁记录会一直存在需要额外的定时清理机制。我也见过用乐观锁版本的在表里加一个 version 字段更新时WHERE version 旧值更新成功就当获取锁成功。这适合短流程操作不适合长时间持锁的场景。5.5 分布式锁面试经常问的几个问题既然热词里有分布式锁面试题我顺便把面试官最喜欢追问的几点列出来分布式锁要具备哪些特性互斥性、可重入性、锁超时释放、高性能、高可用。Redis 分布式锁和 ZooKeeper 分布式锁有什么区别Redis 是 AP 系统ZooKeeper 是 CP 系统ZooKeeper 通过临时顺序节点实现锁客户端断开会话后临时节点自动删除不会出现锁不被释放的问题。锁过期时间怎么设置建议根据业务的最长执行时间估算必要时做续期。分布式锁能不能保证绝对安全不能任何分布式系统都存在网络分区绝对的强一致性需要付出巨大性能代价所以业务上要做好幂等和重试。6. 线上锁问题排查与性能优化实录6.1 死锁的检测与预防死锁是锁最常见的故障模式。四个必要条件互斥、持有并等待、不可剥夺、循环等待。破坏任何一个条件就能破除死锁。在 Java 中jstack命令可以直接打印线程的堆栈信息检测到死锁时会在最后提示 “Found one Java-level deadlock”。所以我遇到服务无响应时第一反应就是执行jstack pid thread.log然后搜索 BLOCKED 和 deadlock 关键字。预防死锁的经验我有三条一是拿多个锁时让所有线程按相同的全局顺序获取比如统一先拿订单锁再拿库存锁二是使用tryLock(timeout)在指定时间内拿不到锁就放弃并回滚已有操作三是尽量缩小锁的范围把锁内外的代码拆分清楚不要把无关代码放进锁块里。6.2 锁竞争怎么定位线上如果发现接口耗时增大可以用两种方式排查锁竞争第一种是看jstack输出中大量线程处于BLOCKED状态这些线程基本都是同一个 Monitor 或同一个 AQS 队列里排队等待第二种是使用jvisualvm或Async Profiler直接抓取 CPU 火焰图火焰图里锁等待会显示为park或synchronized相关的栈帧。定位到具体锁之后优化的基本思路无非是降低锁持有时间把耗时的 I/O 移出锁块、降低锁竞争频率读写分离、分段锁、把阻塞型锁替换为无锁方案。这些手段不是孤立的需要结合具体业务选择合适的组合。6.3 一个真实的锁优化案例最后分享一个我实际操刀过的案例。有一个订单服务的库存扣减接口压测时发现 TPS 上不去火焰图显示大量线程阻塞在 synchronized 的 Monitor 上。排查后发现之前的开发者把整个订单校验、库存扣减、活动优惠、优惠券核销全部放在一个大的 synchronized 代码块里而且加锁的对象还是orderService这个 Spring 单例所有订单请求都串行执行。优化方案分三步第一步把锁对象从orderService改为库存维度每个 SKU 用独立的锁对象第二步从 synchronized 改为ConcurrentHashMap ReentrantLock对不同 SKU 的锁进行细粒度管理第三步把库存扣减的数据库操作改为乐观锁UPDATE stock SET stock stock - ? WHERE sku_id ? AND stock ?只有影响行数为 0 时才走锁降级流程。最终压测 TPS 提升了差不多一个数量级接口平均耗时从 80ms 降到 20ms 左右。这个案例说明一个道理锁不是银弹而且锁方案没有一劳永逸随着并发模型和访问模式的变化锁的粒度也需要持续演进。7. 最后想说的几句经验做了这么久的并发开发我最大的体会是绝大多数并发问题都不是靠锁解决的而是靠合理的数据设计和流量削峰解决的。锁本身就意味着串行化而串行化在互联网高并发场景下等于瓶颈。能用无锁就用无锁能缩小粒度就缩小粒度能削峰就削峰尽量把锁留给真正需要严格的临界区保护的地方。如果你正在准备 Java 面试建议把 synchronized 锁升级、AQS 原理、ConcurrentHashMap 的分段思想、分布式锁的常见实现和优缺点多花时间理解彻底刷题只能应付表面面试官追问两次就露馅了。如果你在实际项目中排查锁问题记住先看 jstack再看火焰图最后再动手改代码顺序不要乱。想继续深入的话可以尝试自己用 AQS 写一个简单的信号量或者把 ConcurrentHashMap 的源码逐行读一遍比看十篇博客都有效。希望这篇关于 Java 锁的完整梳理能给你带来一些真正能落地的启发。