Lock接口
1. 简介,地位,作用
锁是一种工具,用于控制对共享资源的访问
Lock不是用来替代Synchronized的,而是在Synchronized不满足的时候提供更多的高级功能的。
Lock接口中最常见的实现类是ReentrantLock.
通常情况下,Lock只允许一个线程访问这个共享资源,在有些时候,一些特殊实现也可以允许并发访问,比如ReadWriteLock中的ReadLock
2. Synchronized不够用?为什么需要Lock?
Synchronized效率低,不能设定超时,不能中断一个试图获得锁的线程获得锁。
Synchronized不够灵活,
Synchronized没法知道是否成功获取到了锁。
3. 方法介绍
lock()
获取锁,如果锁被其他线程占用,就等待。
lock一定得手动释放锁,通常在finally中释放。
lock()不能被中断,所以隐患是:陷入死锁,永久等待。
tryLock()
尝试获取锁。获取成功返回true,获取失败返回false。
可以根据获取结果决定后续的逻辑,怎么处理。
这个方法会立刻返回,拿不到锁不会一直等待
tryLock(long time,TimeUnit unit)
尝试获取锁,可以等一段时间,如果超时还没获取到就返回结果
false,如果在时间内拿到了锁,返回true。
if (lock.tryLock(1000, TimeUnit.MILLISECONDS)) {
//获取锁成功的代码段里面要try{}finally{}释放锁。
try{
//do some work
} finally {
lock.unLock();
}
}
lockInterruptibly()
相当于tryLock(long time, TimeUnit unit)将time设置为无限大。在等待锁的过程中线程可以中断。
unlock() 解锁
建议在每次获取锁的时候先写finally{lock.unlock()}
,之后再写获得锁之后业务逻辑,这是一个好习惯。
4. 可见性保证
符合happens-before
锁的分类
乐观锁和悲观锁
乐观锁也叫非互斥同步锁,悲观锁也叫互斥同步锁
为什么会诞生非互斥同步锁(乐观锁)
互斥同步锁的劣势:
- 阻塞和唤醒带来性能问题,互斥同步锁锁住之后就是独占的,其他线程想要同样的资源必需等待。涉及到上下文切换,带来性能问题。而乐观锁不需要挂起线程。
- 互斥同步锁可能会陷入永久阻塞:比如遇到无限循环,死锁等活跃性问题。那等待锁的那几个悲催的线程永远也得不到执行。
- 优先级反转:优先级高的线程等待优先级低的线程释放锁,如果低优先级的线程迟迟不释放, 高优先级的线程即使优先级更高,也得不到执行。
什么是乐观锁,悲观锁?
乐观锁:
- 乐观锁,认为事情总是不大容易失败的,失败是小概率,先做事,遇到问题再面对。
- 乐观锁不会锁住被操作对象。
- 在更新的时候,去对比在我操作这段时间是否其他的线程修改过对象数据,如果没有修改过,就说明真的只有我自己在操作,那我就正产修改数据。
- 如果数据和我一开始拿到的不一样,说明这段事件数据被其他线程改过了,那我就不能继续更新,此时可以采取放弃,报错,重试等等策略。
乐观锁的实现一般都是利用CAS算法实现的。典型例子就是原子类,并发容器, Git
悲观锁:
- 悲观锁,认为事情出问题是大概率的事件,出错可能是一种常态,担惊受怕。事无巨细都考虑的滴水不漏,保证万无一失,这个就是悲观锁的思想。
- Java中悲观锁的实现就是Synchronized和Lock相关类
开销对比
悲观锁的原始开销要高于乐观锁,但是一劳永逸
相反,虽然乐观锁一开始的开销小于悲观锁,但是如果自旋时间很长或者不停重试,那消耗的资源也会越来越多。
两种锁各自的适用场景
悲观锁适用场景:
适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋消耗。
典型情况:
- 临界区有IO操作
- 临界区代码复杂或者循环量大
- 临界区竞争非常激烈
乐观锁适用场景:
适用并发写入少,大部分是读取的场景,不加锁能让读取性能大幅提高。
可重入锁和非可重入锁,以ReentrantLock为例
Reentrant:就是可重入的意思
ReentrantLock lock = new ReentrantLock();
//加锁几次
lock.lock();
lock.lock();
lock.lock();
//对应就要解锁几次
lock.unLock();
lock.unLock();
lock.unLock();
可重入锁ReentrantLock和非可重入锁ThreadPoolExecutor的Worker类,源码对比
公平锁和非公平锁
什么是公平和非公平
公平指的是按照请求顺序分配锁。非公平指的是可以插队。
非公平也同样不提倡插队,“插队”也要在合适的时机插队,不能盲目插队。
非公平是为了避免唤醒带来的空档期,提高效率.
ReentrantLock默认是非公平的,但是可以通过给构造方法参数设置为true,成为公平的。
针对tryLock(),它不遵守设定的公平策略,tryLock() 的时候如果正好有线程释放锁,那么tryLock() 的线程就是获得锁,不管其他线程是否在队列里等待。
公平锁:每个线程总有执行的机会,但是稍慢,吞吐量小。
不公平锁:快,吞吐量大,但是线程可能会饥饿。
共享锁和排它锁
以ReentrantReadWriteLock读写锁为例
排它锁
也叫独占锁,独享锁
共享锁
也称为读锁,获取共享锁之后,可以查看数据,但不能修改和删除,
ReentrantReadWriteLock中,读锁是共享锁,写锁是排它锁。
读写锁的规则
- 多个线程只申请读锁都可以申请到。
- 如果有一个线程已经占用了读锁,那其他线程如果要申请写锁,只能等待读锁释放。因为在别人读的时候,你来写是有风险的,所以等他读完才能写。
- 如果有线程已经占用了写锁,那其他线程不论读、写都得等待。
简单说:要么多读,要么一写。
换一种方式理解:
读锁,写锁,只是一把锁,通过读锁定,写锁定这两种方式锁定。
ReentrantReadWriteLock
适用于读多,写少的情况
//创建读写锁,true代表是公平锁
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);
//拿到读锁
ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
//拿到写锁
ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
插队
公平锁:不允许插队
非公平锁:
1.写锁可以插队
2.读锁只有在等待队列头节点同样是想获得读锁的线程这种情况,才允许插队
不允许读锁插队,如果允许会造成想要写锁的线程饥饿
升降级
升级:获取了读锁,还要获得写锁
降级:获得了写锁,还想持有读锁
策略:允许降级,不允许升级
//读锁升级,不允许
private static void readUpgrading() {
//获得读锁
readLock.lock();
try {
//获得写锁
writeLock.lock();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放读锁
readLock.unlock();
}
}
//写锁降级,允许
private static void readUpgrading() {
//获得写锁
writeLock.lock();
try {
//获得读锁
readLock.lock();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放读锁
readLock.unlock();
//释放写锁
writeLock.unlock();
}
}
降级能提高效率,升级可能会产生死锁
自旋锁和阻塞锁
阻塞或唤醒线程需要切换CPU状态,这种状态转换需要耗费处理器时间,如果同步的代码内容过于简单,状态转换等待时间比代码执行的事件还要长,
为了让当前线程稍等一下,让当前线程自旋,如果前面的线程释放了锁,当前自旋的线程可以不必阻塞直接获得锁,从而避免切换线程的开销。
自旋锁的缺点:
如果锁被占用的时间很长,自旋会浪费处理器资源。自旋的过程,其实一直在消耗CPU,起始的开销低,但是随着自旋时间的增加,开销也是线程增长的。
自旋锁原理是CAS
可中断锁,不可中断锁
Synchronized即不可中断锁
Lock类即可中断锁
Java虚拟机对锁的优化
自旋锁和自适应
自适应就是多次自旋依然不能获得锁,就就转为阻塞
锁消除
有些场景不需要加锁,是安全的,jvm会把锁去掉
锁粗化
如果一片代码段反反复复加同一把锁,性能并不好,干脆给这一片代码加一个锁
写代码时如何优化锁
1.缩小同步代码块
2.尽量不锁方法
3.减少请求锁的次数
4.锁中尽量不包含锁,处理不好容易死锁
5.选择合适的锁类型,合适的工具类