这是我参与更文挑战的第 15 天,活动详情查看:更文挑战
作者:花Gie
微信公众号:Java开发零到壹
前言
多线程系列我们前面已经更新过很多章节,强烈建议小伙伴按照顺序学习:
前面我们已经学习过synchronized锁,也知道他的用法和原理,但是它属于锁中的哪一类呢,它属于悲观锁、可重入锁、不可中断锁等,那为什么会有这么多锁类型呢,看完本文你就能够掌握锁究竟是什么。
正文
锁的总览
锁可以从不同的角度进行分类,这些分类并不是互斥的,比如一个人既可以是医生,又可以是父亲。分类总览如图:
乐观锁与悲观锁
- 概念
这其实和不同人的性格一样,乐观锁就对应乐观开朗性格的小伙伴,他们总会看到事物美好的一面;而悲观锁则对应生活中凡是都将事情考虑到最坏的状态。
悲观锁又叫互斥同步锁
,它为了确保结果的正确性,会在每次获取到数据后,都会将其锁住,因此当其他线程也来访问时,就会进入阻塞状态,这样就可以防止其他线程访问该数据,从而保证数据的安全性。Java中我们常用的Synchronized
及RenntrantLock
都是悲观锁,在数据库很多地方就用到了这种锁机制,比如行锁,表锁,读锁,写锁等。
乐观锁又叫非互斥同步锁
,它认为自己在处理数据的时候,不会有其他线程来进行干扰,因此它不会把数据锁住;如果一个线程在修改数据时,没有其他线程干扰,那就会正常执行修改;而如果该数据已经被其他线程修改过,那当前线程为了保证数据正确性,就会执行放弃
或报错
等策略。常用的悲观锁例子有原子类
、并发集合
等。
我们对原子类也有过分析,有兴趣小伙伴建议看一下AtomicInteger使用方式及源码介绍。
-
乐观锁实现方式
-
版本号机制
在数据表中添加一个version字段,用于表示数据被更新的次数,当读取出数据时,将此版本号一同读出,此后每更新完一次数据后,对版本号version加一,然后保存到表记录中。假如进行更新操作时,会将当前version版本号和数据库中的版本号进行对比,如果提交的数据版本号较大,就进行更新操作,否则认为是过期数据,就会重试更新操作,直到更新成功。
-
CAS
全称为compare and swap,从字面上理解就是比较与转换。CAS包含三个操作数
目标数据内存位置(V)
、进行比较的预期值(A)
、拟写入的新值(B)
,在修改数据时,会将内存位置V与预期值A进行比较,如果两者相等,会将内存位置V修改为新值B,否则就不执行任何操作。拓展:
- CAS包含比较和转换两个操作,但它属于原子操作,由CPU在硬件层面保证其原子性,
- 许多CAS的操作是自旋的,即操作不成功时不断重试,直到操作成功为止。
-
-
优缺点对比
我们前面已经用过Synchronized这种悲观锁,感觉也是非常简单方便,那为什么还要搞一个乐观锁呢,这就要对两种锁的不足之处进行对比:
悲观锁缺陷
- 当一个线程进入悲观锁时,如果该线程永久阻塞(如死锁),那其他等待该锁线程只能进行等待,并且永远不会得到执行。
- 限制并发数:
乐观锁缺陷
- ABA问题:如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
- 自旋时间长开销大:如果数据修改不成功,就会一直循环执行直到成功,会给CPU带来非常大的执行开销。
- 功能限制:CAS只能保证单个变量的原子性,如果我们需要保证一段代码的原子性,乐观锁就不再适合。
-
使用场景选择
悲观锁或乐观锁并不是想要替代对方,也没有优劣之分,只是他们各自适用在不同的场景。
-
对于资源竞争较少(线程冲突较轻)的情况,使用乐观锁更佳。使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,因此可以获得更高的性能。
-
对于资源竞争严重(线程冲突严重)的情况,CAS自旋的几率会比较大,这样会造成CPU资源浪费,效率低于悲观锁。
可重入、不可重入锁
- 概念
不可重入锁是指当前线程执行中已经获取了锁,如果再次获取该锁时,就会被阻塞。
下面我们以wait/notify来设计一个不可重入锁(此外还可以通过CAS + 自旋来实现):
//wait、notify实现不可重入锁
public class NonReentrantLockDemo1 {
//记录是否被锁
private volatile boolean locked = false;
public synchronized void lock() {
//当某个线程获取锁成功,其他线程进入等待状态
while (locked) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//加锁成功,locked 设置为 true
locked = true;
}
//释放锁
public synchronized void unlock() {
locked = false;
notify();
}
}
//测试类
class ThreadDemo implements Runnable{
NonReentrantLockDemo lock = new NonReentrantLockDemo();
public static void main(String[] args) {
new Thread(new ThreadDemo()).start();
}
/**
* 方法1:调用方法2
*/
public void method1() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " method1()");
method2();
} finally {
lock.unlock();
}
}
/**
* 方法2:打印前获取 obj 锁
*
*/
public void method2() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " method2()");
} finally {
lock.unlock();
}
}
@Override
public void run() {
method1();
}
}
测试结果:
结果显示:当线程执行到method1时,已经拿到lock锁,只要该锁没有被释放,其他代码块无法使用此锁来加锁。当该线程再去调用method2时,而method2也需要获取lock才能执行,这样都导致了死锁,这种会出现问题的重入一把锁的情况,叫不可重入锁
。
可重入锁又叫递归锁,指在同一个线程在外层方法获取锁的时候,进入内层方法会自动获取锁。因为《蹲坑也能进大厂》多线程系列-synchronized深入原理终结篇已经对该锁进行详细的讲解,所以不再赘述。
- 代码示例
这里使用一段代码简单演示ReentrantLock可重入:
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.unlock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
}
打印结果:
0
1
2
1
0
结果显示:同一个线程获取到ReentrantLock锁后,在不释放该锁的情况下可以再次获取。
- 可重入性优点
- 避免死锁
- 提升封装性
总结
以上就是对锁
的部分介绍,理论性比较强,但是这部分内容非常重要,不然后续别人问你悲观锁有哪些,即使你用过,但你可能都不理解这个悲观锁是什么,造成场面一度尴尬。
关于锁的剩余部分会在下一章继续输出。
点关注,防走丢
以上就是本期全部内容,如有纰漏之处,请留言指教,非常感谢。我是花GieGie ,有问题大家随时留言讨论 ,我们下期见🦮。
文章持续更新,可以微信搜一搜 Java开发零到壹 第一时间阅读,并且可以获取面试资料学习视频等,有兴趣的小伙伴欢迎关注,一起学习,一起哈🐮🥃。
原创不易,你怎忍心白嫖,如果你觉得这篇文章对你有点用的话,感谢老铁为本文点个赞、评论或转发一下
,因为这将是我输出更多优质文章的动力,感谢!
参考链接: