# 三、锁
# 1. Lock
# 1.1 简介
- 锁是一种工具,用于控制对共享资源的访问。
- Lock 和 synchronized,这两个是最常见的锁,它们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同。
- Lock 并不是用来代替 synchronized 的,而是当 synchronized 不适合或不足以满足要求的时候,来提供高级功能的。
- Lock 接口中最常见的实现类是
ReentrantLock
- 通常情况下,Lock 只允许一个线程来访问这个共享资源。不过有的时候,一些特殊的实现也可允许并发访问,比如 ReadWriteLock 里面的 ReadLock。
# 1.2 为什么 synchronized 不够用?
- 效率低:锁的释放情况少、试图获得锁时不能设定超时、不能中断一个正在尝试获得锁的线程。
- 不够灵活(读写锁更灵活):加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的。
- 无法知道是否成功获取到锁。
# 1.3 主要方法
void lock()
获取锁。如果锁已被其他线程获取,则等待。
需要主动在 finally 中释放锁。
lock() 方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock() 就会陷入永久等待。
boolean tryLock()
尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,则返回 true,否则返回 false,代表获取失败。
boolean tryLock(long time, TimeUnit unit)
在一定时间内一直尝试获取锁,超时则放弃。
void lockInterruptibly() throws InterruptedException
相等于 tryLock(long time, TimeUnit unit) 把时间设置为无限。在等待锁的过程中,线程可以被中断。
void unLock()
尝试释放锁。
ReentrantLock 的拓展方法
boolean isHeldByCurrentThread()
判断锁是否被当前线程持有。
int getQueueLength()
可以返回当前正在等待这把锁的队列有多长。
# 1.4 可见性的保证
# 2. 锁的分类
# 2.1 乐观锁和悲观锁
# 2.1.1 悲观锁的劣势
- 阻塞和唤醒带来的性能劣势。
- 永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个悲催的线程,将永远也得不到执行。
- 可能造成优先级反转。
# 2.1.2 乐观锁
想假设不会有问题,继续操作,操作完再检查有没有问题,没问题就直接提交,有问题再具体处理。
乐观锁的实现一般都是利用 CAS 算法来实现的。
# 2.1.3 使用场景
- 悲观锁
- 适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量无用自旋等消耗,典型情况:
- 临界区有 IO 操作
- 临界区代码复杂或者循环量大
- 临界区竞争非常激烈
- 适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量无用自旋等消耗,典型情况:
- 乐观锁
- 适合并发写入少、大部分是读取的场景,不加锁能让性能大幅度提高。
# 2.2 可重入锁和非可重入锁
# 2.2.1 可重入
- 同一个线程获得锁后,可以在不释放锁的情况下再次获得该锁。
# 2.2.2 源码对比:可重入锁 ReentrantLock 和非可重入锁 ThreadPoolExecutor 的 Worker 类
# 2.3 公平锁和非公平锁
# 2.3.1 什么是公平和非公平
公平指的是按照线程请求的顺序来分配锁。
非公平是指不完全按照请求的顺序,在一定情况下可以插队。
PS:非公平是指“在合适的时机”插队,而不是盲目插队。
只有在申请锁的那一瞬间可以插队,一旦进入了等待队列,就不能插队了。
# 2.3.2 为什么要有非公平锁
- 提高效率
- 避免唤醒带来的空档期。
# 2.3.3 演示
公平锁
/** * 演示公平锁 * * @author Hedon Wang * @create 2021-03-19 9:17 PM */ public class FairLock { public static void main(String[] args) throws InterruptedException{ PrintQueue printQueue = new PrintQueue(); Thread[] threads = new Thread[10]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new Job(printQueue)); } for (int i = 0; i < threads.length; i++) { threads[i].start(); Thread.sleep(100); } } } class Job implements Runnable{ private PrintQueue printQueue; public Job(PrintQueue printQueue){ this.printQueue = printQueue; } @Override public void run() { System.out.println(Thread.currentThread().getName() + "开始打印"); printQueue.printJob(printQueue); System.out.println(Thread.currentThread().getName() + "打印完毕"); } } class PrintQueue{ //true 表示公平 private Lock queueLock = new ReentrantLock(true); public void printJob(Object document){ //打印第一次 queueLock.lock(); try { Long duration = (long)new Random().nextInt(10000)+1000; System.out.println(Thread.currentThread().getName() + "正在打印1,需要" + duration/1000 + "s"); Thread.sleep(duration); }catch(InterruptedException e) { e.printStackTrace(); }finally { queueLock.unlock(); } //打印第二次 queueLock.lock(); try { Long duration = (long)Math.random() * 10000; System.out.println(Thread.currentThread().getName() + "正在打印2,需要" + duration/1000 + "s"); Thread.sleep(duration); }catch(InterruptedException e) { e.printStackTrace(); }finally { queueLock.unlock(); } } }
结果:可以看出在公平锁的情况下,10个线程都进入队列,本来线程 Thread0 要打印 2 次的,但是因为打印完第 1 次后就释放锁了,锁就被排在队列的后面那个线程 Thread1 拿到了,然后当前线程 Thread0 想在获取锁就只能去队列后面继续排队了,所以打印顺序非常整齐。
Thread-0开始打印 Thread-0正在打印1,需要2s Thread-1开始打印 Thread-2开始打印 Thread-3开始打印 Thread-4开始打印 Thread-5开始打印 Thread-6开始打印 Thread-7开始打印 Thread-8开始打印 Thread-9开始打印 Thread-1正在打印1,需要7s Thread-2正在打印1,需要2s Thread-3正在打印1,需要9s Thread-4正在打印1,需要3s Thread-5正在打印1,需要10s Thread-6正在打印1,需要4s Thread-7正在打印1,需要9s Thread-8正在打印1,需要3s Thread-9正在打印1,需要4s Thread-0正在打印2,需要0s Thread-0打印完毕 Thread-1正在打印2,需要0s Thread-1打印完毕 Thread-2正在打印2,需要0s Thread-2打印完毕 Thread-3正在打印2,需要0s Thread-3打印完毕 Thread-4正在打印2,需要0s Thread-4打印完毕 Thread-5正在打印2,需要0s Thread-5打印完毕 Thread-6正在打印2,需要0s Thread-6打印完毕 Thread-7正在打印2,需要0s Thread-7打印完毕 Thread-8正在打印2,需要0s Thread-8打印完毕 Thread-9正在打印2,需要0s Thread-9打印完毕
非公平锁
/** * 演示非公平锁 * * @author Hedon Wang * @create 2021-03-19 9:17 PM */ public class UnFairLock { public static void main(String[] args) throws InterruptedException{ UnFairPrintQueue unFairPrintQueue = new UnFairPrintQueue(); Thread[] threads = new Thread[10]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new UnFairJob(unFairPrintQueue)); } for (int i = 0; i < threads.length; i++) { threads[i].start(); Thread.sleep(100); } } } class UnFairJob implements Runnable{ private UnFairPrintQueue unFairPrintQueue; public UnFairJob(UnFairPrintQueue unFairPrintQueue){ this.unFairPrintQueue = unFairPrintQueue; } @Override public void run() { System.out.println(Thread.currentThread().getName() + "开始打印"); unFairPrintQueue.printJob(unFairPrintQueue); System.out.println(Thread.currentThread().getName() + "打印完毕"); } } class UnFairPrintQueue{ //false 表示不公平,默认就是 false private Lock queueLock = new ReentrantLock(false); public void printJob(Object document){ queueLock.lock(); try { Long duration = (long)new Random().nextInt(10000)+1000; System.out.println(Thread.currentThread().getName() + "正在打印1,需要" + duration/1000 + "s"); Thread.sleep(duration); }catch(InterruptedException e) { e.printStackTrace(); }finally { queueLock.unlock(); } queueLock.lock(); try { Long duration = (long)Math.random() * 10000; System.out.println(Thread.currentThread().getName() + "正在打印2,需要" + duration/1000 + "s"); Thread.sleep(duration); }catch(InterruptedException e) { e.printStackTrace(); }finally { queueLock.unlock(); } } }
结果:可以看到在非公平锁的情况下,因为唤醒线程需要开销,而且开销比较大, 而当 Thread0 释放锁后想再次尝试获取锁,它是不需要唤醒线程的,效率非常快,所以就插队成功,获取到锁了。于是就出现了 Thread0 连续打印 2 次的情况了。
Thread-0开始打印 Thread-0正在打印1,需要10s Thread-1开始打印 Thread-2开始打印 Thread-3开始打印 Thread-4开始打印 Thread-5开始打印 Thread-6开始打印 Thread-7开始打印 Thread-8开始打印 Thread-9开始打印 Thread-0正在打印2,需要0s Thread-0打印完毕 Thread-1正在打印1,需要6s Thread-1正在打印2,需要0s Thread-1打印完毕 Thread-2正在打印1,需要8s Thread-2正在打印2,需要0s Thread-2打印完毕 Thread-3正在打印1,需要7s Thread-3正在打印2,需要0s Thread-3打印完毕 Thread-4正在打印1,需要6s Thread-4正在打印2,需要0s Thread-4打印完毕 Thread-5正在打印1,需要10s Thread-5正在打印2,需要0s Thread-5打印完毕 Thread-6正在打印1,需要6s Thread-6正在打印2,需要0s Thread-6打印完毕 Thread-7正在打印1,需要9s Thread-8正在打印1,需要2s Thread-8正在打印2,需要0s Thread-8打印完毕 Thread-9正在打印1,需要4s Thread-9正在打印2,需要0s Thread-9打印完毕 Thread-7正在打印2,需要0s //第二次打印前申请锁的时候没有抢到锁,所以只能进入队列里面按“公平锁”的方式等待。 Thread-7打印完毕
# 2.3.4 特例
针对 tryLock()
方法,它不遵守设定的公平的规则。
当有线程执行 tryLock() 的时候,一旦有现场释放了锁,那么这个正在 tryLock 的线程就能获取到锁,即使在它之前已经有其他线程在等待队列里了。
# 2.3.5 对比
# 2.3.6 源码
# 2.4 共享锁和排它锁
# 2.4.1 什么是共享锁和排它锁
- 排它锁:独占锁、独享锁。
- 共享锁:又称为读锁。获得共享锁后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看但无法修改和删除数据。
- ReentrantReadWriteLock,其中读锁是共享锁,写锁是排它锁。
# 2.4.2 作用
- 在没有读写锁之前,我们假设使用 ReentrantLock,那么虽然我们保证了线程安全,但是也浪费了一定的资源:多个读同时进行,并没有线程安全问题。
- 在读的地方使用读锁,在写的地方使用写锁,灵活控制,提高了程序的执行效率。
# 2.4.3 规则
- 多个线程只申请读锁,都可以申请到。
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待读锁的释放。
- 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或读锁,则申请的线程会一直等待释放写锁。
- 要么多读,要么一写。
# 2.4.4 使用示例
/**
* 演示读写锁的用法
*
* @author Hedon Wang
* @create 2021-03-20 11:00 AM
*/
public class ReadWriteLock {
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() + "得到了读锁。");
try {
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() + "得到了写锁。");
try {
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()).start();
new Thread(()->read()).start();
new Thread(()->write()).start();
new Thread(()->write()).start();
}
}
结果:写锁可以共存,读锁只能有一个。
Thread-0得到了读锁。
Thread-1得到了读锁。
Thread-1释放读锁。
Thread-0释放读锁。
Thread-2得到了写锁。
Thread-2释放写锁。
Thread-3得到了写锁。
Thread-3释放写锁。
# 2.4.5 读写锁交互方式
# ① 读写锁插队策略
公平锁:不插队
非公平:写锁可以随时插队,读锁仅在等待队列头结点不是想获取写锁的线程的时候可以插队。
假设线程2和线程4正在同时读取,线程3想要写入,拿不到锁,于是进入等待队列。线程5不在队列,现在过来想要读取。
- 策略[1]:让5插队。效率高,但是容易造成饥饿。
- 策略[2]:不让5插队。避免饥饿,但是效率较低。(ReentrantReadWriteLock 默认采用该策略)
# ② 锁的升降级策略
- 升级:读锁 -> 写锁(ReentrantReadWriteLock 不支持)
- 读的话大家可以一起读,写只能一个写,读升级写的锁,就可能存在读和写共存的状态,所以要求其他读线程要释放读锁。
- 如果这个时候2个读锁都要升级,那么这2个都要求对方释放读锁,就造成死锁。
- 降级:写锁 -> 读锁(ReentrantReadWriteLock 支持)
- 写完后想读又不想被打断,可以提高性能。
# 2.5 自旋锁和阻塞锁
# 2.5.1 自旋锁的意义
- 阻塞和唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。
- 如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
- 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复线程的花费可能会让系统得不偿失。
- 如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否很快就会释放锁。
- 而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成之前前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。
# 2.5.2 自旋锁的缺点
- 如果锁被占用的时间很长,那么自旋的线程只会白白浪费 CPU 资源。
# 2.5.3 自旋锁的应用
在 java1.5 版本及以上的并发框架 JUC 和 atomic 包下的类基本都是自旋锁的实现。
AtomicInteger 中自旋锁的实现原理是 CAS。
AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在 while 里死循环,直至修改成功。
public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }
# 2.5.4 手写一个自旋锁
/**
* 实现一个自旋锁
*
* @author Hedon Wang
* @create 2021-03-20 12:04 PM
*/
public class CreateSpinLock {
private AtomicReference<Thread> sign = new AtomicReference<Thread>();
/**
* 加锁
*/
public void lock(){
Thread current = Thread.currentThread();
//CAS 操作,锁就是期望将锁赋值为当前线程
while (!sign.compareAndSet(null,current)){
System.out.println(current.getName() + "正在自旋");
}
}
/**
* 解锁
*/
public void unlock(){
Thread current = Thread.currentThread();
//CAS 操作,就是希望将当前锁设置为 null,释放
sign.compareAndSet(current,null);
}
public static void main(String[] args) {
CreateSpinLock spinLock = new CreateSpinLock();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "尝试获取自旋锁");
spinLock.lock();
System.out.println(Thread.currentThread().getName() + "获得了自旋锁");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
spinLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了自旋锁");
}
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
}
}
# 2.6 可中断和不可中断锁
- 如果某一线程 A 正在执行锁中的代码,另一线程 B 正在等待获取该锁。可能由于等待时间过长,线程 B 不想等待了,想先处理其他的事情,我们可以中断它,这就是可中断锁。
- synchronized 就是不可中断锁,ReentrantLock 就是可中断锁。
# 2.7 锁优化
# 2.7.1 自旋锁和自适应
自旋:如果线程可以很快获取锁,那么可以不在 OS 层挂起线程,而是让线程做几个忙循环,这就是自旋。
自适应自旋:自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者状态来决定。
- 如果锁被占用的时间很短,自旋成功,那么能节省线程挂起以及切换时间,从而提升系统性能。
- 如果锁被占用时间很长,自旋失败,会白白耗费处理器资源,降低系统性能。
# 2.7.2 锁消除
在编译代码的时候,JVM 检查到根据不存在共享数据竞争,自然也就无需同步加锁了,JVM 会帮我们把我们加的锁去掉。
用 -XX:+EliminateLocks
来开启。
同时要使用 -XX:+DoEscapeAnalysis
开启逃逸分析,所谓逃逸分析:
- 如果一个方法中定义的一个对象,可能被外部方法引用,称为方法逃逸。
- 如果对象可能被其他外部线程访问,称为线程逃逸。
- 比如赋值给类变量或者可以在其他线程中访问的实例变量。
(因为一旦出现逃逸,就不能轻易消除锁了,无法得知外部是如何使用我们的资源了,该同步还是得同步。)
# 2.7.3 锁粗化
通常我们都要求同步块要小,但一系列连续的操作导致对一个对象反复的加锁和解锁,这会导致不必要的性能损耗,这种情况建议把锁同步的范围加大到整个操作序列。
# 2.7.4 轻量级锁
轻量级是相对于传统锁机制而言的,本意是没有多线程竞争的情况下,减少传统锁机制使用 OS 实现互斥所产生的性能损耗。
实现原理很简单,就是类似乐观锁的方式。
如果轻量级锁失败,表示存在竞争,那么 JVM 就会将其升级为重量级锁,导致性能下降(因为多了一次尝试)。
# 2.7.5 偏向锁
偏向锁是在无竞争情况下,直接把整个同步消除了,连乐观锁都不用,从而提高性能。
所谓偏向,就是偏向,即锁会偏向于当前已经占有锁的线程。
只要没有竞争,获得偏向锁的线程在将来进入同步块也不需要做同步。
当有其他线程请求相同的锁时,偏向模式结束。
如果程序中大多数锁总是被多个线程访问的时候,也就是竞争比较激烈,偏向锁反而会降低性能。
使用 -XX:-UseBiasedLocking
来禁用偏向锁,默认是开启的。
# 2.7.6 JVM 中获取锁的步骤
st=>start: 尝试偏向锁
e=>end: 尝试普通锁,使用 OS 互斥量在操作系统层挂起
op2=>operation: 尝试轻量级锁
op3=>operation: 尝试自旋锁
st->op2->op3->e