多线程进阶

多线程常见面试题

1. 常见的锁策略

1.1乐观锁&悲观锁

在加锁的态度的角度去执行加锁逻辑

乐观锁:在获取锁的时候预期这个锁竞争不太激烈,那么就可以先不加锁,或者少加锁(有真实的竞争再来加锁)

悲观锁:在获取锁的时候预期这个锁竞争非常激烈,那么就必须先加锁再执行任务

1.2 轻量级锁&重量级锁

站在加锁过程角度的描述

轻量级锁:加锁的过程比较简单,用到的资源比较少,典型就是用户态的一些加锁操作(在Java层面就可以完成加锁)

重量级锁:加锁的过程比较复杂,用到的资源比较多,典型就是内核态的一些操作

乐观锁是能不加就不加,从而导致他干的活就比较少了,那么他消耗的资源就比较少了, 从而可以说乐观锁是一个轻量级锁

悲观锁是不管怎么样都先把锁加上,从而导致他干的活多了,那么消耗的资源就比较多了,可以说被悲观锁也是一种重量级锁

1.3 自旋锁&挂起等待锁

自旋锁:不停的检查锁是否被释放,如果一旦锁被释放掉那么就直接获取锁资源

自旋锁的优点:

  1. 他是一个纯用户态的操作,比较轻量
  2. 锁一旦被释放就可以马上知道

还有一些缺点:

  1. 不停的循环比较浪费系统的资源

    可以通过控制自旋的次数来提高效率,并且避免系统资源的过度浪费

挂起等待锁:不主动询问锁资源,而是让系统调度去竞争锁资源

  1. 通过阻塞与就绪状态的切换来获取锁资源
  2. 如果锁一旦释放,没有办法立马知道
  3. 是通过系统内核来处理的

自旋锁是一种典型的轻量级锁的具体实现

挂起等待锁是一种典型的重量级锁的具体实现

1.4 读写锁&普通互斥锁

读写锁:在锁中标识读或写,在竞争时根据这个标识来判断是否参与竞争

读的时候加读锁(共享锁),多个锁可以共存,同时多个读锁是互不影响的

写的时候加写锁(排他锁),只能有一个写锁在执行任务,和别的锁是冲突的

写锁和写锁不能共存

写锁和读锁不能共存

读锁和读锁可以共存

为什么要用读写锁

在我们的程序中可能会出现激烈的锁竞争,但是获取锁之后的操作,有可能是大量的读操作,读操作又不涉及修改

所以所有的读都可以并发执行,只有读的时候不让别的线程来修改就可以了

如果一个操作是读操作,当前也是读锁,那么就直接读了,不产生锁竞争,从而节省了资源

如果一个操作是写操作,这个时候就让他等待

针对高并发读的业务场景中,锁竞争就会大大降低,从而提交程序的运行效率

互斥锁

有竞争关系,只能一个线程释放锁之后,别的线程再来抢

1.5 公平锁&非公平锁

公平锁:先来后到,先排队的线程先获取到锁,后排队的后获取到锁

非公平锁:谁先抢到就是谁的

在Java JUC中有一个类实现了公平锁

synchronized是一个非公平锁

1.6可重入锁&不可重入锁

可重入锁:对一把锁可以连续加锁多次,而不造成死锁

不可重入锁:对一把锁连续加锁多次,造成死锁

对比sychronized

乐观锁&悲观锁即是乐观锁也是悲观锁
轻量级锁&重量级锁即是轻量级锁也是重量级锁
自旋锁&挂起等待锁即是自旋锁也是挂起等待锁
读写锁&普通互斥锁是互斥锁
公平锁&非公平锁是非公平锁
可重入锁&不可重入锁是可重入锁

当锁竞争不激烈时,是一个乐观锁,轻量级锁,自旋锁

如果竞争不激烈这个状态就一直保证

如果锁竞争激烈的时候,就会升级为悲观锁,重量级锁,挂起等待锁

2. CAS

compare and swap 比较并交换

