Lock锁 精讲
1.为什么需要Lock
- 为什么synchronized不够用,还需要Lock
Lock和synchronized这两个最常见的锁都可以达到线程安全的目的,但是功能上有很大不同。
Lock并不是用来代替synchronized的而是当使用synchronized不满足情况或者不合适的时候来提供高级功能的
- 为什么synchronized不够用
- 效率低:锁的释放情况较少,试图获得锁不能设定超时,不能中断一个正在试图获得锁的线程
- 不够灵活:加锁和释放的时候单一,每个锁仅有单一的条件可能是不够的
- 无法知道是否成功的获取锁
2.Lock锁的意义
- 与使用synchronized方法和语句相比, Lock实现提供了更广泛的锁操作。 它们允许更灵活的结构,可以具有完全不同的属性,并且可以支持多个关联的Condition对象。
- 锁是一种用于控制多个线程对共享资源的访问的工具。 通常,锁提供对共享资源的独占访问,一次只能有一个线程可以获取该锁,并且对共享资源的所有访问都需要首先获取该锁。 但是,某些锁可能允许并发访问共享资源,例如ReadWriteLock的读取锁。
- 使用synchronized方法或语句可访问与每个对象关联的隐式监视器锁,但会强制所有锁的获取和释放以块结构方式进行。当获取多个锁时,它们必须以相反的顺序释放锁。
- 虽然用于synchronized方法和语句的作用域机制使使用监视器锁的编程变得更加容易,并且有助于避免许多常见的涉及锁的编程错误,但在某些情况下,您需要以更灵活的方式使用锁。 例如,某些用于遍历并发访问的数据结构的算法需要使用“移交”或“链锁”:您获取节点A的锁,然后获取节点B的锁,然后释放A并获取C,然后释放B并获得D等。 Lock接口的实现通过允许在不同范围内获取和释放锁,并允许以任意顺序获取和释放多个锁,从而启用了此类技术。
3.锁的用法
灵活性的提高带来了额外的责任。 缺少块结构锁定需要手动的去释放锁。 在大多数情况下,应使用以下惯用法:
Lock lock = new ReentrantLock();
lock.lock();
try{
}finally {
lock.unlock();
}
当锁定和解锁发生在不同的范围内时,必须小心以确保通过try-finally或try-catch保护持有锁定时执行的所有代码,以确保在必要时释放锁定。
Lock实现通过使用非阻塞尝试获取锁( tryLock() ),尝试获取可被中断的锁( lockInterruptibly以及尝试获取锁),提供了比使用synchronized方法和语句更多的功能。可能会超时( tryLock(long, TimeUnit) )。
Lock类还可以提供与隐式监视器锁定完全不同的行为和语义,例如保证顺序,不可重用或死锁检测。 如果实现提供了这种特殊的语义,则实现必须记录这些语义。
请注意, Lock实例只是普通对象,它们本身可以用作synchronized语句中的目标。 获取Lock实例的监视器锁与调用该实例的任何lock方法没有指定的关系。 建议避免混淆,除非在自己的实现中使用,否则不要以这种方式使用Lock实例。
4.内存同步
所有Lock实现必须强制执行与内置监视器锁所提供的相同的内存同步语义,如Java语言规范中所述 :
- 一个成功的lock操作具有同样的内存同步效应作为一个成功的锁定动作。
- 一个成功的unlock操作具有相同的存储器同步效应作为一个成功的解锁动作。
不成功的锁定和解锁操作以及可重入的锁定/解锁操作不需要任何内存同步效果。
实施注意事项
锁获取的三种形式(可中断,不可中断和定时)在其性能特征可能有所不同。 此外,在给定的Lock类中,可能无法提供中断正在进行的锁定的功能。 因此,不需要为所有三种形式的锁获取定义完全相同的保证或语义的实现,也不需要支持正在进行的锁获取的中断。 需要一个实现来清楚地记录每个锁定方法提供的语义和保证。 在支持锁获取中断的范围内,它还必须服从此接口中定义的中断语义:全部或仅在方法输入时才这样做。
5.Lock提供的接口
5.1 获取锁
void lock(); // 获取锁。
- 最普通的的获取锁,如果锁被其他线程获取则进行等待
- lock不会像synchronized一样在异常的时候自动释放锁
- 因此必须在finally中释放锁,以保证发生异常的时候锁一定被释放
注意:lock()方法不能被中断,这会带来很大的隐患:一旦陷入死锁、lock()就会陷入永久等待状态
5.2 获取中断锁
void lockInterruptibly() throws InterruptedException;
除非当前线程被中断,否则获取锁。
获取锁(如果有)并立即返回。
如果该锁不可用,则出于线程调度目的,当前线程将被挂起,并在发生以下两种情况之一之前处于休眠状态:
- 该锁是由当前线程获取的;
- 其他一些线程中断当前线程,并支持锁定获取的中断。
如果当前线程:在进入此方法时已设置其中断状态;要么获取锁时被中断,并且支持锁获取的中断,然后抛出InterruptedException并清除当前线程的中断状态。
注意事项
在某些实现中,中断锁获取的能力可能是不可能的,并且如果可能的话可能是昂贵的操作。 程序员应意识到可能是这种情况。 在这种情况下,实现应记录在案。与正常方法返回相比,实现可能更喜欢对中断做出响应。Lock实现可能能够检测到锁的错误使用,例如可能导致死锁的调用,并且在这种情况下可能引发(未经检查的)异常。
注意 synchronized 在获取锁时是不可中断的
5.3 尝试获取锁
boolean tryLock();
非阻塞获取锁(如果有)并立即返回true值。 如果锁不可用,则此方法将立即返回false值。相比于Lock这样的方法显然功能更加强大,我们可以根据是否能获取到锁来决定后续程序的行为
注意:该方法会立即返回,即便在拿不到锁的时候也不会在一只在那里等待
该方法的典型用法是:
Lock lock = new ReentrantLock();
if(lock.tryLock()){
try{
// TODO
}finally {
lock.unlock();
}
}else{
// TODO
}
5.4 在一定时间内获取锁
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
如果线程在给定的等待时间内获取到锁,并且当前线程尚未中断,则获取该锁。
如果锁可用,则此方法立即返回true值。 如果该锁不可用,则出于线程调度目的,当前线程将被挂起,并处于休眠状态,直到发生以下三种情况之一:
- 该锁是由当前线程获取的。
- 其他一些线程会中断当前线程,并支持锁定获取的中断。
- 经过指定的等待时间如果获得了锁,则返回值true 。
如果经过了指定的等待时间,则返回值false 。 如果时间小于或等于零,则该方法将根本不等待。
注意事项
在某些实现中,中断锁获取的能力可能是不可能的,并且如果可能的话可能是昂贵的操作。 程序员应意识到可能是这种情况。 在这种情况下,实现应记录在案。与正常方法返回或报告超时相比,实现可能更喜欢对中断做出响应。Lock实现可能能够检测到锁的错误使用,例如可能导致死锁的调用,并且在这种情况下可能引发(未经检查的)异常。
5.5 解锁
void unlock(); //释放锁。
注意事项
Lock实现通常会限制哪些线程可以释放锁(通常只有锁的持有者才能释放锁),并且如果违反该限制,则可能引发(未经检查的)异常。
5.6 获取等待通知组件
Condition newCondition(); //返回绑定到此Lock实例的新Condition实例。
该组件与当前锁绑定,当前线程只有获得了锁。 才能调用该组件的wait()方法,而调用后,当前线程将释放锁。
注意事项
Condition实例的确切操作取决于Lock实现。
5.7总结
Lock对象锁还提供了synchronized所不具备的其他同步特性,如可中断锁的获取(synchronized在等待获取锁时是不可中断的),超时中断锁的获取,等待唤醒机制的多条件变量Condition等,这也使得Lock锁具有更大的灵活性。Lock的加锁和释放锁和synchronized有同样的内存语义,也就是说下一个线程加锁后可以看到前一个线程解锁前发生的所有操作。
6.锁的分类
根据一下6种情况可以区分多种不同的锁,下面详细介绍
6.1要不要锁住同步资源
是否锁住 | 锁名称 | 实现方式 | 例子 |
---|---|---|---|
锁柱 | 悲观锁 | synchronized、lock | synchronized、lock |
不锁住 | 乐观锁 | CAS算法 | 原子类、并发容器 |
悲观锁又称互斥同步锁,互斥同步锁的劣势:
- 阻塞和唤醒带来的性能劣势
- 永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环,死锁等活跃性问题
- 优先级反转
悲观锁:
当一个线程拿到锁了之后其他线程都不能得到这把锁,只有持有锁的线程释放锁之后才能获取锁。
乐观锁:
自己才进行操作的时候并不会有其他的线程进行干扰,所以并不会锁住对象。在更新的时候,去对比我在修改期间的数据有没有人对他进行改过,如果没有改变则进行修改,如果改变了那就是别人改的那我就不改了放弃了,或者重新来。
开销对比:
- 悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸,临界区持锁的时间哪怕越来越长,也不会对互斥锁的开销造成影响
- 悲观锁一开始的开销比乐观锁小,但是如果自旋时间长,或者不停的重试,那么消耗的资源也会越来越多
使用场景:
- 悲观锁:适合并发写多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免,大量的无用自旋等消耗
- 乐观锁:适合并发读比较多的场景,不加锁能让读取性能大幅度提高
6.2能否共享一把锁
是否共享 | 锁名称 |
---|---|
可以 | 共享锁(读锁) |
不可以 | 排他锁(独占锁) |
共享锁:
获取共享锁之后,可以查看但是无法修改和删除数据,其他线程此时也可以获取到共享锁也可以查看但无法修改和删除数据
案例:ReentrantReadWriteLock的读锁(具体实现后续系列文章会讲解)
排他锁:
获取排他锁的之后,别的线程是无法获取当前锁的,比如写锁。
案例:ReentrantReadWriteLock的写锁(具体实现后续系列文章会讲解)
6.3是否排队
是否排队 | 锁名称 |
---|---|
排队 | 公平锁 |
不排队 | 非公平锁 |
非公平锁:
先尝试插队,插队失败再排队,非公平是指不完全的按照请求的顺序,在一定的情况下可以进行插队
存在的意义:
- 提高效率
- 避免唤醒带来的空档期
案例:
- 以ReentrantLock为例,创建对象的时候参数为false(具体实现后续系列文章会讲解)
- 针对tryLock()方法,它是不遵守设定的公平的规则的
例如:当有线程执行tryLock的时候一旦有线程释放了锁,那么这个正在执行tryLock的线程立马就能获取到锁即使在它之前已经有其他线程在等待队列中
公平锁:
排队,公平是指的是按照线程请求的顺序来进行分配锁
案例:以ReentrantLock为例,创建对象的时候参数为true(具体实现后续系列文章会讲解)
注意:
非公平也同样不提倡插队行为,这里指的非公平是指在合适的时机插队,而不是盲目的插队
优缺点:
非公平锁:
- 优势:更快,吞吐量大
- 劣势:有可能产生线程饥饿
公平锁:
- 优势: 线程平等,每个线程按照顺序都有执行的机会
- 劣势:更慢,吞吐量更小
6.4 是否可以重复获取同一把锁
是否可以重入 | 锁名称 |
---|---|
可以 | 可重入锁 |
不可以 | 不可重入锁 |
案例:以ReentrantLock为例(具体实现后续系列文章会讲解)
6.5是否可以被中断
是否可以中断 | 锁名称 | 案例 |
---|---|---|
可以 | 可中断锁 | Lock是可中断锁(因为tryLock和lockInterruptibly都能响应中断) |
不可以 | 不可中断锁 | Synchronized就是不可中断锁 |
6.6等锁的过程
是否自旋 | 锁名称 |
---|---|
是 | 自旋锁 |
否 | 阻塞锁 |
使用场景:
- 自旋锁一般用于多核的服务器,在并发度不是很高的情况下,比阻塞锁效率高
- 自旋锁适合临界区比较短小的情况,否则如果临界区很大,线程一旦拿到锁,很久以后才会释放那也不合适的,因为会浪费性能在自旋的时候
7.锁优化
7.1 虚拟机中带的锁优化
- 自旋锁
- 锁消除
- 锁粗化
这三种锁优化的方式在前一篇Synchronized文章种所有讲解
7.2写代码的时候锁优化
- 缩小同步代码块
- 尽量不锁住方法
- 减少请求锁的次数
- 避免人为制造热点
- 锁中尽量不要再包含锁
- 选择合适的锁类型或者合适的工具类