一、Lock
简介
锁是一种工具,用于控制对共享资源的访问
Lock和Synchronized 它们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同
Lock并不是用来代替Synchronized的,而是当使用Synchronized不合适或不足以满足要求的时候来提供高级功能的
Lock 接口最常见的实现类是ReentrantLock
通常情况下,Lock值允许一个线程访问这个共享资源。不过有的时候,一些特殊的视线也可允许并发访问,比如ReadWriteLock里面的ReadLock
为什么需要Lock
Synchronized的弊端
- 效率低:锁的释放情况少,试图获得锁时不能设定超时,不能中断一个正在试图获得锁的线程
- 不够灵活(读写锁更灵活): 加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的
- 无法知道是否成功获取到锁
Lock主要方法介绍
lock():
-
获取锁,如果锁已经被其他线程获取,则进行等待
- Lock不会想Synchronized一样在异常时自动释放锁,因此需要在finally中释放锁,以保发生异常时锁一定被释放。
- lock()方法不能被中断,一旦陷入死锁,lock()就会陷入永久等待
tryLock()
- 用来尝试获取锁,如果当前锁没有其他线程占用则获取成功(返回true),获取失败(返回false)
- 可以根据是否能获取到锁来决定后续程序行为
- 该方法会立即返回,即使在拿不到锁时不会一直在那等待
- tryLock(long time,TimeUnit unit):超时就放弃
- lockInterruptibly() : 相当于tryLock(long time,TimeUnit unit) 把超时时间设置为无限,在等锁的过程中,线程可以被中断
unlock()
- 解锁:一定要在fianlly中解锁
可见性
Lock的加解锁和synchronize有同样的语义,也就是说下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作(happen-before)
二、锁的分类
1. 乐观锁和悲观锁
悲观锁(互斥同步锁)的劣势
- 阻塞和唤醒带来的性能劣势
- 永久阻塞:如果持有锁的线程被永久阻塞例如遇到了无限循环。死锁等活跃性问题,那么等待该线程释放锁的将永远得不到执行
- 优先级反转
乐观锁
- 认为在自己处理操作的时候不会有其他线程来干扰,所以不会锁住被操作对象
- 在更新的时候,去对比咋我修改期间数据有没有被修改过,如果没有被修改过就正常去修改数据,如果有被修改过会选择放弃、重试等策略
- 乐观锁的视线一般都是利用CAS算法来实现的
悲观锁
- 如果不锁住这个资源,别人就会来争抢就会造成数据结果的错误,所以每次悲观锁为了确保结果的正确性会在每次获取并修改数据时把数据锁住,让别人无法访问该数据这样就可以确保数据内容万无一失
- java中的悲观锁的视线就是Synchronize和Lock相关类
悲观锁和乐观锁开销对比
- 悲观锁的原始开销要高于乐观锁但特点是一劳永逸,临界区持锁时间就算越来越长也不会对悲观锁的开销造成影响
- 乐观锁虽然一开始的开销被悲观锁小,但是如果自旋时间很长或者不停地重试,那么小号的资源也会越来越多
- 悲观锁适合并发写入多的情况,适用于临界区吃锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,典型情况
临界区有IO操作
临界区代码复杂或者循环量大
临界区竞争非常激烈
- 乐观锁适合并发写入少,大部分是读取的场景,不加锁的能让读取性能大幅度提高
二、可重入锁和非重入锁 ReentrantLock
什么是可重入
同一个线程可以多次获取同一把锁
好处
- 避免死锁
- 提升封装性
源码
三、公平锁和非公平锁
公平:按照线程请求的顺序来分配锁
非公平:不完全按照请求的顺序,在一定情况下可以插队
注意:非公平同样不提倡“插队”行为,这里的非公平,指的是“在合适的时间”插队,而不是盲目插队,非公平可以提高效率,避免唤醒带来的空档期
公平的情况
- 以ReentrantLock为例,默认是非公平锁,参数填写true,那么这就是个公平锁
- 假设线程1234按顺序调用lock,那么线程会按照顺序得到lock
不公平情况
如果在线程1释放锁的时候,线程5恰好去执行lock(),由于ReentrantLock 发现此时并没有线程持有lock这把锁(线程2还没来及获取到,因为获取需要时间)线程5可以插队直接拿到这把锁,这也是ReentrantLock默认的公平策略,也就是“不公平”。
特例
- 针对tryLock()方法,它不遵守设定的公平规则例如:
- 当线程执行tryLock()的时候,一旦有线程释放了锁,那么这个正在tryLock的线程就能获取到锁,即使在它之前已经有其他现在在等待队列里了
公平和非公平的优缺点
优势 | 劣势 | |
公平锁 | 各线程公平等待,每个线程在等待一段时间后,总有执行机会 | 更慢,吞吐量更小 |
非公平锁 | 更快,吞吐量更大 | 有可能产生线程饥饿,也就是某些线程在长时间始终得不到执行 |
源码
四、共享锁和排它锁
- 排它锁:又称为独占锁,独享锁(例如 synchronize)
- 共享锁:又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看但无法修改和删除数据
共享锁和排它锁的典型是读写锁ReentrantReadWriteLock,其中读锁是共享锁,写锁是独享锁
读写锁作用
在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率,读写锁适用于读多写少的情况
读写锁的规则
要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现(要么多读,要么一写)
- 多个线程只申请读锁,都可以申请到
- 如果有一个线程已经占用读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁
- 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放锁
读锁插队策略
公平锁:不能插队
非公平锁:
- 写锁可以随时插队
- 读锁仅在等待队列头结点不是想获取写锁的线程的时候可以插队
读写锁的升降级
- 只支持降级(写锁->读锁)不支持升级(读锁->写锁),因为锁的升级会造成死锁
五、自旋锁和阻塞锁
-
阻塞或唤醒一个java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理时间
- 如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长
- 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复线程的花费可能会让系统得不偿失
- 如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面的那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁
- 而为了让当前线程“稍等一下”,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是追获取同步资源,从而避免切换线程的开销,这就是自旋锁
- 阻塞锁和自旋锁相反,阻塞锁如果没遇到没拿到锁的情况会直接把线程阻塞掉,直到被唤醒。
自旋锁的缺点
- 如果锁占用时间很长,那么自旋的线程只会浪费处理器资源
- 在自旋过程中,一直消耗CPU,所以虽然自旋的其实开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的。
适用场景
- 自旋锁一般使用于多核服务器并且并发读不是特别高的情况下,比阻塞锁的效率要高
- 自旋锁适用于临界区比较短小的情况,否则如果临界区很大(线程一旦拿到锁,很久才会释放),那也是不合适的
六、可中断锁
- synchronize就是不可中断锁,而Lock是可中断锁,因为TryLock(time)和lockInterruptibly 都能响应中断
- 如果某一线程A正在执行锁中的代码,另一个线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以中断它,这就是可中断锁。