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

简介: 记一次synchronized锁字符串引发的坑兼再谈Java字符串

转自:www.cnblogs.com/xrq730/p/66…

问题描述

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

通过代理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",这就意味着两段没什么关联的代码块却要串行执行,代码块执行时间短还好,代码块执行时间长影响极其大),针对字符串加锁。

预期的结果是并发下,比如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("[" + 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源码底层实现原理什么的没用,恰恰相反,这些都是技术人员成长路上最宝贵的东西。


目录
相关文章
|
2月前
|
SQL Java 索引
java小工具util系列2:字符串工具
java小工具util系列2:字符串工具
157 83
|
26天前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
26天前
|
安全 Java 编译器
深入理解Java中synchronized三种使用方式:助您写出线程安全的代码
`synchronized` 是 Java 中的关键字,用于实现线程同步,确保多个线程互斥访问共享资源。它通过内置的监视器锁机制,防止多个线程同时执行被 `synchronized` 修饰的方法或代码块。`synchronized` 可以修饰非静态方法、静态方法和代码块,分别锁定实例对象、类对象或指定的对象。其底层原理基于 JVM 的指令和对象的监视器,JDK 1.6 后引入了偏向锁、轻量级锁等优化措施,提高了性能。
52 3
|
2月前
|
Java 数据库
java小工具util系列1:日期和字符串转换工具
java小工具util系列1:日期和字符串转换工具
67 26
|
2月前
|
缓存 Java
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
本文介绍了几种常见的锁机制,包括公平锁与非公平锁、可重入锁与不可重入锁、自旋锁以及读写锁和互斥锁。公平锁按申请顺序分配锁,而非公平锁允许插队。可重入锁允许线程多次获取同一锁,避免死锁。自旋锁通过循环尝试获取锁,减少上下文切换开销。读写锁区分读锁和写锁,提高并发性能。文章还提供了相关代码示例,帮助理解这些锁的实现和使用场景。
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
|
2月前
|
存储 缓存 安全
java 中操作字符串都有哪些类,它们之间有什么区别
Java中操作字符串的类主要有String、StringBuilder和StringBuffer。String是不可变的,每次操作都会生成新对象;StringBuilder和StringBuffer都是可变的,但StringBuilder是非线程安全的,而StringBuffer是线程安全的,因此性能略低。
75 8
|
2月前
|
Java 开发者
Java 中的锁是什么意思,有哪些分类?
在Java多线程编程中,锁用于控制多个线程对共享资源的访问,确保数据一致性和正确性。本文探讨锁的概念、作用及分类,包括乐观锁与悲观锁、自旋锁与适应性自旋锁、公平锁与非公平锁、可重入锁和读写锁,同时提供使用锁时的注意事项,帮助开发者提高程序性能和稳定性。
120 3
|
2月前
|
缓存 算法 Java
本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制
在现代软件开发中,性能优化至关重要。本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制。通过调整垃圾回收器参数、优化堆大小与布局、使用对象池和缓存技术,开发者可显著提升应用性能和稳定性。
62 6
|
2月前
|
Java 开发者
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
52 4
|
8月前
|
存储 XML 缓存
Java字符串内幕:String、StringBuffer和StringBuilder的奥秘
Java字符串内幕:String、StringBuffer和StringBuilder的奥秘
82 0