【多线程进阶】--- 常见锁策略,CAS,synchronized底层工作原理,JUC,线程安全的集合类,死锁

目录

1.常见的锁策略

1.1 乐观锁 vs 悲观锁

1.2 普通的互斥锁 vs 读写锁

2.CAS

2.1 CAS的应用 

2.2  CAS 的 ABA 问题

2.2 CAS 面试题

3.synchronized 底层工作过程

3.1 synchronized 使用的锁策略

3.2 synchronized 是怎样进行自适应的(升级的过程)

3.3 锁消除

3.4 锁粗化

 4. JUC (java.util.concurrent)的常见类

4.1 Callable 接口

4.2 ReentrantLock

4.3 原子类 

4.4 线程池

4.5 信号量 Semaphore

4.6 CountDownLatch

4.7 JUC 相关面试题

5.线程安全的集合类

5.1 多线程环境使用 ArrayList

5.2 多线程环境使用队列

5.3 多线程环境使用哈希表 (重点掌握这个)

 5.4 多线程环境使用哈希表相关面试题

6. 死锁

6.1 死锁的 3 种情况

 6.2 死锁的四个必要条件(重点)

6.3 破坏循环等待的方法(重点)

6.4  死锁面试题


1.常见的锁策略

这些锁策略,锁机制且基本上都是脱离实际开发的(只有读写锁和可重入锁有点用)但面试可能会考,所以这里只是简单的谈谈。

1.1 乐观锁 vs 悲观锁

🍃乐观锁:预测接下来锁冲突的概率不大,总是假设最好的情况并做出相应的策略。

🍃悲观锁:预测接下来锁冲突的概率很大,总是假设最坏的情况并作出相应的策略。

🍁【举例】

同学 A 和同学 B 都想去问老师问题,假设同学 A 是悲观锁,那么他去问老师问题,他总是会先考虑,老师可能在忙,没空,于是就先发消息给老师,等到老师回复有空才会去问老师;而同学 B 是乐观锁,他就是直接跑到老师那里去问问题,如果老师有空,就直接解决了,如果老师没空,就下次再来。

1.2 普通的互斥锁 vs 读写锁

🍃普通的互斥锁:两个加锁操作之间会发生竞争。 synchronized 就属于普通的互斥锁。

🍃读写锁:把加锁操作细化了,加锁分成了"加读锁"和"加写锁"。

读写锁的三种情况

🍊情况一:线程 A 尝试加 "写锁",线程 B 尝试加 "写锁"。 

结果:A,B 产生竞争,和普通的锁没啥区别。


🍉情况二:线程 A 尝试加 "读锁",线程 B 尝试加 "读锁"。

结果:A,B 不产生竞争,因为多线程读,不涉及修改,线程是安全的,所相当于没加。


🍈情况三:线程 A 尝试加 "读锁",线程 B 尝试加 "写锁"。

结果:A,B 产生竞争,和普通的锁没啥区别。

1.3 重量级锁 vs 轻量级锁

🍃重量级锁:锁的开销比较大,做的工作比较多。

🍃轻量级锁:锁的开销比较小,做的工作比较少。

通常来说:悲观锁,经常会是重量级锁;乐观锁,经常会是轻量级锁。但是这不是绝对的。

锁的核心特性"原子性" ,这样的机制追根溯源是底层的硬件设备提供的。

🍁【区别】 

🍃重量级锁:主要依赖了操作系统提供的锁,使用这种锁,就容易产生阻塞等待。

🍃轻量级锁:尽量的避免使用操作系统提供的锁,而是尽量在用户态来完成功能。尽量避免用户态和内核态的切换,尽量避免挂起等待。

这里所谓的轻量,重量只是泛泛而谈,在时间的开销上的一个却别。如果再细化一点,我们认为越依赖使用底层的锁机制,成本就越高;如果纯用户态自己实现的锁,成本就更低一些。

1.4 自旋锁 vs 挂起等待锁

🍃自旋锁:是轻量级锁(乐观锁)的具体实现。

🍃挂起等待锁:是重量级锁(悲观锁)的具体实现。

🍔【区别】

🍃自旋锁:当发现锁冲突的时候,不会挂起等待,会迅速再来尝试看这个锁能不能获取到。

1.一旦锁被释放,就可以第一时间获取到。

2.如果锁一直不释放,就会消耗大量的 CPU 资源。

【自旋锁伪代码】

while(抢锁(lock) == 失败) {}

