Java锁全解析:从synchronized到分布式锁,一文掌握并发核心
1. 为什么要给Java上锁从一次线上故障说起先讲个我自己经历的事。前几年做一个电商类的小项目核心功能是商品秒杀。开发的时候单机测试一切正常上到测试环境压了一把库存直接超卖到负数。排查下来原因很简单当时用的是HashMap做库存计数在高并发情况下多个线程同时读到同一个库存值然后各自加加减减最后写回的结果完全乱了。这个问题的本质就是Java多线程环境下经典的“竞态条件”。你可能会问加一个synchronized不就解决了吗确实能解决但这个“锁”字背后的门道深得很。从早期的synchronized到后来的ReentrantLock从JVM层面的锁升级到分布式场景下的Redis锁整个Java并发体系里“锁”始终是绕不开的核心话题也是面试八股文里的重灾区。这篇文章我从实际项目出发把锁相关的技术点拆开揉碎什么场景该用哪种锁、底层原理大概是怎么回事、平时写代码容易踩哪些坑、分布式环境下又该怎么加锁。不管你是刚接触多线程的初学者还是被线上并发问题折磨过的老手应该都能从中找到点有用的东西。2. 线程安全问题的根源从内存模型说起2.1 原子性、可见性、有序性的直观理解在深入锁之前得先搞清楚一个问题为什么多线程会出乱子Java内存模型规定所有变量存在主内存Main Memory中每个线程有自己的工作内存Working Memory线程对变量的所有操作都必须在工作内存中进行不能直接读写主内存。这就带来了三个经典问题原子性一个操作或者多个操作在CPU执行过程中不被中断。典型如i看起来是一条语句实际是“读-改-写”三步操作线程切换时中间状态就暴露了。可见性线程A修改了变量线程B不一定能立即看到。因为B读的是自己工作内存里的副本。有序性编译器、CPU为了优化可能对指令进行重排序。在单线程内重排序不影响结果但多线程环境下就可能出问题。打个生活化的比方你家有一个公共记事本主内存你和室友线程各自有一张草稿纸工作内存。你在自己草稿纸上记“买了牛奶”然后去洗澡了室友看自己草稿纸上写着“没牛奶”于是又买了一箱。两个人各干各的记事本上的信息根本没同步。2.2 用经典的“库存扣减”描述竞态条件回到开头说的超卖问题。假设库存初始为10两个线程同时执行“读取库存-扣减-写回”的逻辑线程A读取库存10 线程B读取库存10 线程A扣减后写回9 线程B扣减后写回9结果卖出了两件商品库存只减了1这就是典型的丢失更新。如果库存恰好只剩1件两个人同时下单就会出现超卖——货明明没有了订单却建成了。解决的思路其实就四个字同步互斥。让同一时刻只有一个线程能执行“读-改-写”过程其他线程必须等它完成后再操作。Java提供的synchronized和Lock就是干这个事的。但从“能锁住”到“锁得优雅、锁得高效”中间还有很大的进步空间。2.3 光靠锁还不够还需要volatile吗很多人有个误解是不是加了锁就完全不需要volatile了锁确实能保证原子性、可见性、有序性但锁是一种“重”手段。有些场景其实只需要可见性并不需要互斥。比如一个只有单个线程写、多个线程读的状态标记private boolean flag false; // 线程A public void shutdown() { flag true; } // 线程B public void run() { while (!flag) { // do something } }这个代码在理论上是有问题的线程B可能一直看不到flag被修改。但如果把flag加上volatile修饰就能保证线程B每次读取都从主内存拿最新值。volatile的本质是禁用缓存和重排序优化它是锁的轻量级补充适合做状态标志位不适合做复合操作。我自己的经验是能用volatile表达的并发语义就不要用锁但涉及“先判断后操作”这种复合逻辑时老老实实加锁别指望volatile能帮你扛。3. 隐式锁synchronizedJava并发的地基3.1 三种使用形态到底锁的是什么synchronized有三种使用方式修饰实例方法锁的是当前实例对象this。修饰静态方法锁的是当前类的Class对象。修饰代码块锁的是括号里指定的任意对象。这个“锁的是谁”特别重要。实践中最常见的错误就是用不同的锁对象去保护同一份共享资源结果相当于没加锁。public class Counter { private int count 0; public synchronized void increment() { count; } public synchronized int get() { return count; } }这里两个方法锁的都是同一个Counter实例所以increment和get之间能够互斥。如果把increment改成synchronized代码块锁一个字符串常量把get锁另一个对象就完全错位了。3.2 底层原理和锁升级路径synchronized在JDK 1.6之后做了大量优化引入了“偏向锁 - 轻量级锁 - 重量级锁”的升级路径。它的核心机制是每个Java对象在堆内存中都有一个对象头对象头里有一部分Mark Word用来记录锁状态。偏向锁同一个线程多次获取锁时只需要做一个线程ID对比成本极低。轻量级锁当出现锁竞争但竞争不激烈时通过CAS自旋尝试获取锁。重量级锁竞争加剧后锁升级为基于操作系统互斥量Mutex的重量级锁未获取到锁的线程会进入阻塞状态涉及用户态和内核态切换开销最大。画个大概流程就是一个线程第一次进入同步块JVM把锁对象的Mark Word设置为偏向自己的线程ID如果另一个线程来竞争偏向锁撤销升级为轻量级锁后者用CAS自旋抢锁自旋超过一定次数或竞争线程增多就膨胀为重量级锁。理解这个升级路径的意义在于synchronized在低竞争场景下性能并不比ReentrantLock差甚至在JDK 15之后还引入了虚拟线程synchronized在虚拟线程中表现更友好。很多面试里问“synchronized为什么重”如果你只回答“因为它要切换内核态”说明你还没跟上现代JVM的优化步伐。3.3 使用synchronized的四个实操禁忌这里非常想分享几个平时排查问题时反复看到的坑第一个坑锁字符串常量。如果把锁对象写成abc这种字面量JVM会把它放到常量池整个JVM里所有引用这个字符串的地方都是同一个对象。你在类A里锁了它类B里的某个无关代码也锁了它两个不相关的模块就被莫名耦合了容易引起莫名其妙的死锁和性能瓶颈。第二个坑synchronized方法粒度过大。如果方法里只有一小段代码需要同步其余全是耗时操作用synchronized修饰整个方法会把并发能力拉低。比如一个方法内部有远程调用RPC、数据库查询远程调用期间还持有锁其他线程全部阻塞在锁上吞吐量直接掉一个量级。第三个坑锁对象被重新赋值。private Object lock new Object(); public void doSomething() { synchronized (lock) { // ... } } public void resetLock() { lock new Object(); // 危险操作 }一旦synchronized块的对象被重新赋值锁就形同虚设因为后来的线程锁的是新对象。要避免这种问题锁对象最好声明为final。第四个坑锁内调用wait()而忘记在finally中处理异常导致锁不释放。虽然wait()必须在同步块中调用但同步块中的业务代码抛异常后如果不在finally里处理锁虽然会随着异常抛出卖方自动释放但等待队列里的线程可能永远等不到正确通知。实际开发中建议优先使用JUC包下更可控的并发工具而不是裸用wait/notify。4. 显式锁Lock可控性才是它的灵魂4.1 为什么有了synchronized还需要Locksynchronized好用但功能上有几个硬伤无法中断一个正在等待锁的线程。如果线程A持有锁线程B在等待此时B不能被外力打断只能一直等下去。无法设置超时时间。一旦发生死锁所有等待线程永远卡死。非公平锁不可选。synchronized是非公平的虽然JVM会尽量让等待时间长的先拿到锁但没有严格保证。无法实现“读读并发”这种细粒度场景。即使两个线程都只读同一个共享数据用synchronized也得串行执行。Lock接口及其实现类解决的就是这些问题。ReentrantLock是使用最广泛的实现它提供了lock()、unlock()、lockInterruptibly()、tryLock(timeout, unit)等方法相当于把“锁”从语言级工具变成了纯Java API开发者拥有更多控制权。4.2ReentrantLock的标准使用模板ReentrantLock必须在finally中释放锁否则异常时锁永远不释放这是和synchronized最大的使用区别。private final ReentrantLock lock new ReentrantLock(); public void update() { lock.lock(); try { // 核心业务逻辑 } finally { lock.unlock(); } }还有带超时时间的获取方式用起来更安全public boolean tryUpdate() { boolean acquired lock.tryLock(3, TimeUnit.SECONDS); if (!acquired) { return false; } try { // 核心业务逻辑 return true; } finally { lock.unlock(); } }这个模板建议每个写并发代码的人都背下来。tryLock的意义在于拿不到锁的时候可以选择放弃或者走降级逻辑而不是无限期阻塞。线上系统最怕的不是报错而是线程悄悄积压最后把线程池占满全面瘫痪。4.3 公平锁和非公平锁的一次性能对比ReentrantLock默认是非公平锁也可以通过构造函数传入true改为公平锁。公平锁的意思是等待时间最长的线程优先获得锁。从代码执行效率来看非公平锁通常有着更高的吞吐量。原因在于当一个持有锁的线程释放锁时如果恰好有一个新的线程来请求锁非公平锁会让这个新线程直接获取锁省去了线程上下文切换而公平锁必须唤醒等待队列中沉睡的线程这里有唤醒的开销。但公平锁并非没有价值。在交易类、支付类系统里如果锁被反复插队某些请求可能长时间得不到资源从而触发上游超时。我个人的建议是默认用非公平锁只有当你识别出明显的饥饿现象部分线程迟迟无法获取锁时再考虑公平锁。不要人云亦云地说“公平锁效果好”要看你系统的实际场景。4.4 读写锁把“并发读”的能力释放出来ReentrantReadWriteLock将锁拆分为读锁和写锁。读锁是共享锁多个线程可以同时持有写锁是独占锁写锁持有期间读写都不可用。它适合读多写少的场景比如缓存系统、配置中心、数据字典等。private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); private final Lock readLock rwLock.readLock(); private final Lock writeLock rwLock.writeLock(); public Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } public void put(String key, Object value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } }要特别注意不要用读写锁去保护写多读少的场景。如果写操作占比高读锁和写锁之间也要互斥那么性能反而不如简单的synchronized。此外ReentrantReadWriteLock存在写锁饥饿的问题在高并发读场景下写线程可能长时间拿不到锁。JDK 8引入了StampedLock提供了乐观读机制但在某些场景下容易产生CPU占用问题需要谨慎使用。5. 锁之外的并发安全方案不只是加锁5.1 集合类的线程安全性辨析热词里有个高频问题“HashMap线程安全吗”。答案是不安全。HashMap在多线程并发写入时扩容阶段可能产生环形链表导致下一次遍历时出现死循环JDK 7JDK 8改成尾插后问题减轻但仍然存在数据覆盖和丢失的问题。应对方案有这么几个层级Hashtable所有方法加synchronized简单但性能差现在基本不推荐。Collections.synchronizedMap()也是全方法加锁同一时刻只能有一个线程操作读写都串行。ConcurrentHashMap最优解JDK 7及之前采用分段锁JDK 8起放弃分段改用CAS synchronized锁头节点的方式把锁粒度细化到单个桶bucket。并发读写性能远胜前两者。类似地ArrayList线程不安全可以用CopyOnWriteArrayList应对读多写少的场景StringBuilder线程不安全在多线程拼接字符串时需要用StringBuffer或者外部加锁。这些实现细节是被问概率极高的面试题。但我想强调的其实是选择并发容器时不要只背结论要理解它是怎么在“安全”和“性能”之间做平衡的。ConcurrentHashMap为什么用synchronized锁头节点而不是整张表因为大部分并发操作只涉及Map的一个子区间锁单个桶就能让不同桶的线程并行执行。5.2AtomicInteger和CAS无锁并发热词里还有一个问题“AtomicInteger线程安全吗”。答案是安全。它依赖的是CASCompare And Swap比较并交换操作这是一种无锁算法。CAS的核心思想是更新一个变量的值时先比对内存中的当前值是否和预期值一致。如果一致说明期间没有其他线程修改过就执行更新如果不一致说明被改过了就重试或者放弃。public class Counter { private AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int get() { return count.get(); } }AtomicInteger用起来比Integer加synchronized要轻量得多因为CAS属于CPU指令级别的原子操作比如cmpxchg不需要线程挂起和唤醒。但CAS也有它的短板ABA问题变量被从A改成B再改回ACAS认为它没有变化过可用AtomicStampedReference解决、循环时间长导致CPU开销高、以及只对单个变量有效。无锁队列的实现也基于CAS典型代表是ConcurrentLinkedQueue。它的吞吐量在高并发下比加锁队列高但没有锁那么强的阻塞控制能力队列可能无限增长。实际项目中我通常用有界阻塞队列ArrayBlockingQueue配合线程池而不是盲目追求无锁。5.3 锁粒度优化从整表锁到行锁的思路在数据库层面也有同样的哲学。MySQL的行锁、表锁、间隙锁的划分逻辑在Java代码设计里也能找到映射。处理一个共享资源时先问三个问题这个资源真的需要全局互斥吗能不能拆成多个独立的子资源操作是读多还是写多读写比例决定要不要用读写锁。能不能先用乐观策略CAS、版本号失败后再上悲观锁所谓“锁的奥秘”本质上就是平衡安全和性能的艺术。盲目的整块加锁就跟在数据库里把一个表的读写都串行化一样虽然不出错但性能惨不忍睹。5.4ThreadLocal是“无锁但安全”的经典方案在锁这个话题下ThreadLocal是常常被人忽略的一种“另类安全”。它不共享数据而是为每个线程维护一份独立副本因此从根本上避免了竞争。典型应用场景是SimpleDateFormat。这个类是非线程安全的多线程共用同一个实例解析日期会出现各种诡异结果。最简单的解决方案不是给它加锁而是每个线程一个实例private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss));还有一种经典做法就是用它传递用户上下文、数据库连接、TraceId等。但要注意ThreadLocal用完后必须调用remove()特别是在使用线程池时。如果线程存活时间很长ThreadLocal里的对象可能一直挂在线程上无法被GC回收最终导致内存泄漏。这是我们线上踩过的真实问题报OOM之后查了半天才发现是ThreadLocal没清理。6. 分布式锁跨进程的互斥之路6.1 单机锁解决不了集群问题前面讨论的所有锁作用域都局限于单个JVM进程内。如果服务部署了多个实例同一个用户请求被负载均衡到不同的机器每个实例内的锁互不感知那么单机锁就完全失效了。场景很典型有一个定时任务每5分钟执行一次生成当天的报表。部署了3个实例后每个实例都会执行一次报表被生成3次可能造成数据重复推送、资源重复回收。这个问题就是分布式锁要解决的——在多个进程之间实现互斥保证同一时刻只有一个实例执行任务。6.2 Redis实现分布式锁SET NX EX为什么够用目前最主流的分布式锁实现方案就是基于Redis。使用它的核心命令是SET key value NX EX timeoutNXWhen Not Exists只有key不存在时才设置成功语义就是“加锁”。EX设置过期时间防止持有锁的线程崩溃后锁永远不释放。加锁的伪代码如下public boolean tryLock(String key, String requestId, long expireMillis) { String result jedis.set(key, requestId, NX, PX, expireMillis); return OK.equals(result); }这里强调两点。第一value必须用唯一标识比如UUID或requestId释放锁时判断这个标识是否匹配防止“别人的锁被自己误删”。第二加锁和过期时间必须是一个原子操作用set key value NX EX一条命令完成不能先SETNX再EXPIRE分开执行否则中间崩溃会导致永不过期。释放锁的安全写法是Lua脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end用Lua保证“判断-删除”两步的原子性。6.3 业务执行时间超过锁过期时间怎么办这是Redis分布式锁最经典的问题。锁的过期时间设了10秒但业务执行了20秒锁已经自动过期了。此时另一个线程趁机加锁成功两个线程同时进入临界区锁形同虚设。解决办法有几个维度设置预估的合理过期时间比如业务最耗时的3倍。这是一种粗粒度保障简单粗暴但可能不准。启动一个守护线程看门狗定期续期。Redisson框架内部已经内置了这个机制默认锁的leaseTime是30秒每10秒看门狗会检查锁是否还在持有若在则自动续期30秒。这样业务只要正常运行锁就不会提前过期。用“锁只能用固定的时长”这种设计倒逼业务超过时间直接失败重试。从我排查过的线上故障来看最怕的就是“锁过期业务还在跑”的双重事故。如果你不方便引入Redisson至少要保证锁的过期时间要远大于单次业务的最大耗时并且做好监控告警出现锁提前失效时能及时感知。6.4Redisson背后的Redlock争议Redisson是Java生态里最常用的Redis客户端之一它对分布式锁做了很好的封装。你只需要RLock lock redissonClient.getLock(inventory:1001); boolean locked lock.tryLock(0, 30, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑 } finally { lock.unlock(); } }它自动完成了加锁、设置过期时间、看门狗续期、解锁等完整流程避免了很多手写Redis分布式锁的坑。至于Redlock算法使用多个Redis节点逐次加锁超过半数节点加锁成功才算获取锁它在理论层面上一直有争议。比如Martin Kleppmann写过文章批评Redlock不是真正的安全锁因为分布式系统中的时钟漂移会让过期时间不可靠而Redis作者也撰文反驳。实战中如果你的系统规模没到需要多Redis节点容灾的程度单机Redis加Redisson通常就够用了。但如果你的业务对锁的安全性有极强诉求资金类、订单状态流转就得慎用Redis锁考虑更严谨的方案比如基于ZooKeeper的锁或者基于数据库唯一约束的方案。6.5 定时任务重复执行的实战案例热词里有个具体场景“redisTemplate分布式锁定时任务重复执行value为当前日期”。这是开发中非常典型的玩法String lockKey task:dailyReport:lock; // value直接存当前日期天然支持“每天只能执行一次”的语义 String lockValue LocalDate.now().toString(); Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 24, TimeUnit.HOURS); if (Boolean.TRUE.equals(success)) { // 当天第一次执行 generateDailyReport(); }这个方案聪明在锁的key和value天然和“天”绑定不需要手动删除锁第二天日期变了setIfAbsent又能成功任务就再次执行了。但这里有几个注意点setIfAbsent对应SET NX要设置过期时间否则宕机后锁残留。这里的过期时间设24小时正好覆盖当天第二天key过期自然释放。key的选择要带业务前缀不能太泛。如果所有任务共用同一个key就会互相竞争导致该执行的任务不执行。对时间特别敏感的任务比如希望零点一过就立即执行要考虑到服务器时钟可能有一只偏差。稳妥的做法是任务本身多做幂等判断即使锁失效也不要紧数据层面能够兜底。6.6 MySQL分布式锁和ZooKeeper锁的适用边界除了Redis分布式锁还有另外两种常见实现方式基于MySQL的锁一种是SELECT ... FOR UPDATE利用数据库行锁做互斥另一种是创建一张锁表利用唯一索引的插入冲突做互斥。INSERT INTO method_lock (lock_key, lock_owner, expire_time) VALUES (task:report, instance-1, 2025-01-01 00:00:00);谁插入成功谁就拿到了锁。这种方式的优点是实现简单适合已有数据库但没有引入Redis的小项目。缺点也明显性能上限低数据库成为瓶颈锁没有自动过期机制需要自己清理而且依赖事务的提交和回滚处理不好容易造成死锁。基于ZooKeeper的锁利用ZooKeeper的临时顺序节点实现。多个客户端同时创建节点序号最小的获得锁锁释放后后续节点通过监听机制感知并继续抢锁。它的优点是具备自动过期会话断开节点删除和顺序公平的特性适合对一致性要求较高的场景。缺点是部署和运维成本更高ZooKeeper集群本身就是一份额外的维护负担吞吐量也不如Redis。我在项目中的选型标准很简单中小团队、强一致需求不高、已有Redis基建——直接Redis加Redisson对锁的可靠性要求非常高、团队也具备ZooKeeper运维能力——考虑ZooKeeper连Redis都没有、又必须做互斥——用MySQL兜底但一定要设计好锁的过期和清理机制。7. 锁相关的经典面试题速查热词里出现了很多面试相关的内容这里把高频考点整理成一张速查表方便大家复习问题关键回答要点synchronized和ReentrantLock的区别前者自动释放、不可中断、只能非公平后者需手动解锁、可中断、可超时、可公平。JDK 6后两者性能差距不大volatile有什么用保证可见性、禁止重排序但不保证原子性。适合状态标志不适合复合操作AtomicInteger线程安全吗安全。基于CAS指令实现无锁、无阻塞。但不能解决所有ABA问题HashMap线程安全吗不安全。并发写会导致数据覆盖JDK 7还有环形链表死循环问题ConcurrentHashMap怎么保证安全JDK 7分段锁JDK 8锁头节点CAS锁粒度降低到单个桶Redis分布式锁用到哪些命令SET key value NX EX释放用Lua脚本保证原子性分布式锁过期了怎么办看门狗自动续期或者预估合理过期时间并做好监控告警什么是锁升级偏向锁-轻量级锁-重量级锁根据竞争激烈程度动态变化这些题目看起来是“八股”其实每道题背后都对应着一次线上事故的教训。把原理吃透真出问题时排查思路才会清晰。8. 排查锁与并发问题的实战路径8.1 首先看日志和时间线遇到锁相关的问题不要一上来就怀疑框架先把全链路日志拉出来。重点关注几个时间点锁请求发起时间、锁获取成功时间、业务执行完成时间、锁释放时间。比如定时任务重复执行大概率是锁没生效或者锁提前过期了看日志能立刻定位是哪一种。8.2 使用jstack定位持锁线程如果怀疑某个线程卡在锁上用jstack导出线程快照是最直接的方式jstack -l pid thread_dump.txt然后搜索“waiting to lock”或“locked”关键字能直接看到线程阻塞在哪个对象的monitor上以及持锁线程是谁。这个方法我用了很多年几乎是Java并发问题排查的第一板斧。8.3 数据库层锁表问题热词里还有“mysql锁表”。线上如果出现大量业务卡住先查information_schema.innodb_trx看看有没有长时间未提交的事务。很多时候不是代码锁的问题而是某个事务没提交导致行锁一直被持有。这时候找到对应事务的线程ID再反向定位代码位置。8.4 不要忽略线程池和自旋另一个常见坑是线程池参数设置不合理。线程数过多时锁竞争加剧线程频繁阻塞、唤醒、切换上下文反而比不并发还慢。线上排查时一定要看线程池的活跃线程数、队列积压量、拒绝策略。如果发现线程都堵在synchronized入口那就是锁竞争导致的线程爆炸优先考虑降低锁粒度或者限流。最后说点个人感受锁这个东西刚学的时候觉得它是个魔法加一行synchronized仿佛就天下太平。后来线上出了几次事故才发现真正难的不是用锁而是判断“该不该锁”“锁多细”“锁多久”。我见过最典型的问题不是不会加锁而是加锁加得太多太粗最后系统被锁拖垮。如果你正在写高并发代码我建议你每次准备加锁前先问自己一句这里真的需要互斥吗能不能用volatile、CAS、ThreadLocal或者并发容器替代如果必须要锁锁的粒度和过期时间是否已经考虑清楚多琢磨这些问题踩坑的次数会少很多。