【面试八股文】- 多线程进阶

一、常见锁策略

1.读写锁 VS 普通的互斥锁

  • 普通的互斥锁: 只要两个线程针对同一个对象加锁,就会产生互斥
  • 读写锁
    1. 加读锁: 如果代码只进行读操作,就加读锁
    2. 加写锁: 如果代码进行了写操作,就加写锁
    3. 将读写锁分开,那么 读锁和读锁之间是不互斥的 ,只有读锁和写锁,写锁和写锁之间才互斥。

2.悲观锁 VS 乐观锁

  • 悲观锁预期锁冲突的概率很高(所以就会做很多准备工作来预防锁冲突)
  • 乐观锁预期锁冲突的概率很低
  • 悲观锁做的工作更多,付出的成本更高,更低效
  • 乐观锁做的工作更少,付出的成本更低,更高效

3.轻量级锁 VS 重量级锁

  • 轻量级锁:做的事情更少,开销更小(一般来说,涉及到内核操作的就是重量级锁,纯用户态就是轻量级锁)
  • 重量级锁: 做的事情更多,开销更大
  • 通常情况下,可以认为 悲观锁都是重量级锁;乐观锁都是轻量级锁

4.挂起等待锁 VS 自旋锁

  • 挂起等待锁:往往是通过内核的一些机制来实现的(较重)
  • 自旋锁: 往往是通过用户态代码来实现的(较轻)

5.公平锁 VS 非公平锁

  • 公平锁:多个线程在等待一把锁时,谁先来的谁先获取到, 遵守先来后到
  • 非公平锁:多个线程在等待一把锁时,获取到锁的概率相同 ,不遵守先来后到
  • 此处的公平指的是 : 遵守先来后到
    在这里插入图片描述

6.可重入锁 VS 不可重入锁

  • 可重入锁:一个线程针对一把锁,连续加锁两次,不会出现死锁
    外部方法获取到锁对象,内部再获取同一把锁时,不会真的去拿锁对象,而是将count+1,当执行完内部方法时,count--,然后执行完外部方法时,count变为0,真正释放该锁对象
  • 不可重入锁:一个线程针对一把锁,连续加锁两次,出现死锁
    外部方法获取到锁对象,进入内部方法,内部方法需要等外部方法释放锁对象,然后获取到该锁对象才能执行下去,而锁对象被外部方法占据着,而外部方法需要内部执行完,才能够释放锁对象,出现死锁

二、CAS

CAS要做的事情就是: 拿着寄存器/某个内存的值 和 另一个寄存器/内存的值进行比较,如果值相等,就将两个的值交换
在这里插入图片描述
CPU提供了一个单独的CAS指令,通过这一条指令,就能完成上述过程,所以上述操作是原子的,线程安全

那么基于CAS能实现什么呢?

  • 基于CAS实现原子类
  1. java标准库里提供了一组原子类,针对一些常用的类型(int / long …)进行了封装,可以基于CAS的方式进行修改其值,并且线程安全在这里插入图片描述
    例子: 使用AtomicInteger类对象自增(线程安全)

    AtomicInteger num = new AtomicInteger(0);
    Thread t1 = new Thread(() -> {
          
          
        for (int i = 0; i < 50; i++) {
          
          
            // 相当于num++,且是原子的
            num.getAndIncrement();
        }
    });
    Thread t2 = new Thread(() -> {
          
          
        for (int i = 0; i < 50; i++) {
          
          
            // 相当于num++,且是原子的
            num.getAndIncrement();
        }
    });
    t1.start();// 启动 t1线程
    t2.start();// 启动 t2线程
    t1.join();
    t2.join();
    System.out.println(num);// 输出100
    

    getAndIncrement():
    在这里插入图片描述

  • 基于CAS实现自旋锁
    在这里插入图片描述
    当该锁对象被其他线程占用时,当前线程就无法获取到锁对象,一直自旋等待(忙等),当前锁对象一为null时,该线程立即就能占用该锁对象,所以自旋锁不适用于锁冲突激烈的场景。

  • CAS中的ABA问题
    CAS在比较时,当两次比较的值相等,则认为是没发生改变,但是实际上却不一定,有以下两种可能:
    1.一直是 A
    2.A —> B —> A

    例如下面的情况,就会出现bug
    在这里插入图片描述
    引入 ABA 问题: 假设在第一次取完款的一瞬间,账户里收到转账50元
    两个线程执行如下:
    请添加图片描述
    当第二次进行CAS时,余额从 100 —> 50 —> 100,而系统却确认为余额没有变过,又进行了一次扣钱,导致多扣了一次钱
    解决问题: 引入一个 " 版本号 ",括号里为版本号,每次针对余额修改时, 版本号+1
    100(1) —> 50(2) —> 100(3),当版本号不一致时,不扣钱