首先比较当前变量值与某个值期望的值是否相同,如果相同则用一个新的值变量内存中的值

boolean CAS(address,exceptValue,swapValue){
	if(&address==exceptValue){
        &address=swapValue;
        return true;
    }
    return false;
}

//核心逻辑
if(value==exceptValue){
    value=oldValue;
}
  1. 用一个预期值去和内存中的值做比较
  2. 如果预期值与内存中的值相等,那么就用新值更新内存中的值
  3. 如果预期值与内存中的值不相等,那么就不做任何操作

画图演示

1.两个线程读取value的值到oldvalue

image-20230909134802205

  1. 线程1执行CAS操作,由于oldvale与value值相等。直接对value赋值

    image-20230909141143120

  2. 线程2再执行CAS操作,第一次CAS时发现oldCValue和value不相等,不能进行赋值。因此需要进入循环(自旋),在循环里重新读取value的值赋给oldValue

    image-20230909141415610

  3. 线程2接下来第二次执行CAS,此时oldValue与value相同,于是直接执行赋值操作

    image-20230909141457567

CAS操作直接修改的是内存中的值,每次都会去读,去比较去修改指定内存地址的值,从而保证了原子性的操作

CAS中ABA的问题

3. Sychronized原理

Sychronize本身会对锁做一些智能的优化,在程序不同的运行时期,适应不同的锁策略

3.1 锁升级

无锁——>偏向锁(JVM启动4秒后,创建的锁对象才会偏向状态)——>轻量级锁——>重量级锁

无锁:没有锁竞争时

偏向锁:只是给锁对象中加入了一个标签,并没有真正的去加锁

轻量级锁:通过自旋锁实现用户态的操作

重量级锁:内核态的加锁操作,调用的是CPU加锁指令

3.2 锁消除

sychronized的一种优化策略

sychronized是程序员自己手动加的获取锁的逻辑,什么时候加,加在哪个代码块,JVM管不了,但是在编译和运行的时候,JVM可以知道程序是读变量,还是写变量

如果程序员对所有的读操作都加了sychronized关键字,但是又没有写操作,那么这是JVM就认为这个锁是多余的,那么sychronized就不会真正的去加锁了,这个现象称为锁消除

在多线程状态下,多个线程对同一变量进行修改才会又线程安全问题,多个线程读一个变量没有线程安全问题

3.3 锁粗化

锁的粒度,也就是锁的范围,也就是sychronized包裹代码的多少,包的越多粒度就越粗,包的越少粒度就越细,对于多个连续的任务,如果每个任务都加一把锁,这个过程就会产生频繁的锁竞争,JVM就会把锁的范围增大到整个任务的开始与结束,减少锁竞争的次数来提高效率。

4. JUC

4.1 Callable接口(创建线程的方式)

描述线程要执行的任务

Callable和Runnable有什么区别

  1. Callable要实现call()且有返回值,Runnable方法要实现run(),没有返回值
  2. Callable的call()方法可以抛出异常,Runnable的Run()方法不能抛出异常(业务异常)
  3. Callable要搭配FutureTask一起使用,通过futureTask.get()来获取call()的返回值
  4. 两者都是描述线程任务的接口

4.2 创建线程的几种方式

创建线程的几种方式

  1. 继承Tread类,实现run()方法
  2. 实现Runnable接口,实现run()方法
  3. 实现Callable接口,实现call()方法
  4. 通过线程池,提交任务

由于Runnable和Callable都是函数式接口,我们可以通过Lambda表达式的方式简化写法,也可以通过匿名内部类的方式来简化写法

4.3 ReentrantLock

JUC中重要的一个技术点

lock()加锁

unLock()解锁

tryLock()尝试解锁

sychrinized与ReentrantLock区别

  1. sychronized退出代码块就自动释放锁,ReentrantLock必须手动释放锁,注意要是有try/finallu处理加锁释放锁的代码
  2. sychrinized是非公平锁,ReentrantLock即是非公平锁也是公平锁
  3. ReentrantLock可以根据不同的条件去进行休眠和唤醒
  4. sychronized在申请锁失败时,会一直等待锁资源,而ReetrantLock可以通过tryLock的方式等待一段时间就放弃
  5. sychronized是JVM的一个对锁的实现,最终调用CPU加锁指令,而ReetrantLock是Java层面的JUC包中的一组实现类

