1、简述
在 Java 开发中,synchronized
是一种常见的同步机制,用于保证线程安全。但是你有没有思考过这样一个问题:
“synchronized 可以给字符串(String)加锁吗?”
答案是:可以,但你应该非常小心。
本文将深入剖析这个问题,讲清楚背后的机制、风险,并给出实际建议。
2、synchronized 本质上加的是什么锁?
synchronized
实际上加的是对象锁,也叫监视器锁(monitor lock)。也就是说:
synchronized (obj) {
// 临界区
}
这段代码表示:只有获取到 obj
这个对象的监视器锁的线程才能进入临界区。
因此,只要是一个对象,包括字符串实例,理论上都可以被用作加锁对象。
3、加锁字符串——看似可行,实则隐患巨大
来看一个例子:
public class StringLockExample {
public void doSomething(String lock) {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " 获得了锁:" + lock);
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {}
}
}
}
启动多个线程调用:
StringLockExample example = new StringLockExample();
Runnable task1 = () -> example.doSomething("LOCK");
Runnable task2 = () -> example.doSomething("LOCK");
new Thread(task1).start();
new Thread(task2).start();
结果是,两个线程会串行执行,因为加的是同一个字符串 "LOCK"
的锁。
但问题来了:
字符串是不可变对象,且 JVM 对字符串常量具有“字符串池”优化机制(String Interning)!
也就是说:
String a = "LOCK";
String b = "LOCK";
System.out.println(a == b); // true
两个字符串变量实际上引用的是同一个对象。
因此,在你以为传进来的是不同的字符串时,可能实际上加的是同一把锁,或者反过来——你以为加的是同一把锁,其实不是!
4、字符串加锁的两个典型陷阱
4.1 锁粒度无法控制
如果你的锁是这样定义的:
synchronized ("user_" + userId)
你以为这是“每个用户一个锁”,但实际上由于字符串拼接会创建新对象,每次拼接都是一个新对象,锁根本不会生效。
除非你手动 .intern()
:
synchronized (("user_" + userId).intern())
这又引入了新的问题:intern 的对象存储在字符串常量池中,频繁使用可能会增加内存压力,甚至引发性能问题。
4.2 外部可控锁对象
如果你用外部传入的字符串作为锁对象,那你根本无法控制到底加的是什么锁。恶意或不规范调用者可能传入一个常量字符串、空字符串、甚至 null,导致同步行为混乱或抛出异常。
5、安全的替代方案
✅ 使用自定义锁对象
最推荐的方式是自己定义一套锁策略,例如使用 ConcurrentHashMap
管理锁对象:
private final ConcurrentHashMap<String, Object> lockMap = new ConcurrentHashMap<>();
public void doSomething(String key) {
Object lock = lockMap.computeIfAbsent(key, k -> new Object());
synchronized (lock) {
// 临界区
}
}
这种方式可以保证每个业务 key 对应一个明确的锁对象,而且不会误用常量字符串,锁粒度清晰可控。
6、使用 Google Guava 的 Interner
实现更安全的字符串锁
Interner
是 Google Guava 提供的一个实用工具类,用于实现“字符串实例的唯一化”。它的作用类似于 String.intern()
,但更灵活、可控,不依赖字符串常量池,避免了 JVM 层级的内存污染和性能隐患。
引入依赖:
<!-- Maven -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.1-jre</version>
</dependency>
使用示例:
import com.google.common.collect.Interner;
import com.google.common.collect.Interners;
public class GuavaInternerLock {
private static final Interner<String> interner = Interners.newWeakInterner();
public void doWork(String key) {
String internedKey = interner.intern(key);
synchronized (internedKey) {
// 同样 key 的线程会同步执行
System.out.println("Processing key: " + key);
}
}
}
Guava Interner 的优势
- 不污染 JVM 的字符串常量池(不像
String.intern()
)。 - 可以选择 Weak 或 Strong 引用,避免内存泄漏。
- 适合在缓存、去重、分布式任务分片等场景中锁定“逻辑键”。
7、总结
并发编程中,锁不是万能的,滥用锁更是灾难。本文完整地分析了:
synchronized
是否能加锁字符串(可以,但不推荐);- 字符串常量池带来的锁隐患;
- 如何使用
Object
、ConcurrentHashMap
构建安全锁; - 如何用 Guava 的
Interner
提供高效、可控的锁机制; - 方法参数中加锁字符串的风险及解决方案。
写高质量的并发代码,关键是理解锁的语义、作用域和生命周期。希望这篇文章能帮你在并发之路上走得更稳更远。
评论区