并发编程之:Lock

大家好,我是小黑,一个在互联网苟且偷生的农民工。

在之前的文章中,为了保证在并发情况下多线程共享数据的线程安全,我们会使用synchronized关键字来修饰方法或者代码块,以及在生产者消费者模式中同样使用synchronized来保证生产者和消费者对于缓冲区的原子操作。

synchronized的缺点

那么synchronized这么厉害,到底有没有什么缺点呢?主要有以下几个方面:

  1. 使用synchronized加锁的代码块或者方法,在线程获取锁时,会一直试图获取直到获取成功,不能中断。
  2. 加锁的条件只能在一个锁对象上,不支持其他条件
  3. 无法知道锁对象的状态,是否被锁
  4. synchronized锁只支持非公平锁,无法做到公平
  5. 对于读操作和写操作都是使用独占锁,无法支持共享锁(在读操作时共享,写操作时独占)
  6. synchronized锁在升级之后不支持降级,如在业务流量高峰阶段升级为重量级锁,流量降低时还是重量级,效率较低(有些JVM实现支持降级,但是降级条件极为苛刻,对于Java线程来说可基本认为是不支持降级)
  7. 线程间通信无法按条件进行线程的唤醒,如生产者消费者场景中生产者完成数据生产后无法做到只唤醒消费者,其他等待的生产者也会被同时唤醒

以上是我能想到的synchronized锁的一些缺点,如果你有不同的看法,欢迎私信交流。(没有留言板的痛/(ㄒoㄒ)/~~)

那么synchronized的这些问题该如何解决呢?或者有没有替代方案?答案是有的,就是使用我们今天要讲的Lock锁。

Lock的优点

Lock锁是Java.util.concurrent.locks(JUC)包中的一个接口,并且有很多不同的实现类。这些实现类基本可以完全解决上面我们说到的所有问题。

Lock锁具备以下优点:

  1. 支持超时获取,中断获取
  2. 可以按条件加锁,灵活性更高
  3. 支持公平和非公平锁
  4. 有独占锁和共享锁的实现,如读写锁
  5. 可以做到等待线程的精准唤醒

接下来具体看看对应的实现。

基础铺垫

在开始之前,先和大家对于一些概念做一下回顾和普及。

可重入锁

可重入锁是指锁具备可重入的特性,可重入的意思是一个线程在获取锁之后,如果再次获取锁时,可以成功获取,不会因为锁正在被占有而死锁。

synchronized锁就是可重入锁,在一个synchronized方法中递归调用本方法,可以成功获取到锁,不会死锁。

Lock锁的实现中基本也都支持可重入。

公平锁和非公平锁

公平锁指在有线程获取锁失败阻塞时,一定会让先开始阻塞的线程先执行,就好比是排队买票,排在前面的先买;

非公平锁则不保证这种公平性,就算有其他线程在阻塞等待,新来的线程也可以直接获取锁,就好比插队。

独占锁和共享锁

独占锁是指一把锁同一时间只能被一个线程持有,举个生活中的例子,我们使用打车软件打专车,那么一辆车同一时间只能让一个用户打到,这辆专车就好比是一把独占锁,被一个用户独自占有了嘛。

共享锁则不一样,一把锁可以被多个线程持有,这个就想我们打拼车,一辆拼车同一时间可以让多个用户打到,这辆拼车就是一把共享锁。

说完这些以后我们来看一下Lock接口的一些具体实现。

ReentrantLock

ReentrantLock从名称理解,就是一把可重入锁,并且它是一把独占锁,而且具有公平和非公平实现。

我们通过代码来看一下如何通过ReentrantLock来做加解锁操作。

public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock(false);
        lock.lock();
        try {
            // do something...
        }finally {
            lock.unlock();
        }
    }

首先创建一个ReentrantLock对象,在创建时构造方法传入的boolean值控制是公平锁还是非公平锁,如果不传参数则默认是非公平锁。

调用lock()方法来进行加锁,可以看到使用try-finally代码块,在finally中进行unlock()解锁操作,这一点一定要注意,因为lock不会自己进行解锁,必须手动进行释放,为了保证锁一定可以被释放,防止发生死锁,所以要在finally中进行。这一点和synchronized有区别,使用synchronized不用关注锁的释放时机,这也是为了灵活性必须要付出的一点代价。

