记⼀次synchronized锁字符串引发的坑兼再谈Java字符串
问题描述
业务有⼀个需求,我把问题描述⼀下:
通过代理IP访问国外某⽹站N,每个IP对应⼀个固定的⽹站N的COOKIE,COOKIE有失效时间。
并发下,取IP是有⼀定策略的,取到IP之后拿IP对应的COOKIE,发现COOKIE超过失效时间,则调⽤脚本访问⽹站N获取⼀次数据。
为了防⽌多线程取到同⼀个IP,同时发现该IP对应的COOKIE失效,同时去调⽤脚本更新COOKIE,针对IP加了锁。为了保证锁的全局唯⼀性,在锁前⾯加了标识业务的前缀,使⽤synchronized(lock){...}的⽅式,锁住"锁前缀+IP",这样保证多线程取不知道这个问题有没有说清楚,没说清楚没关系,写⼀段测试代码:
public class StringThread implements Runnable {
private static final String LOCK_PREFIX = "XXX---";
private String ip;
public StringThread(String ip) {
this.ip = ip;
}
@Override
public void run() {
String lock = buildLock();
synchronized (lock) {
System.out.println("[" + ThreadName() + "]开始运⾏了");
// 休眠5秒模拟脚本调⽤
JdkUtil.sleep(5000);
System.out.println("[" + ThreadName() + "]结束运⾏了");
}
}
private String buildLock() {
StringBuilder sb = new StringBuilder();
sb.append(LOCK_PREFIX);
sb.append(ip);
String lock = sb.toString();
System.out.println("[" + ThreadName() + "]构建了锁[" + lock + "]");
return lock;
}
}
简单说就是,传⼊⼀个IP,尽量构建⼀个全局唯⼀的字符串(这么做的原因是,如果字符串的唯⼀性不强,⽐⽅说锁的"192.168.1.1",如果另外⼀段业务代码也是锁的这个字符串"192.168.1.1",这就意味着两段没什么
关联的代码块却要串⾏执⾏,代码块执⾏时间短还好,代码块执⾏时间长影响极其⼤),针对字符串加锁。
预期的结果是并发下,⽐如5条线程传⼊同⼀个IP,它们构建的锁都是字符串"XXX---192.168.1.1",那么这5条线程针对synchronized块,应当串⾏执⾏,即⼀条运⾏完毕再运⾏另外⼀条,但是实际上并不是这样。
写⼀段测试代码,开5条线程看⼀下效果:
public class StringThreadTest {
private static final int THREAD_COUNT = 5;
@Test
public void testStringThread() {
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] = new Thread(new StringThread("192.168.1.1"));
}
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i].start();
}
for (;;);
}
}
执⾏结果为:
[Thread-1]构建了锁[XXX---192.168.1.1]
[Thread-1]开始运⾏了
[Thread-3]构建了锁[XXX---192.168.1.1]
[Thread-3]开始运⾏了
[Thread-4]构建了锁[XXX---192.168.1.1]
[Thread-4]开始运⾏了
[Thread-0]构建了锁[XXX---192.168.1.1]
[Thread-0]开始运⾏了
[Thread-2]构建了锁[XXX---192.168.1.1]
[Thread-2]开始运⾏了
[Thread-1]结束运⾏了
[Thread-3]结束运⾏了
[Thread-4]结束运⾏了
[Thread-0]结束运⾏了
[Thread-2]结束运⾏了
看到Thread-0、Thread-1、Thread-2、Thread-3、Thread-4这5条线程尽管构建的锁都是同⼀个"XXX-192.168.1.1",但是代码却是并⾏执⾏的,这并不符合我们的预期。
关于这个问题,⼀⽅⾯确实是我⼤意了以为是代码其他什么地⽅同步控制出现了问题,⼀⽅⾯也反映出我对String的理解还不够深⼊,因此专门写⼀篇⽂章来记录⼀下这个问题并写清楚产⽣这个问题的原因和应当如何
解决。
问题原因
这个问题既然出现了,那么应当从结果开始推导起,到问题的原因。先看⼀下synchronized部分的代码:
@Override
public void run() {
String lock = buildLock();
synchronized (lock) {
System.out.println("[" + ThreadName() + "]开始运⾏了");
// 休眠5秒模拟脚本调⽤
JdkUtil.sleep(5000);
System.out.println("[" + ThreadName() + "]结束运⾏了");
}
}
因为synchronized锁对象的时候,保证同步代码块中的代码执⾏是串⾏执⾏的前提条件是锁住的对象是
同⼀个,因此既然多线程在synchronized部分是并⾏执⾏的,那么可以推测出多线程下传⼊同⼀个IP,构建出来的
lock字符串并不是同⼀个。
接下来,再看⼀下构建字符串的代码:
private String buildLock() {
StringBuilder sb = new StringBuilder();
sb.append(LOCK_PREFIX);
sb.append(ip);
String lock = sb.toString();
System.out.println("[" + ThreadName() + "]构建了锁[" + lock + "]");
return lock;
}
lock是由StringBuilder⽣成的,看⼀下StringBuilder的toString⽅法:
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
那么原因就在这⾥:尽管buildLock()⽅法构建出来的字符串都是"XXX-192.168.1.1",但是由于StringBuilder的toString()⽅法每次都是new⼀个String出来,因此buildLock出来的对象都是不同的对象。
如何解决?
上⾯的问题原因到了,就是每次StringBuilder构建出来的对象都是new出来的对象,那么应当如何解决?这⾥我先给解决办法就是sb.toString()后再加上intern(),下⼀部分再说原因,因为我想对String再做⼀次总结,加深对String的理解。
OK,代码这么改:
1public class StringThread implements Runnable {
2
3private static final String LOCK_PREFIX = "XXX---";
4
5private String ip;
6
7public StringThread(String ip) {
8this.ip = ip;
9    }
10
11    @Override
12public void run() {
13
14        String lock = buildLock();
15synchronized (lock) {字符常量池是什么意思
16            System.out.println("[" + ThreadName() + "]开始运⾏了");
17// 休眠5秒模拟脚本调⽤
18            JdkUtil.sleep(5000);
19            System.out.println("[" + ThreadName() + "]结束运⾏了");
20        }
21    }
22
23private String buildLock() {
24        StringBuilder sb = new StringBuilder();
25        sb.append(LOCK_PREFIX);
26        sb.append(ip);
27
28        String lock = sb.toString().intern();
29        System.out.println("[" + ThreadName() + "]构建了锁[" + lock + "]");
30
31return lock;
32    }
33
34 }
看⼀下代码执⾏结果:
[Thread-0]构建了锁[XXX---192.168.1.1]
[Thread-0]开始运⾏了
[Thread-3]构建了锁[XXX---192.168.1.1]
[Thread-4]构建了锁[XXX---192.168.1.1]
[Thread-1]构建了锁[XXX---192.168.1.1]
[Thread-2]构建了锁[XXX---192.168.1.1]
[Thread-0]结束运⾏了
[Thread-2]开始运⾏了
[Thread-2]结束运⾏了
[Thread-1]开始运⾏了
[Thread-1]结束运⾏了
[Thread-4]开始运⾏了
[Thread-4]结束运⾏了
[Thread-3]开始运⾏了
[Thread-3]结束运⾏了
可以对⽐⼀下上⾯没有加intern()⽅法的执⾏结果,这⾥很明显5条线程获取的锁是同⼀个,⼀条线程执⾏完毕synchronized代码块⾥⾯的代码之后下⼀条线程才能执⾏,整个执⾏是串⾏的。
再看String
JVM内存区域⾥⾯有⼀块常量池,关于常量池的分配:
1. JDK6的版本,常量池在持久代PermGen中分配
2. JDK7的版本,常量池在堆Heap中分配
字符串是存储在常量池中的,有两种类型的字符串数据会存储在常量池中:
1. 编译期就可以确定的字符串,即使⽤""引起来的字符串,⽐如String a = "123"、String b = "1" + B.getStringDataFromDB() + "2" + C.getStringDataFromDB()、这⾥的"123"、"1"、"2"都是编译期间就可以确定的
字符串,因此会放⼊常量池,⽽B.getStringDataFromDB()、C.getStringDataFromDB()这两个数据由于编译期间⽆法确定,因此它们是在堆上进⾏分配的
2. 使⽤String的intern()⽅法操作的字符串,⽐如String b = B.getStringDataFromDB().intern(),尽管B.getStringDataFromDB()⽅法拿到的字符串是在堆上分配的,但是由于后⾯加⼊了intern(),因此
常量池中的String数据有⼀个特点:每次取数据的时候,如果常量池中有,直接拿常量池中的数据;如果常量池中没有,将数据写⼊常量池中并返回常量池中的数据。
因此回到我们之前的场景,使⽤StringBuilder拼接字符串每次返回⼀个new的对象,但是使⽤intern()⽅法则不⼀样:
"XXX-192.168.1.1"这个字符串尽管是使⽤StringBuilder的toString()⽅法创建的,但是由于使⽤了intern()⽅法,因此第⼀条线程发现常量池中没有"XXX-192.168.1.1",就往常量池中放了⼀个
"XXX-192.168.1.1",后⾯的线程发现常量池中有"XXX-192.168.1.1",就直接取常量池中的"XXX-192.168.1.1"。
因此不管多少条线程,只要取"XXX-192.168.1.1",取出的⼀定是同⼀个对象,就是常量池中的"XXX-192.168.1.1"
这⼀切,都是String的intern()⽅法的作⽤
后记
就这个问题解决完包括这篇⽂章写完,我特别有⼀点点感慨,很多⼈会觉得⼀个Java程序员能把框架⽤好、能把代码流程写出来没有bug就好了,研究底层原理、虚拟机什么的根本就没什么⽤。不知道这个问题能不能给⼤家⼀点启发:
这个业务场景并不复杂,整个代码实现也不是很复杂,但是运⾏的时候它就出了并发问题了。
如果没有扎实的基础:知道String⾥⾯除了常⽤的那些⽅法indexOf、subString、concat外还有很不常⽤的intern()⽅法
不了解⼀点JVM:JVM内存分布,尤其是常量池
不去看⼀点JDK源码:StringBuilder的toString()⽅法
不对并发有⼀些理解:synchronized锁代码块的时候怎么样才能保证多线程是串⾏执⾏代码块⾥⾯的代码的
这个问题出了,是根本⽆法解决的,甚⾄可以说如何下⼿去分析都不知道。
因此,并不要觉得JVM、JDK源码底层实现原理什么的没⽤,恰恰相反,这些都是技术⼈员成长路上最宝贵的东西。

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。