多线程进阶

本片对面试中常见的多线程问题进行总结.

目录

一.常见的锁策略

乐观锁与悲观锁

读写锁

重量级锁与轻量级锁

挂起等待锁与自旋锁

公平锁与非公平锁

可重入锁与不可重入锁

synchronized是什么锁

synchronized的优化手段

锁膨胀/锁升级

锁粗化

锁消除

二. CAS

什么是CAS

有什么用

案例

原子类为什么线程安全

如何理解CAS中的ABA问题

如何解决ABA问题?

三. Callable接口

实例

四. JUC的常用类

ReentrantLock

基础用法:

与synchronized的区别

信号量

CountDownLatch

五. 线程安全的集合类

多线程使用ArrayList

多线程使用队列

多线程使用哈希表

HashTable

ConcurrentHashMap

一.常见的锁策略


乐观锁与悲观锁

  • 悲观锁: 预期锁冲突的概率很高, 做的工作更多, 成本更高, 更低效
  • 乐观锁: 预期锁冲突的概率很低, 做的工作更少, 成本更低, 更高效

就像一个人是否乐观一样.

如果他很乐观, 什么都不需要考虑, 自然也就轻松,.

如果一个人很悲观, 天天愁这愁那, 自然也就很低效.


读写锁

区别于普通的互斥锁, 互斥锁只有加锁解锁两种操作, 读写锁就是在加锁的时候额外表明一个意图

对于读写锁来说, 分成了三个操作

  • 加 读锁: 如果代码只是进行了读, 就加 读锁
  • 加 写锁: 如果代码中进行了修改操作, 就加 写锁
  • 解锁

注意: 读锁和读锁之间是不存在互斥的, 但是读锁与写锁, 写锁与写锁是存在互斥的

一旦互斥就会使线程挂起等待, 所以要减少互斥的情况, 提高效率.


重量级锁与轻量级锁

  • 重量级锁: 就像是悲观锁, 做了很多事情, 开销很大
  • 轻量级锁: 就像是乐观锁, 做的事少, 开销也小

在我们使用的锁中, 如果锁基于内核的一些功能来实现的(如调用了操作系统的mutex接口),一般这种是重量级锁

(操作系统的锁会在内核中做很多事情, 比如让线程阻塞等待)

如果锁是纯用户态实现的, 一般认为就是轻量级锁(用户态代码可控)


挂起等待锁与自旋锁

挂起等待锁: 通过内核的一些机制来实现, 往往较重(重量级锁的典型实现)

自旋锁: 通过用户态代码实现, 往往较轻(轻量级锁的典型实现)


公平锁与非公平锁

  • 公平锁: 多个线程等待一把锁, 谁先来谁先得 (先来后到)
  • 非公平锁: 多个线程等待一把锁, 但不遵守先来后到(机会均等)

对于操作系统来说, 线程间的调度是随机的, 所以操作系统提供的 mutex 锁是一个非公平锁

想要实现公平锁, 反而要付出更多代价


可重入锁与不可重入锁

之前介绍过, 如果一个线程能对一把锁连续锁两次, 不出现死锁, 那么就是可重入锁. 反之就是不可重入锁


synchronized是什么锁

  1. 既是乐观锁, 也是悲观锁(根据竞争程度自适应)
  2. 普通互斥锁
  3. 既是轻量级锁,也是重量级锁(同一)
  4. 轻量级锁基于自旋锁实现, 重量级锁基于挂起等待锁实现
  5. 非公平锁
  6. 可重入锁

synchronized的优化手段

锁膨胀/锁升级

这个优化体现了synchronized的自适应能力

synchronized在加锁时会经历这样的过程:

  1. 偏向锁: 首个线程加锁, 会进入偏向锁状态, 偏向锁不是真的加锁, 只是加了一个标记. 就像你捡了一了100块, 这100块暂时属于你的
  2. 自旋锁: 这时有人刚刚也看见地上的100块, 跑过来和你竞争, 你俩就进入了竞争状态, 对应锁就是自旋锁
  3. 重量级锁: 你在众目睽睽之下捡了100块, 大家全和你竞争, 竞争量很大, 对应就进入重量级锁状态