ReentrantLock除了通过lock()方法加锁之外,还有以下方式加锁:

  • tryLock():只有在调用时它不被另一个线程占用才能获取锁
  • tryLock(long timeout, TimeUnit unit) 如果在给定的等待时间内没有被另一个线程占用,并且当前线程尚未被中断,则获取该锁
  • lockInterruptibly() 获取锁定,除非当前线程是interrupted

除了获取锁的方法之外,还有一些其他的方法可以获得一些锁相关的状态信息:

  • isLocked() 查询此锁是否由任何线程持有
  • isHeldByCurrentThread() 查询此锁是否由当前线程持有
  • getOwner() 返回当前拥有此锁的线程,如果不拥有,则返回null

ReentrantLock本身是独占锁,不支持共享,那么如何做到线程的精准唤醒,我们接着说。

Condition

Condition也是JUC包下的locks包中的一个接口,提供了类似于Object的wait(),notify(),notifyAll()这样的对象监听器方法,可以与Lock的实现类配合做到线程的等待/唤醒机制,并且能够做到精准唤醒。接下来我们看下面的例子:

public class ProdConsDemo {

    public static void main(String[] args) {
        KFC kfc = new KFC();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                kfc.product();
            }
        }, "店员1").start();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                kfc.product();
            }
        }, "店员2").start();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                kfc.consume();
            }
        }, "顾客1").start();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                kfc.consume();
            }
        }, "顾客2").start();
    }
}

class KFC {
    int hamburgerNum = 0;

