【JavaEE】多线程进阶问题-锁策略and死锁,CAS操作,Synchronized原理

简介: JavaEE & 多线程进阶问题 & 锁策略and 死锁,CAS操作,Synchronized原理

JavaEE & 多线程进阶问题 & 锁策略and 死锁,CAS操作,Synchronized原理

1. 锁策略

不仅限于Java,其他语言也适用这套规则

1.1 乐观锁 vs 悲观锁

锁的实现者通过锁的冲突概率,做出相应的决策


乐观锁 ==> 预测接下来冲突概率小

工作量更少,效率大

悲观锁 ==> 预测接下来冲突概率大

工作量更多,效率小

但是并不绝对。

这里的工作量就是锁操作的内部一些操作

了解概念即可~


1.2 轻量级锁 vs 重量级锁

虽然和乐观锁悲观锁不是一回事,但是有部分概念重合~


轻量级锁 ==> 加锁解锁更快更高效

重量级锁 ==> 加锁解锁更慢更低效

轻量级锁很可能是乐观锁

重量级锁很可能是悲观锁

但是不绝对~

1.3 自旋锁 & 挂起等待锁

这跟轻重锁有密切联系


自旋锁 ==> 轻量级锁的典型实现

挂起等待锁 ==> 重量级锁的典型实现

对于自旋锁:


对于遇到锁冲突,并没有选择等待,而是没闲着,反复确认是否锁被释放

这样子做虽然精度高,但是消耗CPU资源大,即忙等

重量级锁这样会,导致其他任务受限,得不偿失~

40b42871268545c89bf0afd58d7a26dc.png


对于挂起等待锁


对于遇到锁冲突,直接中断任务,等待锁释放

这样子做虽然精度不高,但是可以让CPU去干其他的任务

轻量级锁这样做,可能会导致轻量级锁的任务不能满足更精的要求

2373acdbbdf543ad88e1dd6f09d118e5.png


自旋 ==> 纯用户态操作,不需要经过内核态,时间相对更短


轻量

挂起等待 ==> 需要通过内核的机制来实现挂起等待,时间相对更长


重量

1.4 synchronized属于哪种锁?

synchronized即是乐观锁又是悲观锁,即是轻量级锁,又是重量级锁

轻量级部分基于自旋锁实现

重量级部分基于挂起等待锁实现

感觉情况,变化,自适应

如果锁冲突不激烈,以轻量级锁/乐观锁去适应

如果锁冲突很激烈,以重量级锁/悲观锁去适应

尽可能节省地,保证线程安全~

没错,JVM就是这么牛

1.5 互斥锁 vs 读写锁

synchronized是互斥锁,就只是单纯加锁,没有更细化的部分~

加锁

释放锁

对于读写锁:


对读加锁

对写加锁

释放锁

读写锁约定:


读锁和读锁之间,不会产生锁竞争 — 多线程读操作,线程安全~


写锁和写锁之间,就会产生锁竞争


读锁和写锁之间,也会产生锁竞争


【非必要不加锁】,适用于一写多读的情况


虽然synchronized并非读写锁,但是Java标准库有~


在特定的场景下可以使用~

ReentrantReadWriteLock的源码发现,


其内部含有ReadLock和WriteLock这两个类,


代表ReentrantReadWriteLock拥有的一对读锁 和写锁,


665e2d8f102e4b6887f6c102f038a94d.png


1.6 公平锁 vs 非公平锁

规定:遵守先来后到称为公平锁,不遵守先来后到称为非公平锁

可能我们直观的想法是,抢到锁的概率是否一样来觉得公平不公平~

而这个规定就是发明这个概念的大佬定下来的~

就是,一个锁被一个线程抢了,而其他线程都在等待,但是等的时间不一样


c536245378684269a001e05beb996dc8.png

而如果是非公平锁,只要锁释放,线程234都有可能抢到锁


即线程4这个刚来的可能会比其他两个线程之快抢到锁~

而如果是公平锁,锁释放后,按照先后顺序分配锁


即线程 3 2 4顺序

实现公平锁的话,加个队列记录顺序即可~


2. 死锁

2.1 可重入锁 vs 不可重入锁

对于一个线程,针对同一把锁,连续加锁两次

出现死锁了,就是不可重入锁