锁粗化

锁的粗细指的是锁的"粒度":

  • 如果加锁的范围越大, 认为锁的粒度越粗
  • 范围越小, 则认为粒度越小

锁的粒度粗细没有好坏之分:

  • 如果锁的粒度细, 并发性就更高
  • 如果锁的粒度粗, 加锁/解锁的开销就更小

所以编译器会有优化, 如果代码有地方粒度太细, 编译器会自动加粗.

锁消除

有时候程序可能不需要加锁, 你却不小心加了上去

编译器会自动帮你把锁去掉.

就比如调用StringBuffer类, 就会产生锁, 但单线程中不需要, 所以会自动帮你去除.

   

二. CAS


什么是CAS

全称是compare and swap, 比较并交换

内存中有一个数据A, 如果它和我们的预期值一样, 就把它改成B

而CAS指的就是, CPU为我们提供了单独一条CAS指令, 就可以实现上面的过程


有什么用

如果没有CAS指令, 我们去完成这样一个代码, 明显是线程不安全

CPU给我们封装成了一条指令, 保证了原子性, 也保证了线程安全

之后写多线程代码也可以用到这个, 也就脱离了锁


案例

  • 基于CAS能实现原子类

如下:

    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();
            }
        });

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

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

这样的代码就不存在线程安全问题, 基于CAS实现的操作, 既能保证线程安全, 又能比 synchronized 高效

因为 synchronized 会涉及到锁的竞争, 需要等待

  • 基于CAS实现自旋锁

先来看伪代码:

如图所示, 第一行代码的变量是记录锁被哪个线程持有, 空就是未加锁

之后循环, 变量空就改成当前线程, 不为空就一直循环, 所以叫做自旋锁

虽然一直自选, 这样的忙等不好, 但是自旋锁本身也是乐观锁, 所以预期很快拿到, 不影响.


原子类为什么线程安全

这是原子类的一个伪代码, 这个value 可能会被多个线程在改, 如果改到合适的值就进行CAS替换

详细步骤我们来画图演示:

假设CPU一开始值为 0 , 加载到线程上

然后先执行thread1的CAS操作, 发现相等, 加一后保存 (这是一气呵成的)

然后是thread2 发现不一样, 直接返回false

然后如伪代码所示, 就一直循环

再加载一次, 就把1加载到了thread2中, 发现一样, 进行自增再保存. 这样就正常加减了


如何理解CAS中的ABA问题

如上面描述, CAS每次都要比较是否相同

但这个相同分为两种情况: 

  • 这个数确实没被改过
  • 这个数被某个线程修改过了

如果不区分, 再某些情况可能会引起bug

比如我们有100, 想去取50 , 摁取钱按钮时卡了一下(但任会执行), 我们再按一次,此时出现两种情况:

  1. 正常情况: 第一次摁, CAS发现是100 块, 于是减到50, 第二次再摁, CAS发现不是100了, 便取不出
  2. 极端情况: 在第一次和第二次之间, 你朋友给你转了50块钱
  • 第一次CAS完后, 第二次发现又是100, 此时系统不知道是本来就有100, 还是别人给的100, 二话不说又取出来50

这就是一个典型的ABA问题


如何解决ABA问题?

引入一个版本号, 这个版本号只能变大, 不能变小

修改变量的时候, 还要去比较版本号

每次对值修改, 就改变一次版本号

这样每次要改值, 会进行两次比对, 就能有效解决ABA问题

这种基于版本号来进行多线程数据的控制, 也是一种乐观锁的典型实现 

  

三. Callable接口

callable接口适合通过线程来计算结果

