前言

列举大家平时在工作中最容易犯的几个并发错误,都是在实际项目代码中看到的鲜活例子,希望对大家有帮助。

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
 
“没有可用现金券”
}

个人公众号

  • 如果你是个爱学习的好孩子,可以关注我公众号,一起学习讨论。
  • 如果你觉得本文有哪些不正确的地方,可以评论,也可以关注我公众号,私聊我,大家一起学习进步哈。