5. JUC工具类

5.1 Semaphore-信号量

申请资源的操作称为P操作,资源数量减一,当减到0的时候其他的线程就要等待

释放资源的操作称为V操作,资源数量就要加一

信号量本质上就是要维护资源的数量

5.2 CountDownLatch

等待所有线程全都完成任务后才执行后面的操作

5.3 CyclicBarrier - 循环栅栏

CountDownLatch的进级版,可以实现线程间的相互等待,计数重置

6.线程安全的集合类

Vector,Stack,HashTable

如何在多线程环境下保证集合类的线程安全

  1. 手动加锁,sychronized包裹代码块,ReentrantLock 加锁解锁
  2. 使用工具类Collections.synchronizedList(array)(不常用)
  3. JUC 提供了集合类CopyOnWriteArraylist

7.多线程使用队列

  1. ArrayBlockingQueue基于数组实现的队列
  2. LinkedBlockingQueue基于链表实现的队列
  3. PriorityBlockingQueue 基于堆实现的优先级队列
  4. TransferQueue最多只包含一个元素的阻塞队列

8.多线程环境使用哈希表

HashTable JDK1.0提供 线程安全不推荐使用

HashMap线程不安全

面试题

  1. 多线程环境下,用哪个类来保证Map的线程安全

    JUC包下的ConcurrentHashMap

  2. ConcurrentHashMap与HashMap/HashTable 的区别

    1. HashMap是线程不安全的
    2. HashTable是线程安全的,但是不推荐使用,因为所有的操作都加锁了
    3. ConcurrentHashMap的锁粒度比较小,不是对整个Hash表加锁,而是对每一个数据的下标进行加锁
    4. ConsurrentHashMap对写操作加锁,对读操作不加锁
    5. 对于共享变量大量运行了volatile关键字去修饰
    6. ConcurrentHashMap对扩容进行了优化

    扩容

    HashMap和HashTable在扩容时,重新创建一个容量是当前容量2倍的新数组,把所有的元素全部重新hash到新数组里去,是一个完整的操作,效率并不是很高

    ConcurrentHashMap,创建一个新数组容量是原来的2倍,原数组与新数组同时存在一段时间,每次调用ConcurrentHashMap方法的时候,都去搬运一部分元素到新数组中,当原数组中的数据搬运完后,原数组就可以删除了,只使用新数组就可以了

    当两个数组同时存在的时候,查询数据就需要在两个Map中同时查询

    删除时在两个Map中查找,找到后删除

    写入时只往新的Map中去写

    典型的以空间换时间的做法,浪费了一些存储空间,但提高了运行效率

9.死锁

在多线程环境中遇到的最严重的问题之一,线程在获取锁资源的时候,由于获取不到导致线程卡死(阻塞),不执行了

造成死锁的原因

  1. 资源互斥:线程1拿到了锁A,线程2不能同时得到锁
  2. 不可抢占:获取到锁的线程,除非自己主动释放锁,别的线程不能从他的手里抢过来
  3. 保持与请求:线程1已经获得了锁A,还要在这个基础再去获取锁B
  4. 循环等待:线程1等待线程2释放锁,线程2等待线程3释放锁,线程3等待线程1释放锁……

以上四条是造成死锁的必要条件,必须同时满足

如何解决死锁

  1. 互斥访问:锁的基本特性,不能打破
  2. 不可抢占:锁的基本特性,不能打破
  3. 保持与请求:和代码实现或是设计的角度来说是可以改变保持与请求的顺序,也就是获取锁的顺序
  4. 循环等待:最有可能也是最常见的解决死锁的策略就是打破循环等待
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小 王

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

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

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

打赏作者

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

抵扣说明:

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

余额充值