18910140161

还记得同步锁串造成的坑吗 再来说说Java串

顺晟科技

2021-08-28 09:42:46

31

问题描述

生意有需求,我就描述一下问题:

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

在并发下,有一定的取IP策略。获取IP后,获取IP对应的COOKIE。如果COOKIE超过了过期时间,调用脚本访问网站N获取数据一次。

为了防止多线程得到同一个IP,发现该IP对应的COOKIE无效,调用脚本更新COOKIE,IP被锁定。为了确保锁的全局性,在锁的前面添加了一个标识服务的前缀,并将“锁前缀IP”锁定在一个同步(lock){中.}的方式,从而确保多个线程获得相同的IP,并且只有一个IP会更新COOKIE。

不知道这个问题有没有说清楚。没说清楚也没关系。编写测试代码:

复制代码

公共类StringThread实现了Runnable {

私有静态最终字符串LOCK _ PREFIX=' XXX-';

私有字符串IP;

公共字符串读取(字符串IP){ 0

this.ip=ip

}

@覆盖

public void run(){ 0

string lock=BuildLock();

同步(锁定){ 0

系统。out . println('[' jdkutil . getthreadname()']开始运行');

//休眠5秒,模拟脚本调用

jdkutil . sleep(5000);

系统。out . println('[' jdkutil . getthreadname()']已完成运行');

}

}

私有字符串buildLock(){ 0

StringBuilder sb=new StringBuilder();

sb . append(LOCK _ PREFIX);

sb . append(IP);

string lock=sb . ToString();

系统;出去;println('[' jdkutil;getthreadname ()']构建了一个锁[' lock ']');

返回锁;

}

}

复制代码