三、synchronized的优化手段

  • 锁膨胀/锁升级
    在这里插入图片描述
    对于偏向锁:

    1. 并不是真正的加锁,而是做了个标记,如果没有其他线程来竞争这个锁,就不真正加锁,减少了加锁解锁的开销
    2. 如果有其他线程来竞争,就转为自旋锁(轻量级锁),真正加锁
  • 锁粗化
    此处的粗细指的是 " 锁的粒度 "(锁的涉及范围)

    1. 如果锁的粒度比较细,那么多个线程之间的并发性更高
    2. 如果锁的粒度比较粗,那么加锁解锁的开销更小

在这里插入图片描述

  • 锁消除
    有些代码明明不需要加锁,你加锁了,编译器就会直接把锁去掉。例如单线程中使用 StringBuffer

四、JUC的常见类

ReentrantLock: 可重入锁

  • lock(): 加锁,如果获取不到锁就死等。
  • trylock(超时时间): 加锁,如果获取不到锁,等待一定的时间之后就放弃加锁。
  • unlock(): 解锁
  • 和synchronized的区别:
    1. synchronized是一个关键字;ReentrantLock是一个类
    2. synchronized不需要手动释放类;ReentrantLock必须手动释放
    3. synchronized如果竞争锁对象失败,就会阻塞等待;ReentrantLock 除了阻塞等待,还能trylock,失败了直接返回
    4. synchronized是非公平锁;ReentrantLock可以指定公平/非公平
    5. 基于synchronized衍生的等待机制是 wait - notify;基于ReentrantLock衍生的等待机制是 Condition类

Callable 接口: 描述一个任务,方便返回计算结果,Runnable不方便返回结果。

例如: 创建一个线程,让该线程实现 1+2+3+…+1000,并返回结果

public static void main(String[] args) {
    
    

    // 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
    // 凭 FutureTask的对象 来取任务的返回结果
    FutureTask<Integer> task = new FutureTask<>(callable);
    Thread t = new Thread(task);
    t.start();

    // 如果还没算出结果,就会阻塞,算出来了就直接返回
    try {
    
    
        System.out.println(task.get());
    } catch (InterruptedException e) {
    
    
        e.printStackTrace();
    } catch (ExecutionException e) {
    
    
        e.printStackTrace();
    }
}

信号量(Semaphore): 信号量,用来表示 “可用资源的个数”,本质上就是一个计数器。" 锁 " 就是一个二元信号量。
例如停车场的车位:每次有车开进去,车位-1;有车开出来,车位+1

public static void main(String[] args) throws InterruptedException {
    
    

    // 可用资源有 4 个
    Semaphore semaphore = new Semaphore(4);
    // 连续 5个申请资源,第五个申请就会阻塞,直到有人释放资源
    semaphore.acquire();
    System.out.println("申请成功");
    semaphore.acquire();
    System.out.println("申请成功");
    semaphore.acquire();
    System.out.println("申请成功");
    semaphore.acquire();
    System.out.println("申请成功");
    // 阻塞,直到有其他线程释放资源
    semaphore.acquire();
    System.out.println("申请成功");
    // 释放资源
    // semaphore.release();
}

CountDownLatch: 同时等待 N 个任务执行结束

就像跑步比赛中的终点线,等所有选手跑完后,再公布结果

  • 构造 CountDownLatch 实例,初始化 10 表示有 10 个任务需要完成。
  • 每个线程调用 latch.countDown(),表示该线程执行完了
  • 主线程中使用 latch.await(),阻塞等待所有任务执行完毕。
public static void main(String[] args) throws InterruptedException {
    
    

    CountDownLatch latch = new CountDownLatch(6);
    for(int i = 0; i < 6; i++){
    
    
        Thread t = new Thread(() -> {
    
    
            try {
    
    
                Thread.sleep(3000);
                System.out.println(Thread.currentThread().getName()+"到达终点!");
                // 通知主线程该线程执行完了
                latch.countDown();
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }
        });
        t.start();
    }
    // 等待所有线程执行完,就执行下面的语句
    latch.await();
    System.out.println("所有选手到达终点!");
}

在这里插入图片描述

五、多线程下使用哈希表

HashMap 本身不是线程安全的,在多线程环境下使用哈希表可以使用 Hashtable 、ConcurrentHashMap

  • Hashtable: 在关键方法上加了 synchronized(对this加锁,即给整个Hashtable加锁),锁冲突的概率很大,效率会很低
    在这里插入图片描述
  • ConcurrentHashMap: 针对每个链表的头结点加锁
  1. 减少了锁冲突,针对每个链表的头结点加锁
  2. 只是针对写操作加锁,读操作没加锁,只是使用了 volatile
  3. 更广泛的使用了CAS,进一步提高效率
  4. 当触发扩容操作时,同时会维护一个新的HashMap,一点点将旧的数据搬运到新的上面去,当其他线程读的时候,读旧的表,插入时,插入到新表。
    在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_45792749/article/details/124578176