在黑马点评项目实战项目中使用了synchronized(),本文主要是为了对在学习过程中遇到的一些问题和疑问以及解决方案进行一个终结。
在Java并发编程中,synchronized
是最基础且常用的锁机制,它像一把“隐形的锁”,守护着多线程环境下的数据安全。但你是否遇到过这样的困惑:单机场景下运行良好的synchronized
,在集群部署时却突然“失效”,导致超卖或重复下单?本文将从原理到实践,带你彻底搞懂synchronized
的底层逻辑、使用场景,以及在集群模式下的局限性。
一、synchronized基础:锁的本质与使用场景
1.1 什么是synchronized?
synchronized
是Java的内置同步关键字,用于控制多线程对共享资源的访问,确保同一时间只有一个线程能执行被修饰的代码块或方法。它的核心是通过对象锁(Monitor Lock)实现线程互斥,解决多线程并发访问时的线程安全问题(如数据不一致、超卖、重复下单等)。
1.2 三种修饰形式:锁的“绑定对象”
synchronized
可以修饰三种代码结构,锁的绑定对象不同:
- 实例方法:锁是当前对象实例(
this
)。例如:public synchronized void updateUser() { ... } // 锁是this(当前User对象)
- 静态方法:锁是类的
Class
对象(如UserService.class
)。例如:public static synchronized void updateCount() { ... } // 锁是UserService.class
- 代码块:锁是括号内指定的对象(如
lockObj
)。例如:synchronized (lockObj) { ... } // 锁是显式指定的lockObj对象
1.3 核心作用:解决哪些问题?
synchronized
的核心价值是保证原子性,典型场景包括:
- 共享变量修改:如库存扣减(
stock--
)、订单数量更新。 - 复合操作:如“查询库存→扣减库存→创建订单”的原子性组合(避免中间状态被其他线程干扰)。
- 防重复操作:如“一人一单”场景(同一用户只能创建一个订单)。
二、代码中的synchronized:为什么用userId.intern()?
2.1 问题背景:集群下的“一人一单”
在秒杀场景中,用户可能通过快速点击发起多个请求。若同一用户的多个线程同时执行“扣库存→创建订单”逻辑,会导致:
- 库存被重复扣减(超卖);
- 同一用户创建多个订单(重复下单)。
因此,需要用synchronized
锁定同一用户的线程,确保操作串行执行。但直接使用userId
作为锁对象会遇到问题。
2.2 关键细节:userId.toString().intern()的作用
用户代码中,锁对象通常写成synchronized (userId.toString().intern())
,这是为了确保同一用户ID的不同String对象共享同一个锁引用。
(1)String的“陷阱”:相同内容≠同一对象
userId
是Long
类型,调用toString()
会生成新的String
对象(即使内容相同)。例如:
Long userId1 = 123L;
Long userId2 = 123L;
String s1 = userId1.toString(); // 内存地址A的新String对象
String s2 = userId2.toString(); // 内存地址B的新String对象
此时s1
和s2
内容相同但内存地址不同。若直接用s1
或s2
作为锁对象,同一用户的不同线程会锁定不同的对象,导致锁失效。
(2)intern():字符串常量池的“去重魔法”
String.intern()
是Java的字符串池化机制,作用是将字符串添加到字符串常量池(String Pool)中,并返回池中已存在的引用(若字符串已存在)。
- JDK 7前:常量池位于方法区(永久代),可能引发内存溢出;
- JDK 7及以后:常量池移至堆(Heap),避免内存限制。
关键逻辑:
String s1 = userId.toString(); // 生成新String对象(堆中)
String s = s1.intern(); // 检查常量池:
// - 存在:返回常量池中的引用(与s1内容相同)
// - 不存在:将s1加入常量池,返回s1的引用
通过intern()
,无论userId.toString()
生成多少次String
对象,只要内容相同,最终都会返回常量池中的同一个引用。因此,同一用户的不同线程锁定的是同一对象,确保同步。
2.3 不加intern()的后果
若省略intern()
,userId.toString()
每次生成新的String
对象(即使内容相同),会导致:
- 同一用户的不同线程锁定不同的
String
对象(锁对象不一致); synchronized
失效,引发超卖或重复下单。
2.4 为什么锁用户ID?
锁用户ID的核心目的是隔离不同用户的并发操作:
- 同一用户的多个线程必须串行执行(防止重复抢购);
- 不同用户的线程使用不同的锁对象(互不影响),保证系统并发效率(不同用户可同时抢购)。
三、synchronized的底层原理:Monitor与对象头
3.1 锁的本质:Monitor监视器
synchronized
的底层依赖Java对象的对象头(Mark Word)中的Monitor
(监视器)。Monitor
是JVM实现锁的核心数据结构,记录锁的状态(如是否被占用、持有线程ID、等待线程队列等)。
(1)对象头的内存布局
Java对象在内存中的布局分为三部分:
- 对象头:包含类型指针(指向类元数据)和Mark Word(存储运行时元数据);
- 实例数据:对象的实际属性值;
- 对齐填充:JVM要求对象内存地址为8字节的倍数,不足部分填充。
其中,Mark Word是锁机制的核心,存储锁状态标志(无锁/偏向锁/轻量级锁/重量级锁)、线程ID、等待队列等信息。
3.2 锁的获取与释放流程
synchronized
的加锁与释放本质是Monitor的获取与释放,流程如下:
(1)加锁阶段
线程尝试进入synchronized
代码块时,检查对象的Mark Word:
- 无锁(标志位01):通过CAS操作将锁状态改为“偏向锁”(标志位01),记录当前线程ID;
- 偏向锁(标志位01):若线程ID匹配,直接获取锁;若不匹配,升级为轻量级锁;
- 轻量级锁(标志位00):通过CAS竞争锁,失败则升级为重量级锁;
- 重量级锁(标志位10):线程被阻塞,加入Monitor的等待队列(EntryList)。
(2)释放锁阶段
线程执行完synchronized
代码块后,释放Monitor锁:
- 偏向锁:清除线程ID,锁状态回退为无锁;
- 轻量级锁:通过CAS释放锁,唤醒等待线程(可能升级为重量级锁);
- 重量级锁:从EntryList唤醒一个等待线程,使其获取锁。
3.3 锁升级:性能优化的关键
JVM对synchronized
做了锁升级优化,避免不必要的性能开销:
- 偏向锁:适用于单线程重复访问场景(如单例模式),减少无竞争时的锁获取开销;
- 轻量级锁:适用于短时间内的多线程竞争(如方法调用),通过自旋减少线程阻塞;
- 重量级锁:适用于长时间竞争场景(如高并发请求),通过操作系统线程阻塞/唤醒机制降低CPU消耗。
四、集群模式下的困境:synchronized的“进程内锁”局限
4.1 集群模式的核心特征
集群模式指系统部署在多台服务器(多JVM实例)上,通过负载均衡(如Nginx)将请求分发到不同实例。每个JVM实例有独立的内存空间和对象实例,线程仅存在于单个JVM中。
4.2 为什么synchronized失效?
synchronized是JVM层面的进程内锁,其锁的生效范围仅限于同一个JVM实例内的对象。具体表现为:
- 锁的标识(Monitor)与对象的内存地址绑定;
- 不同JVM实例中的同一类对象(如
UserService
)是不同的内存对象,拥有独立的Monitor; - 线程只能在所属JVM内竞争Monitor锁,无法感知其他JVM实例中的锁状态。
4.3 典型问题:同一用户多把锁
假设用户在集群环境中访问,请求被负载均衡到不同JVM实例(如Server A和Server B):
- 用户在Server A中创建订单,Server A的
synchronized
锁保护该操作; - 用户的第二个请求被负载均衡到Server B,Server B的
UserService
对象是独立的,其Monitor未被锁定; - 结果:Server B的线程正常获取锁,导致同一用户创建多个订单(重复下单)。
总结:synchronized的锁仅在同一JVM内有效,无法跨实例同步,集群模式下无法保证“同一用户只有一把锁”。
五、集群模式下的替代方案:分布式锁
为解决集群环境下的并发问题,需使用分布式锁,其核心是全局唯一的锁标识,确保不同JVM实例中的线程竞争同一把锁。常见实现包括:
5.1 Redis分布式锁(RedLock)
利用Redis的SETNX
(原子性设置键值)命令实现锁:
// 获取锁(设置过期时间防止死锁)
String lockKey = "user_lock:" + userId;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (locked) {
try {
// 执行原子操作(查询、扣库存、创建订单)
} finally {
// 释放锁(Lua脚本保证原子性)
redisTemplate.execute(new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
Long.class
), Collections.singletonList(lockKey), "1");
}
}
优点:性能高,支持集群部署;
缺点:依赖Redis可用性,需处理锁续期(如使用Redisson的WatchDog机制)。
5.2 ZooKeeper分布式锁
利用ZooKeeper的临时有序节点实现锁:
- 客户端在
/locks/user_${userId}
路径下创建临时有序节点(如node_12345
); - 客户端监听比自己序号小的前一个节点,若前一个节点删除(释放锁),则当前节点获取锁。
优点:强一致性,自动失效(客户端断开则节点删除);
缺点:性能低于Redis,适合对一致性要求高的场景。
5.3 数据库分布式锁(不推荐)
通过数据库的唯一索引或FOR UPDATE
行锁实现,但性能较差且易引发死锁,仅适用于小规模场景。
六、总结:synchronized的适用边界与最佳实践
场景 | synchronized是否适用 | 原因 |
---|---|---|
单机多线程 | 是 | 锁作用于同一JVM内的对象Monitor,保证线程互斥 |
集群多JVM实例 | 否 | 不同JVM的Monitor独立,锁无法跨进程同步 |
分布式系统(跨机器) | 否 | 需分布式锁(如Redis、ZooKeeper)实现全局互斥 |
核心结论:
synchronized是单机环境下的高效锁机制,但其“进程内锁”的本质决定了无法在集群模式下跨JVM同步。集群环境下,需使用分布式锁(如Redis、ZooKeeper)解决跨实例的并发问题。
下次遇到集群下的线程安全问题时,记得:synchronized管单机,分布式锁管集群!