🍃挂起等待锁:发现锁冲突,就挂起等待。

1.一旦锁被释放,不能第一时间获取到。

2.在锁被其他线程占用的时候,会放弃 CPU 资源。

1.5 公平锁 vs 非公平锁

🍃公平锁:在多个线程竞争锁的时候,遵守 "先来后到"的原则。

🍃非公平锁:在多个线程竞争锁的时候,不遵守 "先来后到"的原则。

 操作系统内部对于挂起等待锁,就是非公平的,没有考虑到先来后到。

1.6 可重入锁 vs 不可重入锁

先看一段代码:

    public static void fun() {
        // 第一次加锁
        synchronized (TestDemo2.class) {
            // 第二次加锁
            synchronized (TestDemo2.class) {

            }
        }
    }

🍃第一次加锁能成功,TestDemo2 处于被加锁的状态。

🍃第二次加锁,由于 TestDemo2 已经是加锁的状态了,所以这里的加锁就会阻塞等待,等到第一个锁释放锁,第二个加锁才能成功;而第一个锁释放锁,需要第二个锁加锁成功,然后释放继续往下,第一个锁才能释放锁。

🍃这就产生了死锁:第二个锁加锁成功,依赖第一个锁释放;第一个锁释放又依赖第二个锁加锁成功。

为了避免上述问题,就引入了"可重入锁":一个线程,可以对同一个锁,反复加锁多次,也没事!

🍃可重入锁:内部记录这个锁是哪个线程获取到的,如果发现当前加锁的线程和持有锁的线程是同一个,则不挂起等待,而是直接取到锁。同时还会给锁内部加上个计数器,记录当前是第几次加锁了,通过计数器来控制啥时候释放锁!!

1.7 常见锁策略的相关面试题

1) 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

🍃悲观锁认为在多线程情况下,访问同一个变量,冲突的概率很大,所以会在每次访问之前都真正的加锁。

🍃而乐观锁认为在多线程情况下,访问同一个变量,冲突的概率不大,所以并不会真正的加锁,而是直接访问数据,在访问的同时判断是否出现访问冲突问题。

🍃悲观锁的实现就是先加锁,获取到锁再访问数据,获取不到就等待。

🍃乐观锁的实现引入一个版本号,借助版本号判断是否存在访问冲突问题。(后面说)

2)介绍下读写锁?

🍃读写锁分为"加读锁"和"加写锁"。

🍃理清读写锁的三种情况,以及每种情况是否互斥。

🍃读写锁最主要应用在"频繁读,少写的场景"。

3)什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

🍃如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁。

🍃优点:没有放弃 CPU 资源,一旦锁被释放就能第一时间获取到锁,更高效。适合锁竞争不激烈的场景。

 🍃缺点:在锁竞争非常激烈的场景下,过于浪费 CPU 资源。

4)synchronized 是可重入锁么?

🍃是可重入锁.

🍃可重入锁指的就是连续多次加锁不会导致死锁。

🍃实现方式:通过计数器记录在加锁的时候,加了几次,释放的时候,计数器就要减减,等到计数器为0的时候,才会释放锁。


2.CAS

CAS是操作系统/硬件,给 JVM 提供的一种更轻量的,原子操作的机制。它是 CPU 提供的一条特殊的指令---compare and swap(比较和交换)。此处的比较,比较的是内存和寄存器的值,如果相等,则把寄存器中的值写入内存(与内存中原数据交换),如果不相等,则不进行操作。

2.1 CAS的应用 

1)实现原子类

// 两个线程分别对 count 自增 50000 次
public class TestDemo2 {
    //public static int count = 0;
    public static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 50000; i++) {
                //count++;
                count.getAndIncrement();
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 50000; i++) {
                //count++;
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count: " + count);
    }
}

这段代码,之前演示过,两个线程操作同一个变量,结果肯定是小于10000 的,之前我们解决线程安全的方式是通过加锁或者 volatile 。此处基于 CAS 实现了原子性的操作,省去了加锁,解决操作,更高效。

AtomicInteger 类的伪代码实现:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            // 循环成立,将需要修改的新值写入内存(交换)
            oldValue = value;
       }
        return oldValue;
   }
}

【结合下图理解伪代码】

 2)实现自旋锁

【自旋锁伪代码】

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 的时候,CAS 才能成功,owner才能获取到锁,循环才能结束;

🍃当 owner 为非 null ,则说明当前的锁已经被其他线程占用了,就继续循环下去。(自旋)