如果用 Runnable 接口实现就比较麻烦, 比如计算1 + 2 + 3 + ... + 1000

    //创建一个类 Result , 包含一个 sum 表示最终结果, lock 表示线程同步使用的锁对象.
    static class Result {
        public int sum = 0;
        public Object lock = new Object();
    }
    public static void main(String[] args) throws InterruptedException {
        Result result = new Result();
        Thread t = new Thread() {
            @Override
            public void run() {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                //当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果
                synchronized (result.lock) {
                    result.sum = sum;
                    result.lock.notify();
                }
            }
        };
        t.start();
        //主线程同时使用 wait 等待线程 t 计算结束. 
        synchronized (result.lock) {
            while (result.sum == 0) {
                result.lock.wait();
            }
            System.out.println(result.sum);
        }
    }

实例

让我们用Callable接口来实现一下:

先安排任务:

        // 通过 callable 来描述一个这样的任务~~
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };

想让线程执行Callable的任务, 还需要一个辅助类FutureTask:

    // 为了让线程执行 callable 中的任务, 光使用构造方法还不够, 还需要一个辅助的类.
    FutureTask<Integer> task = new FutureTask<>(callable);
    // 创建线程, 来完成这里的计算工作~~
    Thread t = new Thread(task);
    t.start();

最后得到结果:

        // 如果线程的任务没有执行完呢, get 就会阻塞.
        // 一直阻塞到, 任务完成了, 结果算出来了
        try {
            System.out.println(task.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

这里为什么要一个辅助类FutureTask呢?

假设大家在学校食堂点餐, 你选好菜, 前台会把任务交给后厨, 当然后厨还有很多任务

但是后厨全部都做好后, 那么多菜之中哪一个才是你的呢?

解决办法就是给你一个牌子, 取餐的时候凭牌子来取

这个辅助类就像一个牌子, 线程中那么多任务, 最后算到的结果哪个是你想要的呢?

就需要这样一个辅助类来判断这件事.

    

四. JUC的常用类

JUC就是指 java.util.concurrent, 表示Java的多线程类包


ReentrantLock

ReentrantLock, 翻译就是可重入锁

基础用法:

这个锁把加锁和解锁两个操作分开了

但其实不是很好, 如果忘了解锁就会出现死锁

public class Test9 {
    public static void main(String[] args) {
        ReentrantLock locker = new ReentrantLock();
        // 加锁
        locker.lock();
        // 抛出异常了. 就容易导致 unlock 执行不到~~
        // 解锁
        locker.unlock();
    }
}

与synchronized的区别

  • synchronized 是一个关键字, 而ReentrantLock是标准库里的一个类
  • synchronized 不需要手动释放锁, 出了代码块自然释放, ReentrantLock必须要手动释放
  • synchronized如果竞争锁的时候失败了, 就会阻塞等待, 但是ReentrantLock 除了阻塞等待, 还有一种情况, 就是trylock, 失败了直接返回

比如女神已经有男友了, 但是你还想去追, 说你可以一直等,一直等就是synchronized, 但是如果你直接放弃, 就是trylock的一种情况, 不会出现死等的情况

  • synchronized是一个非公平锁, ReentrantLock提供了非公平锁和公平锁两个版本, 在构造方法中通过改变参数来选定
//加上true就是公平锁
ReentrantLock locker = new ReentrantLock(true);
  • 基于synchronized衍生出来的等待机制, 是wait和notify, 功能有限
  • 基于ReentrantLock衍生出来的等待机制是Condition类,功能更丰富

信号量

信号量, 英文是semaphore

是一个更广义的锁, 锁是信号量里的第一种特殊情况, 叫做"二元信号量".

什么叫做信号量呢? 描述了可用资源的个数

比如你去停车, 停车场外有一个牌子会写还剩xx位, 这个牌子就是信号量

下面是信号量的基础操作:

  • 每次申请一个可用资源, 计数器-1(称为P操作)
  • 每次释放一个可用资源, 计数器+1(称为V操作)
  • 当信号量为0, 再进行P操作就会阻塞等待

锁就可以视为"二元信号量", 可用资源就一个, 计数器的取值, 非0即1

而信号量就把锁推广到了一般情况, 来判断可用资源更多的时候如何处理

基本操作:

    public static void main(String[] args) throws InterruptedException {
        // 初始化的值表示可用资源有 4 个.
        Semaphore semaphore = new Semaphore(4);
        // 申请资源, P 操作, 一次拿俩
        semaphore.acquire(2);
        // 释放资源, V 操作, 一次释放俩
        semaphore.release(2);
    }

如果申请量超过了可用资源数, 便会阻塞:

    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(2);
        semaphore.acquire();
        System.out.println("1");
        semaphore.acquire();
        System.out.println("2");
        semaphore.acquire();
        System.out.println("3");
        semaphore.acquire();
    }

第三次在阻塞了, 除非其他线程release, 否则一直阻塞 


CountDownLatch

这个不太好翻译, 可以理解为终点线.

比如我们下载时, 怎么才能下载更快? 答案就是通过多线程

把一个文件拆分成多个部分, 每个线程负责下载其中一个, 就能加速

CountDownLatch就能实现这样的功能.

CountDownLatch主要是两个方法:

  • countDown 给每个线程里面去调用, 运行时就是表示到达终点了
  • await 是给等待线程去调用, 当所有任务到达终点, await 就从阻塞中返回, 就表示任务完成

代码实现:

    public static void main(String[] args) throws InterruptedException {
        // 构造方法的参数表示有几个选手参赛.
        CountDownLatch latch = new CountDownLatch(10);

        //创建10个线程来跑
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(() -> {
                try {
                    Thread.sleep(3000);
                    System.out.println(Thread.currentThread().getName() + " 到达终点!");
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }

        // 裁判就要等待所有的线程到达
        // 当这些线程没有执行完的时候, await 就阻塞, 所有的线程都执行完了, await 才返回.
        latch.await();
        System.out.println("比赛结束!");
    }

当调用countDown次数和构造函数里的数字一样就说明执行结束.

  

五. 线程安全的集合类

在集合类中, 很多都不是线程安全的,但我们也可以将其替换为线程安全


多线程使用ArrayList

  • 自己加上synchronized
  • 利用Java的方法: Collections.synchronizedList(new ArrayList);
  • 使用 CopyOnWriteArrayList容器(写时拷贝)

使用CopyOnWriteArrayList容器在修改集合时, 会创建一个副本, 在多线程写时, 先修改副本, 当修改完, 再将副本转正

这样子不会干扰读, 不会出现读到一个"修改了一半的值", 读的时候优先读旧的值


多线程使用队列

要使用队列, 就用上面介绍过的阻塞队列, 下面列出来阻塞队列的种类:


多线程使用哈希表

HashMap本身不安全

有两个安全的哈希表:

  1. HashTable [不推荐]
  2. ConcurrentHashMap [推荐]

HashTable

HashTable保证线程安全的方法就是给关键方法加锁, 如下:

但是这样的设计会导致大量的锁竞争, 效率较低

因为这样的加锁是针对 this 的, 也就意味着HashTable对象只有一把锁

ConcurrentHashMap

而ConcurrentHashMap针对这个元素所在链表的头节点来加锁(HashMap内部就是链表数组实现的)

给每一个链表头节点都加锁, 锁多了就减少了锁冲突的概率

列举一下ConcurrentHashMap的优点:

  1. ConcurrentHashMap减少了锁冲突概率 ,让锁加到每个链表的头节点上(锁桶)
  2. ConcurrentHashMap只是针对写操作加锁, 读操作没加锁, 只使用了volatile
  3. ConcurrentHashMap中更广泛使用了CAS, 效率也进一步提高
  4. ConcurrentHashMap针对扩容, 进行了巧妙地化整为零

哈希表的扩容需要创建一个更大的数组, 然后把旧数据搬进去

如用HashTable扩容, 一次put就需要把所有内容搬完

而ConcurrentHashMap每次只要搬运一点点, 同时维护一个新的哈希表和旧的哈希表, 查找时既查新也查旧

但插入只插新的, 直到搬运完才销毁旧的  

本次整理不易, 恳请点个赞, 大家一起加油, ヽ( ̄ω ̄( ̄ω ̄〃)ゝ

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

丶chuchu丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值