1.什么是锁
锁是一种工具,用于控制对共享资源的访问,最常见的就是
Lock
和synchronized
,这两个是最常见的锁,它们都可以达到线程安全的目的,但是使用方式和功能上又有较大不同。Lock
并不是用来替代synchronized
的,而是当使用synchronized
不合适或者不能满足需求时,来提供高级功能的。
Lock
接口最常见的实现类是ReentrantLock
,通常情况下,Lock
只允许一个线程访问这个共享资源,不过有的时候,一些特殊的实现也可以允许并发访问,比如ReadWriteLock
里面的ReadLock
。
2.为什么需要 Lock
因为synchronized
的哪些原因才要使用Lock
?
- 效率低:锁的释放情况少、试图获取锁时不能设定超时、不能中断一个正在获取锁的过程。
- 不够灵活(读写锁更灵活):加锁和释放锁时机单一,每个锁只有单一的条件(某个对象),可能是不够的。
- 无法知道是否成功获取锁:没有拿到就要开始等待。
3.Lock 主要方法介绍
-
lock():
- 这个方法就是最普通的获取锁,如果锁已经被其他线程获取,则进行等待。
- 在使用
Lock
时需要手动的释放锁,它不会像synchronized
那样遇到了异常自动释放锁,所以在使用时需要在finally
代码块中释放锁。 lock()
方法不能被中断,这会带来很大隐患:一旦陷入死锁,lock()
就会永远等待。
-
tryLock():
- 这个方法可以用来尝试获取锁,如果当前锁没有被其它线程占用,则获取成功,返回
true
,否则获取失败,返回false
。 - 相比
lock()
,这样显然更强大,我们可以根据是否能够获取到锁来决定后续程序的行为。 - 该方法会立即返回,不会因为暂时没有获取到锁而进行等待。
- 这个方法可以用来尝试获取锁,如果当前锁没有被其它线程占用,则获取成功,返回
-
tryLock(long time, TimeUnit unit)
- 这个方法和上面的方法是重载的关系,它可以通过传入的参数进行等待,超时了才会放弃。
演示
tryLock
避免死锁
/**
* 用 tryLock 避免死锁
*/
public class TryLockDeadLock implements Runnable {
int flag = 1;
static Lock lock1 = new ReentrantLock();
static Lock lock2 = new ReentrantLock();
public static void main(String[] args) {
TryLockDeadLock r1 = new TryLockDeadLock();
TryLockDeadLock r2 = new TryLockDeadLock();
r1.flag = 1;
r2.flag = 0;
Thread thread1 = new Thread(r1);
Thread thread2 = new Thread(r2);
thread1.start();
thread2.start();
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if (flag == 1) {
try {
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程1获取到了锁1");
// 避免获取到锁立刻就释放
Thread.sleep(new Random().nextInt(1000));
if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程1获取到了锁2");
System.out.println("线程1成功获取到了两把锁");
break;
} finally {
lock2.unlock();
}
} else {
System.out.println("线程1获取锁2失败,已重试");
}
} finally {
lock1.unlock();
// 给对方一些获取锁的时间
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println("线程1获取锁1失败,已重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (flag == 0) {
try {
if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程2获取到了锁1");
// 避免获取到锁立刻就释放
Thread.sleep(new Random().nextInt(1000));
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程2获取到了锁2");
System.out.println("线程2成功获取到了两把锁");
break;
} finally {
lock1.unlock();
}
} else {
System.out.println("线程2获取锁2失败,已重试");
}
} finally {
lock2.unlock();
// 给对方一些获取锁的时间
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println("线程2获取锁1失败,已重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
复制代码
运行结果:
总结:tryLock
可以互相谦让避免死锁的发生。
-
lockInterruptibly()
- 这个方法相当于上面的方法把时间设置为无限,在等待锁的过程中,线程可以被中断。
演示 lockInterruptibly 可以被中断
/**
* 演示 lockInterruptibly 可以被中断
*/
public class LockInterruptibly implements Runnable {
private Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
LockInterruptibly lockInterruptibly = new LockInterruptibly();
Thread thread0 = new Thread(lockInterruptibly);
Thread thread1 = new Thread(lockInterruptibly);
thread0.start();
thread1.start();
Thread.sleep(2000);
thread0.interrupt();
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "尝试获取锁");
try {
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName() + "获取到了锁");
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "睡眠期间被中断了");
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了锁");
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "等锁期间被中断了");
}
}
}
复制代码
运行结果
结果1
结果24.锁的可见性保证
在 JMM 中存在 happens-bofore 规则,这个原则是指“我”做了某件事,后面的“其他人”一定可以看到我做的这些操作,这就符合 happens-before 规则。
关于 JMM 以及 happens-before 相关知识点可以戳下方链接 juejin.im/post/5d8b5e…
Lock
的加锁和synchronized
有同样的内存语义,也就是说在下一个线程加锁后可以看到前一个线程解锁前的全部操作。
5.锁的分类
5.1 乐观锁与悲观锁
5.1.1 为什么会诞生非互斥同步锁(乐观锁)——互斥同步锁的劣势(悲观锁)
- 阻塞和唤醒会带来性能劣势
- 可能会永久阻塞:当持有锁的线程一直不释放,比如发生了死锁或者死循环,那么等待锁的线程就永远无法获取锁。
- 优先级反转
5.1.2 什么是乐观锁和悲观锁
- 从性格方面看:乐观锁和悲观锁如果拿性格举例的话,乐观锁就好比是一个乐天派,认为做某事不会发生意外。而悲观锁与它相反,认为事事都可能发生错误,为此就准备了一些措施,确保事情万无一失。
- 从锁住的资源方面看:悲观锁认为如果我不把资源锁住,其它线程就会来争抢资源,从而导致数据错乱,所以就通过加锁的方式,只有获取锁才可以进行执行,这样就可以保证万无一失。在 Java 中典型的悲观锁代表就是
synchronized
和Lock
相关的类。乐观锁认为在执行的过程中不会发生问题,所以不会锁住对象。在更新时,会对比数据在此期间有没有被修改过,如果没被改变过,就说明只有我自己在操作,那我就去正常修改数据。如果在此期间数据发生了变化,就不能对刚才的数据做更新了,会选择放弃、报错、重试等策略。乐观锁通常多是使用CAS算法实现。乐观锁的典型代表就是原子类和并发容器等。
5.1.3 典型例子
/**
* 演示乐观锁与悲观锁
*/
public class PessimismOptimismLock {
private int a;
public static void main(String[] args) {
// 原子类内部使用 CAS 保证线程安全
AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
}
/**
* 通过加锁保证普通变量的线程安全
*/
public synchronized void testMethod() {
a++;
}
}
复制代码
Git:Git就是典型的乐观锁,它使用本地的版本号与远端仓库的版本号做对比,如果远端仓库的代码版本号领先于本地,就说明在这期间仓库的代码得到了更新,这样就会导致提交失败,如果与远端仓库的版本号一致,那么就可以提交成功。
5.1.4 开销对比
- 悲观锁的原始开销要高于乐观锁,但是悲观锁一劳永逸,临界区持锁的时间就算越来越长,也不会对互斥锁的开销造成影响。
- 乐观锁一开始的性能开销要小于悲观锁,但是随着自旋时间和重试次数的增加,消耗的资源也会越来越多。
5.1.5 两种锁各自的使用场景
- 悲观锁:适合并发写入多的情况,适合临界区持锁时间较常的情况,悲观锁可以避免大量无用自旋。例如:锁住的代码有 I/O 操作、或者有复杂的代码逻辑、大量循环等。
- 乐观锁:适合并发写入少的情况,大部分是读取的场景,不加锁能让读取的性能得到提升。
5.2 可重入锁与非可重入锁
5.2.1 买电影票的案例(ReentrantLock 的使用)
通常在买电影票时选座成功后这期间就不能把座位卖给别人,如果用程序描述的话可以将人比喻为线程,共享资源当作电影院座位,多个人去买票,为了防止出现多人买同一个座位的情况,就需要“加锁”保证线程安全。下面使用 ReentrantLock
进行演示。
/**
* 演示多线程预定电影院座位
*/
public class CinemaBookSeat {
private static ReentrantLock lock = new ReentrantLock();
private static void bookSeat() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "开始预定座位");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + "完成预定座位");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
new Thread(() -> bookSeat()).start();
new Thread(() -> bookSeat()).start();
new Thread(() -> bookSeat()).start();
new Thread(() -> bookSeat()).start();
}
}
复制代码
运行结果在意料之中,同一时刻只有一个线程可以访问共享资源,保证了线程安全。
5.2.2 可重入性质
什么是可重入?
例如给车摇号,把我比作线程,牌照比作共享资源,当我摇号成功后可以为第一辆车上牌照(线程第一次获取到锁),因为刚才已经摇号成功,我可以不必再参加摇号直接给第二辆车上牌照(线程获取到锁之后,不再参与竞争锁直接进入到锁里)这就是可重入。反之,摇号成功之后不让我再摇号了(线程获取到锁后必须释放掉锁,再次参与竞争),这就是不可重入。
总结:获取到锁后无需释放直接继续使用这把锁是可重入锁,获取到锁后必须释放掉再次竞争是不可重入锁。
5.2.3 可重入的好处
- 避免死锁
- 提升封装性
5.2.4 演示可重入
public class GetHoldCount {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
}
}
复制代码
获取到锁之后无需解锁再次获取锁。
在递归中使用可重入锁
public class RecursionDemo {
private static ReentrantLock lock = new ReentrantLock();
private static void accessResource() {
lock.lock();
try {
System.out.println("已经对资源进行了处理");
if (lock.getHoldCount() < 5) {
System.out.println(lock.getHoldCount());
accessResource();
System.out.println(lock.getHoldCount());
}
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
accessResource();
}
}
复制代码
5.2.5 源码分析:可重入与非可重入对比
5.3 公平锁与非公平锁
5.3.1 什么是公平与非公平
公平是指按照线程请求顺序来分配锁,非公平是指不按照请求顺序,可以出现插队的情况。
注意:非公平也同样不提倡“插队”行为,这里的非公平是指在“合适的时机”插队,而不是盲目插队。
5.3.2 为什么要有非公平
- 可以提高执行效率,避免唤醒带来的空档期
- 比如有线程A和线程B,线程A正在执行那么线程B就会被挂起阻塞住,当线程A释放掉锁后B再被唤醒和获取锁是需要时间的,但是此时线程C直接获取到锁(实现了插队),并在线程B被唤醒之前释放掉了锁,这样线程C既得到了执行,又没有影响到线程B,通过这次插队就实现了双赢,提升了吞吐量。
5.3.3 演示公平与非公平
公平锁
/**
* 描述: 演示公平和不公平的情况
*/
public class FairLock {
public static void main(String[] args) throws InterruptedException {
PrintQueue queue = new PrintQueue();
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Job(queue));
}
for (int i = 0; i < threads.length; i++) {
threads[i].start();
Thread.sleep(500);
}
}
}
class Job implements Runnable {
PrintQueue queue;
public Job(PrintQueue queue) {
this.queue = queue;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "开始打印");
queue.printJob(new Object());
System.out.println(Thread.currentThread().getName() + "打印完毕");
}
}
class PrintQueue {
private Lock queueLock = new ReentrantLock(true); // 在此处更改锁类型
public void printJob(Object document) {
queueLock.lock();
try {
Long duration = (long) (Math.random() * 10000);
System.out.println(Thread.currentThread().getName() +
"正在打印需要" + 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() +
"正在打印需要" + duration / 1000 + "s");
Thread.sleep(duration);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
}
}
复制代码
打印结果: 释放锁后必须进入waitQueue
的队尾等待,然后会让队首的线程执行打印任务。
非公平锁
/**
* 描述: 演示公平和不公平的情况
*/
public class FairLock {
public static void main(String[] args) throws InterruptedException {
PrintQueue queue = new PrintQueue();
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Job(queue));
}
for (int i = 0; i < threads.length; i++) {
threads[i].start();
Thread.sleep(500);
}
}
}
class Job implements Runnable {
PrintQueue queue;
public Job(PrintQueue queue) {
this.queue = queue;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "开始打印");
queue.printJob(new Object());
System.out.println(Thread.currentThread().getName() + "打印完毕");
}
}
class PrintQueue {
private Lock queueLock = new ReentrantLock(false); // 在此处更改锁类型
public void printJob(Object document) {
queueLock.lock();
try {
Long duration = (long) (Math.random() * 10000);
System.out.println(Thread.currentThread().getName() +
"正在打印需要" + 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() +
"正在打印需要" + duration / 1000 + "s");
Thread.sleep(duration);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
}
}
复制代码
打印结果: 在线程第一次释放锁后,因为唤醒队首的等待的线程需要时间,所以该线程在释放完毕后再次获取到锁执行打印任务,样式为每个线程执行完自己的所有任务才会让等待在队首的线程执行任务。
5.3.4 特例(tryLock)
tryLock
不会遵守设定的公平原则,当有线程释放锁后,那么正在tryLock
的线程不会进入等待队列就可以获取到锁。
5.3.5 公平与非公平锁的优缺点
5.3.6 公平与非公平锁源码分析
它们之间的区别在于公平锁在获取锁时会判断在等待队列前面是否有线程,而非公平锁不会进行判断。5.4 共享锁与排它锁
5.4.1 共享锁与排它锁是什么
共享锁又被称为读锁,获取共享锁后,可以查看但是无法删除或修改数据,其它线程也可以获取共享锁,同样也无法删除或修改数据。排它锁又被称为写锁、独占锁,获取排它锁后,可以修改或删除数据,如果排它锁已经被一个线程持有,那么别的线程就无法获取排它锁。共享锁与排它锁的典型就是读写锁
ReentrantReadWriteLock
,其中读锁是共享锁、写锁是排它锁。
5.4.2 读写锁的作用
在没有读写锁之前,假设使用
ReentrantLock
,那么虽然我们保证了线程安全,但是也浪费了一定资源:因为多个读操作同时进行并没有线程安全问题。如果在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率。
5.4.3 读写锁规则
- 多个线程只申请读锁,都可以申请到。
- 如果有一个线程已经占用了读锁,其它线程如果要申请写锁,则申请写锁的线程会一直等待该线程释放读锁,因为已经有线程在读同时再执行写操作是有风险的。
- 如果有一个线程已经占用了写锁,则此时其它线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。
5.4.4 ReentrantReadWriteLock 具体用法
/**
* 描述: 演示读写锁的使用
*/
public 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();
}
}
复制代码
执行结果:
Thread1
和
Thread2
可以同时获取锁并执行,当
Thread3
获取了写锁时
Thread4
无法获取写锁,必须等待
Thread3
释放后
Thread4
才能获取。
5.4.5 读锁和写锁的交互方式
5.4.5.1 读锁插队策略(不允许)
- 公平场景:公平场景是肯定不可以插队的,这一点毋庸置疑。
- 非公平场景:假设线程2和线程4正在同时读取,线程3想要写入却拿不到锁,于是进入等待队列,线程5不在队列里,现在过来想要读取有以下两个策略。
- 策略1(效率高):让线程5插队,线程2、线程4和线程5同时执行读操作,这样效率比较高,但是缺点是当线程多的时候可能会造成正在等待获取写锁的线程3一直获取不到,造成线程饥饿。
- 策略2(避免饥饿):当线程2和线程4正在进行读操作时,让后来的线程5进入等待队列,当线程2和线程4执行完读操作后,轮到等待队列队首的线程3执行写操作,等待线程3写入完毕后再轮到线程5执行读操作。这样虽然整体的效率没有那么高,但是可以避免获取写锁的线程出现线程饥饿的现象。其中
ReentrantReadWriteLock
采用的就是这个策略。
总结:
- 写锁可以随时插队
- 读锁在等待队列队首不是要获取写锁的线程的时候可以插队
源码分析
- 公平锁场景
/**
* Fair version of Sync
*/
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
// 写操作应该等待
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
// 读操作应该等待
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
复制代码
- 非公平场景
/**
* Nonfair version of Sync
*/
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -8159625535654395037L;
// 写操作可以插队
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
/*
* 看等待队列队首是否是独占锁,如果是则等待否则可以插队
*/
return apparentlyFirstQueuedIsExclusive();
}
}
复制代码
演示非公平情况下等待队列队首是要执行写操作的线程时后面的读操作不允许插队
/**
* 描述: 演示非公平情况下等待队列队首线程要执行写操作时,
* 后面的读操作不允许插队
*/
public class CinemaReadWriteQueue {
// 声明读写锁
private static ReentrantReadWriteLock reentrantReadWriteLock =
new ReentrantReadWriteLock(false);
// 声明读锁
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(() -> write(), "Thread1").start();
new Thread(() -> read(), "Thread2").start();
new Thread(() -> read(), "Thread3").start();
new Thread(() -> write(), "Thread4").start();
new Thread(() -> read(), "Thread5").start();
}
}
复制代码
运行结果:
运行结果分析:因为第一个线程执行写操作,所以只有Thread1
可以执行,当Thread1
释放后,Thread2
和Thread3
可以同时执行读操作,此时等待队列队首已经是要执行写操作的线程Thread4
,在这时后面新来的Thread5
无法插队执行读操作。
5.4.5.2 升降级(允许降级不允许升级)
为什么需要升级和降级
如果有一个任务需要先写日志再进行读取,所以获取锁的顺序是先使用写锁再使用读锁,但是如果在读取的时候释放掉写锁,后面再执行写操作时不知要等待多久,所以就需要锁进行升级和降级了。
演示读写锁的升降级
- 演示降级是可以的
/**
* 描述: 演示读写锁的升降级
*/
public class Upgrading {
// 声明读写锁
private static ReentrantReadWriteLock reentrantReadWriteLock =
new ReentrantReadWriteLock(false);
// 声明读锁
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 writeDowngrading() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
Thread.sleep(1000);
readLock.lock();
System.out.println("在不释放写锁的情况下直接获取读锁,成功降级");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
System.out.println("演示降级是可以的");
new Thread(() -> writeDowngrading(), "Thread1").start();
}
}
复制代码
运行结果
- 演示升级是不可以的
/**
* 描述: 演示读写锁的升降级
*/
public class Upgrading {
// 声明读写锁
private static ReentrantReadWriteLock reentrantReadWriteLock =
new ReentrantReadWriteLock(false);
// 声明读锁
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 writeDowngrading() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
Thread.sleep(1000);
readLock.lock();
System.out.println("在不释放写锁的情况下直接获取读锁,成功降级");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
// System.out.println("演示降级是可以的");
// new Thread(() -> writeDowngrading(), "Thread1").start();
System.out.println("演示升级是不可以的");
new Thread(() -> readUpgrading(), "Thread2").start();
}
}
复制代码
运行结果
5.4.6 读写锁总结
- 当有一个线程正在读取数据时,另外一个线程想获取写锁进行写入操作,是不可以的。
- 当一个线程在进行读取操作时,另一个线程也要获取读锁进行读取操作,是可以的。
- 如果有一个线程正在执行写入操作,另一个线程想进行读取或者写入操作都需要等待。
- 要么一个或多个线程持有读锁,要么一个线程持有写锁,两者不会同时出现。
- 在读多写少的场景下
ReentrantReadWriteLock
相比于ReentrantLock
更合适。
5.5 自旋锁和阻塞锁
5.5.1 什么是自旋锁
阻塞或者唤醒一个线程需要操作系统进行用户态到内核态的切换,但是每次切换开销比较大,如果阻塞的时间较短还频繁的切换对程序的性能影响不可估量。
如果有两个或以上的线程同时并行执行,可以让后面那个请求锁的线程不放弃CPU的执行时间,让后面的线程稍微等一下,进行自旋操作,如果自旋完成后前面的线程已经执行完任务释放了锁,后面的线程就可以直接获取锁进行任务的执行,这样就避免了用户态到内核态的切换,从而避免了不必要的资源开销,这就是自旋锁以及它的作用。
5.5.2 什么是阻塞锁
阻塞锁与自旋锁恰恰相反,阻塞锁如果遇到没获取到锁的情况会直接把线程阻塞,直到被唤醒。
5.5.3 自旋锁的缺点
前面说了自旋锁可以带来的好处,但是它的缺点也很明显,例如前面线程持有锁的时间很长,后面的锁还进行自旋操作,那这样就会白白浪费CPU的资源,随着时间的变长,开销也是线性增长的。
5.5.4 演示自旋锁
/**
* 描述: 演示自旋锁
*/
public class SpinLock {
// 声明自旋锁
private AtomicReference<Thread> sign = new AtomicReference<>();
/**
* 加锁
*/
public void lock() {
Thread current = Thread.currentThread();
// 使用 while 循环加 CAS 操作实现自旋
// 希望没有线程持有锁,传入 null
// 希望更新的值是自己,传入 current
while (!sign.compareAndSet(null, current)) {
System.out.println(Thread.currentThread().getName() + "自旋获取失败,再次尝试");
}
}
/**
* 解锁
*/
public void unlock() {
Thread current = Thread.currentThread();
// 解锁首先要有锁,所以一次就可以解掉,不需要 while 循环
// 希望持有锁的线程是自己,传入 current
// 因为是解锁,所以要更新的值为 null
sign.compareAndSet(current, null);
}
public static void main(String[] args) {
SpinLock spinLock = new SpinLock();
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(300);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
spinLock.unlock();
System.out.println(Thread.currentThread().getName()
+ "释放了自旋锁");
}
}
};
// 用两个线程执行任务,模拟争抢
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
}
}
复制代码
运行结果
运行结果分析
在Thread0
加锁成功后Thread1
无法进行加锁,所以会进行自旋操作,直到Thread0
解锁后,Thread1
才获取到并执行相应逻辑。
自旋锁的适用场景
- 自旋锁适用于并发量不是特别高的场景,因为并发量很大的话自旋会大量消耗系统资源。
- 另外自旋锁适用于临界区较小的场景,如果临界区较大自选时间也会变长。
5.6 可中断锁和不可中断锁
5.6.1 为什么需要可中断锁
在
Java
中,synchronized
就是不可中断锁,而Lock
是可中断锁,因为tryLock(time)
和lockInterruptibly
都能响应中断,可中断锁相对于不可中断锁会更加灵活,不会一直做“头铁”的事情。
具体可以参照第 2 小结—为什么需要Lock和第 3 小结—Lock的主要方法介绍。
6.怎么才能让锁更好用
6.1 Java 虚拟机对锁的优化
-
自旋锁和自适应自旋锁
前面说到了自旋锁具有的缺点,但是在后来虚拟机对自旋锁做了优化,也就是自适应自旋锁,自适应自旋锁相对于自旋锁来说更加聪明,它的自旋次数是可以改变的,比如第一次自旋时消耗的时间很短,这可能就意味着临界区较小,自适应自旋锁可能就会减少自旋的次数,反之可能前一次自旋的次数比较多,自适应自旋锁就会响应的增加自选的次数。
-
锁粗化与锁消除
1.演示锁粗化
/**
* 描述: 演示锁粗化
*/
public class LockCoarse {
public String appendString100Time(String str) {
StringBuffer sb = new StringBuffer();
int count = 0;
while (count < 100) {
sb.append(str);
count++;
}
return sb.toString();
}
public static void main(String[] args) {
LockCoarse lockCoarse = new LockCoarse();
System.out.println(lockCoarse.appendString100Time("a"));
}
}
复制代码
在调用StringBuffer
类中的append()
方法会加上synchronized
锁,但是执行上面的代码时并不会加100次锁,而是将锁的范围加大,从而减少加锁的次数,达到提升执行效率的目的。
2.演示锁消除
/**
* 描述: 演示锁消除
*/
public class LockElimination {
public void append() {
StringBuffer sb = new StringBuffer();
sb.append("aaa");
}
public static void main(String[] args) {
LockElimination lockElimination = new LockElimination();
lockElimination.append();
}
}
复制代码
因为StringBuffer
是线程安全的,并且StringBuffer
只会在append()
方法中使用,不属于共享资源,所以JVM会对这种不会产生线程安全问题而加的锁进行清除。
6.2 使用锁时的注意事项
- 缩小同步代码块,只锁住真正需要的代码块
- 尽量不要锁住方法,如果锁住方法在今后扩展代码后可能会增加锁的范围
- 减少加锁的次数
- 避免人为增加“热点”
- 锁种不要包含锁,这样可能会造成死锁
- 选择合适的锁类型或者合适的工具类
本文是自己对多线程学习的知识点笔记,如果有兴趣进一步了解可以点击下方的链接,从而更好的学习并发相关的知识点