不出现死锁,就是可重入锁

对于不可重入锁:


同一把锁:同一锁对象

aac27007f4df4a0a8fe598f5164d22d7.png


举个栗子:

还有个栗子,疫情期间,一些店铺要求你进店铺需要带口罩,而你如果没有口罩想进去买口罩呢?


27f5f1e4a3474814aa640ad15d84b3a8.png

日常开发是很容易触发这些代码的,例如:


synchronized void A() {
    ·······
}


这是一个被锁住的方法

synchronized void B() {
    A();
    ·······
}


在同一个类之中的另一个锁方法,调用A方法

这样,就会出现,一个线程对同一把锁的,连续加锁两次~


然而,出现这些情况就会触发死锁bug吗?


显然是不会的,synchronized就是可重入锁


synchronized会识别,竞争的锁是否本就被自己“抢到”,如果是,就不需要阻塞等待~

即会判断自己是否是锁的拥有者

没错,JVM就是这么牛


2.2 两个线程两把锁

对于这种情况,即使是可重入锁,也会死锁~

22a985f94eaf4dc08a78aa00e634ad48.png


当然,若线程1或者线程2进入第二层锁,那么另一个线程也不会进入第一层锁,这样也不会出现死锁

但是,线程调度无序性呀,出现同时进入第一层锁是很有可能的!


58b64efc75524963ad90e617e13d4e22.png

所以就出现相互限制的情况!


06c55e6e459449078237a10c462b8d58.png

2.3 N个线程,M把锁

线程越多,锁越多,更容易出现死锁情况了~

2.3.1 哲学家就餐问题


f69ad01b4f4245939322cdafb7016cee.png

那么就会出现如下情况:

假设五个哲学家,同时拿起左手边的筷子,那么他们就会固执的嗦不了粉


因为,他们在等待别人放下筷子~

而这里的别人也在等他们自己放下筷子~


29db73727e2f4e559af37db4610fb6d6.png

由此可见,线程越多,锁越多,更容易出现死锁情况了~

2.3.2 死锁的基本特点,四个必要条件

互斥使用

不可抢占,一个锁只能由锁的拥有者自行释放,不能强行占用~

请求和保持,(去抢别的锁,但是却没有释放原本的锁)

循环等待,(逻辑形成循环)

四个条件缺一不可!


1和2是锁的基本特点

而3和4,代码的特点

就是哲学家就餐问题的根本原因!

2.3.3 死锁的处理

死锁是一个很严重的bug,我们要如何处理呢?


有很多方法可以处理

而在开发中,最实用简单的就是:【按顺序加锁】

其实,死锁的产生就是因为加锁链是交叉的


例如,两个线程两把锁:

84ed6b1f843848f69422a72fe985a0bb.png



交叉代表了,

可能会互相影响!

锁内部要抢的锁,可能会被别人抢走,进而导致我无法释放锁,别人无法获得我的锁

依照这个思路,我们只需要规定一个顺序,只要一层层加锁的时候,都严格按照同一个顺序来加锁,就可以巧妙的防止出现交叉,防止死锁的出现!


注意,这里只需要都满足一个顺序就行,并不是要按照锁编号大小顺序~

给锁编号,方便排序管理~

例如以下的,四个线程,四把锁


都按照1234

不会导致交叉,即不会死锁

ef5dce3490d7449ab6995e4c51dfb60d.png


若其中有线程不满足!

就会导致交叉,即有可能会死锁!

7bc2b96bb68b4d459c3abbffffaad53c.png


总结就是,注意写代码时,按照加锁顺序!!!


【两个线程两把锁】解决:

8bafbf457433416eaafa08cab485d3e7.png


【哲学家就餐问题】解决:

首先,对筷子进行编号:


7ea95c83f95043dca75b1b10544a25d5.png

约定,哲学家只能先拿编号小的筷子,才能拿编号大的筷子

【外提一嘴,对于这种实际问题,我觉得哲学家太固执了,估计也不会遵循】

那么,无论谁先开始拿(随机调度)


最终,至少会导致一个哲学家拿不到一根筷子,


那么就至少会有一个哲学家拿到两根筷子!


那么的那么,这个哲学家嗦完粉后,就可以放下筷子,这样其他等待的哲学家就可以嗦粉了


