Java实现锁的几种方式

锁和同步,学习多线程避不开的两个问题,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使用率极高,甚至系统崩溃。

yield+自旋

要解决自旋锁的性能问题,首先就是尽可能的防止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的调度开销。

Sleep+自旋

除了使用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时间不可控

使用多线程的目的就是为了提升性能,减少响应时间,我们无法预估线程运行结束的时间,sleep的时间是不可控的,在高并发的场景下,哪怕1毫秒、1纳秒都应该分秒必争。

性能测试

笔者进行了简单的测试,抢夺一亿张票,结果如下:

  • 自旋:耗时21806ms。
  • yield+自旋:耗时2543ms。
  • sleep+自旋:耗时1593ms。

测试结果仅供参考。

park+自旋

相较于前几种,是比较好的一种实现方式,需要借助于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。

你可能感兴趣的:(#,多线程)