锁和同步,学习多线程避不开的两个问题,Java提供了synchronized关键字来同步方法和代码块,还提供了很多方便易用的并发工具类,例如:LockSupport、CyclicBarrier、CountDownLatch、Semaphore…
有没有想过自己实现一个锁呢?
笔者通过一个“抢票”的程序,分别用几种不同的方式来实现方法的同步和加锁,并分析它们的优劣。
就是让加锁失败的线程死循环,不要去执行逻辑代码。
/**
* @author 潘
* @Description 抢票-自旋锁
*/
public class Ticket {
//加锁标记
private AtomicBoolean isLock = new AtomicBoolean(false);
//票库存
private int ticketCount = 10;
//抢票
public void bye(){
while (!lock()) {
//加锁失败,自旋
}
String name = Thread.currentThread().getName();
//加锁成功,执行业务逻辑
System.out.println(name + ":加锁成功...");
System.out.println(name + ":开始抢票...");
//SleepUtil.sleep(1000);
ticketCount--;
System.out.println(name + ":抢到了,库存:" + ticketCount);
System.out.println(name + ":释放锁.");
unlock();
}
//加锁的过程必须是原子操作,否则会导致多个线程同时加锁成功。
public boolean lock(){
return isLock.compareAndSet(false, true);
}
//释放锁
public void unlock() {
isLock.set(false);
}
public static void main(String[] args) {
Ticket lock = new Ticket();
//开启10个线程去抢票
for (int i = 0; i < 10; i++) {
new Thread(() -> lock.bye()).start();
}
}
}
输出如下:
Thread-0:加锁成功...
Thread-0:开始抢票...
Thread-0:抢到了,库存:9
Thread-0:释放锁.
Thread-3:加锁成功...
Thread-3:开始抢票...
Thread-3:抢到了,库存:8
Thread-3:释放锁.
Thread-4:加锁成功...
Thread-4:开始抢票...
Thread-4:抢到了,库存:7
Thread-4:释放锁.
......
加锁的过程必须是原子操作,否则会导致多个线程同时加锁成功。
自旋是实现加锁最简单的方式,但是缺点也很明显:
要解决自旋锁的性能问题,首先就是尽可能的防止CPU空转,让获取不到锁的线程主动让出CPU资源。
获取不到锁的线程主动让出CPU资源,可以通过Thread.yield()实现。
bye()可以做如下优化:
public void bye(){
while (!lock()) {
//获取不到锁,主动让出CPU资源
Thread.yield();
}
String name = Thread.currentThread().getName();
//加锁成功,执行业务逻辑
System.out.println(name + ":加锁成功...");
System.out.println(name + ":开始抢票...");
//SleepUtil.sleep(1000);
ticketCount--;
System.out.println(name + ":抢到了,库存:" + ticketCount);
System.out.println(name + ":释放锁.");
unlock();
}
Thread.yield()虽然让出了CPU资源,但还是会继续争夺,很可能CPU下次还会继续分配时间片给该线程。
yield+自旋适用于两个线程竞争的情况,如果线程太多,频繁的yield也会增加CPU的调度开销。
除了使用yield让出CPU资源外,还可以使用Sleep将获取不到锁的线程暂时休眠,不占用CPU的资源。
bye()可以做如下优化:
public void bye(){
while (!lock()) {
//获取不到锁的线程,暂时休眠1ms,释放CPU资源
SleepUtil.sleep(1);
}
String name = Thread.currentThread().getName();
//加锁成功,执行业务逻辑
System.out.println(name + ":加锁成功...");
System.out.println(name + ":开始抢票...");
//SleepUtil.sleep(1000);
ticketCount--;
System.out.println(name + ":抢到了,库存:" + ticketCount);
System.out.println(name + ":释放锁.");
unlock();
}
使用Sleep可以减轻CPU的压力,但是缺点也很明显:
使用多线程的目的就是为了提升性能,减少响应时间,我们无法预估线程运行结束的时间,sleep的时间是不可控的,在高并发的场景下,哪怕1毫秒、1纳秒都应该分秒必争。
笔者进行了简单的测试,抢夺一亿张票,结果如下:
测试结果仅供参考。
相较于前几种,是比较好的一种实现方式,需要借助于LockSupport来完成。
/**
* @author 潘
* @Description 抢票-park+自旋
*/
public class TicketPark {
//加锁标记
private AtomicBoolean isLock = new AtomicBoolean(false);
//票库存
private int ticketCount = 10;
//等待线程队列
private final Queue<Thread> WAIT_THREAD_QUEUE = new LinkedBlockingQueue<>();
//抢票
public void bye(){
while (!lock()) {
//获取不到锁的线程,添加到队列,并休眠
lockWait();
}
String name = Thread.currentThread().getName();
//加锁成功,执行业务逻辑
System.out.println(name + ":加锁成功...");
System.out.println(name + ":开始抢票...");
ticketCount--;
System.out.println(name + ":抢到了,库存:" + ticketCount);
System.out.println(name + ":释放锁.");
unlock();
}
//加锁的过程必须是原子操作,否则会导致多个线程同时加锁成功。
public boolean lock(){
return isLock.compareAndSet(false, true);
}
//释放锁
public void unlock() {
isLock.set(false);
//唤醒队列中的第一个线程
LockSupport.unpark(WAIT_THREAD_QUEUE.poll());
}
public void lockWait(){
//将获取不到锁的线程添加到队列
WAIT_THREAD_QUEUE.add(Thread.currentThread());
//并休眠
LockSupport.park();
}
}
java.util.concurrent包下很多类都是采用park+自旋来实现同步的,ReentrantLock也不例外!
Java实现锁大致分为这么几种方式,感兴趣的同学也可以自己动手写一个Lock。