并且,之后的过程,也不会产生“死锁现象”

这样,原本死锁的问题被巧妙的破解了~

8bbdb7deb7df4ff5b07b86448f1b6679.png


以此类推,不会出现死锁!程序正常执行~

3. CAS操作

3.1 CAS介绍

CAS ==> Compare-And-Swap


即,比较 和 交换


作用就是:


boolean CAS(M, A, B)


M为内存地址,A和B为寄存器的值

发现M对应的值与A相等,则交换B和内存M对应的值,返回true

B可能也来自另一个内存,不好说~

发现M对应的值与A不同,啥也不做,则返回false

对于这个操作,我们更关心,M对应的值被赋值~


下面为CAS的伪代码~

boolean CAS(M, A, B) {
    if (*M == A) {
        *M = B;
        return true;
    }
    return false;
}


重点:


CAS并不是上述描述的一段伪代码

而是单一的一条原子的CPU指令!!!

不存在线程安全问题~

意义:打开新世界大门,不加锁也能线程安全!


当然,CAS操作需要有CPU的支持~

由JVM封装,我们的CPU也可以做CAS操作了

3.2 CAS操作实现原子类

Java标准库提供了很多原子类

AtomicXXX,原子的XXX


cbaf84d3ed0145ef86607fe02e8980d7.png

3.2.1 AtomicInteger类

这是一个原子普通类

可以保证++,–操作是线程安全的!

3.2.2 AtomicInteger使用

这四个方法很好的展现了前置加加减减,和 后者加加减减的原理


c1d64d1040674213a7c2557a33d8f3c6.png

不加锁检测线程安全不安全

老问题:count给两个线程,各自加50000次

public static void main(String[] args) throws InterruptedException {
    AtomicInteger atomicInteger = new AtomicInteger(0);
    //只要引用指向不改变,就可以被lambda表达式捕获~
    Thread thread1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            atomicInteger.getAndIncrement();
        }
    });
    Thread thread2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            atomicInteger.getAndIncrement();
        }
    });
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    System.out.println(atomicInteger.get());//获取对应值~
    //不过这个原子类的打印方法,就是打印其对应的值
}


结果表示线程安全:

5635e0c41c734224be9c9c469f9b70cb.png


源码看不懂_(¦3」∠)_


看伪代码~

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue + 1) != true) {
            oldValue = value;
        }
        return oldValue;
   }
}


Java没法表示寄存器里的值,而C里面有个register,建议编译器把变量优化到寄存器中

但是这也只是建议,大部分情况,你的优化是不如编译器自己去优化的

这个过程不涉及阻塞等待,比之前加锁的方案,快很多~

解析:

e6fa59ca79df48afb03c79e35f49a1e3.png



线程安全分析:


ac10428a132b4f0ab280473390c1cc7f.png

3.3 CAS操作实现自旋锁

自旋锁:反复检测是否锁释放~

如下为伪代码:


public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}


owner记录了锁的拥有者~

为 null的时候,说明锁未被任何线程争夺到~

此时,是可以抢夺锁的

6870867e2f53403d9121c34f9adcf207.png



好处:一旦锁释放,就可以立即获得锁

坏处:忙等,很耗cpu资源

乐观锁—锁冲突概率小的,适用自旋锁~


3.3.1 aba问题

我们在CAS操作的原理中看出compare操作起到的作用


但是会有这么一种情况,就是一个value值,从a —> b —> a

不是没变过,而是变回来了~

而CAS只能判断值是否变化,但是无法确定这个值中间是否发生过变化~

此时,我们有一定概率会出问题。

大部分情况下,都没事!!!

极小概率下会出bug

3.3.2 解决aba问题缺陷

我们只需要,让数据只能单方向变化,那么问题就迎刃而解了~

就是说,让数据一直递增 / 递减

这样就不会又这两种情况:

即解决反复横跳的问题

072de4c48e3148948da6991d2f4d09fe.png


问题:我们肯定不能强制这个数据只能增只能减啊!!!


但是我们可以添加一个成员属性,版本号


而这个成员是只能增 / 只能减的


这样,每次CAS操作,对比的就不是数据本身,而是对比版本号~


版本号于每次CAS返回true时应自增 / 自减

伪代码:


class V {
    int number = 100;
    int version = 1;
}
if(CAS(version, old, old + 1)) {
    number++;//键值别忘了变~
}


