文章目录
目录
文章目录
前言
一 . 常见的锁策略
乐观锁 VS 悲观锁
读写锁
轻量级锁 VS 重量级锁
自旋锁 VS 挂起等待锁
公平锁 VS 非公平锁
可重入锁 VS 不可重入锁
二 . 死锁
死锁的三种典型情况
死锁产生的必要条件
死锁的解决办法
三 . CAS
ABA问题
四. Sychronized原理
基本特点
加锁策略
五 . ReentrantLock
六 . Semaphore
七 . 线程安全集合类
多线程环境使用 ArrayList
多线程环境使用哈希表
1. hashtable(不推荐使用)
2 . ConcurrentHashMap
八 . 面试题总结
1. 进程和线程的区别
2 . 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
3 . 介绍下读写锁?
4 . 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
5 . synchronized 是可重入锁么?
6 . 讲解下你自己理解的 CAS 机制
7 . ABA问题怎么解决?
8 . 什么是偏向锁?
9 . synchronized 实现原理 是什么?
10 . 线程同步的方式有哪些?
11 . 为什么有了 synchronized 还需要 juc 下的 lock?
12 . AtomicInteger 的实现原理是什么?
13 . 信号量听说过么?之前都用在过哪些场景下?
14 . 解释一下 ThreadPoolExecutor 构造方法的参数的含义
15 . ConcurrentHashMap的读是否要加锁,为什么?
16 . 介绍下 ConcurrentHashMap的锁分段技术?
17 . ConcurrentHashMap在jdk1.8做了哪些优化?
18 . Hashtable和HashMap、ConcurrentHashMap 之间的区别?
19 . 谈谈死锁
20 . 谈谈 volatile关键字的用法?
21 . Java线程共有几种状态?状态之间怎么切换的?
22 . 多次start一个线程会怎么样
总结
前言
大家好,今天给大家带来的是多线程进阶相关的内容。
一 . 常见的锁策略
乐观锁 VS 悲观锁
乐观锁
乐观锁是一种乐观估计并发情况的锁机制,它假设并发冲突的概率较低,所以在读取数据时不会立即加锁,而是在更新数据时才会进行锁定。当更新数据时,会先检查数据是否被其他线程修改过,如果没有则进行更新,如果有则放弃更新或者进行相应的处理。
悲观锁
悲观锁则是一种悲观估计并发情况的锁机制,它假设并发冲突的概率较高,所以在读取数据时就会立即加锁,以防止其他线程对数据 的修改。这种锁机制会在读取数据时就进行加锁,直到数据处理完毕才会释放锁。
悲观乐观是对接下来锁冲突的概率进行的预测,如果预计接下来锁冲突的概率并不大(乐观估计),就可以少做一些工作,如果预测接下来锁冲突的概率大(悲观估计),就应该多做一些工作
读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需 要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。 读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复 数读者之间并不互斥,而写者则要求与任何人互斥。
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
- 两个线程都只是读一个数据, 此时并没有线程安全问题.直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题.
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行 加锁解锁.
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进 行加锁解锁.
读写锁适用于读多写少的场景,可以提高系统的并发性能。在读操作远多于写操作的情况下,读写锁可以允许多个线程同时读取数据,从而提高并发读取的效率。但需要注意的是,读写锁的使用需要谨慎,过度的读写锁使用也可能会导致写操作长时间被阻塞,影响系统的性能。
轻量级锁 VS 重量级锁
轻量级锁: 加锁开销比较小的锁
重量级锁: 加锁开销比较大的锁
自旋锁 VS 挂起等待锁
自旋锁: 是一种典型的轻量级锁, 在用户态下,通过自旋的方式(while循环),类似于加锁的效果
自旋锁适用于锁被占用时间较短的情况,避免线程频繁地进入阻塞状态,提高了线程的执行效率。
挂起等待锁: 挂起等待锁是一种在获取锁时如果锁已经被其他线程占用,则将该线程挂起(即暂停执行),直到锁被释放后再唤醒线程继续执行。挂起等待锁适用于锁被占用时间较长的情况,避免线程一直处于忙等状态,节省了CPU资源。
在实际应用中,选择自旋锁还是挂起等待锁取决于锁被占用的时间长短和系统的具体需求
公平锁 VS 非公平锁
如果加锁顺序是按先来后到的话就是公平锁,在java中操作系统通过抢占式对线程进行调度,加锁是非公平的
可重入锁 VS 不可重入锁
如果一个线程可以针对一把锁进行多次加锁,那么这把锁就是可重入锁,典型的监视器锁就是可重入的
二 . 死锁
死锁是指在多线程或多进程的并发程序中,若每个线程或进程都在等待其他线程或进程释放资源而无法继续执行,导致所有线程或进程都无法继续执行,称为死锁。
死锁的三种典型情况
1. 一个线程, 一把锁, 但是是不可重入锁, 线程尝试对这把锁加锁多次, 产生死锁
2. 两个线程, 两把锁, 一个线程获取一把, 但是还想要获取另一把
/**
* 死锁经典场景二
*/
public class TestDemo {
int count;
Object A = new Object();
Object B = new Object();
public void thread1(){
synchronized (A){
// 我走到这了, 我需要B!
synchronized (B){
count++;
}
}
}
public void thread2(){
synchronized (B){
// 我走到这了, 我需要A!
synchronized (A){
count++;
}
}
}
}
3. N个线程, M把锁
在这里引入哲学家就餐问题
问题描述:一张圆桌上坐着五名哲学家,在每两名哲学家中间放着一根筷子,哲学家们的生活方式只做两件事:思考和进餐。饥饿时哲学家必须同时拿起两只筷子时才能进餐,进餐完毕后,放下筷子,进行思考。如果筷子被紧挨着的一名哲学家使用着,则不能争抢,必须等待,当这名哲学家就餐完毕后,放下筷子,才能使用。
如果同一时刻五个哲学家都想吃饭,同时拿起左边的筷子, 想拿右边的筷子的时候,发现没有,产生死锁
死锁产生的必要条件
基于上面的三个经典问题,总结一下死锁产生的必要因素(缺一不可)
1. 互斥使用: 一个线程获取到这把锁,别的线程不能再次获取这把锁
2. 不可抢占: 锁只能被持有者主动释放, 不能被其他线程抢夺
3. 请求和保持: 一个线程尝试获取多把锁,在获取第二把锁的时候,会保持对第一把锁的获取状态
4. 循环等待: t1尝试获取锁A,等待t2释放A , t2尝试获取锁B,等待t1释放锁A
死锁的解决办法
可以通过对锁进行编号, 如果一个线程尝试获取多把锁, 要求它必须先获取编号小的锁,再获取编号大的锁
三 . CAS
什么是CAS?
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。
CAS操作的优点是无锁,避免了传统锁机制带来的性能损耗,适用于对共享变量进行频繁读写的场景。
ABA问题
CAS操作也存在ABA问题(即在操作过程中共享变量的值被改变了两次,但最终回到了原值)
如何解决?
在解决CAS操作中的ABA问题时,可以采用版本号或标记等方式来确保操作的正确性。具体的方法包括:
-
版本号(Version Number):在进行CAS操作时,除了比较值之外,还可以比较版本号。每次对共享变量进行修改时,都会增加版本号。这样在比较时不仅要求值相等,还要求版本号相等,从而避免ABA问题。
-
标记(Marking):在进行CAS操作时,可以在共享变量中添加一个标记位,用来记录变量的状态。当进行CAS操作时,不仅要比较值,还要比较标记位,确保操作的一致性。
四. Sychronized原理
基本特点
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
加锁策略
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁。会根据情况,进行依次升级。
偏向锁
偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别 当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销. 但是该做的标记还是得做的, 否则无法区分何时需要真正加锁
五 . ReentrantLock
位于java.util.concurrent下的一个线程安全类 可重入互斥锁
用法
lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
unlock(): 解锁
ReentrantLock 和 synchronized的区别
synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准 库的一个类, 在 JVM 外实现的(基于 Java 实现).
synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就 放弃.
synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启 公平锁模式.
如何根据场景选择这两把锁?
锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
如果需要使用公平锁, 使用 ReentrantLock.
六 . Semaphore
信号量, 用来表示 "可用资源的个数". 本质上就是一个计数器
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源. 当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作) 当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作) 如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
七 . 线程安全集合类
多线程环境使用 ArrayList
1. 使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
- 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy, 复制出一个新的容器,然后新的容器里添加元素,
- 添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会 添加任何元素。 所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器
2. Collections.synchronizedList(new ArrayList)
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List. synchronizedList 的关键操作上都带有 synchronized
3. 自己使用同步机制保证线程安全
多线程环境使用哈希表
1. hashtable(不推荐使用)
只是简单的把关键方法加上了 synchronized 关键字.这相当于直接针对 Hashtable 对象本身加锁
- 如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
- size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
- 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低.
2 . ConcurrentHashMap
相比于 Hashtable 做出了一系列的改进和优化. 以 Java1.8 为例
- 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然 是用 synchronized, 但是不是锁整个对象, 而是 "锁桶" (用每个链表的头结点作为锁对象), 大大降 低了锁冲突的概率
- 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况
- 优化了扩容方式: 化整为零
- 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
- 扩容期间, 新老数组同时存在.
- 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素. 搬完最后一个元素再把老数组删掉.
- 这个期间, 插入只往新数组加.
- 这个期间, 查找需要同时查新数组和老数组
八 . 面试题总结
1. 进程和线程的区别
1. 进程是系统进行资源分配和调度的一个独立单位,线程是程序执行的最小单位。
2. 进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈。
3. 由于同一进程的各线程间共享内存和文件资源,可以不通过内核进行直接通信。
4. 线程的创建、切换及终止效率更高
2 . 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.
引入银行转账案例:
3 . 介绍下读写锁?
读写锁就是把读操作和写操作进行加锁
读锁和读锁之间不互斥
读锁和写锁之间互斥
写锁和写锁之间互斥
主要用于 频繁读 不频繁写的场景中
4 . 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
自旋锁是指如果线程尝试获取一把锁没有获取到的话,就会不断尝试获取, 类似于while循环
优点是没有放弃CPU资源, 只要这把锁被释放,那么该线程就能第一时间得到,适用于锁持有时间比较短的场景中
缺点: 如果锁持有时间比较长, 会比较消耗CPU资源
5 . synchronized 是可重入锁么?
是可重入锁
可重入锁是指 一个线程针对同一把锁连续多次加锁而不会产生死锁问题
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁 的线程就是持有锁的线程, 则直接计数自增.
6 . 讲解下你自己理解的 CAS 机制
CAS: compare and swap 比较并交换
相当于通过一个原子操作完成 读取内存 比较是否相等 修改内存 这三步
本质上需要 CPU 指令的支撑
7 . ABA问题怎么解决?
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. 如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当 前版本号比之前读到的版本号大, 就认为操作失败
8 . 什么是偏向锁?
偏向锁并不是真正加锁,而是在对象头中进行一个标记,如果没有其他线 程参与竞争锁, 那么就不会真正执行加锁操作, 从而降低程序开销. 一旦真的涉及到其他的线程竞 争, 再取消偏向锁状态, 进入轻量级锁状态
9 . synchronized 实现原理 是什么?
synchronized 是自适应的
synchronized 开始是乐观锁, 如果锁冲突比较严重转变为悲观锁
synchronized 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
synchronized 实现轻量级锁的时候大概率用到的自旋锁策略
synchronized 是不公平锁
synchronized 是可重入锁
synchronized 不是读写锁
自适应过程
无锁 -> 偏向锁 ->轻量级锁 -> 重量级锁
10 . 线程同步的方式有哪些?
同步方式有 synchronized ReentrantLock Semaphore
11 . 为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例
synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更 灵活,
synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时 间就放弃.
synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的 线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线 程.
12 . AtomicInteger 的实现原理是什么?
CAS机制
13 . 信号量听说过么?之前都用在过哪些场景下?
信号量, 用来表示 "可用资源的个数". 本质上就是一个计数器.
使用信号量可以实现 "共享锁", 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为 加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作.
14 . 解释一下 ThreadPoolExecutor 构造方法的参数的含义
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
- corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
- maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退). keepAliveTime: 临时工允许的空闲时间.
- unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
- workQueue: 传递任务的阻塞队列
- threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
- RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
- AbortPolicy(): 超过负荷, 直接抛出异常.
- CallerRunsPolicy(): 调用者负责处理
- DiscardOldestPolicy(): 丢弃队列中最老的任务.
- DiscardPolicy(): 丢弃新来的任务.
15 . ConcurrentHashMap的读是否要加锁,为什么?
不需要进行加锁 , 为了降低锁冲突的概率, 但是为了保证读到刚修改的数据, 搭配使用了Volatile关键字
16 . 介绍下 ConcurrentHashMap的锁分段技术?
锁分段技术是JDK1.7使用的技术 简单的说就是把若干个哈希桶分成一个 "段" (Segment), 针对每个段分别加锁.
目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.
17 . ConcurrentHashMap在jdk1.8做了哪些优化?
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象
存储结构发生了变化 由数组+链表 变为 数组+链表+红黑树结构(默认链表长度为八是转为红黑树, 红黑树长度为6是退化为链表)
18 . Hashtable和HashMap、ConcurrentHashMap 之间的区别?
HashMap: 线程不安全. key 允许为 null
Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null.
ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用 CAS 机制. 优化了扩容方式. key 不允许为 null
19 . 谈谈死锁
参考上文
20 . 谈谈 volatile关键字的用法?
volatile 能够保证内存可见性. 强制从主内存中读取数据. 此时如果有其他线程修改被 volatile 修饰 的变量, 可以第一时间读取到最新的值
21 . Java线程共有几种状态?状态之间怎么切换的?
NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态. RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在 CPU 上运行/在即将准备运行 的状态.
BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状 态.
WAITING: 调用 wait 方法会进入该状态.
TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态.
22 . 多次start一个线程会怎么样
第一次调用 start 可以成功调用. 后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常
总结
以上就是这篇博客的主要内容了,大家多多理解,下一篇博客见!