Synchronized 和 Lock 该如何选择

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第9天,点击查看活动详情

Lock是什么

在之前的文章 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:

image-20210924111305416

方法 描述
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()方法获取锁而不被阻塞。

image-20210924113009101

可打断

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

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

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

image-20210924114635464

再让t2去获取锁:

image-20210924114711638

完整代码:

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();
    }
}
复制代码

输出:

image-20210924114844039

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

可超时

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

image-20210924140328043

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

image-20210924140751337

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,因为读的场景多于写嘛。

猜你喜欢

转载自juejin.im/post/7105677601934409759