以版本号为基准,而不是以变量数值为基准!!!


4. Synchronized原理

对于synchronized


既是乐观锁,也是悲观锁

既是轻量级锁,也是重量级锁

轻量级锁基于自旋实现,重量级锁基于挂起等待实现

是互斥锁,不是读写锁

是可重入锁

是非公平锁

以后遇到其他锁,可以依照这些特定对号入座~


本章节并不涉及,实现一把锁的操作

在Java标准库里有,AQS实现锁的框架,感兴趣可以去研究研究

4.1 锁升级

锁升级是synchronized关键字的重要策略


d14c0fb10afd43f3a0c4f3e8817f4a5b.png

这个升级策略,可以尽可能省的去干活~

而偏向锁是这里唯一没讲到过的。


偏向锁, 是一个线程抢到锁之后,并没有处于一种锁的状态,而是拿着锁不锁~

即,此时没人跟我竞争,我其实可以一直不加锁,即使我抢到锁,但是非必要不加锁~

而一旦有人参与锁竞争,我就立马加锁!

这就相当于 “搞暧昧”

主要还是为了尽可能优化速率


偏向锁就相当于给对象处于 “偏向于锁的状态”,就是做了个标记,而做标记这个操作很轻快~

这样做既保证了效率,又保证了线程安全

对于自旋锁变为挂起等待锁


我们主流的系统windows和linux调度开销很大。系统不承诺能在某时间段内完成调度。

所以,极端情况下,调度的开销会极端的大

还存在另外一种,实时操作系统,例如vxworks

就能够以更低的成本完成任务调度,但是这种系统牺牲了主流操作系统的很多功能! !

对于一些任务,要求时间精度高且不需要其他功能

我们就可以用这种操作系统去搞!

此时自旋锁的CPU消耗比前者低~

主流的JVM的实现,只能锁升级,不能锁降级~


不是实现不了

而是收益与代价相比,大佬们可能觉得不划算~

4.2 锁消除

非必要不加锁,但是我们之前的是在代码运行层面的不加锁


而锁消除机制则是在编译时期就做出了优化~

编译时,智能检测当前代码,是否是多线程或者是是否有必要加锁


如果没有必要,但是你把锁给写了,就会在编译时期把锁消除掉~

synchronized不应该被滥用的,开销大~


比如说,StringBuilder和StringBuffer这两个类

前者线程不安全,后者线程安全

如果每次无脑都用StringBuffer的话,开销大

本质上就是synchronized修饰了StringBuffer的所有关键的方法(下一篇文章会讲)

而synchronized的锁消除机制会很好的将这种滥用或者不经意滥用的情况消除掉~

而本质不影响最终效果

af67a30ff54f4d87bdaeaee07ebaeabf.png


4.3 锁粗化

粒度: 即细化的程度。被封锁的对象的粒度。例如数据项、记录、文件或整个数据库,锁粒度越小事务的并行度越高。


粒度就相当于代码的规模大小,代码越多粒度越粗,代码越少粒度越细

一般我们写代码是希望粒度细点好,减少串行,增加并行并发

充分利用CPU资源

但是,如果在一个需要频繁加锁解锁的场景下,粒度细会导致开销更大!


这样,编译器就会优化你的代码,粗化代码粒度,减少加锁解锁


类比一下,如果你给你老板打电话汇报工作1、2、3,你打通了,他也被你“锁”住了,而你却没一次性说完,分三次汇报。这样老板就会看你不爽~

这样也可以保证一个任务的完整性,毕竟线程调度随机,一个任务可能会因为部分环节延后了。


一样的,不会影响最终结果~

a5038d6c1a9d4280b0c9f50de4fffa86.png


目录
相关文章
|
26天前
|
Java 应用服务中间件 测试技术
Java21虚拟线程:我的锁去哪儿了?
【10月更文挑战第8天】
30 0
|
1月前
|
安全 调度 数据安全/隐私保护
iOS线程锁
iOS线程锁
25 0
|
1月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
38 1
C++ 多线程之初识多线程
|
15天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
13 3
|
15天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
12 2
|
15天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
27 2
|
15天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
26 1
|
15天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
26 1
|
15天前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
21 1
|
1月前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
44 6