1.前言
前面小节讲到了线程安全问题的概念,产生线程安全的原因,以及解决线程安全问题的常见方法。
前面章节也讲到了字符串的本质,讲到了字符串的不可变性等概念。
本节将两方面的知识综合运用起来解读一个经典面试题。
2.经典面试题
2.1 面试问题
题目:
使用字符串作为 synchronized 的锁对象,在多线程情况下有没有线程安全问题?
一个同学的回答:
如果字符串在字符串池中就线程安全,new 出来的字符串在堆中就不是线程安全。
解析:
上面同学的回答存在一些问题,
synchronized 关键字要保证线程安全,就要锁的对象为同一个才行。
因此不管是在字符串池中还是非字符串池的堆区域,只要是同一个字符串对象就可以。
另外 Java 8 字符串池已经在堆内,允许垃圾回收。
上面一个看似简单的问题,可以看出一个人的基础是否真正扎实。
2.2 理论和实践相结合
学习最忌讳的是理论脱离实际,结合上面的面试题的场景编写一个模拟的代码。
import java.util.concurrent.TimeUnit;
public class LockThread implements Runnable {
private static final String PREFIX = "Order-LOCK:";
private String orderId;
public LockThread(String orderId) {
this.orderId = orderId;
}
private static String buildLock(String orderId) {
return PREFIX + orderId;
}
@Override
public void run() {
synchronized (buildLock(orderId)) {
System.out.println(Thread.currentThread().getName()+"拿到锁..");
try {
TimeUnit.SECONDS.sleep(5);
// 其他逻辑
} catch (InterruptedException ignored) {
}
System.out.println(Thread.currentThread().getName()+"释放锁..");
}
}
}
示例:
public class SafeDemo {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(new LockThread("127.0.0.1")).start();
}
}
}
一次运行的结果:
Thread-0拿到锁…
Thread-1拿到锁…
Thread-2拿到锁…
Thread-3拿到锁…
Thread-4拿到锁…
Thread-0释放锁…
Thread-1释放锁…
Thread-2释放锁…
Thread-4释放锁…
Thread-3释放锁…
发现虽然使用了 synchronized 关键字,但是仍然没有实现多线程同步的效果。
大家可以使用 System.identityHashCode 函数来打印锁对象的哈希值:
String lock = buildLock(orderId);
System.out.println(System.identityHashCode(lock));
可以明显地看到每次都是新的字符串对象。
按照惯例,我们用反编译的方式来查看 buildLock 背后的原理:
buildLock 反编译后的字节码:
0 new #3 <java/lang/StringBuilder>
3 dup
4 invokespecial #4 <java/lang/StringBuilder.<init>>
7 ldc #6 <Order-LOCK:>
9 invokevirtual #7 <java/lang/StringBuilder.append>
12 aload_0
13 invokevirtual #7 <java/lang/StringBuilder.append>
16 invokevirtual #8 <java/lang/StringBuilder.toString>
19 areturn
基本等价于下面的写法:
private static String buildLock(String orderId) {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(PREFIX);
stringBuilder.append(orderId);
return stringBuilder.toString();
}
其中 StringBuilder#toString 的源码如下:
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
因此字符串常量和局部变量的 + 号拼接方式,转换为 StringBuilder 方式,最终通过 toString 底层通过 new String 的方式创建新的字符串对象。
那么如何保证用到同一把锁呢?
我们去 String 类里可以找到答案, 我们可以使用 String.intern 函数:
/**
* 该函数的主要功能是返回字符串对象的规范表示形式。
* 如果字符串池已经包含了通过 equals 函数判断到和当前字符串对象相等的对象,则返回字符串池中的对象。
* 否则将此字符串对象添加到池中,返回这个字符串对象的引用。
* 任何两个字符串对象 s 和 t , `s.equals(t) ` ,那么 `s.intern() == t.intern()` 。
*
* 所有的字符串字面量和字符串常量表达式都是 interned。
*
* @return 返回和此字符串对象内容相同的字符串对象, 保证字符串池中对象的唯一性.
*/
public native String intern();
因此 synchronized (buildLock(orderId)) 改为 synchronized (buildLock(orderId).intern()) 即可。
@Override
public void run() {
synchronized (buildLock(orderId).intern()) {
System.out.println(Thread.currentThread().getName() + "拿到锁..");
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException ignored) {
}
System.out.println(Thread.currentThread().getName() + "释放锁..");
}
}
修改后的输出:
Thread-0拿到锁…
Thread-0释放锁…
Thread-4拿到锁…
Thread-4释放锁…
Thread-3拿到锁…
Thread-3释放锁…
Thread-2拿到锁…
Thread-2释放锁…
Thread-1拿到锁…
Thread-1释放锁…
3.总结
本文我们通过读源码、反编译,结合前面讲到的字符串、线程安全相关知识,来研究一个看似简单的面试题。
希望通过这个简单的面试题,让大家明白本专栏所讲述方法的通用性,让大家了解如何学以致用。