2.2  CAS 的 ABA 问题

什么时 ABA 问题??

🍁【举例分析】

 上述 CAS 的时候是比较余额,余额相同,就可以进行修改。余额随时可以变大变小的,因此就能出现 ABA 问题。如何解决???

解决上述 ABA 问题,可以引入一个" 版本号 ",每次修改操作都让 版本号 自增,判断的时候,就不再比较金额是否相等了,直接比较版本号是否相等,不相等则扣款失败。这样就避免了 ABA 问题而导致二次扣款了!!

2.2 CAS 面试题

1) 讲解下你自己理解的 CAS 机制
全称 Compare and swap, " 比较并交换 "。  相当于通过一个原子的操作(一条指令完成的) , 同时完成 " 读取内存 , 比较是否相等, 修改内存 " 这三个步骤 . 本质上需要 CPU 指令的支撑。

2)ABA问题怎么解决?

给要修改的数据引入版本号 . CAS 比较数据当前值和旧值的同时 , 也要比较版本号是否符合预期 . 如果发现当前版本号和之前读到的版本号一致, 就执行修改操作 , 并让版本号自增 ; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败 .


3.synchronized 底层工作过程

3.1 synchronized 使用的锁策略

  • synchronized 是一把自适应锁(既是一个悲观锁,也是一个乐观锁)

🍃1.当前锁冲突概率不大,以乐观锁的方式运行,往往是纯用户态执行的。

🍃2.当所冲突概率大了,就以悲观锁的方式运行,往往要进入内核态,对当前线程进行挂起等待。

  • synchronized 既是轻量级锁,又是重量级锁(自适应)。

🍃冲突概率不高:轻量级锁

🍃冲突概率很高:重量级锁

  • synchronized 作为轻量级锁的时候,内部是自旋锁,作为重量级锁的时候,内部是挂起等待锁。
  • synchronized 是一种非公平锁。

  • synchronized 属于可重入锁。

  • synchronized 不是读写锁。

3.2 synchronized 是怎样进行自适应的(升级的过程)

synchronized 在加锁的时候的升级过程:

🍁【偏向锁】

偏向锁,不是真正加锁,只是用标记表示"这把锁是我的",在遇到其他线程来竞争锁之前,都始终保持这个状态。直到有其他线程来竞争锁,此时才真正加锁。这个过程类似于单例模式中的 "懒汉模式",必要的时候再加锁,节省开销。

3.3 锁消除

🍔什么是锁消除??

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

 StringBuffer 里面,每次 append 的调用都会涉及到加锁,解锁,如果是在单线程的情况下,是没有线程安全的,加锁、解锁的过程反而还降低了效率,于是编译器/JVM 就根据判断,当前锁如果没有其他线程竞争,就把锁去掉了。

3.4 锁粗化

🍔什么是锁粗化??

谈到 "锁粗化",不得不先说说 "锁的粒度" 。锁的粒度和 synchronized 包含的代码范围有关,包含那的范围越大,锁的粒度越粗;反之,锁的粒度越细。锁的粒度细了,能够更好的提高线程的并发,但是也会增加 "加锁解锁" 的次数。

🍁【总结】

🍃1.能够理解 synchronized 的基本执行过程,理解锁对象,理解锁竞争。

🍃2.能够知道 synchronized 的基本锁策略。

🍃3.能够理解 synchronized 内部的一些锁优化的过程。(锁升级,锁消除,锁粗化)


 4. JUC (java.util.concurrent)的常见类

4.1 Callable 接口

Callable 接口和 Runnable 非常相似,都是可以在创建线程的时候,来指定一个 "具体的任务"。并且 Callable 指定的任务是带返回值的,Runnable 是不带返回值的。

🍔Callable 的使用示例:创建线程计算 1 + 2 + 3 + ... + 1000。

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        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;
            }
        };
        FutureTask task = new FutureTask(callable);
        // 获取到后续的结果
        Thread t = new Thread(task);
        t.start();
        // 在 t 线程结束之前,get() 方法会一直阻塞,直到线程结束了,才能返回。
        System.out.println(task.get());
    }

这里如果不使用 Callable ,继续像我们之前那样,使用不带返回值的 run() 方法,就会显得很麻烦,要在线程外头定义一个变量,然后通过加锁,wait(),notify() 等一系列操作,非常麻烦。

🍔上述代码不能直接把 callable 加入到 Thread 的构造方法中,而是加一层 FutureTsak。

