ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java并发编程核心:synchronized用法、锁升级与常见陷阱深度解析

Java并发编程核心:synchronized用法、锁升级与常见陷阱深度解析 1. synchronized到底解决了什么问题从一次线上事故说起先讲一个我实际遇到过的场景。早期做订单系统的时候有一段给用户账户加余额的逻辑代码写得看起来没什么问题先查出当前余额加上充值金额再写回去。上线后运营反馈说有用户充了100块账户里只多了50。查日志发现同一个人同时发起了两笔充值两个线程同时读到余额是0各自加上100后写回最终余额只有100而不是200。这就是典型的竞态条件Race Condition问题根源在于读-改-写这个操作不是原子的。synchronized正是为了解决这类问题而生的。它是Java语言层面提供的内置锁机制可以让一段代码在同一时刻只允许一个线程进入执行其他线程必须等当前线程执行完释放锁后才能进来。说白了就是把你需要保护的共享资源操作变成一条单行道。学习synchronized到底适合谁我的看法是正在入门Java并发编程的初学者、准备面试的开发者、以及项目中已经出现线程安全隐患想找到根治方案的工程师这三类人都应该把它吃透。它是Java并发领域的地基后面学的volatile、Lock、并发容器全都建立在对锁这个概念的理解之上。注意如果你只是背下来synchronized加在方法上就能同步这句话而不理解锁的粒度和锁对象那么线上出问题的时候照样无从下手。这篇内容我会把用法、原理、陷阱一次讲透。2. synchronized的三种使用姿势锁的粒度别搞混2.1 同步实例方法锁的是this最基础也最常用的写法就是在实例方法上直接加synchronized修饰public class Account { private double balance; public synchronized void credit(double amount) { this.balance amount; } public synchronized double getBalance() { return this.balance; } }这行代码背后有几层含义你必须清楚。第一锁的对象是当前实例this。也就是说同一个Account对象的所有synchronized实例方法共用同一把锁线程A在调用这个对象的credit方法时线程B就算调的是getBalance方法也会被挡在外面。这其实是一个很多人忽略的点synchronized锁的是对象而不是方法方法只是被保护的代码块而已。第二如果你new了两个不同的Account对象两个线程分别操作这两个对象那么锁是互相不干扰的。每个对象有自己独立的锁这符合直觉但如果两个线程共享的是同一个Account对象就必须排队执行。很多初学者写代码时明明多个线程用的是同一个对象却在方法加锁后依然出现数据错乱多半是因为锁的是不同的对象。第三使用synchronized修饰的方法锁的获取和释放是由JVM自动完成的。方法正常执行完会释放执行过程抛了异常也会释放这一点非常关键意味着你不需要像用Lock那样在finally里手动unlock天然避免了锁忘记释放的bug。2.2 同步代码块只锁必要的部分方法级别的同步粒度太大。假如你的方法里有大量耗时的非共享操作只有中间几行代码操作了共享变量整方法加锁就会把并发度拉低。这时候就该用同步代码块public class OrderService { private final Object lock new Object(); private MapString, Integer stockMap new HashMap(); public void deductStock(String skuId, int count) { // 这里可以做一些耗时的校验操作不需要锁 validateSku(skuId); synchronized (lock) { Integer current stockMap.get(skuId); if (current null || current count) { throw new RuntimeException(库存不足); } stockMap.put(skuId, current - count); } // 锁外记录操作日志 saveLog(skuId, count); } }这段代码的核心思路是同步块的范围越小锁持有的时间越短其他线程等待的时间就越短系统吞吐量就越高。我在代码评审时经常看到有人一上来就把整个方法都加锁遇到IO操作也在锁内执行结果并发量一上来就出现大量线程阻塞。正确的做法是把共享资源的读改写操作单独拎出来锁住这一小段就够了。这里还有一个值得注意的细节同步代码块使用的锁对象要选好。上面例子中我用了独立的private final Object lock好处是锁对象对外不可见别的代码没法不小心用同一个对象来捣乱。如果你直接用this当锁那么外部代码如果也有synchronized(this)块就会跟你内部的锁互相干扰扩大锁的范围。2.3 静态同步方法锁的是Class对象静态方法上的synchronized锁的粒度跟实例方法完全不同。它锁的是当前类的Class对象也就是Account.class。来看个对比public class IdGenerator { private static int currentId 0; public static synchronized int nextId() { return currentId; } }因为锁是挂在Class对象上的所以无论你创建多少个IdGenerator实例所有实例之间对nextId的调用都会被同一把锁串行化。这一点在实现全局唯一ID生成器、统计全局在线人数等场景中非常有用。但这里有一个特别容易踩坑的地方实例锁和类锁是两把不同的锁。一个线程在调用实例同步方法锁this另一个线程同时调用静态同步方法锁Class对象两者完全不互斥。如果一个类里既有实例同步方法操作共享静态变量又有静态同步方法操作同一个静态变量那么两个线程可能会同时进入数据照样会错乱。解决办法很简单静态变量统一用类锁保护实例变量统一用实例锁保护别混着来。2.4 锁对象速查表我把synchronized的几种写法和对应的锁对象整理成了表格建议你直接收藏写法锁对象作用范围synchronized修饰实例方法this当前实例同一实例的同步方法之间互斥synchronized修饰静态方法类的Class对象所有实例的该方法之间互斥synchronized(this)代码块this与实例同步方法共用锁synchronized(Class对象)代码块Class对象与静态同步方法共用锁synchronized(任意对象)代码块该对象实例仅该对象作为锁时互斥判断synchronized最终能不能保证线程安全核心就问一句话多个线程竞争的是不是同一把锁。如果各自拿着不同的锁那写再多synchronized也没用。3. 底层原理字节码、Monitor与锁升级3.1 从字节码看锁的获取与释放用好synchronized是一回事理解它底层怎么工作是另一回事尤其是面试时这块几乎是必问的。我们用javap -verbose反编译一个简单的同步代码块public class Demo { private final Object lock new Object(); public void doSomething() { synchronized (lock) { System.out.println(hello); } } }反编译后你会看到字节码中有两条关键指令monitorenter和monitorexit。翻译过来就是进入同步块时线程尝试获取lock对象的监视器Monitor执行完毕后释放Monitor。更有意思的是如果你的同步块正常执行完了会有一条明确的monitorexit指令来释放锁同时编译器还会为异常情况准备第二条monitorexit保证即使代码块里抛出了异常锁也会在异常路径上被释放不会因为异常跳出导致死锁。这套机制对应JVM规范中的Monitor概念。你可以把Monitor想象成房间门口的准入牌谁拿到了准入牌谁就能进入房间操作其他线程只能在门口等待等持牌者归还后才能去抢。synchronized的重量级锁实现本质上就是基于操作系统的Mutex Lock互斥锁来保证这个准入牌只有一个。3.2 对象头与Mark Word锁的信息不是存放在某个全局表里而是直接存放在Java对象头中。64位JVM中对象头里的Mark Word标记字段结构会根据对象状态不同而变化主要用于存放锁状态、hashCode、GC分代年龄等信息。在无锁状态下Mark Word中记录了对象的identity hashcode和分代年龄等信息。一旦有线程竞争锁Mark Word就会改写为指向锁记录的指针或指向Monitor的指针。这就解释了为什么JVM可以将锁信息跟随对象走每个对象都天然具备成为锁的潜质不需要额外创建锁对象。3.3 锁升级路径无锁 → 偏向锁 → 轻量级锁 → 重量级锁JDK 1.6之后HotSpot对synchronized做了大量优化引入了锁升级机制目的是降低锁竞争的开销。整个过程是这样的无锁状态对象刚创建没有线程访问Mark Word处于无锁状态。偏向锁第一个线程访问同步块时JVM认为这个锁可能只会被一个线程反复使用于是通过CAS操作把线程ID记录到Mark Word中。之后该线程再加锁就不需要做任何同步操作性能开销几乎为零。这就是偏向锁它假设大多数锁不会被多线程竞争。轻量级锁一旦有第二个线程来竞争偏向锁被撤销。此时JVM在锁线程的栈帧中创建Lock Record用CAS尝试将对象Mark Word更新为指向该Lock Record的指针。如果成功持有轻量级锁如果失败则膨胀为重量级锁。重量级锁当竞争激烈时轻量级锁的CAS自旋适应自旋也拿不到锁锁膨胀为重量级锁。这时线程会进入阻塞状态由操作系统调度需要在用户态和内核态之间切换性能开销大。理解锁升级不仅是为了面试能说上几句更重要的是指导编码实践如果锁的竞争不激烈synchronized的性能其实非常好因为它可以从偏向锁一路省掉很多系统调用千万不要因为听说synchronized性能差就盲目加Lock现代JDK中两者的差距已经很小关键是锁粒度本身合不合理。4. 实战场景三个典型同步需求的正确解法4.1 场景一用synchronized实现线程安全的计数器先给个错误示范这种代码在面试里经常被拿来考public class BadCounter { private int count 0; public int incrementAndGet() { return count; // 非原子操作读、加、写三步 } }即使你给incrementAndGet加上synchronized如果getCount方法没有加锁读线程一样可能读到中间状态。正确的写法是读和写都要遵循同一把锁public class SafeCounter { private int count 0; public synchronized int incrementAndGet() { return count; } public synchronized int getCount() { return count; } }这里顺带说一句实际开发中如果只需要计数器更推荐AtomicInteger这样的并发原子类因为它的CAS操作在低竞争下更高效。但学习synchronized时自己用锁实现一遍计数器是必经练习能帮你彻底搞懂互斥的含义。4.2 场景二单例模式中的双重检查锁单例模式是最经典的synchronized使用场景。懒加载方式下最原始的写法是给getInstance方法加synchronized但这样每次获取单例都要抢锁高并发下影响性能。于是有了双重检查锁Double Checked Locking的写法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; } }这段代码有两个关键点。第一synchronized块内为什么还要再判断一次instance null因为如果两个线程同时通过了第一次检查线程A进入同步块创建了实例线程B随后进入同步块时如果不加第二次判断就会再创建一个实例破坏单例。第二次检查保证了只有第一个拿到锁的线程才真正去创建对象。第二为什么instance必须用volatile修饰这里涉及synchronized和volatile的分工。new Singleton()在底层不是一步完成的它包含分配内存、初始化对象、将引用赋值给instance三个步骤。JVM和CPU的指令重排序可能导致先赋值引用后执行初始化。另一个线程在第一次检查时发现instance不为null直接返回了一个尚未初始化完成的对象。volatile通过禁止指令重排序杜绝了这个隐患。synchronized保证的是锁内代码的互斥volatile保证的是锁外检查的可见性和顺序性两者配合才能做到既安全又高效。4.3 场景三简单的生产者-消费者与wait/notifysynchronized经常和wait()、notify()一起使用这两兄弟是面试和生产中常见搭配。标准写法是这样的public class MessageQueue { private final LinkedListString queue new LinkedList(); private final int capacity 10; public synchronized void put(String message) throws InterruptedException { while (queue.size() capacity) { wait(); // 队列满进入等待释放锁 } queue.addLast(message); notifyAll(); // 唤醒等待的消费者 } public synchronized String take() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空等待生产者的通知 } String message queue.removeFirst(); notifyAll(); // 唤醒等待的生产者 return message; } }这里有一个关键陷阱必须指出判断条件必须用while不能只用if。为什么wait()返回时线程并非立即获得锁继续执行而是要和别的线程重新竞争锁。即使被notifyAll唤醒后抢到了锁也可能由于虚假唤醒spurious wakeup或者多个生产者同时被唤醒等原因队列状态已经不满足当初等待的条件了。用while是醒来后重新检查条件用if则是醒了就直接往下走后者极容易导致队列元素超过容量或者取出null。另外一个容易忽略的点wait()被调用后会释放当前持有的锁。这是它与Thread.sleep最大的区别——sleep不会释放锁。如果你在同步块里调用sleep其他线程依然无法进入同一个锁保护的代码块所以千万不要用sleep来模拟等待逻辑。5. 常见陷阱与排查技巧实录5.1 陷阱一锁对象选成了字符串常量或包装类对象有不少人图省事直接拿字符串常量做锁private static final String LOCK LOCK; public void doSomething() { synchronized (LOCK) { // ... } }看起来没问题但要注意JVM中字符串常量是会被驻留intern的多个类如果都写了LOCK这个字面量实际引用的是同一个字符串对象。这可能导致两个完全不相关的业务代码共享同一把锁引起莫名奇妙的性能下降。更麻烦的是你无法控制其他代码是否也用它当锁。同样的道理适用于Integer包装类Integer有缓存机制-128到127这两个数字作为锁时很可能变成全局共享锁。我的建议是锁对象优先使用private final Object lock new Object();这种专用对象语义清晰又安全。5.2 陷阱二synchronized锁的粒度与顺序引发死锁死锁的经典场景是循环等待。线程A持有锁1等待锁2线程B持有锁2等待锁1。两个线程互相不放手程序就卡死了。实际排查中如果怀疑发生死锁用jstack抓线程栈是非常有效的手段。命令是jstack pid输出中会有专门标记Found one Java-level deadlock并列出每个线程正持有的锁和正在等待的锁。在代码层面规避死锁的核心策略是给所有锁排定全局顺序。比如同时需要锁A和锁B时所有线程都先拿A再拿B就不会出现循环等待。另一个技巧是用Lock接口的tryLock获取不到就释放已有的锁重试能做到可恢复的死锁规避但这是后面学习JUC时的事了。5.3 陷阱三误以为synchronized能保证原子性内的可见性synchronized不仅能保证互斥它还有一个隐藏作用内存可见性。进入synchronized块时线程会清空自己的工作内存副本重新从主内存读取共享变量退出synchronized块时会把修改后的值刷新到主内存。这保证了一个线程在锁内做的修改对后续获得同一把锁的线程是可见的。但有个前提读写必须都在同一把锁的保护下。如果写操作在synchronized块内读操作没有加锁读线程可能永远看不到最新值或者看到过期值。这一点我见过很多人栽跟头尤其在一写多读的场景中只给写方法加锁却不给读方法加锁结果读端一直读到旧数据。5.4 陷阱四锁内执行耗时操作把IO、网络调用、数据库查询等耗时操作放在synchronized块里会让其他线程排长队系统吞吐量雪崩。我遇到过一次极端情况有人在synchronized块里发了HTTP请求单次耗时300毫秒并发上来后所有线程堵死在锁上接口耗时从几十毫秒涨到几十秒。正确的做法是锁内只保留对共享变量的读写和必要的业务校验逻辑耗时操作移到锁外。如果确实需要在锁的保护下做耗时操作就要评估是否需要拆分锁、减小锁粒度或者改用其他并发工具。6. synchronized与Lock怎么选才不后悔6.1 两者本质区别JDK 1.5之后引入的java.util.concurrent.locks.Lock接口提供了一套比synchronized更灵活的锁机制。对比一下能力synchronizedLock如ReentrantLock获取方式自动JVM管理手动lock必须finally中unlock锁释放自动释放异常也释放手动释放忘记unlock就是死锁隐患中断响应不支持线程阻塞后不可中断lockInterruptibly支持中断超时获取不支持tryLock(timeout)支持公平性非公平可配置公平/非公平多个条件队列每个锁只有一个条件队列支持多个Condition可精确唤醒锁的底层对象头的Monitor有锁升级AQSAbstractQueuedSynchronizer性能低竞争良好有偏向锁优化较低竞争场景也不错但开销略大6.2 我的选型建议先说结论这个结论也回答了很多初学者的疑惑能默认用synchronized就不要用Lock。原因有三第一synchronized是语言内置的写法简洁不会出现忘记释放锁的问题编译器会在所有路径上帮你插入释放逻辑天然防呆。第二JVM对synchronized的优化一直在推进锁升级机制让它在低竞争场景下非常高效。很多网上性能差的说法是早期JDK的旧黄历了。第三synchronized代码块的可读性和维护性好别人一看就明白这段代码在互斥执行。Lock手动解锁的代码如果throw遗漏了unlock生产环境死锁排查起来非常痛苦。什么时候必须用Lock我认为有三种场景需要可中断地等待锁用户取消操作时要能立即退出阻塞需要设置获取锁的超时时间避免永久等待需要多个条件队列来精确唤醒某一类线程如读写分离中的读线程包和写线程包。这些场景下Lock是唯一合适的选择。最后再分享一个排查线程阻塞的小技巧很多时候你写了一个synchronized块线上出问题后发现某个线程一直卡住但又不知道是谁占着锁。这时候光看业务代码可能看不出名堂我的习惯是直接用jstack抓一把线程快照重点关注挂着java.lang.Thread.State: BLOCKED的线程。BLOCKED状态基本就意味着它在等待一把被别的线程持有的synchronized锁配合栈顶的- waiting to lock 0x00000007ad123456你能直接看到等的是哪个对象。然后再看有没有别的线程栈底显示locked 0x00000007ad123456一对上号谁占着锁一目了然。另外关于synchronized还有一层体会它是Java并发入门阶段最适合反复琢磨的知识点因为它把互斥可见性锁的粒度可重入这些并发的基础概念全串起来了。可重入性我前面没展开说这里补一句——拿到一把锁的线程可以继续进入同一个锁保护的其他同步方法不会自己把自己锁死这也是可重入锁名字的由来。把synchronized这个关键字吃透了再去看ReentrantLock、ReadWriteLock这些JUC下的锁你会觉得一切都顺理成章。
返回列表