前言
列举大家平时在工作中最容易犯的几个并发错误,都是在实际项目代码中看到的鲜活例子,希望对大家有帮助。
First Blood
线上总是出现:ERROR 1062 (23000) Duplicate entry 'xxx' for key 'yyy',我们来看一下有问题的这段代码:
UserBindInfo
info
=
selectFromDB
(
userId
);
if
(
info
==
null
){
info
=
new
UserBindInfo
(
userId
,
deviceId
);
insertIntoDB
(
info
);
}
else
{
info
.
setDeviceId
(
deviceId
);
updateDB
(
info
);
}
在并发情况下,第一步判断都为空,就会有2个或者多个线程进入插入数据库操作,这时候就出现了同一个ID插入多次。
正确处理姿势:
insert
into
UserBindInfo
values
(#{
userId
},#{
deviceId
})
on duplicate key update deviceId
=#{
deviceId
}多次的情况,导致插入失败。
一般情况下,可以用insert...on duplicate key update... 解决这个问题。
注意: 如果UserBindInfo表存在主键以及一个以上的唯一索引,在并发情况下,使用insert...on duplicate key,可能会产生死锁(Mysql5.7),可以这样处理:
try
{
UserBindInfoMapper
.
insertIntoDB
(
userBindInfo
);
}
catch
(
DuplicateKeyException
ex
){
UserBindInfoMapper
.
update
(
userBindInfo
);
}
Double Kill
小心你的全局变量,如下面这段代码:
public
class
GlobalVariableConcurrentTest
{
private
static
final
SimpleDateFormat
sdf
=
new
SimpleDateFormat
(
"yyyy-MM-dd HH:mm:ss"
);
public
static
void
main
(
String
[]
args
)
throws
InterruptedException
{
ThreadPoolExecutor
threadPoolExecutor
=
new
ThreadPoolExecutor
(
10
,
100
,
1
,
TimeUnit
.
MINUTES
,
new
LinkedBlockingQueue
<>(
1000
));
while
(
true
){
threadPoolExecutor
.
execute
(()->{
String
dateString
=
sdf
.
format
(
new
Date
());
try
{
Date
parseDate
=
sdf
.
parse
(
dateString
);
String
dateString2
=
sdf
.
format
(
parseDate
);
System
.
out
.
println
(
dateString
.
equals
(
dateString2
));
}
catch
(
ParseException
e
)
{
e
.
printStackTrace
();
}
});
}
}
}
可以看到有异常抛出 全局变量的SimpleDateFormat,在并发情况下,存在安全性问题,阿里Java规约明确要求谨慎使用它。
除了SimpleDateFormat,其实很多时候,面对全局变量,我们都需要考虑并发情况是否存在问题,如下
@Component
public
class
Test
{
public
static
List
<
String
>
desc
=
new
ArrayList
<>();
public
List
<
String
>
getDescByUserType
(
int
userType
)
{
if
(
userType
==
1
)
{
desc
.
add
(
"普通会员不可以发送和查看邮件,请购买会员"
);
return
desc
;
}
else
if
(
userType
==
2
)
{
desc
.
add
(
"恭喜你已经是VIP会员,尽情的发邮件吧"
);
return
desc
;
}
else
{
desc
.
add
(
"你的身份未知"
);
return
desc
;
}
}
}
因为desc是全局变量,在并发情况下,请求getDescByUserType方法,得到的可能并不是你想要的结果。
Trible Kill
假设现在有如下业务:控制同一个用户访问某个接口的频率不能小于5秒。一般很容易想到使用redis的 setnx操作来控制并发访问,于是有以下代码:
if
(
RedisOperation
.
setnx
(
userId
,
1
)){
RedisOperation
.
expire
(
userId
,
5
,
TimeUnit
.
SECONDS
));
//执行正常业务逻辑
}
else
{
return
“访问过于频繁”;
}
假设执行完setnx操作,还没来得及设置expireTime,机器重启或者突然崩溃,将会发生死锁。该用户id,后面执行setnx永远将为false,这可能让你永远损失那个用户。
那么怎么解决这个问题呢,可以考虑用SET key value NX EX max-lock-time ,它是一种在 Redis 中实现锁的方法,是原子性操作,不会像以上代码分两步执行,先set再expire,它是一步到位。
客户端执行以上的命令:
- 如果服务器返回 OK ,那么这个客户端获得锁。
- 如果服务器返回 NIL ,那么客户端获取锁失败,可以在稍后再重试。
- 设置的过期时间到达之后,锁将自动释放
Quadra Kill
我们看一下有关ConcurrentHashMap的一段代码,如下:
//全局变量
Map
<
String
,
Integer
>
map
=
new
ConcurrentHashMap
();
Integer
value
=
count
.
get
(
k
);
if
(
value
==
null
){
map
.
put
(
k
,
1
);
}
else
{
map
.
put
(
k
,
value
+
1
);
}
假设两条线程都进入 value==null,这一步,得出的结果是不是会变小?OK,客官先稍作休息,闭目养神一会,我们验证一下,请看一个demo:
public
static
void
main
(
String
[]
args
)
{
for
(
int
i
=
0
;
i
<
1000
;
i
++)
{
testConcurrentMap
();
}
}
private
static
void
testConcurrentMap
()
{
final
Map
<
String
,
Integer
>
count
=
new
ConcurrentHashMap
<>();
ExecutorService
executorService
=
Executors
.
newFixedThreadPool
(
2
);
final
CountDownLatch
endLatch
=
new
CountDownLatch
(
2
);
Runnable
task
=
()->
{
for
(
int
i
=
0
;
i
<
5
;
i
++)
{
Integer
value
=
count
.
get
(
"k"
);
if
(
null
==
value
)
{
System
.
out
.
println
(
Thread
.
currentThread
().
getName
());
count
.
put
(
"k"
,
1
);
}
else
{
count
.
put
(
"k"
,
value
+
1
);
}
}
endLatch
.
countDown
();
};
executorService
.
execute
(
task
);
executorService
.
execute
(
task
);
try
{
endLatch
.
await
();
if
(
count
.
get
(
"k"
)
<
10
)
{
System
.
out
.
println
(
count
);
}
}
catch
(
Exception
e
)
{
e
.
printStackTrace
();
}
表面看,运行结果应该都是10对吧,好的,我们再看运行结果 : 运行结果出现了5,所以这样实现是有并发问题的,那么正确的实现姿势是啥呢?
Map
<
K
,
V
>
map
=
new
ConcurrentHashMap
();
V v
=
map
.
get
(
k
);
if
(
v
==
null
){
V v
=
new
V
();
V old
=
map
.
putIfAbsent
(
k
,
v
);
if
(
old
!=
null
){
v
=
old
;
}
}
可以考虑使用putIfAbsent解决这个问题
(1)如果key是新的记录,那么会向map中添加该键值对,并返回null。
(2)如果key已经存在,那么不会覆盖已有的值,返回已经存在的值
我们再来看看以下代码以及运行结果:
public
static
void
main
(
String
[]
args
)
{
for
(
int
i
=
0
;
i
<
1000
;
i
++)
{
testConcurrentMap
();
}
}
private
static
void
testConcurrentMap
()
{
ExecutorService
executorService
=
Executors
.
newFixedThreadPool
(
2
);
final
Map
<
String
,
AtomicInteger
>
map
=
Maps
.
newConcurrentMap
();
final
CountDownLatch
countDownLatch
=
new
CountDownLatch
(
2
);
Runnable
task
=
()->
{
AtomicInteger
oldValue
;
for
(
int
i
=
0
;
i
<
5
;
i
++)
{
oldValue
=
map
.
get
(
"k"
);
if
(
null
==
oldValue
)
{
AtomicInteger
initValue
=
new
AtomicInteger
(
0
);
oldValue
=
map
.
putIfAbsent
(
"k"
,
initValue
);
if
(
oldValue
==
null
)
{
oldValue
=
initValue
;
}
}
oldValue
.
incrementAndGet
();
}
countDownLatch
.
countDown
();
};
executorService
.
execute
(
task
);
executorService
.
execute
(
task
);
try
{
countDownLatch
.
await
();
System
.
out
.
println
(
map
);
}
catch
(
Exception
e
)
{
e
.
printStackTrace
();
}
}
Penta Kill
现有如下业务场景:用户手上有一张现金券,可以兑换相应的现金,
错误示范一
if
(
isAvailable
(
ticketId
){
1
、给现金增加操作
2
、
deleteTicketById
(
ticketId
)
}
else
{
return
“没有可用现金券”
}
解析: 假设有两条线程A,B兑换现金,执行顺序如下:
- 1.线程A加现金
- 2.线程B加现金
- 3.线程A删除票标志
- 4.线程B删除票标志
显然,这样有问题了,已经给用户加了两次现金了。
错误示范2
if
(
isAvailable
(
ticketId
){
1
、
deleteTicketById
(
ticketId
)
2
、给现金增加操作
}
else
{
return
“没有可用现金券”
}
并发情况下,如果一条线程,第一步deleteTicketById删除失败了,也会多添加现金。
正确处理方案
if
(
deleteAvailableTicketById
(
ticketId
)
==
1
){
1
、给现金增加操作
}
else
{
return
“没有可用现金券”
}
个人公众号
- 如果你是个爱学习的好孩子,可以关注我公众号,一起学习讨论。
- 如果你觉得本文有哪些不正确的地方,可以评论,也可以关注我公众号,私聊我,大家一起学习进步哈。