套上一层 FutureTask,是为了获取到结果。可以这样理解:当我们取餐馆吃饭的时候,点了一份蛋炒饭,当餐馆人多的时候,老板就会给你一张小票,这时候如果别人也点了蛋炒饭,老板根据小票就知道这碗蛋炒饭是谁的了。而此处的 FutureTask 就可以认为是这张小票。

4.2 ReentrantLock

ReentrantLock 也是可重入互斥锁,和 synchronized 定位类似。

🍔ReentrantLock 的用法:

🍃lock(): 加锁 , 如果获取不到锁就死等 .
🍃trylock( 超时时间 ): 加锁 , 如果获取不到锁 , 等待一定的时间之后就放弃加锁 .
🍃unlock(): 解锁
🍁简单使用
    public static void main(String[] args) {
        ReentrantLock locker = new ReentrantLock();
        try {
            locker.lock();
            // 代码逻辑
        } finally {
            locker.unlock();
        }
    }

🍔ReentrantLock 和 synchronized 的区别:

🍃1.synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock

🍃2.synchronized只是非公平锁,ReentranLock 提供了公平锁和非公平锁两种实现,可以通过构造方法来切换。

🍃3.ReentrantLock 还提供了一个特殊的加锁操作-- tryLock()。默认的 lock() 加锁失败就阻塞,而 tryLock() 加锁失败,则不阻塞,继续往下执行,并且返回 false。除了立即失败之外,tryLock() 还能设定一定的等待时间。

🍃4.ReentrantLock 提供了更强大的等待/唤醒 机制。 synchronized 搭配的是 Object 类的 wait,notify,只能随机唤醒其中一个线程;ReentrantLock 搭配了 Condition 类来实现等待唤醒,可以做到能随机唤醒一个,也能指定线程唤醒。

大部分情况下,使用锁还是 synchronized 为主,特殊场景下,才使用 ReentrantLock

4.3 原子类 

原子类内部用的是 CAS 实现,所以性能要比加锁实现自增高 很多。原子类有以下几个(了解)
  • AtomicBoolean
  • AtomicInteger(最常用)
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

AtomicInteger 在前面讲 CAS 的时候已经演示过,这里就不再赘述了。

4.4 线程池

🍁ExecutorService Executors
  • ExecutorService 表示一个线程池实例.
  • Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
  • ExecutorService submit 方法能够向线程池中提交若干个任务.
ThreadPoolExecutor 类
ThreadPoolExecutor 的构造方法:

理解 ThreadPoolExecutor 构造方法的参数:
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
1.corePoolSize: 正式员工的数量 . ( 正式员工 , 一旦录用 , 永不辞退 )
2.maximumPoolSize: 正式员工 + 临时工的数目 . ( 临时工 : 一段时间不干活 , 就被辞退 )
3.keepAliveTime: 临时工允许的空闲时间。
4.unit: keepAliveTime 的时间单位。
5.workQueue: 传递任务的阻塞队列。
6.threadFactory: 创建线程的工厂 , 参与具体的创建线程工作。
7.RejectedExecutionHandler: 拒绝策略 , 如果任务量超出公司的负荷了接下来怎么处理。
  • AbortPolicy(): 超过负荷, 直接抛出异常。
  • CallerRunsPolicy(): 调用者负责处理。
  • DiscardOldestPolicy(): 丢弃队列中最老的任务。
  • DiscardPolicy(): 丢弃新来的任务。

4.5 信号量 Semaphore

 信号量用来表示 "可用资源的个数". 本质上就是一个计数器.

🍁【画图理解】

🍃把车从入口开进来,就相当于申请一个可用资源,信号量就 -= 1,称为 P 操作。把车从出口开出来,就相当于释放一个可用资源,信号量就 += 1,称为 V 操作。

🍃且信号量加加,减减的过程都是原子的,于是Semaphore 就可以用于多线程安全的控制。我们可以把信号量视为一个更广义的锁,当信号量的取值为 0 和 1 的时候,就退化成了一个普通的锁。

🍁代码示例

    public static void main(String[] args) throws InterruptedException {
        // 构造方法 : 传入有效资源的个数
        Semaphore semaphore = new Semaphore(3);

        // P 操作 : 申请资源
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");

        // V 操作 : 释放资源
        semaphore.release();
    }

这段代码中,我们的信号量有 3 个,但是由于申请了 4 次资源,资源没有及时释放,并且没有其他线程参与的情况下,所以会在第 4 次申请资源的时候,一直阻塞等待。

