多线程(进阶)

悲观锁 VS 乐观锁

悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
预期锁冲突的概率很高
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做.
预期锁冲突的概率很低
例子
同学 A 和 同学 B 想请教老师一个问题.
同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师
你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题.
如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没
加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B
也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.
这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 “白跑很多趟”, 耗费额
外的资源.
如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.
总的来说
悲观锁,做的工作更多,付出的成本更多,更低效
乐观锁,做的工作更少,付出的成本更低,更高效

读写锁 VS普通的互斥锁

对于普通的互斥锁,只有两个操作,加锁和解锁

只要两个线程对同一个对象加锁,就会产生互斥
对于读写锁来说,分成了三个操作
加读锁:如果代码只进行了读操作,就加读锁
加写锁:如果代码进行了修改操作,就加写锁
解锁

针对读锁和读锁之间,是不存在互斥关系的
读锁和写锁之间,写锁和写锁之间,才需要互斥

重量级锁 VS轻量级锁

重量级锁,就是做了更多的事情,开销更大
轻量级锁,做的事情更少,开销更小
通常情况下,可以理解为悲观锁就是重量级锁,乐观锁就是轻量级锁
在使用的锁中,如果锁是基于内核的一些功能来实现的(比如调用了操作系统的mutex接口),此时一般认为是重量级锁(操作系统的锁会在内核中做很多事情,比如让线程阻塞等待…)
如果锁是纯用户态来实现的,此时一般认为这是轻量级锁(用户态的代码更可控更高效)

挂起等待锁 VS 自旋锁

挂起等待锁,往往就是通过内核的一些机制来实现的,往往较重[重量级锁的一种典型实现]
自旋锁往往是通过用户态代码来实现的,往往较轻[轻量级锁的一种典型实现]
理解自旋锁 vs 挂起等待锁
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意,
这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能
立刻抓住机会上位

公平锁 VS 非公平锁

这俩可能有点容易搞反,要记住
公平锁:多个线程在等待一把锁的时候,谁是先来的,谁就先获得这把锁(遵循先来后到原则)
非公平锁:多个线程等待同一把锁,不遵守先来后到原则,每个人等待线程获取锁的概率是均等的;(对于操作系统来说,本身线程之间的调度就是随机的(机会均等),操作系统提供的mutex这个锁,就属于非公平锁

可重入锁 VS 不可重入锁

一个线程针对一把锁,能连续加锁两次,会死锁,就是不可重入锁,如果不会死锁,就是可重入锁
理解 “把自己锁死”
一个线程没有释放锁, 然后又尝试再次加锁
// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();
例子:
一个人闪现进了上锁的厕所,里面出不去,外面进不来
谈谈常用的synchronized这把锁
1:既是一个乐观锁,也是一个悲观锁(根据竞争的激烈程度,自适应)
2:不是读写锁,只是一个普通互斥锁
3:既是一个重量级锁,也是一个轻量级锁(根据竞争的激烈程度,自适应)
4:轻量级锁的部分基于自旋来实现,重量级的部分基于挂起等待锁来实现
5:非公平锁
6:可重入锁

CAS

什么是CAS:
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。
    当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线
    程只会收到操作失败的信号。
    CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)
    CAS最大的意义,就是我们写这种线程安全的代码,提供了一个新的思路和方向
    CAS都能2干啥?如何帮我们解决一些线程安全问题??

基于CAS能够实现"原子类"

(java标准库里提供了一组 原子类,针对锁常用一些int,long,int array…进行封装,可以基于CAS的方式进行修改,并且线程安全)

import java.util.concurrent.atomic.AtomicInteger;

public class Demo27 {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        AtomicInteger num = new AtomicInteger(0);

        Thread t1 = new Thread(() -> {
    
    
            for (int i = 0; i < 50000; i++) {
    
    
                // 这个方法就相当于 num++
                num.getAndIncrement();
            }
        });
        Thread t2 = new Thread(() -> {
    
    
            for (int i = 0; i < 50000; i++) {
    
    
                // 这个方法就相当于 num++
                num.getAndIncrement();
            }
        });

//        // ++num
//        num.incrementAndGet();
//        // --num
//        num.decrementAndGet();
//        // num--
//        num.getAndDecrement();
//        // += 10
//        num.getAndAdd(10);

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        // 通过 get 方法得到 原子类 内部的数值.
        System.out.println(num.get());
    }
}

在这里插入图片描述这个代码里面就不存在线程安全问题,基于CAS实现的++操作,这里就可以保证既能够线程安全,又能够比synchronized高效
synchronized会涉及到锁的竞争,两个线程要互相等待
CAS不涉及到线程阻塞等待
针对上诉为啥++是线程安全的

这里谈到CAS
==重点理解:==这整个代码实现的就是++,CAS他同时要执行比较,+1,交换这三个动作,如果失败,就没有交换的事情
伪代码:
在这里插入图片描述

在这里插入图片描述

基于CAS能够实现自旋锁

伪代码:
在这里插入图片描述

CAS中的ABA问题

ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
先读取 num 的值, 记录到 oldNum 变量中.
使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这
个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.

ABA 问题引来的 BUG
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.

假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50
操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程

  1. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
    望更新为 50.
  2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
  3. 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.
    异常的过程
  4. 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
    望更新为 50.
  5. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
  6. 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !!
  7. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
    这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼

解决方案
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候,
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
对比理解上面的转账例子
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50
操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.

  1. 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100,
    版本号为 1, 期望更新为 50.
  2. 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
  3. 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.
  4. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读
    到的版本号为 1, 版本小于当前版本, 认为操作失败

synchronized中的优化机制

锁膨胀/锁升级

体现了synchronized能够自适应这样的能力
在这里插入图片描述

锁粗化

细化,粗化,此处粗细指的是"锁的粒度"
加锁代码涉及到的范围
加粗代码的范围越大,认为锁的粒度越粗
范围越细,则认为粒度越细
在这里插入图片描述
如果锁粒度比较细,多个线程之间并发性就更高
如果比较粗,加锁解锁的开销就更小

编译器就会有一个优化,就会自动判定,如果某个地方锁的粒度太细了就会进行粗化,
如果两次加锁之间的间隔较大(中间隔的代码多),一般不会进行这种优化,如果间隔较小(中间隔的代码少),就可能触发这个优化

锁消除

有些代码,明明不用加锁,如果你给加上了锁,编译器发现了这个加锁好像没啥必要,就直接把锁干掉了

有的时候加锁操作不是很明显,稍不留神就做出了这种错误的决定

StringBuffer,Vector…在标准库中进行了加锁操作,在单个线程中用到了上诉的类,就是单线程进行了加锁

猜你喜欢

转载自blog.csdn.net/chenbaifan/article/details/124016766
今日推荐