锁是并发中非常非常重要的部分,从最开始学并发常用的synchronized或者Lock到更进一步了解并发编程,会发现锁非常的多,概念也很多,不容易区分。
在较为全面的了解了之后决定先写下这篇博客打个底,并在后期的学习中进一步完善我的锁的知识体系
本文整理自慕课网《玩转Java并发工具》
快速到达看这里->
Lock接口
简介
- 锁时一种工具,用于控制对共享资源的访问
- Lock和synchronized是最常见的两个锁,他们都能够达到线程安全的目录,但是使用和功能上又有较大的不同
- Lock接口最常见的实现类是ReentrantLock
- 通常情况下Lock只允许一个线程访问共享资源,特殊情况也允许并发访问,如ReadWriteLock的ReadLock
为什么需要Lock
- synchronized不够用!!
- 效率低:锁释放的情况少、不支持尝试锁
- 不够灵活(比不上读写锁):加锁和释放锁时机单一,每个锁只有个一个条件,不够用
- 无法知道是否成功获得锁
方法介绍
Lock中声明了四个方法来获取锁
- lock()
- 最普通的获取锁,如果所被其他线程获得了,进行等待
- Lock不会像synchronized一样在异常时自动释放锁
- 使用时,一定要在finally中释放锁
- lock不能被中断,一旦死锁就会永久等待
lock.lock();
try {
//获取本锁保护的资源
System.out.println(Thread.currentThread().getName()+"开始执行任务");
}finally {
lock.unlock();
}
-
tryLock()
- 尝试获取锁,如果当前锁没有被占用,则获取成功,否则获取失败
- 可以根据是否获取到锁决定后续程序的行为
- 该方法立刻返回,即使拿不到也不会等
-
tryLock(long time,TimeUnit unit)
- 加超时时间的尝试获取锁,一段时间内等待锁,超时就放弃
- tryLock()避免死锁案例代码
/**
* 〈用trylock避免死锁〉
*
* @author Chkl
* @create 2020/3/11
* @since 1.0.0
*/
public class TryLockDeadLock implements Runnable {
int flag = 1;
static Lock lock1 = new ReentrantLock();
static Lock lock2 = new ReentrantLock();
public static void main(String[] args) {
TryLockDeadLock r1 = new TryLockDeadLock();
TryLockDeadLock r2 = new TryLockDeadLock();
r1.flag = 1;
r2.flag = 0;
new Thread(r1).start();
new Thread(r2).start();
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if (flag == 1) {
try {
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程1获取到了锁1");
Thread.sleep(new Random().nextInt(1000));
if (lock2.tryLock(800,TimeUnit.MILLISECONDS)){
try {
System.out.println("线程1获取到了锁2");
System.out.println("线程1获取到了两把锁");
break;
}finally {
lock2.unlock();
}
}else {
System.out.println("线程1获取锁2失败");
}
} finally {
lock1.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println("线程1获取锁1失败,已重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (flag == 0) {
try {
if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程2获取到了锁1");
Thread.sleep(new Random().nextInt(1000));
if (lock1.tryLock(800,TimeUnit.MILLISECONDS)){
try {
System.out.println("线程2获取到了锁2");
System.out.println("线程2获取到了两把锁");
break;
}finally {
lock1.unlock();
}
}else {
System.out.println("线程2获取锁2失败");
}
} finally {
lock2.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println("线程2获取锁1失败,已重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
- lockInterruptibly()
- 相当于把tryLock(long time,TimeUnit unit)的超时时间设置为无限长,在等待锁的过程中,线程可以中断
/**
* 〈验证尝试获取锁期间可中断线程〉
*
* @author Chkl
* @create 2020/3/11
* @since 1.0.0
*/
public class LockInterruptibly implements Runnable {
private Lock lock = new ReentrantLock();
public static void main(String[] args) {
LockInterruptibly lockInterruptibly = new LockInterruptibly();
Thread thread0 = new Thread(lockInterruptibly);
Thread thread1 = new Thread(lockInterruptibly);
thread0.start();
thread1.start();
try {
Thread.sleep(2000);
}catch (InterruptedException e){
e.printStackTrace();
}
//线程启动2秒后,一个线程获得锁并处于睡眠,另一个线程处于等待锁状态
thread1.interrupt();
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "尝试获取锁");
try {
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName()+"获取到了锁");
//等待5秒,期间第二个线程被中断
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "睡眠期间被中断");
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了锁");
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "等锁期间被中断");
}
}
}
运行结果可能如下图所示(线程先执行顺序不一定)
中断thread0
Thread-0尝试获取锁
Thread-0获取到了锁
Thread-1尝试获取锁
Thread-1等锁期间被中断
Thread-0释放了锁
中断thread1
Thread-0尝试获取锁
Thread-1尝试获取锁
Thread-0获取到了锁
Thread-0睡眠期间被中断
Thread-0释放了锁
Thread-1获取到了锁
Thread-1释放了锁
- unlock()
- 解锁,最好每次都先把unlock写在finally内再写业务逻辑
可见性保证
- lock符合happens-before规则,具有可见性
- 当线程解锁,下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作
锁的分类
根据不同的划分标准,常见的锁的划分如思维导图所示
乐观锁和悲观锁
为什么会诞生非互斥同步锁(乐观锁)
- 互斥同步锁(悲观锁)的劣势
- 阻塞和唤醒带来的性能劣势
- 永久阻塞:如果持有锁的线程被永久阻塞,如无限循环,死锁等活跃性问题,那么等待该线程释放锁的线程永远得不到执行
- 优先级反转:阻塞的优先级高,持有锁的优先级低,导致优先级反转
什么是乐观锁和悲观锁
悲观锁:
- 如果我不锁住这个资源,别人就会来争抢,就会造成数据结果错误,所以为了结果的正确性,悲观锁会在每次获取并修改结果时把数据锁住,让别人无法访问
- Java中悲观锁典型的实现就是synchronized和lock相关类
乐观锁:
- 认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住操作对象
- 在更新的时候,去对比在我修改的期间数据有没有被其他人改变过。
- 如果没有被改变过,就说明只有自己在操作,就正常修改数据
- 如果数据与最初拿到的不一致,说明其他人在这段时间内修改过数据,就会执行放弃、报错或重试等策略
- 乐观锁的实现通常是利用
CAS
算法,典型例子是原子类,并发容器
案例演示:实现累加器
public class PessimismOptimismLock {
int a;
//悲观锁
public synchronized void testMethod(){
a++;
}
public static void main(String[] args) {
//乐观锁
AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
//悲观锁
new PessimismOptimismLock().testMethod();
}
}
典型例子
Git:Git是乐观锁的典型应用,当我们向远程仓库push的时候,git会检查远程仓库的版本是不是领先我们现在的版本,
- 如果远端版本和本地版本不一致,表明远端代码被人修改过了,提交就失败
- 如果版本一直,才能顺利提交到远程仓库
数据库:
- select for update就是悲观锁
- 用version控制就是乐观锁
- 添加一个字段lock_version
- 更新操作前先查出这条数据的version 记为mversion
- 进行更新操作时:update set num = 2 ,version = version = vsersion+1 where version = mversion and id = 5
- 如果version更新了不等于查询出来的值了,更新就无效
开销对比
- 悲观锁的原始开销要高于乐观锁,但是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响
- 乐观锁一开始的开销比悲观锁小,如果自旋时间很长或者不停重试,name消耗的资源也会越来越多
使用场景
- 悲观锁:适合于并发写入多的情况,适合于临界区持锁时间较长的情况,悲观锁可以避免大量的无用自旋锁等消耗
- 临界区有IO操作
- 临界区代码复杂或者循环量大
- 临界区竞争非常激烈
- 乐观锁:适合并发写入少,大部分都是读取的场景,不加锁能让读取性能大幅提高
可重入锁和非可重入锁
非可重入锁就是最常见的锁,一旦锁被使用,如果没有释放,就不能再使用这个锁了
可重入锁以ReentrantLock为例进行展开
- 什么是可重入:再次获取同一把锁时不需要释放之前的锁
- 代码演示1,反复调用:
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
}
运行结果:
0
1
2
3
2
1
0
- 代码演示2:递归调用
public class RecursionDemo {
private static ReentrantLock lock = new ReentrantLock();
private static void accessResource(){
lock.lock();
try {
System.out.println("已经对资源进行处理");
if (lock.getHoldCount()<5){
//递归调用
System.out.println(lock.getHoldCount());
accessResource();
System.out.println(lock.getHoldCount());
}
}finally {
lock.unlock();
}
}
public static void main(String[] args) {
new RecursionDemo().accessResource();
}
}
运行结果:
已经对资源进行处理
1
已经对资源进行处理
2
已经对资源进行处理
3
已经对资源进行处理
4
已经对资源进行处理
4
3
2
1
从结果可以看出获得锁之后是可以重复获得锁再最后释放的,这就是可重入锁
- 可重入锁的好处
- 避免了死锁
- 提高了封装性
公平锁和非公平锁
什么是公平和非公平
- 公平:指按照线程请求的顺序来分配锁
- 非公平:不完全按照请求的顺序,在合适的时机下,可以插队
为什么要有非公平锁
- 为了提高效率(大多数都默认采用非公平锁)
- 避免唤醒带来的空档期
公平的情况(以ReentrantLock 为例)
- 如果创建ReentrantLock 对象时,参数填写为true,那么这个锁就是公平锁
演示案例:模拟打印机打印任务,有两个类,一个是打印作业Job类,一个是打印队列PrintQueeue 类,一个打印任务包含两次打印,两次获得锁。在main方法中创建10个线程执行Job,当锁使用公平锁时:
/**
* 〈演示公平锁和不公平锁〉
*
* @author Chkl
* @create 2020/3/11
* @since 1.0.0
*/
public class FairLock {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
PrintQueeue printQueeue = new PrintQueeue();
Thread thread[] = new Thread[10];
for (int i = 0; i < 10; i++) {
thread[i] = new Thread(new Job(printQueeue));
}
for (int i = 0; i < 10; i++) {
thread[i].start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Job implements Runnable {
PrintQueeue printQueeue;
public Job(PrintQueeue printQueeue) {
this.printQueeue = printQueeue;
}
@Override
public void run() {
System.out.println(
Thread.currentThread().getName() + "开始打印");
printQueeue.printJob(new Object());
System.out.println(
Thread.currentThread().getName() + "打印结束");
}
}
class PrintQueeue {
//公平锁
private Lock queueLock = new ReentrantLock(true);
//非公平锁
// private Lock queueLock = new ReentrantLock();
public void printJob(Object document) {
queueLock.lock();
try {
int duration = new Random().nextInt(10) + 1;
System.out.println(Thread.currentThread().getName() + "正在打印,需要时间" + duration);
Thread.sleep(duration * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
queueLock.lock();
try {
int duration = new Random().nextInt(10) + 1;
System.out.println(Thread.currentThread().getName() + "正在打印,需要时间" + duration);
} finally {
queueLock.unlock();
}
}
}
使用公平锁进行打印操作,每个锁会依次执行,一定是一个锁结束之后另一个锁开始打印,不会出现插队。一次运行结果如下,因为每次打印后需要休眠n秒模拟打印耗时,休眠时间足够所有的线程依次启动,所以执行顺序一定是线程0-9执行第一个打印后线程0-9执行第二次打印,顺序一定不会变
Thread-0开始打印
Thread-0正在打印,需要时间1
Thread-0正在打印,需要时间2
Thread-0打印结束
Thread-1开始打印
Thread-1正在打印,需要时间9
Thread-2开始打印
Thread-3开始打印
Thread-4开始打印
Thread-5开始打印
Thread-6开始打印
Thread-7开始打印
Thread-8开始打印
Thread-9开始打印
Thread-2正在打印,需要时间9
Thread-3正在打印,需要时间1
Thread-4正在打印,需要时间8
Thread-5正在打印,需要时间3
Thread-6正在打印,需要时间5
Thread-7正在打印,需要时间2
Thread-8正在打印,需要时间6
Thread-9正在打印,需要时间2
Thread-1正在打印,需要时间4
Thread-1打印结束
Thread-2正在打印,需要时间6
Thread-2打印结束
Thread-3正在打印,需要时间6
Thread-3打印结束
Thread-4正在打印,需要时间7
Thread-4打印结束
Thread-5正在打印,需要时间8
Thread-5打印结束
Thread-6正在打印,需要时间1
Thread-6打印结束
Thread-7正在打印,需要时间1
Thread-7打印结束
Thread-8正在打印,需要时间3
Thread-8打印结束
Thread-9正在打印,需要时间5
Thread-9打印结束
不公平的情况(以ReentrantLock 为例)
修改PrintQueeue 中的锁为非公平锁
//非公平锁
private Lock queueLock = new ReentrantLock();
一次运行结果如下,从结果可以看到,打印顺序并没有再按照0-9、0-9执行了,线程2的第一次打印结束后马上又开始了第二次打印,这就是非公平锁的好处了,线程2执行完第一个打印之后,线程3准备打印,但是准备的空窗期线程2干脆一次性把第二次打印也完成了,不影响线程3打印的正常运行,同理下面的线程56789都是这种情况,提高了效率,充分利用了空窗期
Thread-0开始打印
Thread-0正在打印,需要时间1
Thread-1开始打印
Thread-2开始打印
Thread-3开始打印
Thread-4开始打印
Thread-5开始打印
Thread-6开始打印
Thread-7开始打印
Thread-8开始打印
Thread-9开始打印
Thread-1正在打印,需要时间4
Thread-2正在打印,需要时间5
Thread-2正在打印,需要时间5
Thread-3正在打印,需要时间8
Thread-2打印结束
Thread-3正在打印,需要时间9
Thread-3打印结束
Thread-4正在打印,需要时间8
Thread-5正在打印,需要时间10
Thread-5正在打印,需要时间5
Thread-5打印结束
Thread-6正在打印,需要时间2
Thread-6正在打印,需要时间10
Thread-6打印结束
Thread-7正在打印,需要时间2
Thread-7正在打印,需要时间5
Thread-7打印结束
Thread-8正在打印,需要时间5
Thread-8正在打印,需要时间9
Thread-8打印结束
Thread-9正在打印,需要时间8
Thread-9正在打印,需要时间6
Thread-9打印结束
Thread-0正在打印,需要时间5
Thread-0打印结束
Thread-1正在打印,需要时间6
Thread-1打印结束
Thread-4正在打印,需要时间1
Thread-4打印结束
特例
- trylock()方法不准守公平规则,自带插队属性
- 当trylock()执行时,一旦有线程释放了锁,就一定被使用trylock()的线程获得,即使现在这个锁的等待队列里有线程在等待
对比非公平和公平的优缺点
共享锁和排它锁
以ReetrantReadWriteLock读写锁为例
什么是共享锁和排它锁
- 排它锁:又称独占锁,独享锁
- 共享锁:又称为读锁,获得共享锁后,可以查看但是无法修改和删除数据,其他线程此时也可以蝴蝶共享锁,同样无法修改和删除数据
- 共享锁和排它锁的典型就是读写锁ReetrantReadWriteLock,其中读锁是共享锁,写锁是排它锁
读写锁的作用
- 在没有读写锁之前,假设我们使用ReetrantLock,虽然保证了线程安全,但是也浪费了一定的资源:多个读操作同时进行,并没有线程安全问题
- 在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率
读写锁的规则
- 当一个线程占用读锁时,其他线程可以申请读锁,不能申请写锁
- 当一个线程占用写锁时,其他线程读锁写锁都不可以申请
- 总结:
要么多读,要么一写
ReetrantReadWriteLock的具体用法
创建4个线程,前两个获取读锁,后两个获取写锁
运行后可以看到读锁可以同时获取,写锁必须获取释放了才能再获取
public class CinemaReadWrite {
private static ReentrantReadWriteLock
reentrantReadWriteLock = new ReentrantReadWriteLock();
//读锁
private static ReentrantReadWriteLock.ReadLock
readLock = reentrantReadWriteLock.readLock();
//写锁
private static ReentrantReadWriteLock.WriteLock
writeLock = reentrantReadWriteLock.writeLock();
public static void main(String[] args) {
new Thread(()->read(),"Thread1").start();
new Thread(()->read(),"Thread2").start();
new Thread(()->write(),"Thread3").start();
new Thread(()->write(),"Thread4").start();
}
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName()
+ "得到了读锁,正在读取ing");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName()+"释放读锁");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName()
+ "得到了写锁,正在写入ing");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName()
+"释放写锁");
writeLock.unlock();
}
}
}
运行结果:
Thread1得到了读锁,正在读取ing
Thread2得到了读锁,正在读取ing
Thread1释放读锁
Thread2释放读锁
Thread3得到了写锁,正在写入ing
Thread3释放写锁
Thread4得到了写锁,正在写入ing
Thread4释放写锁
读锁和写锁的交互方法
- 读锁插队策略
- 公平锁:不允许插队
- 非公平锁:
- 写锁可以随时插队
- 读锁仅在等待队列头节点不是想要获取写锁的线程的时候可以插队(有写锁马上要执行了就不允许插队)
不能插队的代码演示:将上面的案例的调用进行修改,顺序为w,r,r,w,r
线程2和线程3执行读的操作的时候,线程5不能插队,因为等待队列头的线程4是写锁
public static void main(String[] args) {
new Thread(()->write(),"Thread1").start();
new Thread(()->read(),"Thread2").start();
new Thread(()->read(),"Thread3").start();
new Thread(()->write(),"Thread4").start();
new Thread(()->read(),"Thread5").start();
}
运行结果:
Thread1得到了写锁,正在写入ing
Thread1释放写锁
Thread2得到了读锁,正在读取ing
Thread3得到了读锁,正在读取ing
Thread3释放读锁
Thread2释放读锁
Thread4得到了写锁,正在写入ing
Thread4释放写锁
Thread5得到了读锁,正在读取ing
Thread5释放读锁