Java并发成神之路-精通JUC并发工具十八般武艺——Lock锁(三)
Lock接口
简介、地位、作用
- 锁是一种工具,用于控制对共享资源的访问
- Lock和synchronized,这两个是最常见的锁,它们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同
- Lock并不是用来代替synchronized的,而是当使用synchronized不合适或不满足要求的时候,来提供高级功能
- Lock接口最常见的实现类就是ReentrantLock
- 通常情况下,Lock只允许一个线程来访问这个共享资源,不过有的时候,一些特殊的实现也可以运行并发访问,比如ReadWriteLock里面的ReadLock
为什么synchronized不够用?为什么需要Lock锁?
- 效率低:锁的释放情况少,试图获得锁时不能设定超时、不能中断一个正在试图获得锁的过程
- 不够灵活(读写锁更灵活):加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的
- 无法知道是否成功获取到锁
方法介绍
- 在Lock中声明了四个方法来获取锁
lock()
、tryLock()
、tryLock(long time,TimeUnit unit)
和lockInterruptibly()
四个方法的区别
lock()
lock()
就是最普通的获取锁,如果锁已经被其他线程获取,则进行等待- Lock不会想synchronized一样在异常时自动释放锁
- 因此最佳实践是,在finally中释放锁,以保证发生异常时锁一定被释放
lock()
方法不能被中断,这会带来很大隐患:一旦陷入死锁,lock()
就会陷入永远等待
tryLock()
- tryLock()用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,则返回true,否则返回false,代表获取锁失败
- 相比与
lock()
,这样的方法显然功能更强大了,我们可以根据是否能获取到锁来决定后续程序的行为 - 该方法会立即返回,即便在拿不到锁时不会一直在那等
tryLock(long time,TimeUnit unit)
- 相比与tryLock()方法,增加了超时时间,和时间类型两个参数
- 尝试获取锁,并不会立即返回,而是会等待指定时间,如果超时了就会返回,或者拿到锁了也会返回
lockInterruptibly()
- 相当于
TryLock(long time,TimeUnit unit)
把超时时间设置为无限,在等待锁的过程中线程可以被中断
unlock()
- 解锁
锁分类
- 这些分类,是从各种不同角度出发去看的
- 这些分类并不是互斥的,也就是多个类型可以并存:有可能一个锁,同时属于两种类型
- 比如ReentrantLock即是互斥锁,又是可重入锁
乐观锁和悲观锁
为什么还会诞生非互斥同步锁(乐观锁)——互斥同步锁(悲观锁)
互斥同步锁的劣势
- 阻塞和唤醒带来的性能劣势(线程状态切换,上下文切换,检查是否有被阻塞线程等,都会带来性能损耗)
- 永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个悲催的线程,将永远也得不到执行
- 优先级反转(低优先级的线程先拿到了锁,高优先级的线程也只能等待它完成之后才能拿到锁)
什么是乐观锁和悲观锁
悲观锁
- 如果我不锁柱这个资源,别人就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果正确性,会在每次获取并修改数据时,把数据锁住,让别人无法访问该数据,这样就可以确保数据内容万无一失
- Java中悲观锁的实现就是synchronized和Lock相关类
乐观锁
- 认为自己在操作的时候不会有其他线程来干扰,所以并不会锁住被操作对象
- 在更新的时候,去对比在我修改期间数据有没有被其他人改过:如果没被改变过,就说明真的是只有我自己在操作,那我就正常去执行修改数据
- 如果数据和我一开始拿到的不一样了,说明其他人在这段时间内修改过数据,那我就不能继续刚才的更新数据过程了,我会选择放弃修改、报错、重试等策略
- 乐观锁的实现一般都是利用CAS算法来实现的
典型例子
- 乐观锁的典型例子就是原子类、并发容器等
- 悲观锁的典型例子就是synchronized和Lock相关类
- Git:Git就是乐观锁的典型例子,当我们往远程仓库push的时候,git会检查远程仓库的版本是不是领先于我们现在大的版本,如果远程仓库的版本号和本地的不一样,就表示有其他人修改了远程代码了,我们的这次提交就失败,如果远端和本地版本号一致,我们就可以顺利提交版本到远端仓库
- 数据库
- select for update就是悲观锁
- 用version控制数据就是乐观锁
- 添加一个字段lock_version
- 先查询这个更新语句的version:select * from table
- 然后update set num = 2,version = version+1 where version = 1 and id = 5
- 如果version被更新等于2,不一样就会出现更新错误,这就是乐观锁的原理
开销对比
- 悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响
- 相反,虽然乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多
两种锁的使用场景
- 悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,典型情况:
- 临界区有IO操作
- 临界区代码复杂或者循环量大
- 临界区竞争非常激烈
- 乐观锁:适合并发写入少,大部分是读取的场景,不加锁能让读取性能大幅度提高
可重入锁和非可重入锁,以ReentrantLock
为例(重点)
可重入性质
什么是可重入
- 当一个线程获取到一把锁之后,可以再次获取这把锁,甚至多次获得这把锁,递归获取
- ReentrantLock和synchronized都是可重入锁
- 好处:可以避免死锁,比如需要多次获取当前锁的时候,不可能放弃再重新获取
- 代码示例如下,递归进行调用,每次不需要重新获取锁可重入锁和不可重入锁源码对比:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class RecursionDemo {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
accessResource();
}
private static void accessResource(){
lock.lock();
try {
System.out.println("已经对资源进行了处理");
if (lock.getHoldCount() < 5)
accessResource();
}finally {
lock.unlock();
}
}
}如图所示,可重入锁,在线程再次获取锁的时候,会判断当前线程是否持有锁,如果有,就在标记上加1
解锁的时候同理,标记减一,最后判断是否等于0,等于0表示彻底解锁
不可重入锁则是直接判断锁的状态,已被持有直接返回
ReentrantLock的其他方法介绍
isHeldByCurrentThread
可以看出锁是否被当前线程持有- getQueueLength可以返回当前正在等待这把锁的队列有多长,一般这两个方法是开发和调试的时候使用,上线后不会使用
公平锁和非公平锁
什么是公平和非公平
- 公平值得是按照线程请求的顺序,来分配锁
- 非公平指的是,不完全按照请求的顺序,在一定情况下可以插队(并不是完全打乱顺序)
- 注意:非公平也同样不提倡“插队”行为,这里的非公平,指的是“在合适的时机”插队,而不是盲目插队
- 合适的时机:在被唤醒的空档期,有一个完全清醒的线程来获取锁,可以直接拿到
为什么要有非公平
- 提高效率
- 避免唤醒带来的空档期
公平情况(以ReentrantLock
为例)
- 如果创建ReentrantLock对象时,参数填写为true,那么这就是一个公平锁
- 假设有1234,四个线程去获取锁,1拿到了锁,234就在队列里面等待,2完就是3,然后4
不公平情况(以ReentrantLock
为例)
- 还是上面的例子,假如在1释放锁的时候,线程5恰好去执行
lock()
- 由于
ReentrantLock
发现此时并没有线程持有lock这把锁(线程2还没有来得及获取,因为唤醒和获取需要时间) - 线程5可以插队,直接拿到这把锁,这也是
ReentrantLock
默认的公平策略,也就是“不公平”
代码示例
如下面代码所示,当ReentrantLock参数为true,设置为公平锁的时候
线程1打印完毕第一次之后,想要再次获取到锁,就会排队到线程10的后面,需要1到10挨个打印完第一伦之后,才会打印第二轮
当ReentrantLock参数为false,设置为非公平锁的时候
线程1打印完之后,就会尝试第二次获取锁,而这个时候线程2唤醒还需要时间,就会出现,线程1连着两次打印,线程2连着打印的情况
1 | public class FairLock { |
特例
- 针对
tryLock()
方法,它不遵守设定的公平规则(无论公平还是非公平) - 例如,当有线程在执行
tryLock()
的时候,一旦有线程释放了锁,那么这个正在tryLock的线程就能获取到锁,即使在它之前已经有其他线程等待在队列里面了
对比公平和非公平的有缺点
优势 | 劣势 | |
---|---|---|
公平 | 各线程公平平等,每个线程在等待一段时间后,总有执行的机会 | 更慢,吞吐量更小 |
不公平 | 更快,吞吐量更大 | 有可能产生线程饥饿,也就是某些线程在长时间内,始终得不到执行 |
源码对比
公平锁和非公平锁的源码对比如下
可以看出,公平锁在线程获取锁的时候会先判断,队列中是否有任务,非公平锁则不会判断,直接就去获取锁
共享锁和排它锁:以ReentrantReadWriteLock
读写锁为例(重点)
什么是共享锁和排他锁
- 排它锁:又叫独占锁、独享锁
- 共享锁:又称为读锁,获取共享锁之后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看但无法修改和删除数据
- 共享锁和排他锁的典型就是读写锁
ReentrantReadWriteLock
,其中读锁是共享锁,写锁是独享锁
读写锁的作用
- 在没有读写锁之前,我们假设使用ReentrantLock,那么虽然我们保证了线程安全,但是也浪费了一定的资源:多个读操作同时进行,并没有线程安全问题
- 在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是没有阻塞的,提高了程序的执行效率
读写锁的规则
- 多个线程只申请读锁,都可以申请到
- 如果又一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁
- 如果又一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁
- 总结:要么是一个或者多个线程同时读锁,要么是一个线程拥有写锁,但是两者不会同时出现(要么多读,要么一写)
- 换一种理解思路:读写锁只是一把锁,可以通过两种方式锁定:
- 读锁定和写锁定
- 读写锁可以同时被一个或多个线程锁定,也可以被单一线程写锁定
- 但是永远不能同时对这把锁进行读锁定和写锁定
读写锁代码实现如下所示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39public class CinemaReadWrite {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void read(){
readLock.lock();
try {
System.out.println(Thread.currentThread().getName()+"得到了读锁,正在读取");
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()+"得到了写锁,正在写入");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName()+"释放了写锁");
writeLock.unlock();
}
}
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();
}
}1
2
3
4
5
6
7
8Thread1得到了读锁,正在读取
Thread2得到了读锁,正在读取
Thread2释放了读锁
Thread1释放了读锁
Thread3得到了写锁,正在写入
Thread3释放了写锁
Thread4得到了写锁,正在写入
Thread4释放了写锁
读写锁插队策略
- 公平锁:不允许插队
- 非公平锁:
- 写锁可以随时插队
- 读锁仅在队列头节点不是想获取写锁的线程的时候可以插队
- (如果读锁可以随意插队,那么可能会造成写锁饥饿,读取需要时间,假如刚好一个结束,另一个开始,这样会导致写锁一直得不到执行)
根据
ReentrantReadWriteLock
中的源码可以看出来,公平锁读写锁每次都会判断队列,在决定是否去获取锁
非公平锁,写锁直接返回false,可以去进行插队,读锁会判断队列第一位是不是排它锁,也就是写锁
1 | public class NonfairBargeDemo { |
插队演示代码如上所示,先将锁设置为非公平锁
读锁插队:我们先将创建线程获取到一个写锁,写锁完毕之后,会唤醒后面的读锁,后面跟了多个读锁,按照顺序唤醒,需要时间去执行,而这个时候,插队线程会不停的发出插队请求
具体结果如下图
线程1释放读锁之后,线程2被唤醒,然后线程158开始插队并插队成功,之后线程3和4拿到了读锁,然后线程159插队成功,线程5拿到读锁之后,线程5后面的线程6拿的是写锁
队列第一位为写锁的时候读锁不允许插队,所以后面的插队读请求全部插队失败
如下图所示,当之前的读锁任务执行完毕之后,并没有其他线程来抢锁,直接由线程7写锁抢锁成功,之后的所有尝试获取读锁的线程都进行等待
如果是公平锁,结果如下两图所示,线程1执行完毕之后,线程2、3、4、5开始执行,期间又线程进行尝试,但都在获取锁的时候进行等待
当线程2、3、4、5执行完毕,线程6开始执行,线程6释放锁之后,线程7才开始和其他线程一起执行读锁
锁的升降级
- 支持降级,不支持升级
- 假设又两个线程同时持有读锁,其中一个想要进行升级,那么等另外一个线程执行完毕就可以进行升级,但是如果两个线程都想要升级,那么就会互相等待,陷入死锁(锁可以支持升级,只不过
ReentrantReadWriteLock
为了避免死锁不支持升级,可以自己实现锁支持升级,但是需要保证升级的时候只有一个线程进行升级,不然就容易陷入死锁)锁升降级代码如下所示
在获取到了写锁的情况下可以直接去拿读锁进行降级,可以成功
在拿到读锁的情况下,再去尝试获取写锁进行升级,会陷入阻塞中1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46public class Upgrading {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void readUpgrading(){
readLock.lock();
try {
System.out.println(Thread.currentThread().getName()+"得到了读锁,正在读取");
Thread.sleep(1000);
System.out.println("升级会带来堵塞");
writeLock.lock();
System.out.println(Thread.currentThread().getName()+"成功获取到了写锁,升级成功");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName()+"释放了读锁");
readLock.unlock();
}
}
private static void writeUpgrading(){
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName()+"得到了写锁,正在写入");
Thread.sleep(1000);
readLock.lock();
System.out.println("在不释放写锁的情况下直接获取读锁成功,锁降级成功");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.lock();
System.out.println(Thread.currentThread().getName()+"释放了写锁");
writeLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> writeUpgrading(), "Thread1");
thread.start();
thread.join();
System.out.println("-----------------------");
new Thread(()->readUpgrading(),"Thread2").start();
}
}
总结
ReentrantReadWriteLock
实现了ReadWriteLock
接口,最主要的有来给两个方法:readLock()
和writeLock()
用来获取读锁和写锁- 锁的申请和释放策略
- 多个线程只申请读锁,都可以申请到
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请的线程会一直等待释放读锁
- 如果有一个线程已经占用了写锁,则此时其他线程如果要申请读锁,则申请的线程会一直等待释放写锁
- 要么是一个或者多个线程同时持有读锁,要么是一个线程持有写锁,但是两者不会同时出现
- 插队策略:为了防止饥饿,读锁不能插队
- 升降级策略:只能降级,不能升级
- 使用场景:相比于
ReentrantLock
适用于一般场合,ReentrantReadWriteLock
适用于读多写少的情况,合理适用可以进一步提高并发效率
自旋锁和阻塞锁
- 阻塞或唤醒一个Java线程需要操作系统切换CPU的状态来完成,这种状态转换需要耗费处理器时间
- 如果同步代码块中的内容过于简单,状态切换消耗的时间有可能比用户代码执行的时间还要长
- 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失
- 如果物理机有多核处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快会释放锁
- 而为了让当前线程“稍微等一下”,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁
- 阻塞锁和自旋锁相反,阻塞锁如果遇到没拿到锁的情况,会直接把线程阻塞,直到被唤醒
自旋锁的缺点
- 如果锁被占用的时间很长,那么自旋的线程只会浪费处理器资源
- 在自旋的过程中,一直消耗CPU,所以虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的
原理和源码分析
- 在Java1.5版本及以上的并发框架
java.util.concurrent
的atomic包下的类基本都是自旋锁的实现 AtomicInteger
的实现:自旋锁的实现原理CAS,AtomicInteger
中调用unsafe
进行自增操作的源码中的do-while
循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在while里死循环,直至修改成功如图所示
自旋锁实现代码如下所示
1 | public class SpinLock { |
自旋锁的适用场景
- 自旋锁一般用于多核服务器,在并发度不是特别高的情况下,比阻塞锁的效率高
- 另外,自旋锁适用于临界区比较短小的情况,否则如果临界区很大(线程一旦拿到锁,很久之后才会释放),那也是不合适的
可中断锁:顾名思义,就是可以相应中断的锁
- 在Java中
synchronized
就是不可中断锁,而Lock
是可中断锁,因为tryLock(time)
和lockInterruptibly
都能响应中断 - 如果某一线程A正在执行锁中的代码,另一个线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以中断它,这种就是可中断锁
锁优化
Java虚拟机对锁的优化
- 自旋锁和自适应(在自旋的时候会自动判断自旋的时间,也许自旋几次之后就会转为阻塞,会根据上次获取锁的情况进行判断)
- 锁消除(jvm会判断对锁的使用,假设是在一个方法内部,根本不会出现并发情况,JVM会自动帮忙消除锁)
- 锁粗化(虚拟机检测到在很短的代码内对同一个多不停的加锁解锁,就会将锁的范围粗化,减少加锁解锁的次数)
我们在写代码时如何优化锁和提高并发性能
- 缩小同步代码块
- 尽量不要锁着方法
- 减少请求锁的次数
- 避免认为制造“热点”
- 锁中尽量不要再包含锁
- 选择合适的锁类型或合适的工具类