4.6 CountDownLatch

CountDownLatch 相当于,当一个大的任务被拆分成若干个子任务的时候,用这个来衡量什么时候这些子任务都执行结束。

例如:我们下载一个很大的文件的时候,可以拆成多个部分,每个线程负责下载一部分,所有线程下载完成,我们才算下载结束。

🍁代码示例

    public static void main(String[] args) throws InterruptedException {
        // 模拟多线程下载大文件
        // 构造方法中设定有几个线程负责下载
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for(int i = 0; i < 10; i++) {
            Thread t = new Thread(() -> {
                try {
                    Thread.sleep(3000);
                    System.out.println("下载完成");
                    // countDown 表示当前线程下载完成
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }
        // 调用 countDown 的次数达到初始化的时候设定的线程个数
        // await 就返回,否则就一直阻塞等待
        countDownLatch.await();
        
        System.out.println("下载结束!!");
    }

4.7 JUC 相关面试题

1) 线程同步的方式有哪些?
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步 .

2)为什么有了 synchronized 还需要 juc 下的 lock

juc ReentrantLock 为例 ,
  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
  • synchronized 是通过 Object wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线.

3)AtomicInteger 的实现原理是什么?

上述讲 CAS 时候写的伪代码以及原理等等!!

4)信号量听说过么?之前都用在过哪些场景下?

信号量 , 用来表示 " 可用资源的个数 ". 本质上就是一个计数器 .
使用信号量可以实现 " 共享锁 ", 比如某个资源允许 3 个线程同时使用 , 那么就可以使用 P 操作作为 加锁, V 操作作为解锁 , 前三个线程的 P 操作都能顺利返回 , 后续线程再进行 P 操作就会阻塞等待 , 直到前面的线程执行了 V 操作
5) 解释一下 ThreadPoolExecutor 构造方法的参数的含义 ?
上述线程池列出来了,或者参考官方文档!!

5.线程安全的集合类

前几篇博客讲到解决线程安全的时候挪列了哪些是线程安全集合类,哪些是线程不安全的集合类,我们在数据结构里学的大部分集合类都是不安全的。

5.1 多线程环境使用 ArrayList

🍃1.自己使用同步机制(synchronized 或者 ReentrantLock)

🍃2.Collections.synchronizedList(new ArayList)

🍃3.使用 CpoyOnWriteArrayList(写时复制)。当多个线程读的时候,是线程安全的,不需要加锁;当多个线程涉及到修改的时候,先将当前容器进行Copy, 复制出一个新的容器,然后新的容器里添加元素, 添加完元素之后,再将原容器的引用指向新的容器。

5.2 多线程环境使用队列

🍃1.ArrayBlockingQueue

🍃2.LinkedBlockingQueue

🍃3.PriorityBlockingQueue

🍃4.TransferQueue(最多只包含一个元素的阻塞队列

5.3 多线程环境使用哈希表 (重点掌握这个)

HashMap 本身不是线程安全的 . 在多线程环境下,可以使用 Hashtable 或者 ConcurrentHashMap
1)Hashtable(不推荐)
Hashtable 是线程安全的,但是不推荐使用,因为它的做法是直接给 Hashtable 对象本身加锁。
🍁【缺点】
🍃1.如果多线程访问同一个 Hashtable 就会直接造成锁冲突。
🍃2.size 属性也是通过 synchronized 来控制同步 , 也是比较慢的。
🍃3.一旦触发扩容 , 就由该线程完成整个扩容过程 . 这个过程会涉及到大量的元素拷贝 , 效率会非常低。

 如果是 Hashtable 这种加锁方式,上图中,两线程访问不同链表中的元素,不会产生线程安全的情况,它也给加锁了,这就导致严重的竞争关系,大大的拖慢了程序的效率!!

2)ConcurrentHashMap

ConcurrentHashMap 相比于 Hashtable 做出了重大改进,它把锁的粒度细化了。(下图的改进是基于 Java 8 的)

此时每个桶一把锁,访问不同链表的元素,就不会产生锁竞争了;并且一个哈希表上面的桶的个数看可能会非常多,这就进一步使锁冲突的概率大大降低了(稀释了)。

🍁总结 ConcurrentHashMap 的优化策略 :

🍃1.【最重要】把锁的粒度细化了,给每个哈希桶都加上一把锁(链表的头结点),降低了锁冲突的概率。

