多线程--关于锁的一些知识考点
一.常见的锁策略
锁策略指的不是某个具体的锁,是一个抽象的概念,描述的是锁的特性,描述的是一类锁
乐观锁和悲观锁
乐观锁:预测该场景中,不太会出现锁冲突的情况(后续做的工作更少)
悲观锁:预测该场景中,非常容易出现锁冲突(后续做的工作更多)
锁冲突:两个线程尝试获取一把锁,一个能获取成功,另一个线程阻塞等待
锁冲突的概率大/小,对后续的工作,是有一定影响的
重量级锁和轻量级锁
重量级锁:加锁开销是比较大的(花的时间多,占用系统资源多)
轻量级锁:加锁的开销比较小(花的时间少,占用系统资源少)
一个悲观锁,很可能是重量级锁(不绝对)
一个乐观锁,很可能是轻量级锁(不绝对)
悲观乐观,是在加锁之前,对锁冲突概率的预测,决定工作的多少
重量轻量,是在加锁之后,考量实际锁的开销
正是因为这样的概念重合,针对一个具体的锁,可能把它叫做乐观锁,也可能叫做轻量级锁
自旋锁和挂起等待锁
自旋锁是轻量级锁的一种典型实现,在用户态下,通过自旋的方式(比如while循环),实现类似于加锁的效果
挂起等待锁是重量级的一种典型实现,通过内核态,借助系统提供的锁的机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,使冲突的线程出现挂起(阻塞等待)
读写锁
实际开发中,读操作的频率要比写操作的频率高很多
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock (这个内部)类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock (这个内部)类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
公平锁和非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生啥呢?
公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.
注意:此处的公平是遵循先来后到的而不是有同等概率拿到释放的锁
synchronized就是一个非公平锁 ReentrantLock可以通过传参true来实现公平锁默认是非公平的
可重入锁和不可重入锁
如果一个线程,针对一把锁,连续加锁两次,会出现死锁,就是不可重入锁;不会出现死锁,就是可重入锁。
死锁的第一种形式:
synchronized是一个可以重入的锁所以上述情况就不会出现死锁
死锁的第二种形式
两个线程两把锁,这两个线程分别获取一把锁,然后再同时尝试获取对方的锁
比如,家钥匙锁车里了,车钥匙锁家里了
家和车两个线程分别获取了一把锁,这时候想达到目的,就要获取对方的锁,这个时候就出现死锁了
比如下面这串代码↓就会出现上面那种情况
public class Demo {private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (locker1) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2) {System.out.println("t1 两把锁加锁成功!");}}});Thread t2 = new Thread(() -> {synchronized (locker2) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker1) {System.out.println("t2 两把锁加锁成功!");}}});t1.start();t2.start();}
}
死锁的第三种情况
上述代码就可以修改成这样,都先获取locker1再获取locker2,就不会产生死锁了
public class Demo {private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (locker1) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2) {System.out.println("t1 两把锁加锁成功!");}}});Thread t2 = new Thread(() -> {synchronized (locker1) {try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2) {System.out.println("t2 两把锁加锁成功!");}}});t1.start();t2.start();}
}
对于我们常用的synchronized锁具体采用了哪些所策略呢
加锁本身就有一定的开销能不加就不加,等有竞争力才会真正的加锁
锁消除:
编译器会智能的判断当前的代码是否有必要加锁,如果你加了没必要加的锁,编译器会把加锁操作自动删除掉,当然也不要盲目的乱加锁,也不要盲目的相信编译器
锁的粒度:
编译器的优化也会把左边频繁加锁操作粗化成右边综合看来右边还是更适用大多数场景
二.锁相关面试题
你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要用在 “频繁读, 不频繁写” 的场景中.
什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁,
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场
景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源.
synchronized 是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增
三.ReentrantLock
reentrantlock应用实例↓
public class Demo {private static int count = 0;public static void main(String[] args) {ReentrantLock locker = new ReentrantLock();Thread t1 = new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < 500000; i++) {locker.lock();try {count++;}finally {locker.unlock();//确保释放锁}}}});Thread t2 = new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < 500000; i++) {locker.lock();try {count++;}finally {locker.unlock();}}}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("count = " + count);}
}
四.信号量
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.Semaphore是并发编程中的一个重要概念/组件,描述的是当前这个线程是否有临界资源可以用(也相当于可用资源的个数)
信号量为什么放到锁这里来说,主要是因为信号量就相当于锁的更广泛的推广
如果是有N个资源的信号量,就可以限制同时有多少个线程可以同时来执行某个逻辑
下面是信号量的使用实例
import java.util.concurrent.Semaphore;//信号量
public class Demo {public static void main(String[] args) throws InterruptedException {//创建一个信号量,初始值为3Semaphore semaphore = new Semaphore(3);semaphore.acquire(); //获取一个许可System.out.println(Thread.currentThread().getName() + "获得许可");semaphore.acquire();System.out.println(Thread.currentThread().getName() + "获得许可");semaphore.acquire();System.out.println(Thread.currentThread().getName() + "获得许可");semaphore.acquire();//这里会阻塞,因为信号量的初始值为3,已经达到最大值,再获取一个许可就会阻塞System.out.println(Thread.currentThread().getName() + "获得许可");}
}
可见在申请第四个许可的时候会阻塞
利用二元信号量来模拟锁↓
import java.util.concurrent.Semaphore;//semaphore(1)等价于互斥锁,但是比互斥锁更灵活,可以控制并发访问的数量。
public class Demo43 {private static int count = 0;public static void main(String[] args) {Semaphore semaphore = new Semaphore(1);Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {try {semaphore.acquire();count++;semaphore.release();} catch (InterruptedException e) {e.printStackTrace();}}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {try {semaphore.acquire();count++;semaphore.release();} catch (InterruptedException e) {e.printStackTrace();}}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("count = " + count);}
}
.
可见也不会发生线程安全问题