简单地说,传入一个IP,并尝试构建一个全局的字符串(其原因是,如果字符串的性不强,例如锁的‘192 . 168 . 1 . 1’,如果另一个业务代码也是锁的字符串‘192 . 168 . 1 . 1’,这意味着必须连续执行两个不相关的代码块,代码

预期的结果是并发。例如,如果五个线程传入同一个IP,并且它们构建的锁都是字符串‘XXX-192 . 168 . 1 . 1’,那么这五个线程应该为同步块串联执行,也就是一个在另一个运行之后运行,但事实并非如此。

写一个测试代码,打开5个线程看看效果:

复制代码

公共类StringThreadTest {

私有静态最终int THREAD _ COUNT=5;

@测试

public void testStringThread(){ 0

THREAD[]THREAD=new THREAD[THREAD _ COUNT];

for(int I=0;i THREAD _ COUNTI){ 0

threads[I]=new Thread(new StringThread(' 192 . 168 . 1 . 1 '));

}

for(int I=0;i THREAD _ COUNTI){ 0

线程[i]。start();

}

for(;);

}

}

复制代码

执行结果是:

复制代码

[Thread-1]建了锁[XXX-192.168.1.1]

[线程1]正在运行

[Thread-3]建了锁[XXX-192.168.1.1]

[线程3]正在运行

[线程-4]建立了锁[XXX-192.168.1.1]

[线程-4]正在运行

[Thread-0]建立了锁[XXX-192.168.1.1]

[线程-0]正在运行

[线程-2]建立了锁[XXX-192.168.1.1]

[线程-2]正在运行

[线程1]运行完毕

[线程3]运行完毕

[线程4]运行完毕

[线程-0]运行完毕

[线程2]运行完毕

复制代码

看到线程-0、线程-1、线程-2、线程-3、线程-4五个线程有相同的锁‘XXX-192 . 168 . 1 . 1’,代码是并行执行的,不符合我们的预期。

在这个问题上,一方面我粗心大意,认为代码其他部分的同步控制有问题;另一方面也反映了我对String的理解不够深刻。所以我写了一篇文章记录这个问题,写清楚这个问题的原因以及如何解决。

问题原因

既然出现了这个问题,我们就应该从结果出发,找出问题的原因。让我们看看代码的同步部分:

复制代码

@覆盖

public void run(){ 0

string lock=BuildLock();

同步(锁定){ 0

系统。out . println('[' jdkutil . getthreadname()']开始运行');

//休眠5秒,模拟脚本调用

jdkutil . sleep(5000);

系统。out . println('[' jdkutil . getthreadname()']已完成运行');

}

}

复制代码

因为当synchronized锁定对象时,保证synchronized代码块中的代码执行是串行执行的前提是锁定的对象是相同的,由于在synchronized部分是并行执行多线程的,可以推断在多线程下引入了相同的IP,构造的锁串也不相同。

接下来,让我们看看构建字符串的代码:

复制代码

私有字符串buildLock(){ 0

StringBuilder sb=new StringBuilder();

sb . append(LOCK _ PREFIX);

sb . append(IP);

string lock=sb . ToString();

系统;出去;println('[' jdkutil;getthreadname ()']构建了一个锁[' lock ']');

返回锁;

}

复制代码

锁是由StringBuilder生成的。看看StringBuilder的toString方法:

公共字符串ToString(){ 0

//创建副本,不共享数组

返回新字符串(值,0,计数);

}

那么原因就在这里:虽然buildLock()方法构建的Strings都是‘XXX-192 . 168 . 1 . 1’,但是因为StringBuilder的toString()方法总是会出来一个新的字符串,所以buildLock生成的对象都是不同的对象。

怎么解决?

上面这个问题的原因已经找到了,那就是每次StringBuilder构建的对象都是一个新的对象,我们应该如何解决呢?我在这里给出的个解决方案是sb.toString(),然后是intern()。下一部分我会讲原因,因为我想再次总结String,加深对String的理解。

好的,代码是这样改变的:

复制代码

1公共类StringThread实现了Runnable {

2

3私有静态最终字符串LOCK _ PREFIX=' XXX-';

5私有字符串IP;

7公共字符串读取(字符串IP){ 0

8 this.ip=ip

9 }

10

11 @覆盖

12公共无效运行(){ 0

13

14字符串锁=buildLock();

15同步(锁定){ 0

16系统。out . println('[' jdkutil . getthreadname()']开始运行');

17 //休眠5秒,模拟脚本调用

18 jdkutil . sleep(5000);

19系统。out . println('[' jdkutil . getthreadname()']已完成运行');

20 }

21 }

22

23私有字符串buildLock(){ 0

24 StringBuilder sb=new StringBuilder();

25 sb . append(LOCK _ PREFIX);

26 sb . append(IP);

27

28字符串锁=sb.toString()。实习生();

29系统。out . println('[' jdkutil . getthreadname()']构建了一个锁[' lock ']');

30

31返回锁定;

32 }

33

34 }

复制代码

查看代码执行结果:

复制代码

[Thread-0]建立了锁[XXX-192.168.1.1]

[线程-0]正在运行

[Thread-3]建了锁[XXX-192.168.1.1]

[线程-4]建立了锁[XXX-192.168.1.1]

[Thread-1]建了锁[XXX-192.168.1.1]

[线程-2]建立了锁[XXX-192.168.1.1]

[线程-0]运行完毕

[线程-2]正在运行

[线程2]运行完毕

[线程1]正在运行

[线程1]运行完毕

[线程-4]正在运行

[线程4]运行完毕

[线程3]正在运行

[线程3]运行完毕

复制代码

不用上面的intern()方法就可以比较执行结果。显而易见,五个线程获取的锁是一样的,只有一个线程执行了同步代码块中的代码,下一个线程才能执行,整个执行是串行的。

再看看字符串

JVM内存区域中有一个常量池。关于常量池的分配:

在JDK6版本中,常量池是在持久生成PermGen中分配的

在JDK7版本中,常量池是在堆堆中分配的

字符串存储在常量池中,常量池中存储有两种类型的字符串数据:

可以在编译时确定的字符串,即由“”引起的字符串,如String a='123 ',String B=' 1 ' B. GetStringdataFromdb()' 2 ' C. GetStringdataFromdb(),这里的' 123 ',' 1 '和' 2 '都是可以在编译时确定的字符串因此,它将被放入常量池,而B . GetStringdataFromdb()和c . GetStringdataFromdb()则在堆上分配,因为它们在编译期间无法确定

由String的inter()方法操作的字符串,如String B=B.getStringDataFromDB()。Inter(),虽然由B.getStringDataFromDB()方法获得的字符串是在堆上分配的,但是由于后来添加了inter(),B. GetStringdataFromdb()方法的结果将被写入常量池

常量池中的字符串数据有一个特点:每次取数据时,如果常量池中有一个,则直接取常量池中的数据;如果不在常量池中,则将数据写入常量池并返回常量池中的数据。

回到我们前面的场景,使用StringBuilder拼接字符串一次返回一个新对象,但是使用intern()方法是不同的:

虽然字符串“XXX-192.168.1.1”是使用StringBuilder的toString()方法创建的,但是个线程发现常量池中没有“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的代码流。研究底层原理和虚拟机是没有用的。不知道这个问题能不能给你一些启发:

复制代码

这个业务场景并不复杂,整个代码实现也不是很复杂,但是在运行的时候存在并发问题。

如果你没有扎实的基础:你知道除了常用的方法indexOf、subString、concat之外,在String中还有非常少见的intern()方法

对JVM一无所知:JVM内存分配,尤其是常量池

不要看JDK源代码:字符串生成器的toString()方法

我对并发没有一些了解:当锁定同步的代码块时,我们如何确保多线程连续执行代码块中的代码

这个问题根本解决不了,我们连怎么分析都不知道。

复制代码

因此,不要认为JVM和JDK源代码的底层实现原理是无用的。相反,这些才是技术人员成长最宝贵的东西。

我们已经准备好了,你呢?
2024我们与您携手共赢,为您的企业形象保驾护航