🍃2.读不加锁,写才加锁。

🍃3.在维护 size 的时候,使用 CAS 机制,又进一步降低了锁冲突。

🍃4.针对扩容场景做出了优化:化整为零

  • 发现需要扩容的线程,只需要创建一个新的数组,同时只搬几个元素过去。
  • 扩容期间,新旧数组同时存在。
  • 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程,每个操作负责搬运一小部分元素。
  • 搬完最后一个元素再把旧数组删掉。
  • 这个期间,插入只往新数组加增加。
  • 这个期间,查找需要同时查新数组和旧数组。

而 Hashtable,HashMap 它们在某次 put 的时候,触发扩容,由这个 put 完成整个扩容操作,就巨慢无比,非常低效!!

在 Java 8 之前,此处的 ConcurrentHashMap 并不是每个桶加锁,而是"分段锁",若干个桶一把锁,类似下图:

 5.4 多线程环境使用哈希表相关面试题

1) ConcurrentHashMap 的读是否要加锁,为什么 ?
  读操作没有加锁,目的是为了进一步降低锁冲突的概率。为了保证读到刚修改的数据, 搭配了
volatile 关键字。 
2)介绍下 ConcurrentHashMap 的锁分段技术  ?
这个是 Java1.7 中采取的技术。Java 8 中已经不再使用了。简单的说就是把若干个哈希桶分分段加锁。目的也是为了降低锁竞争的概率。当两个线程访问的数据恰好在同一个段上的时候,才触发锁竞争。
3) ConcurrentHashMap 在 Java 8 中 做了哪些优化?
🍃1.取消了分段锁, 直接给每个哈希桶分配了一个锁。
🍃2.将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式。

4)HashtableHashMapConcurrentHashMap 之间的区别?

🍃1.HashMap 线程不安全。Hashtable 和 concurrentHashMap 是线程安全的!!

🍃2.Hashtable 是给整个哈希表加锁,锁冲突概率非常高。而ConcurrentHashMap 则是每个哈希桶一把锁,锁冲突概率大大降低了。

🍃3.详细说 ConcurrentHashMap 其他的优化策略。

🍃 4.HashMap 中 key 允许为 null ,Hashtable 和 ConcurrentHashMap 不允许。


6. 死锁

死锁是多线程代码中常见的 bug。

死锁:当一个或多个线程尝试加锁的时候发现上次锁因为一些原因没有及时释放,导致加锁加不上。

6.1 死锁的 3 种情况

🍃1.一个线程一把锁(可重入锁):

像我们前面讲锁策略的时候,synchronized 里面套一个 synchronized,如果是不可重入锁,那么外面锁的释放就依赖里面锁的获取,而里面锁的获取就依赖外面锁的释放,就导致死锁了。

🍃2.两个线程两把锁:

例如有两个线程两把锁:线程1,线程2,锁A,锁B。当 线程1 获取到 锁A,线程2 获取到 锁B,然后 线程1 尝试获取 锁B,线程2 尝试获取 锁A 的时候,就会导致死锁。

🍃3.N 个线程 M 把锁(哲学家就餐问题):

 6.2 死锁的四个必要条件(重点)

🍃1.互斥使用:当资源被一个线程占有时,别的线程不能使用

🍃2.不可抢占:资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。

🍃3.请求和保持:当资源请求者在请求其他的资源的同时保持对原有资源的占有。

🍃4.循环等待:即存在一个等待队列:线程1 占有 线程2 的资源,线程2 占有 线程3 的资源,线程3 占有 线程1 的资源。这样就形成了一个等待环路。

上述前三个条件都是在描述锁的基本特点。当四个条件都成立的时候,就会产生死锁,我们只需要打破其中一个条件,就能避免死锁问题,且最容破坏的就是循环等待。

6.3 破坏循环等待的方法(重点)

🍃1.针对多把锁,进行编号 1,2,3,4...

🍃2.约定在获取多把锁的时候,要明确获取锁的顺序是从小到大的顺序。

我们用这种破坏循环等待的思路解决哲学家就餐问题:

6.4  死锁面试题

谈谈死锁是什么?如何避免?

🍃1.一句话概括死锁是什么?

🍃2.产生死锁的三个典型场景。

🍃3.死锁的四个必要条件。

🍃4.给锁编号,破坏循环等待。


本篇博客就到这里了,谢谢观看!!

  • 6
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Master_hl

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

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

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

打赏作者

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

抵扣说明:

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

余额充值