业务场景
电商活动的秒杀场景,在并发过来的时候很容易出现库存扣超的情况,一个很简单的例子是,两个线程同时拿到了库存为1的数据,同时扣减库存,那么库存就会变为负的。
所以要解决这个问题还是要让请求串行,串行的最好方式就是加锁,今天我们要探讨的是AQS的锁实现。
AQS锁的三大核心
- 自旋
- lockSupport
- CAS
自旋
获取锁失败的线程会通过自旋的方式重复获取锁,自旋也可以理解为死循环。
lockSupport
lockSupport解决的问题是线程自旋过程中浪费cpu资源的问题,他的原理是阻塞当前自旋线程,直到被唤醒后再次自旋获取锁。
CAS
CAS是获取锁的关键,他是属于一种无锁状态下的一个原子操作,更新时我们需要传入预期值和更新值,只有我们的预期值与当前的内存值相同时才允许改变。
CAS的问题
- ABA的问题
ABA的问题大概如上图所示
- T1期望值A,想要改成B成功
- T3期望值B,想要改成A
- T2和T1同样,只不过他阻塞住了,如果T2在T3完成后获取到cpu的时间片,将会将A重新改为B
解决ABA的问题的方案是增加一个版本号,对内存值维护一个版本号。
实现AQS锁
设计方案如图
依据这种设计至少维护三个相关变量
- state // cas操作的变量
- lockHolder // 持有锁的线程
- waiters // 等待队列,将阻塞的线程放进队列
加锁代码
private int state = 0;
private Thread lockHolder;
private ConcurrentLinkedQueue<Thread> waiters = new ConcurrentLinkedQueue<>();
// 通过Unsafe进行cas操作
private static final Unsafe unsafe = UnsafeInstance.getInstance();
private static long stateOffset;
static {
try {
stateOffset = unsafe.objectFieldOffset(MyLock.class.getDeclaredField("state"));
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
/**
* 加锁
*/
public void lock(){
// 同步获取锁
if(acquire()){
return;
}
Thread current = Thread.currentThread();
// 获取锁失败的 添加进队列里
waiters.add(current);
// 自旋加锁
for(;;){
if (current == waiters.peek() && acquire()){
// 移除队列
waiters.poll();
return;
}
// 让出cpu的使用权
LockSupport.park(current);
}
}
获取锁的实现
/**
* 获取锁
* @return
*/
private boolean acquire() {
int state = getState();
Thread current = Thread.currentThread();
// 获取锁成功有两种情况 第一种是第一个获取到的队列里没有等待的 第二种是唤醒队列里等待的线程
if ((waiters.size() == 0 || current == waiters.peek()) && state ==0){
// 没有线程获取到锁
if(compareAndSwapState(0,1)){
// 同步修改成功 将线程持有者修改为当前线程
setLockHolder(current);
return true;
}
}
return false;
}
cas操作
/**
* cas操作
* @param expect
* @param update
* @return
*/
public final boolean compareAndSwapState(int expect,int update){
return unsafe.compareAndSwapInt(this,stateOffset,expect,update);
}
解锁代码
/**
* 解锁
*/
public void unlock(){
// 1.校验释放锁的线程是不是当前持有锁的线程
if (Thread.currentThread() != lockHolder){
throw new RuntimeException("threadHolder is not current thread");
}
// 2. 释放锁修改state
if(getState() == 1 && compareAndSwapState(1,0)){
// 将锁的持有线程置为空
setLockHolder(null);
// 2.唤醒队列里的第一个线程
Thread first = waiters.peek();
if (first != null){
LockSupport.unpark(first);
}
}
}
压测
为了模拟压测,写了一个秒杀的demo
@RestController
@RequestMapping("/shop")
public class ShoppingController {
private volatile int trick = 5;
private MyLock lock = new MyLock();
private static final Logger logger = LoggerFactory.getLogger(ShoppingController.class);
@RequestMapping("/go")
public void shopping(){
// lock.lock();
if (trick >0){
try {
Thread.sleep(10L);
} catch (InterruptedException e) {
e.printStackTrace();
}
trick = trick -1;
logger.info("余票 trick = {}",trick);
}else {
logger.info("秒杀失败,余票 trick = {}",trick);
}
// lock.unlock();
}
}
无锁压测,200个并发压测
很明显出现了超卖。下面进行有锁200并发压测
很明显没有出现并发。
备注:并发测试使用的ab,先是用的postman,发现请求是串行的。