美文网首页JavaJava 程序员
Synchronized 和 Lock 该如何选择

Synchronized 和 Lock 该如何选择

作者: 程序花生 | 来源:发表于2022-06-06 16:03 被阅读0次

    在之前的文章 synchronized 底层实现说到synchronized是属于JVM层面的锁,而且它只是一个关键字,是不能查看Java源码的,因此我们可以把它当做隐式锁。

    有了 synchronized 为什么还要 Lock?

    Lock又是做什么的呢?我们知道synchronized在1.6之前把它叫做重量锁,这时还没有偏向锁和轻量锁级别的优化,因此 Doug Lea 觉得很不爽,于是就自己开发了一套锁,也就是我们熟知的JUC(java.util.concurrent )包的作者,我们可以叫他并发大师。

    Lock是一个接口,提供了无条件的、可轮询的、定时的、可中断的锁获取操作,所有的加锁和解锁操作方法都是显示的,因而称为显示锁。

    使用 synchronized 关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放,中间是不能有其他操作的。如下:

    特性 描述
    尝试非阻塞地获取锁 当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取,则成功获取并持有锁
    能被中断地获取锁 与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放
    超时获取锁 在指定的截至时间之前获取锁,如果截止时间到了仍旧无法获取锁,则返回

    Lock的使用范式

    synchronized的使用归根到底就是要么同步方法要么同步代码块,那么我们来看看Lock的标准使用范式:

    //加锁
    lock.lock();
    try {
        //业务代码
    } finally {
        //释放锁
        lock.unlock();
    }
    

    在 finally 块中释放锁,目的是保证在获取到锁之后,最终能够被释放。不要将获取锁的过程写在 try 块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放。

    Lock的常用API

    Lock接口提供的几个API:

    方法 描述
    void lock() 获取锁,调用该方法当前线程将会获取锁,当获取锁后,从该方法返回
    void lockInterruptibly() 可中断的获取锁,和lock()方法不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程
    boolean tryLock() 尝试非阻塞地获取锁,调用该方法后立刻返回,如果能够获取返回true,反之false
    boolean tryLock(long time, TimeUnit unit) 超时的获取锁,当前线程在一下3种情况下会返回: ①当前线程在超时时间内获得了锁 ②当前线程在超时时间内被中断 ③超时时间结束,返回false
    void unlock() 释放锁
    newCondition() 返回一个Condition对象,与Lock配合可以实现等待/通知模式

    我们知道Lock只是一个接口,具体是由其子类显示具体功能的,其中常见的就是可重入锁ReentrantLock和读写锁ReentrantReadWriteLock

    独占锁概念

    独占锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排他锁后,则其他线程不能再对 A 加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的 synchronized 和 JUC 中 Lock 的实现类就是互斥锁。

    共享锁概念

    共享锁是指该锁可被多个线程所持有。如果线程T对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。如 ReentrantReadWriteLock。

    独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

    ReentrantLock

    ReentrantLock是可重入的互斥锁,虽然具有与synchronized相同功能,但是会比synchronized更加灵活,我们先来使用体验一下。

    可重入

    简单地讲就是:同一个线程对于已经获得到的锁,可以多次继续申请到该锁的使用权。

    synchronized 关键字隐式的支持重进入,比如一个 synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。ReentrantLock 在调用 lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。

    可打断

    通过Lock提供的API可知,lockInterruptibly() 可中断的获取锁,和lock()方法不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程。

    简单来说就是如果线程 1 获取到锁,在没释放之前线程 2 也想去获取锁,那线程 2 就会中断而不会阻塞。

    如线程t1先拿到锁,让它在 5s 后释放:

    再让t2去获取锁:

    完整代码:

    public static void main(String[] args) throws InterruptedException
        ReentrantLock lock = new ReentrantLock();
        //t1首先获取锁 然后阻塞5s
        new Thread(() -> {
            try {
                lock.lock();//获取锁
                System.out.println("t1获取锁");
                TimeUnit.SECONDS.sleep(5);
                System.out.println("t1 5s 之后释放锁");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "t1").start();
        //主要是为了让t1先拿到锁
        TimeUnit.SECONDS.sleep(1);
        //t2加锁失败因为被t1持有
        Thread t1 = new Thread(() -> {
            try {
                //中断式获取锁
                lock.lockInterruptibly();
                System.out.println("t2 获取了锁--执行代码");
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println("t2被打断了没有获取锁");
                return;
            } finally {
                lock.unlock();
            }
        }, "t2");
        t1.start();
        try {
            TimeUnit.SECONDS.sleep(2);
            System.out.println("主线程---2s后打断t2");
            t1.interrupt();//打断
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    

    输出:

    可以看到,如果在时间范围内获取不到锁,是可以打断不在让它获取锁,从而使线程不会堵塞。

    可超时

    可超时和可打断实际是是不是同一个意思呢?Lock提供了一个tryLock()方法,用来尝试获取锁,并且还可以超时获取。如下:

    main 线程先获取锁并且休眠 3s后在释放锁,然后这时t1线程是拿不到锁的,如果我们给tryLock加上超时呢?

    ReentrantReadWriteLock

    ReentrantReadWriteLock,也叫读写锁,在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。

    读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。

    在没有读写锁支持的(Java 5 之前)时候,如果需要完成上述工作就要使用Java 的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠 synchronized 关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。

    改用读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了。

    一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量,ReentrantReadWriteLock 其实实现的是 ReadWriteLock 接口。

    实际上,上面的可以总结为三种情况:

    1. 读读并发,即同一时刻可以允许多个读线程访问,共享。
    2. 读写互斥,即在写线程访问时,所有的读线程和其他写线程均被阻塞。
    3. 写写互斥,在写线程访问时,其他写线程均被阻塞。

    什么意思呢?我们来看下面的代码。

    读读并发

    public static void main(String[] args) {
        //读写锁
        ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
        //读锁
        Lock readLock = rwl.readLock();
        //写锁
        Lock writeLock = rwl.writeLock();
        //线程1
        new Thread(()->{
            readLock.lock();
            try {
                for (int i = 0; i < 10; i++) {
                    read();
                }
            } finally {
                readLock.unlock();
            }
        },"t1").start();
        //线程2
        new Thread(()->{
            readLock.lock();
            try {
                for (int i = 0; i < 10; i++) {
                    read();
                }
            } finally {
                readLock.unlock();
            }
        },"t2").start();
    }
    
    
    public static void read() {
        System.out.println("我是" + Thread.currentThread().getName());
        try {
            //休眠1s是为了更清楚演示
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    

    输出:

    我是t1
    我是t2
    我是t2
    我是t1
    我是t2
    我是t1
    我是t1
    我是t2
    ......
    

    可以看到t1在 for 循环没有执行完成之前,t2也可以拿到锁。

    读写互斥

    当把t2换成写锁时:

    //线程2
    new Thread(()->{
        writeLock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                read();
            }
        } finally {
            writeLock.unlock();
        }
    },"t2").start();
    

    输出:

    我是t1
    我是t1
    我是t1
    我是t1
    我是t1
    我是t1
    我是t1
    我是t1
    我是t1
    我是t1
    我是t2
    我是t2
    ......
    

    可以看到t1在 for 循环执行完成之后,t2才拿到锁开始执行。

    读写都互斥,写写就不用说了吧。

    读写锁的适用场景

    在一些共享资源的读和写操作,且写操作没有读操作那么频繁的场景下可以用读写锁。

    常见的有:

    • 商品的库存,因为一般看的人多,买的人少。
    • 缓存,多线程更新和获取。

    Condition接口

    Lock中还有一个方法newCondition()

    任意一个 Java 对象,都拥有一组监视器方法(定义在 java.lang.Object 上),主要包括 wait()、wait(long timeout)、notify()以及 notifyAll()方法,这些方法与 synchronized 同步关键字配合,可以实现等待/通知模式。Condition 接口也提供了类似 Object 的监视器方法,与 Lock 配合可以实现等待/通知模式。

    通过对比Object的监视器方法和Condition接口,可以更详细地了解Condition的特性,对比如下

    对比项 Object Monitor Methods Condition
    前置条件 获取对象的锁 1.调用Lock.lock()获取 2.调用Lock.newCondition()获取Condition对象
    调用方式 直接调用,如:object.wait() 直接调用,如:condition.await()
    等待队列个数 一个 多个
    当前线程释放锁并进入等待状态 支持 支持
    当前线程释放锁并进入等待状态,在等待状态中不响应终端 不支持 支持
    当前线程释放锁并进入超时等待状态 支持 支持
    当前线程释放锁并进入等待状态到将来的某个时间 不支持 支持
    唤醒等待队列中的一个线程 支持 支持
    唤醒等待队列中的全部线程 支持 支持

    Condition常用方法

    方法 描述
    void await() throws InterruptedException 当线程进入等待状态直到被通知(signal)或中断,当前线程将进入运行状态且从await()方法返回的情况,包括: 其他线程调用该Condition的signal()或signalAll()方法,而当前线程被选中唤醒 1. 其他线程(调用interrupt()方法)中断当前线程 2. 如果当前等待线程从await()方法返回,那么表明该线程已经获取了Condition对象所对应的锁
    void awaitUninterruptibly() 当前线程进入等待状态直到被通知,从方法名称上可以看出该方法对中断不敏感
    long awaitNanos(long nanosTimeout) throws InterruptedException 当前线程进入等待状态直到被通知、中断或者超时。返回值表示剩余的时间,如果在nanosTimeout纳秒之前被唤醒,那么返回值就是(nanosTimeout - 实际耗时)。如果返回值是0或者负数,那么可以认定已经超时了
    boolean awaitUntil(Date deadline) throws InterruptedException 当前线程进入等待状态直到被通知、中断或者到某个时间。如果没有到指定时间就被通知,方法返回true,否则,表示到了指定时间,返回false
    void signal() 唤醒一个等待在Condition上的线程,该线程从等待方法返回前必须获得与Condition相关联的锁
    void signalAll() 唤醒所有等待在Condition上的线程,能够从等待方法返回的线程必须获得与Condition相关联的锁

    Condition使用范式

    Lock lock = new ReentrantLock();
    //获取一个Condition必须通过Lock的newCondition()方法
    Condition condition = lock.newCondition();
    
    public void conditionWait() throws InterruptedException {
        lock.lock();
        try {
            condition.await();
        } finally {
            lock.unlock();
        }
    }
    
    public void conditionSignal() throws InterruptedException {
        lock.lock();
        try {
            condition.signal();
        } finally {
            lock.unlock();
        }
    }
    

    如示例所示,一般都会将Condition对象作为成员变量。当调用await()方法后,当前线程会释放锁并在此等待,而其他线程调用Condition对象的signal()方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。

    示例:

    public class ConditionTest {
    
        static ReentrantLock lock = new ReentrantLock();
        static Condition condition = lock.newCondition();
        static boolean isMoney = false;//工资
    
        public static void main(String[] args) {
    
            new Thread(() -> {
                lock.lock();
                try {
                    //如果不发工资
                    while (!isMoney) {
                        System.out.println(Thread.currentThread().getName() + ":不发工资不干活...");
                        condition.await();
                    }
                    System.out.println(Thread.currentThread().getName() + ":工资已到账,我爱公司...");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
    
            }, "打工人").start();
    
    
            new Thread(() -> {
                lock.lock();
                try {
                    isMoney = true;
                    System.out.println("老板:发工资了");
                    condition.signal();
                } finally {
                    lock.unlock();
                }
    
            }, "老板").start();
        }
    }
    

    输出:

    打工人:不发工资不干活...
    老板:发工资了
    打工人:工资已到账,我爱公司...
    

    选择synchronized还是Lock

    先看看他们的区别:

    • synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。
    • synchronized会自动释放锁,而Lock必须手动释放锁。
    • synchronized是不可中断的,Lock可以中断也可以不中断。
    • 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
    • synchronized能锁住方法和代码块,而Lock只能锁住代码块。
    • Lock可以使用读锁提高多线程读效率。
    • synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。

    所以,具体用什么取决于我们具体的场景,像上面说的商品的读写,那肯定使用Lock,因为读的场景多于写嘛。

    作者:Ayue丶
    链接:https://juejin.cn/post/7105677601934409759
    来源:稀土掘金

    相关文章

      网友评论

        本文标题:Synchronized 和 Lock 该如何选择

        本文链接:https://www.haomeiwen.com/subject/qsujmrtx.html