记一次synchronized锁字符串引起的坑兼再谈Java字符串

问题描述程序员

业务有一个需求,我把问题描述一下:多线程

经过代理IP访问国外某网站N,每一个IP对应一个固定的网站N的COOKIE,COOKIE有失效时间。

并发下,取IP是有必定策略的,取到IP以后拿IP对应的COOKIE,发现COOKIE超过失效时间,则调用脚本访问网站N获取一次数据。

为了防止多线程取到同一个IP,同时发现该IP对应的COOKIE失效,同时去调用脚本更新COOKIE,针对IP加了锁。为了保证锁的全局惟一性,在锁前面加了标识业务的前缀,使用synchronized(lock){...}的方式,锁住"锁前缀+IP",这样保证多线程取到同一个IP,也只有一个IP会更新COOKIE。

不知道这个问题有没有说清楚,没说清楚不要紧,写一段测试代码:并发

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("[" + JdkUtil.getThreadName() + "]开始运行了");
            // 休眠5秒模拟脚本调用
            JdkUtil.sleep(5000);
            System.out.println("[" + JdkUtil.getThreadName() + "]结束运行了");
        }
    }
    
    private String buildLock() {
        StringBuilder sb = new StringBuilder();
        sb.append(LOCK_PREFIX);
        sb.append(ip);
        
        String lock = sb.toString();
        System.out.println("[" + JdkUtil.getThreadName() + "]构建了锁[" + lock + "]");
        
        return lock;
    }
    
}

简单说就是,传入一个IP,尽可能构建一个全局惟一的字符串(这么作的缘由是,若是字符串的惟一性不强,比方说锁的"192.168.1.1",若是另一段业务代码也是锁的这个字符串"192.168.1.1",这就意味着两段没什么关联的代码块却要串行执行,代码块执行时间短还好,代码块执行时间长影响极其大),针对字符串加锁。app

预期的结果是并发下,好比5条线程传入同一个IP,它们构建的锁都是字符串"XXX---192.168.1.1",那么这5条线程针对synchronized块,应当串行执行,即一条运行完毕再运行另一条,可是实际上并非这样。框架

写一段测试代码,开5条线程看一下效果:ide

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-一、Thread-二、Thread-三、Thread-4这5条线程尽管构建的锁都是同一个"XXX-192.168.1.1",可是代码倒是并行执行的,这并不符合咱们的预期。网站

关于这个问题,一方面确实是我大意了觉得是代码其余什么地方同步控制出现了问题,一方面也反映出我对String的理解还不够深刻,所以专门写一篇文章来记录一下这个问题并写清楚产生这个问题的缘由和应当如何解决。ui

 

问题缘由this

这个问题既然出现了,那么应当从结果开始推导起,找到问题的缘由。先看一下synchronized部分的代码:

@Override
public void run() {
    String lock = buildLock();
    synchronized (lock) {
        System.out.println("[" + JdkUtil.getThreadName() + "]开始运行了");
        // 休眠5秒模拟脚本调用
        JdkUtil.sleep(5000);
        System.out.println("[" + JdkUtil.getThreadName() + "]结束运行了");
    }
}

由于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("[" + JdkUtil.getThreadName() + "]构建了锁[" + 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,代码这么改:

 1 public class StringThread implements Runnable {
 2 
 3     private static final String LOCK_PREFIX = "XXX---";
 4     
 5     private String ip;
 6     
 7     public StringThread(String ip) {
 8         this.ip = ip;
 9     }
10 
11     @Override
12     public void run() {
13         
14         String lock = buildLock();
15         synchronized (lock) {
16             System.out.println("[" + JdkUtil.getThreadName() + "]开始运行了");
17             // 休眠5秒模拟脚本调用
18             JdkUtil.sleep(5000);
19             System.out.println("[" + JdkUtil.getThreadName() + "]结束运行了");
20         }
21     }
22     
23     private 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("[" + JdkUtil.getThreadName() + "]构建了锁[" + lock + "]");
30         
31         return 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(),所以B.getStringDataFromDB()方法的结果,会写入常量池中

常量池中的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源码底层实现原理什么的没用,偏偏相反,这些都是技术人员成长路上最宝贵的东西。

相关文章
相关标签/搜索