Java面试最容易被刷的重难点之锁的使用策略
在多线程的学习中,很多时候都要用到锁,但我们都知道,加锁这个操作是一个计算机中开销比较大的操作,因此,本篇文章我会带大家学习在不同场景中进行不同的加锁处理方式,以让程序更高效一些有关锁策略不仅仅局限于某一种语言,在很多语言中都可能会遇到加锁操作,而且这部分知识点也是面试中常见的问题,所以本篇文章内容基本都是需要大家自己认真理解并做到会用自己的语言组织起来的。内容均为博主认真总结,大家可以收藏起来慢慢学习,希望可以帮到大家哦!
一. 乐观锁和悲观锁
1. 字面理解
乐观锁认为多个线程访问同一个共享数据时产生并发冲突的概率不大,并不会真的加锁, 而是直接尝试访问数据, 在访问的同时识别当前的数据是否出现访问冲突,若冲突,则会返回当前的错误信息让用户去决定如何去处理悲观锁会认为多个线程访问同一个共享数据时产生并发冲突的概率很大,因此往往会在取数据时会进行加锁操作,这样的话其他线程想要拿到这个数据时就会阻塞等到直到其他线程获取到锁
补充:在Java中synchronized
这一加锁操作主要以悲观锁为主,初始使用乐观锁策略,但当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略
2. 生活实例
在生活中有很多情况都能涉及到乐观和悲观的心态,比如今天是阴天,A认为可能会下雨,会提前带好伞,这就对应到了悲观锁这一策略;而B比较乐观,不会认为会下雨,因此B不会带伞,这显然可以类比为乐观锁这一策略。
3. 基于版本号方式实现乐观锁
实现乐观锁策略这一功能的方式有很多,接下来我带大家去学习一种:基于版本号方式。
假设我们要使用多线程修改用户的账户余额,我们可以引入一个版本号来实现,具体方法如下:
设当前的余额为100,引入一个版本号version,将其初始值设为1,并且我们规定,提交版本必须大于数据库中记录的当前版本号才能执行更新余额的操作,若不满足此条件,则认为修改失败
图示
以线程1想把主内存中的数据减50,线程2把主内存中的数据减20为例:
线程1此时准备将主内存中的数据读入自己的工作内存中并修改,而线程2也想将主内存的数据读入自己的工作内存中并修改,此时线程1和线程2以及主内存中的版本号都为1
当线程1把主内存的数据减50后,即修改后,会将自己工作内存中的版本号加1,此时线程1工作内存中的版本号大于主内存中的版本号(2大于1),因此线程1成功修改了主内存中的数据,并将数据50写入主内存中,最后将主内存中的版本号加1(即为2)
此时线程2修改了自己工作内存中的数据,随后将自己的工作内存版本号改为2:
但正当线程2准备将其改好后的数据80写入主内存时,发现自己的版本号和主内存的版本号都一样,并不满足大于关系,因此此次修改失败,有效避免了多线程并发修改数据时引起的数据安全问题。
总结
基于版本号这样实现乐观锁的机制就是一种典型的实现方式,这个实现方式和之前所学过的单纯的互斥的加锁方式来说更加轻量一些(只修改版本号,只是在计算机中用户态上进行操作,而互斥加锁方式会涉及到用户态和内核态之间的切换,不仅效率不太高,也容易引起线程阻塞)对于这个机制来说,如果修改数据失败,就会涉及到重试操作,如果频繁重试的话那么效率也就不高了,因此,最好在线程并发冲突率比较低的场景下使用乐观锁这一方式比较好
二. 读写锁
1. 理解
我们都知道,当我们通过多线程方式尝试修改同一数据时,一般都可能引发线程安全问题,但当我们通过多线程方式尝试读取同一数据时,一般不会引发线程安全问题,因此,我们可以根据读和写的不同场景来给读和写操作分别加上不同的锁。
Java当中的synchronized不会对读和写进行区分,默认使用后线程都是互斥的
2. 用法
以Java为例,在标准库中存在这样一个类ReentrantReadWriteLock
源代码如下
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable {
private static final long serialVersionUID = -6992448646407690164L;
private final ReentrantReadWriteLock.ReadLock readerLock;
private final ReentrantReadWriteLock.WriteLock writerLock;
final Sync sync;
public ReentrantReadWriteLock() {
this(false);
}
该类中提供了两个方法:
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
此方法可以创建出一个读锁实例
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
此方法可以创建出一个写锁实例
某个线程被读锁修饰后,这两个线程之间不会互斥,而是完全同时并发执行,一般将读锁用于线程读取数据比较多的场景;而当某个线程被写锁修饰后,这两个线程会互斥,一个线程会执行,而另一个线程会阻塞等待,因此必须是一个线程执行完了,另一个线程才会执行,一般用于修改数据比较多的场景
三. 重量级锁和轻量级锁
1. 原理
锁的核心特性 “原子性”,这样的机制追根溯源是 CPU 这样的硬件设备提供的
1.CPU 提供了 “原子操作指令”。
2. 操作系统基于 CPU 的原子指令,实现了 mutex 互斥锁.
3. JVM 基于操作系统提供的互斥锁。实现了 synchronized 和 ReentrantLock 等关键字和类。
注意:synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作
2. 理解
1.重量级锁依赖了OS提供的mutex,的开销一般很大,往往是通过内核来完成的
2.轻量级加锁一般不使用mutex,开销一般比较小,一般通过用户态就能直接完成
3. 区分用户态和内核态
我们可以类比一个生活中的例子,当去银行办理业务时,如果是通过用户在银行工作人员的指导下自己在窗口外完成,那么效率会比较高,就像计算机中的用户态一样。而当我们把自己的业务交给银行相关人员去完成时,由于银行工作人员的闲忙时间是不可控的,因此无法保证效率,就好比计算机中的内核态。
四. 自旋锁
1. 理解
当两个线程为了完成任务同时竞争一把锁时, 拿到锁的那个线程会立马执行任务,而没拿到就会阻塞等待,当一个线程把锁释放后,另一个线程不会被立即唤醒,而是等操作系统将其进行一系列的调度到CPU中的操作才能被唤醒然后执行任务,这种锁叫做挂起等待锁,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放,所以没必要就放弃 CPU。这个时候就可以使用自旋锁来处理这样的问题。
2. 实现方式
自旋锁的伪代码为:while (抢锁(lock) == 失败) {}
如果获取锁失败,就会立即再尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败, 第二次的尝试会在非常短的时间内到来,一旦锁被其他线程释放, 就能第一时间获取到锁
3. 优缺点
自旋锁是一种典型的轻量级锁的实现方式,它没有放弃 CPU, 不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁,这样会大大提高代码的执行效率,但如果锁被其他线程持有的时间比较久, 那么就会持续地消耗 CPU 资源。(而挂起等待的时候是不消耗 CPU 的)
因此,我们应该注意自旋锁的适用场合:
- 如果多个线程执行任务时锁的冲突比较低,或者线程持有锁的时间比较短,此时使用自旋锁比较合适
- 如果某个线程任务对CPU比较敏感,且不希望吃太多CPU资源,那么此时就不太适合使用自旋锁。
注意:synchronized自身已经设置好了自旋锁和挂起等待锁,会根据不同的情况自动选择最优的使用方案
五. 公平锁和非公平锁
1. 理解
若有三个线程 A,B,C。
A先尝试获取锁,获取成功了,因为只有一把锁,所以B和C线程都会阻塞等待,那么如果A用完了锁后,B和C线程哪一个会最先获取到锁呢?
- 公平锁:遵守先来后到原则,因为B线程比C线程来的早一点,所以B线程先获取到锁
- 非公平锁:没有先来后到原则,B和C线程获取到锁的概率是随机的
2. 注意事项
操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁。如果要想实现公平锁,就需要依赖额外的数据结构(比如队列) 来记录线程们的先后顺序。公平锁和非公平锁没有好坏之分, 关键还是看适用场景(大部分情况下非公平锁就够用了,但当我们希望线程的调度时间成本是可控的,那么此时就需要用到公平锁了)
注意:synchronized为非公平锁
六. 可重入锁和不可重入锁
1. 为什么要引入这两把锁
(1)实例一
在介绍可重入锁和不可重入锁之前,大家先来思考一个问题,为什么Java中的main函数要用static来修饰?
public class Test {
public static void main(String[] args) {
}
}
试想以下,如果main函数不是static来修饰的话:
public class Test {
public void main(String[] args) {
Test a=new Test();
a.main();
}
}
那么此时这段代码能否被执行呢?答案是不能,因为在java中,没有static的变量或函数,如果想被调用的话,是要先新建一个对象才可以。而main函数作为程序的入口,需要在其它函数实例化之前就启动,这也就是为什么要加一个static。main函数好比一个门,要探索其它函数要先从门进入程序。static提供了这样一个特性,无需建立对象,就可以启动。也可以利用反证法说明,如果不是static修饰的,若不是静态的,main函数调用的时候需要new对象,new完对象才能调用main函数。那么你既想进入到main函数new对象,又想new完对象来调用main函数,那么就不行了,相当于自己把自己锁在里面出不来了
(2)实例二
另外一个Java当中的例子:
synchronized void func1(){
func2();
}
synchronized void func2(){
}
我们对func1这个方法进行加锁时,是可以成功的,但当我们对func2这个方法再次加锁后,就比较危险了。因为要执行完func1这个方法,就必须执行完func2,而此时锁已经被func1这个方法占用了,func2获取不到锁,所以func2就会一直阻塞等待,去等func1释放锁,但func1一直执行不完成,所以锁永远不会释放,func2永远也获取不到锁,这样就形成了一个闭环,相当于自己把自己锁在里面出不来了,此时这个线程就会崩溃,是比较危险的
2. 实现方案
了解了上面两个实例的严重性后,我们引入了可重入锁这个机制,当我们形成死锁后,如果是可重入锁的话,它不会让线程阻塞等待最终死锁从而奔溃,而是运用计数器的方法,去记录当前某个线程针对某把锁尝试加了几次,每加一次锁计数都会加1,每次解锁计数都会减1,这样当计数器里面的计数完全为0的时候才会真正释放锁,正是因为有了这样的机制,才有效避免了死锁问题。而在Java中,synchronized
就是一把可重入锁,它给我们提供了很大的方便,保证在我们即使造成死锁问题时,程序也不至于崩溃。
七. 面试题
第一题
如何理解乐观锁和悲观锁,具体实现方式如何 如何理解?
见乐观锁和悲观锁字面理解部分(尝试用自己的语言组织)实现方式:
(1)乐观锁:见基于版本号方式实现乐观锁部分
(2)悲观锁:多个线程访问同一个共享数据时产生并发冲突时,会在取数据时会进行加锁操作,这样的话其他线程想要拿到这个数据时就会阻塞等到直到其他线程获取到锁
第二题
简单介绍一下读写锁
读写锁实际是一种特殊的自旋锁,它能把同一块共享数据的访问者分为读者和写者,读写锁会把读操作和写操作分别进行加锁,且读锁和读锁之间的线程不会发生互斥,写锁和写锁之间以及读锁和写锁之间的线程会发生互斥。读锁适用于线程读取数据比较多的场景,而写锁适用于线程修改数据比较多的场景。
第三题
简单介绍一下自旋锁
- 理解:当两个线程为了完成任务同时竞争一把锁时, 拿到锁的那个线程会立马执行任务,而没拿到锁的线程就会立即再尝试获取锁,无限循环,直到获取到锁为止,这样的锁就叫自旋锁
- 优点和缺点:见自旋锁优缺点部分
第四题
简单介绍一下Java中synchronized充当了哪些锁
- 主要以悲观锁为主,初始使用乐观锁策略,但当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略
- 并不区分读写锁
- synchronized自身已经设置好了自旋锁和挂起等待锁,会根据不同的情况自动选择最优的使用方案
- synchronized是一把非公平锁
- synchronized就是一把可重入锁
到此这篇关于Java面试最容易被刷的重难点之锁的使用策略的文章就介绍到这了,更多相关Java 锁内容请搜索编程网以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程网!
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341