    public synchronized void product() {
        while (hamburgerNum == 10) {
            try {
                // 数量到达最大,生产者等待
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("生产一个汉堡" + (++hamburgerNum));
        // 唤醒其他线程
        this.notifyAll();
    }

    public synchronized void consume() {
        while (hamburgerNum == 0) {
            try {
				//数量到达最小,消费者等待
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("卖出一个汉堡" + (hamburgerNum--));
        // 唤醒其他线程
        this.notifyAll();
    }
}

看过小黑之前文章的朋友应该还记得这个例子,KFC里的店员生产汉堡,顾客来消费,典型的生产者消费者模式,我们可以看到在上面的代码中,是使用的锁对象this的wait()和notifyAll()方法来做线程等待和唤醒。那么这里会有一个问题,就是在notifyAll()时,无法做到只唤醒消费者或者只唤醒生产者。而在线程被唤醒之后就会面临更多的线程切换,而线程切换是很消耗CPU资源的。

那么我们使用Condition和ReentrantLock来修改一下我们的代码。

class KFC {
    int hamburgerNum = 0;
    ReentrantLock lock = new ReentrantLock();
    Condition isEmpty = lock.newCondition();
    Condition isFull = lock.newCondition();
    public void product() {
        lock.lock();
        try {
            while (hamburgerNum == 10) {
                // 数量到达最大,生产者等待
                isFull.await();
            }
            System.out.println("生产一个汉堡" + (++hamburgerNum));
            // 唤醒消费者线程
            isEmpty.signalAll();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void consume() {
        lock.lock();
        try {
            while (hamburgerNum == 0) {
                //数量到达最小,消费者等待
                isEmpty.await();
            }
            System.out.println("卖出一个汉堡" + (hamburgerNum--));
            // 唤醒生产者线程
            isFull.signalAll();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

可以看到,我们使用ReentrantLock来进行线程安全控制,进行加解锁,然后创建两个Condition对象,分别代表生产者和消费者的标记,当生产者生产完一个之后,就会准确的唤醒消费者线程,反之同理。

ReadWriteLock

ReadWriteLock是读写锁接口,通过ReadWriteLock可以实现多个线程对于读操作共享,对于写操作独占。

在ReadWriteLock中有两个Lock变量,通过两个Lock分别控制读和写。

class Data {
    private int num = 0;
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
    public void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "读取=>" + num);
            TimeUnit.SECONDS.sleep(5);
            System.out.println(Thread.currentThread().getName() + "读取结束");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            readLock.unlock();
        }
    }

    public void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "写入=>" + num++);
            TimeUnit.SECONDS.sleep(1);
            System.out.println(Thread.currentThread().getName() + "写入结束");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            writeLock.unlock();
        }
    }
}

对于读写锁,线程对于锁的竞争情况如下:

  1. 读-读操作共享;
  2. 读-写操作独占;
  3. 写-读操作独占;
  4. 写-写操作独占;

也就是说,当有一个线程持有读锁时,其他线程也可以获取读到读锁,但是不能获取写锁,必须等读锁释放;当有一个线程持有写锁时,其他线程都不能获取到锁。

StampedLock

StampedLock是JDK1.8新引入的,主要是为了优化ReadWriteLock的读写锁性能,相比于普通的ReadWriteLock主要多了乐观获取读锁的功能。

那么ReadWriteLock有什么性能问题呢?主要出现在读-写操作上,当有一个线程在读取时,写线程只能等读取完之后才能获取,读的过程中不允许写,是一个悲观读锁。

StampedLock允许在读的过程中写,但是这样会导致我们读线程获取的数据不一致,所以需要增加一点代码来判断在读的过程中是否有些操作,这是一种乐观读的锁;我们来看一下代码。

class Data {
    private int num = 0;

    private final StampedLock lock = new StampedLock();

    public void read() {
//        long stamp = lock.readLock();
        // 获取乐观读,拿到一个版本戳
        long stamp = lock.tryOptimisticRead(); 
        try {
            System.out.println(Thread.currentThread().getName() + "读取=>" + num);
            TimeUnit.SECONDS.sleep(5);
            System.out.println(Thread.currentThread().getName() + "读取结束");
			// 读取完之后对刚开始拿到的版本戳进行验证
            if (!lock.validate(stamp)) {
                // 验证不通过,说明发生了写操作,这是需要重新获取悲观读锁进行处理
                System.out.println("validatefalse");
                stamp = lock.readLock();
                // do something...
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlockRead(stamp);
        }
    }

    public void write() {
        long stamp = lock.writeLock();
        try {
            System.out.println(Thread.currentThread().getName() + "写入=>" + num++);
            TimeUnit.SECONDS.sleep(1);
            System.out.println(Thread.currentThread().getName() + "写入结束");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlockWrite(stamp);
        }
    }
}

所以StampedLock就是先乐观的认为在读的过程中不会有写操作,所以是乐观锁,而悲观锁就是悲观的认为在读的过程中会有些操作,所以拒绝写入。

显然在并发高的情况下乐观锁的并发效率要更高,但是会有一小部分的写入导致数据不准确,所以需要通过validate(stamp)检测出来,重新读取。

总结

简单总结一下,首先我们讲了synchronized的7个缺点:不能超时中断;只能在一个对象上加锁;获取不到锁的状态;不支持公平锁;不支持共享锁;锁升级后不能降级;无法做到精准唤醒阻塞线程等。

然后我们通过Lock的具体实现看到,Lock都解决了这些问题,ReentrantLock支持超时中断获取锁,并且可以按条件判断进行加锁,有方法可以看到锁的状态信息,支持公平和非公平实现等,通过Condition的await()和signal()/signalAll()可以做到精准唤醒等待线程;ReadWriteLock可以支持共享锁,读锁共享,写锁独占;然后StampedLock在性能上对读写锁进行优化,主要是通过乐观读锁和vaidate(stamp)验证读取过程中有没有写入。

使用Lock锁很重要的一点就是需要自己手动释放锁,所以一定要写在finally中;

使用Conditon进行唤醒线程时要记清楚是signal()/signalAll()方法,不是notify()/notifyAll()方法,不要用错了。

Lock锁的底层实现逻辑都是依赖于AbstractQueuedSynchronizer(AQS)和CAS无锁机制来实现的,这部分内容比较复杂,我们下期单独来说一说。


好的,今天的内容就到这里,我们下期见。

关注我的公众号【小黑说Java】,更多干货内容。
image

posted @ 2021-09-02 19:09  小黑说Java  阅读(249)  评论(0编辑  收藏  举报