synchronized 锁字符串:常见坑点与解决策略
文章目录
- 问题分析
- 字符串常量池引发的锁复用问题
- 字符串的不可变性
- 使用intern() 方法以及对应的性能问题
- 为什么不能将所有字符串都放入常量池?
- 问题1:频繁的 Full GC
- 问题2:性能下降
- 使用建议
- 不要用字符串作为锁对象
- 使用 Google Guava 提供的 Interner 类
问题分析
在 Java 中,synchronized 关键字常被用于解决并发问题,确保线程安全。然而,当我们锁定的是字符串时,会引发一些意想不到的问题。这里分析synchronized 锁字符串相关问题点和优化方案整理。
字符串常量池引发的锁复用问题
在 JVM 中,字符串常量池是一个特殊的内存区域,用于存储字符串字面量。当两个字符串字面量相同时,它们实际上会引用常量池中的同一个对象。这意味着,如果你在不同的类或代码片段中使用了相同的字符串来作为锁对象,可能无意中锁定了同一个对象,导致跨代码段的竞争问题。
public class Test1 {private String lock1 = "lock";public void method() {synchronized (lock1) {System.out.println("Thread 1 ");}}
}public class Test2 {private String lock2 = "lock";public void method() {synchronized (lock2) {System.out.println("Thread 2 ");}}
}
问题案例:
假设系统多个流程中使用synchronized使用字符串锁,锁的信息可能是手机号,用户ID,姓名,账号等等,可能会出现相同冲突的问题,那么这种就有一定的几率出现跨代码段的竞争问题。
字符串的不可变性
字符串是不可变对象,这意味着一旦创建,字符串对象就无法被修改。当我们使用字符串作为锁时,如果在代码中不小心修改了这个字符串(例如使用 + 操作符拼接字符串),锁对象就会被改变,从而导致 synchronized 失效,线程安全性得不到保障。
使用intern() 方法以及对应的性能问题
日常中如果通过锁字符串对象的方式是锁不住字符串。因此字符串对象不是同一个地址,因此如果想要锁住字符串,需要把字符串对象添加到字符串常量池中。如果通过XXX user = XXX()的方式锁user.getUserId()是无法有效锁住的。
intern() 是 String 类中的一个方法,用于将字符串放入常量池中。具体来说,intern() 方法会检查当前字符串在常量池中是否存在。如果存在,则返回常量池中该字符串的引用;如果不存在,则将该字符串放入常量池中,并返回其引用。
使用synchronized 锁字符串,需要将字符串添加到字符串常量池中。日常使用中通过通过new对象的方式创建对象,再取对象的字段,因此需要使用intern把字符串放入常量池中,但是直接使用String的intern全部把字符串放入常量池会存在一些问题。显然在数据量很大的情况下,将所有字符串都放入常量池是不合理的,常量池大小依赖服务器内存,且只有等待fullGC,极端情况下会导致频繁fullGC。并且在数据量很大的情况下,将字符串放入常量是存在性能问题。
为什么不能将所有字符串都放入常量池?
Java 中的字符串常量池是一个专门用于存储字符串字面量的内存区域,常量池能够减少重复字符串的内存占用,提升性能。然而,常量池的大小是有限的,并且受到服务器内存的限制。将大量动态生成的字符串都放入常量池可能会带来以下几个问题:
问题1:频繁的 Full GC
常量池是 JVM 堆内存的一部分,堆内存有限。当常量池中存储了大量的字符串,且达到堆内存的上限时,JVM 可能需要进行 Full GC 来回收内存。Full GC 是一种相对耗时的操作,特别是在大数据量的场景下,它会导致应用停顿时间变长,影响系统性能。如果每次 Full GC 后,常量池内存依然不足以存储新字符串,那么 Full GC 可能会变得频繁,从而影响系统的整体稳定性和响应速度。
问题2:性能下降
intern() 方法会在常量池中查找或插入字符串对象。当字符串数据量非常大时,常量池的查找操作需要消耗一定的时间。在高并发和大数据量的场景下,这种查找操作的开销可能会变得非常明显,进而影响系统的性能表现。
使用建议
不要用字符串作为锁对象
为了避免上述问题,最佳实践是不要使用字符串作为锁对象。可以选择使用其他不可变且唯一的对象作为锁,例如 Object 或者定义一个专门的锁对象。
private final Object lock = new Object();public void method() {synchronized (lock) {System.out.println("Thread 1");}
}
使用 Google Guava 提供的 Interner 类
Guava 的 Interner 是一个接口,它的主要功能是确保相同的对象引用同一个实例。这类似于 String.intern()的功能,但更加灵活,并且不会将对象放入 JVM 的字符串常量池中,而是使用自己管理的池来存储对象。Guava 提供了两种 Interner 的实现:
• Interners.newWeakInterner():使用弱引用来存储对象,允许 GC 在内存紧张时回收这些对象。
• Interners.newStrongInterner():使用强引用来存储对象,直到这些对象不再被引用时才会被回收。
假设我们在应用中需要锁定大量动态生成的字符串,如果直接使用 String.intern() 会导致频繁的 Full GC 和性能问题,此时我们可以使用 Guava 的 Interner 类来替代。
import com.google.common.collect.Interner;
import com.google.common.collect.Interners;public class InternerExample {// 使用 Interner 来管理锁对象private final Interner<String> stringInterner = Interners.newWeakInterner();public void method(String key) {// 将字符串放入 Interner,确保相同字符串共享同一个实例String internedKey = stringInterner.intern(key);synchronized (internedKey) {// 执行同步代码块System.out.println("Locked on: " + internedKey);}}
}
使用 Interner 类替代 String.intern() 具有以下几个优点:
- 避免 JVM 字符串常量池的限制
String.intern() 会将字符串放入 JVM 的字符串常量池,常量池的大小受限于堆内存,而且频繁操作可能导致 Full GC。使用 Interner,我们可以避免这些问题,因为 Interner 使用的是自己管理的对象池,池的大小和内存管理都由 Guava 来处理,不依赖于 JVM 的字符串常量池。 - 提供灵活的内存管理
通过 Interners.newWeakInterner(),可以使用弱引用来管理池中的对象。这样,当 JVM 内存紧张时,GC 可以回收这些不再使用的对象,避免了堆内存过度占用的问题。