我先给你交个底:如果你去面试Java岗位,synchronized几乎是必考题。但很多人对它的理解停留在“加锁可以保证线程安全”这个层面,再往深问一句“它是怎么保证的?锁升级是怎么回事?和Lock有什么区别?”,就开始含糊了。
倒也不怪大家,synchronized这个关键字从JDK 1.0就有,经过这么多年迭代,底层实现已经相当复杂。早期它是重量级锁,性能不好,网上很多老文章还在这么写;但JDK 6之后做了大规模优化,引入了偏向锁、轻量级锁、锁升级机制,现在的synchronized早就不再是“性能差”的代名词了。
这篇文章我想从一个实际项目里踩过的坑讲起,把synchronized从用法到底层原理完整拆一遍。你会看到它解决什么问题、怎么用才是正确的、哪些写法看着对其实有坑、面试官最喜欢从哪个角度追问。内容不设门槛,但越往后越深,建议拿个笔记本边看边记。
1. 曾经踩过的坑:一个漏加锁引发的线上事故
先说一段真实经历。之前做一个活动系统,里面有个给用户发奖励的接口,逻辑很简单:先查用户当前的奖励次数,如果没超过上限就加一次,然后发奖。伪代码长这样:
public void grantReward(Long userId) { // 1. 查询用户已领取次数 int count = rewardMapper.getCountByUser(userId); // 2. 判断是否超限 if (count >= MAX_COUNT) { throw new BusinessException("已达领取上限"); } // 3. 发奖并增加计数 rewardMapper.addCount(userId); sendReward(userId); }单线程下这段代码没任何问题。但线上是Tomcat多线程并发调用,同一个用户同时点了两次领取,两个线程同时读到count=9(上限10),都通过判断,然后各自加了一次,最终用户领了11次。
这就是典型的竞态条件。解决方式有很多,数据库乐观锁、Redis分布式锁、应用内synchronized都可以。当时这个场景是单机应用,最简单可靠的就是给方法加synchronized。于是改成了:
public synchronized void grantReward(Long userId) { // 逻辑不变 }问题解决。
但你以为这样就完了?没有。没过多久,新的问题出现了:这个接口的QPS开始下降,因为synchronized把整个方法锁住了。用户A和用户B是两个不同的人,本来可以并行处理,现在因为synchronized方法锁默认锁的是this对象,导致所有用户的请求都串行化了。
这就是synchronized用得不对的典型后果——锁的粒度太大。后来我们把锁拆细,改成锁用户维度:
public void grantReward(Long userId) { synchronized (getLockByUserId(userId)) { // 查询、判断、发奖 } }把同一个用户的请求串行化,不同用户之间互不影响,性能一下就上来了。
这段经历是我理解synchronized的起点。它让我意识到三件事:第一,synchronized能解决问题,但要用对;第二,锁的粒度直接决定系统性能;第三,只靠关键字是不够的,你得理解它底层到底怎么工作,才能用好它。
2. synchronized的三种用法,锁的到底是什么
synchronized在Java里有三种使用方式,每一种锁定的目标都不同。很多初学者搞混,其实记住一句话就够了:synchronized锁的一定是一个对象,不是一个方法,也不是一段代码。
2.1 修饰实例方法:锁的是this
public class Counter { private int count = 0; public synchronized void increment() { count++; } }当synchronized修饰实例方法时,锁的是调用这个方法的对象,也就是this。两个线程同时调用同一个Counter实例的increment方法,会互相竞争同一把锁;但如果是两个不同的Counter实例,锁就是两把,互不干扰。
这带来一个隐蔽的坑:如果你用synchronized修饰实例方法,但实际使用中创建了多个实例,锁就形同虚设。Spring默认单例所以还好,但手动new出来的对象要格外小心。
2.2 修饰静态方法:锁的是Class对象
public class Counter { private static int count = 0; public static synchronized void increment() { count++; } }静态方法锁的是当前类的Class对象,比如Counter.class。Class对象在JVM里是全局唯一的,所以即使是不同实例,也会竞争同一把锁。
要注意的是,实例方法的锁和静态方法的锁不是同一把。如果一个类里既有synchronized实例方法又有synchronized静态方法,它们可以并行执行,因为锁的目标不同。这个细节面试也常考,很多人以为都是“这个类的锁”,其实不是。
2.3 修饰代码块:可精确指定锁对象
public void increment() { synchronized (this) { count++; } } public void incrementWithLock(Object lock) { synchronized (lock) { count++; } }代码块方式最灵活。你可以锁this,也可以锁一个专门的锁对象,还可以根据业务维度动态选择锁对象。这是实际开发中用的最多的一种方式,因为粒度可控。
我习惯的做法是:如果一个方法里有大量耗时操作(远程调用、IO)但只有一小段涉及共享资源,一定不要直接锁整个方法,而是锁住那一小段,其他代码保持并行。这不是微优化,在高并发下差别非常大。
2.4 不同用法的锁对象对照表
| 用法 | 锁对象 | 作用范围 | 常见误区 |
|---|---|---|---|
| 修饰实例方法 | this | 同一实例内串行 | 以为不同实例也会互斥 |
| 修饰静态方法 | Class对象 | 全局唯一 | 以为实例方法也受影响 |
| 修饰代码块(synchronized(this)) | this | 同一实例内串行 | 锁粒度偏大 |
| 修饰代码块(synchronized(obj)) | 指定的obj | 由obj决定 | 锁对象选错导致失效 |
最后补充一个很重要的点:synchronized是可重入的。同一个线程已经持有了某把锁,再次执行同一个锁保护的代码时,不需要重新竞争,直接就能进入。比如一个synchronized方法内部调用了另一个synchronized方法,如果锁不可重入,就会死锁。Java的synchronized天然支持重入,这也是它比手动lock简单的原因之一。
3. synchronized的底层原理,从字节码到对象头
“synchronized底层是怎么实现的”是面试官最爱深挖的问题。我尽量用大白话讲,但该严谨的地方不会含糊。
3.1 字节码层面的秘密
写一个简单的同步代码块,编译后用javap看字节码,你会看到两条指令:monitorenter和monitorexit。
public void test() { synchronized (this) { System.out.println("hello"); } }对应的字节码核心片段:
monitorenter ... 业务代码 ... monitorexit ... 如果发生异常,走异常处理器 ... monitorexit // 异常时也会执行,保证锁一定释放monitorenter表示尝试获取对象的monitor锁,monitorexit表示释放锁。注意字节码里有两次monitorexit,这是为了处理异常——即使代码块内抛异常,也会走异常表路径执行第二次monitorexit,确保锁能释放。这也是synchronized比手动lock更安全的原因,你永远不会忘记解锁。
如果是synchronized修饰方法,字节码里并没有monitorenter/monitorexit,而是在方法的访问标志里多了一个ACC_SYNCHRONIZED标记。JVM在调用方法时检查这个标志,如果设置了,就尝试获取锁。本质上和monitor机制是一回事,只是表现形式不同。
3.2 对象头和Mark Word:锁信息存哪了
Java对象在内存里的布局分三块:对象头、实例数据、对齐填充。锁的信息存在对象头里。
对象头里有一个关键部分叫Mark Word,64位JVM下占8字节,里面存储了对象的hashCode、分代年龄、锁状态标记等信息。最关键的是,锁状态决定了Mark Word里存的到底是什么。
不同锁状态下Mark Word的布局(64位JVM):
| 锁状态 | 存储内容 |
|---|---|
| 无锁 | 对象的hashCode、分代年龄、偏向锁标志位0、锁标志位01 |
| 偏向锁 | 线程ID、epoch、分代年龄、偏向锁标志位1、锁标志位01 |
| 轻量级锁 | 指向栈中锁记录的指针、锁标志位00 |
| 重量级锁 | 指向monitor对象的指针、锁标志位10 |
你看,锁升级的本质就是Mark Word里的内容在不断变化。偏向锁存持有线程的ID,轻量级锁存栈帧中锁记录的指针,重量级锁存monitor对象的地址。
3.3 monitor机制:重量级锁的核心
monitor(监视器锁)是synchronized最底层的实现机制。每个Java对象都可以关联一个monitor对象,这个monitor是C++实现的,在HotSpot里叫ObjectMonitor。
ObjectMonitor里有几个关键字段:
_owner // 当前持有锁的线程 _EntryList // 等待获取锁的线程队列 _WaitSet // 调用wait()后等待的线程队列 _recursions // 锁重入次数当一个线程执行到monitorenter时,JVM会尝试获取对象的monitor。如果_owner为null,说明锁空闲,线程直接获取成功,_owner设置为当前线程;如果_owner已经指向当前线程,说明是重入,_recursions加1;如果_owner指向其他线程,当前线程进入_EntryList阻塞等待。
这就是synchronized重量级锁的工作模型。它依赖操作系统的互斥量(mutex)实现阻塞和唤醒,所以涉及到用户态和内核态的切换,开销比较大。这也是synchronized早期性能差的原因。
不过别担心,现代JVM已经不需要每次都走到重量级锁,因为它有了一套完整的锁升级机制。
4. 锁升级全流程:从偏向锁到重量级锁
JDK 6之后,synchronized的锁有四种状态:无锁、偏向锁、轻量级锁、重量级锁。锁只能升级不能降级(偏向锁可以批量撤销,但大方向是升级)。
这个设计思路很聪明:大多数锁在大部分时间里没有竞争,没必要一上来就搞重量级。
4.1 偏向锁:让同一个线程重复获取锁
偏向锁的理念是:如果一把锁从头到尾只有一个线程在获取,那就没必要做同步操作,在Mark Word里记录这个线程的ID就行了。下次这个线程再来,直接判断Mark Word里存的线程ID是不是自己,是就直接进入,啥也不用做。
这有点像你办公室工位的门锁:如果一栋楼只有你用这扇门,你把指纹录进去,每次直接推开就行,不需要每次都掏出钥匙锁门开锁。
偏向锁默认是开启的,但有延迟——JVM启动后4秒内创建的对象不会启用偏向锁。这是为了避免JVM启动时的竞争。这个参数可以用-XX:BiasedLockingStartupDelay=0关闭延迟。
如果另一个线程尝试获取偏向锁,说明有竞争了。此时会先撤销偏向锁,将锁升级为轻量级锁。如果大量线程频繁竞争,偏向锁会被批量撤销。
4.2 轻量级锁:用CAS代替阻塞
当第二个线程来竞争时,偏向锁会被撤销,升级为轻量级锁。
轻量级锁的核心是自旋+CAS。线程在自己的栈帧中创建一块锁记录空间,然后尝试用CAS把对象头Mark Word替换成指向锁记录的指针。如果成功,说明获取锁成功;如果失败,说明有竞争,线程开始自旋等待。
自旋就是让线程在一个循环里不断尝试获取锁,而不是直接阻塞。线程在用户态自旋,避免了内核态切换的开销。JDK 6之后自旋是自适应的:JVM会根据历史情况动态调整自旋次数,第一次自旋成功率高,以后就多自旋几次;一直失败,就减少甚至不自旋。
自旋的代价是占用CPU。如果锁持有时间很短,自旋很划算;锁持有时间很长,自旋就是白白烧CPU。
4.3 重量级锁:进入真正的阻塞
如果自旋超过一定次数(或自适应自旋判定不应该再自旋),锁升级为重量级锁。这时候走的就是前面说的monitor机制,线程真正阻塞,依赖操作系统调度。
重量级锁的好处是不占CPU,阻塞的线程不会参与调度。代价是线程阻塞和唤醒涉及到用户态和内核态的切换,开销大。
这就是为什么长时间持有锁的场景下,重量级锁反而更合适——它的等待成本是可控的。
4.4 一个完整的锁升级案例
我把锁升级过程串起来,结合场景讲:
假设一个计数器对象,刚创建,无锁状态。
- 线程A第一次执行synchronized代码块,JVM发现无锁,判断当前线程ID为空,直接把偏向锁指向线程A,Mark Word记录线程A的ID。此时A后续再来,零成本通过。
- 线程B尝试执行同一段代码,发现Mark Word里的线程ID是A,不是自己。说明出现竞争,偏向锁撤销,升级轻量级锁。
- B用CAS尝试替换Mark Word指向自己的锁记录。如果A还没执行完,CAS失败,B自旋等待。
- 假设A很快执行完,释放锁,B自旋过程中CAS成功,拿到锁。
- 假设A持有锁时间很长,B自旋多次失败,锁升级为重量级锁,B进入阻塞队列,等待操作系统唤醒。
这是synchronized最完整的生命周期。理解了这个流程,你就理解了为什么现代synchronized性能不差——大多数场景下它根本不走到重量级锁那一步。
5. synchronized保证的三个核心特性:原子性、可见性、有序性
面试官还会换个角度问:synchronized是怎么保证线程安全的?标准回答是它保证了三个特性。
5.1 原子性:锁保证了执行的不可分割
原子性指的是一个操作或者多个操作要么全部执行,要么全部不执行,不能被线程调度机制打断。
synchronized通过monitor机制保证了原子性。线程必须获得锁才能进入同步代码块,没拿到锁的线程只能等待。整个同步代码块的执行过程,对其它线程来说是不可分割的。
但要注意,synchronized保证的是代码块内的原子性,不是方法内所有操作的原子性。如果你在同步代码块里又调用了别的不加锁的方法操作共享数据,原子性一样会被破坏。
5.2 可见性:锁释放时刷新主内存
可见性指的是一个线程对共享变量的修改,对其他线程是可见的。
这里牵涉Java内存模型(JMM)。JMM规定,线程对共享变量的操作在自己的工作内存中,不是直接操作主内存。所以一个线程改了变量值,另一个线程可能看不到。
synchronized的可见性保障机制是:
- 线程加锁时,会清空工作内存中共享变量的值,直接从主内存重新加载;
- 线程释放锁时,会把工作内存中修改的变量值刷新到主内存。
这就像开会时的白板:大家进会议室先看白板上最新内容,散会时谁修改了数据,必须写到白板上给其他人看。
5.3 有序性:防止指令重排
编译器和CPU为了优化性能,可能会调整指令执行顺序,这就是指令重排。在单线程下没问题,多线程下就可能出幺蛾子。
synchronized通过锁的互斥性,保证同步代码块内的代码不会被其他线程干扰。一个线程在锁内执行的指令重排,对其他线程不可见,因为它们根本进不来。
注意,synchronized内部如果没有其他同步机制(比如volatile),它不能完全禁止代码块内部的指令重排,但它保证了重排的结果对其它线程不可见,因为其它线程要看到结果必须先拿到锁。这在逻辑上就保证了安全。
5.4 三个特性之间的关系
| 特性 | synchronized如何保证 | 通俗理解 |
|---|---|---|
| 原子性 | monitor互斥,同一时刻只有一个线程执行 | 不是你的回合,你就进不了球场 |
| 可见性 | 加锁刷新工作内存,解锁刷新主内存 | 交接工作必须对账 |
| 有序性 | 互斥执行,防止指令重排对其它线程可见 | 别人看不到你内部的小动作 |
日常开发中不需要刻意分这三个维度去写代码,但面试时能讲清楚这三个维度的实现机制,会显得你真有深入研究过。
6. synchronized使用中的常见错误与正确姿势
这部分内容多,因为我在实际开发中见过太多用错synchronized的案例。挑几个典型的分享给你。
6.1 错误一:锁对象用了字符串常量
public class LockService { private String lock = "LOCK"; public void doSomething() { synchronized (lock) { // ... } } }字符串常量在JVM里可能被缓存(字符串常量池)。如果代码里有另一处也用"LOCK"做锁,它们就是同一把锁,会导致两个毫无关系的模块互相阻塞。
正确的做法是使用私有静态final的Object实例作为锁:
private static final Object LOCK = new Object();6.2 错误二:锁对象在方法里创建
public void doSomething() { Object lock = new Object(); synchronized (lock) { // ... } }每次调用方法都新建一个锁对象,锁就是全新的,互斥效果完全失效。锁对象必须是被所有线程共享的同一个对象。
6.3 错误三:锁的粒度太大或太小
粒度太大,性能下降,前面发奖接口的例子已经很典型。粒度太小,可能锁不住完整的业务流程,导致数据不一致。
判断粒度是否合适的标准是:锁保护的代码范围是否能覆盖共享资源从读、判断到写回的全过程。只锁写不锁读,照样出问题。
// 错误:锁没有覆盖整个检查+更新流程 public void grantReward(Long userId) { int count = rewardMapper.getCountByUser(userId); // 无锁查询 if (count >= MAX_COUNT) { throw new BusinessException("已达领取上限"); } synchronized (lock) { rewardMapper.addCount(userId); sendReward(userId); } }两个线程可能同时读到count=9,都通过判断,然后串行进入锁内各自加一次。因为判断过程没有加锁,锁保护的范围不够。正确做法是把“读count→判断→加count”放在同一把锁内。
6.4 错误四:在循环里使用synchronized做等待
有些初学者会这样写:
synchronized (lock) { while (condition) { // 空转等待 } }如果condition由其他线程修改,这个循环会锁死当前线程,其他线程进不来,永远无法修改condition,形成死锁。
正确做法是使用wait/notify机制,让出锁并等待通知:
synchronized (lock) { while (condition) { lock.wait(); // 释放锁并等待 } }这是wait/notify必须放在synchronized里的根本原因:必须先持有锁,才能安全地检查条件并等待。
6.5 synchronized和Lock怎么选
除了synchronized,Java并发包里还有Lock体系(ReentrantLock等)。我给的选型建议:
| 需求 | 推荐方案 |
|---|---|
| 基本互斥同步 | synchronized,简单可靠 |
| 需要锁超时、可中断 | ReentrantLock |
| 需要公平锁 | ReentrantLock(true) |
| 需要多个条件变量 | ReentrantLock + Condition |
| 需要非阻塞尝试获取锁 | ReentrantLock.tryLock() |
| 读写场景差异明显 | ReentrantReadWriteLock |
JDK 6之后synchronized经过优化,性能上和ReentrantLock基本没有明显差距。日常开发我优先用synchronized,只有在上面表格里的特殊需求时才上Lock体系。这也是Java官方文档的建议方向。
7. 高频面试题一:多线程同时调用同一个synchronized方法结果会怎样
这是面试出现频率极高的一道题,考察的就是对锁行为的理解。
题目:有一个类,里面一个synchronized方法,循环执行5秒。现在创建两个线程,同时调用同一个实例的这个方法,最后总耗时是多少?
答案是大约10秒。因为两个线程竞争同一把锁,必须等第一个线程执行完释放锁,第二个线程才能进入。
但这个答案有几个变体,面试官会逐步加深难度:
变体一:如果调用的是两个不同实例的同一个synchronized方法,耗时多少?
答案是大约5秒。因为实例方法锁的是this,不同实例是不同锁,线程并行执行。
变体二:如果一个线程调用synchronized实例方法,另一个线程调用同一个类的synchronized静态方法,会互斥吗?
答案是不会。因为前者锁this,后者锁Class对象,是两把锁,可以并行。
变体三:如果一个synchronized方法内部又调用了同一个类的另一个synchronized方法,会死锁吗?
答案是不会。因为synchronized可重入,同一个线程已经持有锁,可以直接再次进入。
这几个变体覆盖了锁对象、锁类型、可重入性三个知识点,面试官通过一道题就能摸清你理解到什么程度。
8. 高频面试题二:JVM是如何实现synchronized的
这道题没有标准答案,但优秀回答应该包含以下几个层面,从浅入深:
第一层(字节码层):synchronized代码块会生成monitorenter和monitorexit两条字节码指令,异常时也有对应的锁释放路径;synchronized方法会在方法访问标志上增加ACC_SYNCHRONIZED。
第二层(对象头层):锁的状态信息存储在对象头的Mark Word中,不同锁状态下Mark Word的用途不同。从无锁到偏向锁到轻量级锁到重量级锁,Mark Word的内容也在动态变化。
第三层(锁升级层):JVM为了减少锁竞争开销,引入了偏向锁(单线程重复获取场景优化)、轻量级锁(CAS自旋获取)、重量级锁(monitor阻塞)。锁会随着竞争程度逐步升级,现代同步代码大多数在轻量级阶段就结束了。
第四层(重量级锁原理):重量级锁依赖ObjectMonitor,核心字段包括_owner持有线程、_EntryList等待队列、_WaitSet等待条件队列、_recursions重入计数。线程阻塞和唤醒依赖操作系统mutex,需要用户态和内核态切换。
如果能从字节码一路讲到ObjectMonitor,面试官基本就会认可你确实研究过原理。
9. 高频面试题三:为什么wait和notify必须放在synchronized里
这也是经典题,考察点在于你有没有真正理解Java线程协作的机制。
直接原因:wait和notify是Object的方法,它们操作的是对象的monitor(监视器锁)上的等待集合。一个线程必须持有对象的monitor锁,才能调用该对象的wait方法释放锁并等待,或者调用notify方法唤醒等待线程。
如果在没有持有锁的情况下调用wait或notify,JVM会抛出IllegalMonitorStateException。
根本原因(需要重点答的部分):这是为了避免丢失通知和竞态条件。假设wait不需要持有锁就能调用,可能出现这样的场景:线程A判断条件不满足,正打算调用wait;线程B此时修改了条件并把条件改为满足,然后调用notify;但A还没进入wait状态,notify通知就丢失了;A之后才进入wait,永远等不到通知,最后死锁。
在synchronized内执行“检查条件→wait/notify→重新检查条件”,整个流程是原子的,不可能被其他线程打断,所以通知不会丢失。这就是wait/notify必须和synchronized配套的根本原因。
补充一点,即使有了synchronized,wait之后也建议用while循环重新检查条件,而不是用if。因为Java官方文档和Effective Java都指出,线程被唤醒后条件可能已经变化,必须重新校验,这就是“虚假唤醒”的防护。
10. 高频面试题四:synchronized是公平锁还是非公平锁
这是最近面试里出现频率比较高的一个新考点。
synchronized是非公平锁。所谓公平锁,是指多个线程按照申请锁的顺序来获取锁,先来后到;非公平锁则允许后来的线程直接竞争锁,不一定按申请顺序。
原因是synchronized的锁升级机制。在轻量级锁阶段,线程通过CAS自旋抢锁,谁能先CAS成功就是谁的,不存在排队顺序的概念。升级为重量级锁后,线程进入ObjectMonitor的_EntryList,理论上按顺序唤醒,但轻量级阶段已经“插队”了,所以整体上是非公平的。
ReentrantLock默认也是非公平锁,但构造时可以传入true开启公平模式。
非公平锁的优点是吞吐量更高——刚释放锁的线程还在CPU上运行,让它立刻再次获取锁可以减少上下文切换。缺点是可能造成某些线程长时间获取不到锁,也就是“饥饿”问题。不过在绝大部分业务场景下,非公平带来的问题并不明显,不用过度担心。
11. 高频面试题五:synchronized可以锁null吗
这个问题看着简单,真能答好的不多。
synchronized不能锁null。如果synchronized的锁对象是null,会抛出NullPointerException。
原因是获取锁本质上是操作对象的monitor,null没有monitor可操作。同理,锁对象不能是基本类型,比如int、boolean,因为synchronized针对的是对象。
实际开发中这容易踩坑:如果锁对象是从外部传入参数或者从缓存里查回来的,要加判空。否则某个请求正好传了空值,直接NPE。
顺便说一个进阶点:如果锁对象被重新赋值(重新new了一个对象),锁也会变成新对象的锁,原本的等待线程会等在原对象上,导致锁失效。所以锁对象初始化后,要避免再重新赋值。
12. 一个完整的优化案例:从方法锁到分段锁
前面讲了理论,最后用一个案例把正确思路串起来。假设现在有一个用户积分服务,每次调用来给指定用户加分。最开始的实现:
public class PointService { private Map<Long, Integer> points = new HashMap<>(); public synchronized void addPoint(Long userId, int point) { Integer old = points.get(userId); if (old == null) { old = 0; } points.put(userId, old + point); } }问题很明显:全表锁,所有用户加分都串行。优化方向是锁分段——不同用户不同锁,减少竞争。
方案一:给每个用户一个锁对象
public class PointService { private Map<Long, Integer> points = new HashMap<>(); private Map<Long, Object> locks = new ConcurrentHashMap<>(); public void addPoint(Long userId, int point) { Object lock = locks.computeIfAbsent(userId, k -> new Object()); synchronized (lock) { Integer old = points.get(userId); if (old == null) { old = 0; } points.put(userId, old + point); } } }每次操作只锁当前用户,不同用户并行。ConcurrentHashMap保证锁对象的获取是线程安全的。
方案二:如果担心用户量太大导致锁对象过多,可以做固定分段
public class PointService { private Map<Long, Integer> points = new HashMap<>(); private Object[] lockArray = new Object[16]; public PointService() { for (int i = 0; i < lockArray.length; i++) { lockArray[i] = new Object(); } } public void addPoint(Long userId, int point) { int slot = userId.hashCode() & 15; // 取模16,落到固定seg synchronized (lockArray[slot]) { Integer old = points.get(userId); if (old == null) { old = 0; } points.put(userId, old + point); } } }这是用数组实现的分段锁,只创建16个锁,不同用户根据hash落到不同槽位,最多16个线程并行。空间固定,不随用户数增长。
如果数据量极大、并发极高,可以考虑用LongAdder、ConcurrentHashMap的原子方法,甚至引入Redis分布式锁。但从单体应用的视角看,固定分段锁已经能应对绝大多数场景。
这个案例的核心是:先明确锁要保护什么,再设计锁的粒度,最后才考虑用哪种锁机制。顺序反了,优化就是瞎折腾。
13. 关于synchronized,我建议你记住的几件事
最后分享几个我在源码阅读和实际项目中总结的经验,不一定每个都能在面试中直接用上,但对理解JVM并发模型很有帮助。
第一,synchronized锁的信息存在对象头里,这意味着锁的不是代码,而是对象。任何时候问自己“这个锁锁的到底是哪个对象”,答案清楚了,用法就错不了。
第二,锁升级是synchronized设计的灵魂。理解了偏向锁、轻量级锁、重量级锁的适用场景,你就理解了为什么现代Java并发编程中,很多场景一个synchronized就够用,不需要炫技似地引入各种复杂同步组件。
第三,在极低竞争场景下尽量不要随意加锁。如果共享数据本身是只读的,或者可以通过ThreadLocal、不可变对象规避,就没必要用synchronized。锁是有开销的,即便优化过也一样。
第四,写并发代码时,先人身安全后性能优化。先把锁加对,保证没有数据竞争,再去考虑减少锁竞争。很多事故不是代码写得不够高性能,而是最基础的并发安全都没守住。
synchronized是Java并发编程的第一课,也是最值得反复咀嚼的一个知识点。把它吃透了,再去学volatile、ReentrantLock、ConcurrentHashMap、AQS,会顺畅很多。希望这篇文章能帮你把这块地基打牢。