目录
4.ConcurrentHashMap VS HashMap
1.关于各种各样的锁
1.1 读写锁
读锁(共享锁,Shared Lock,S锁) VS 写锁(独占锁,eXclusive Lock,X锁)
我们目前用的都是独占锁(只有一个线程能持有锁,读与读互斥,读与写互斥,写与写互斥)
而读锁(共享锁),读与读是不互斥的
在业务中,当读的次数远大于写的次数,共享锁优于独占锁
Java中的读写锁——ReadWriteLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* @author sunny
* @date 2022/05/05 20:42
**/
public class ReadWriteLock {
public static void main(String[] args) {
java.util.concurrent.locks.ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
Lock readLock = readWriteLock.readLock();
Lock writeLock = readWriteLock.writeLock();
// “写”的角色,请求写锁
// "只读“角色,请求读锁
// readLock.lock(); // 读锁已经有了
writeLock.lock(); // 写锁锁上
Thread t = new Thread() {
@Override
public void run() {
// readLock.lock();
writeLock.lock();
System.out.println("子线程也可以加读锁成功");
}
};
t.start();
}
}
1.2 重入锁(ReentrantLock)与不可重入锁
是否允许持有锁的线程成功请求到同一把锁,就是是否记录是哪个线程锁定的
允许申请到同一把锁——重入锁
不允许申请到同一把锁——不可重入锁
synchronized锁是?可重入锁
1.3 公平锁(fairLock)和非公平锁
公平锁:严格按照请求锁的顺序获得锁
公平锁实现复杂,所以一般都是非公平锁
synchronized锁是不公平的
juc下的ReentrantLock可根据传入的true还是false来定义公平,不传默认是不公平的
synchronized锁是独占、可重入、非公平锁
1.4 乐观锁 和 悲观锁
这两个是实现并发控制的两种不同方案,其实不是锁的概念
乐观锁:评估后,并发情况,多个线程同时修改一个共享资源的情况少见,所以可以采用轻量级(无锁)方式进行并发控制
悲观锁:多个线程会频繁的修改同一个共享资源,必须采用互斥(锁)的方式来并发控制
1.5 互斥锁 与 自旋锁
默认情况我们锁的实现是采用OS提供的锁(mutex锁:互斥锁)——一旦请求失败,会导致当前线程放弃CPU,进入阻塞状态,等待被唤醒——这种互斥锁成本比较大
改良:
【储备知识:硬件提供了CAS机制,CAS(地址,现值,修改值)
该操作是原子的;
如果修改成功,即地址现值正确,则修改,并返回true
否则,就是false】
自旋锁(spin lock) ,不公平的,不可重入的,独占的
请求锁失败后,强行执行一些空指令(没有任何用途的指令,以不放弃CPU,占着CPU,等待锁释放),这种方式更适用于现代的多核计算机
2.CAS
CAS:Compare and Swap比较并交换(在进行写的操作时,先将‘我’知道的旧值和内存中的现在的值进行比较,如果一致,我就写入内存,否则,就放弃写入)相当于通过一个原子的操作, 同时完成 "读取内存, 比 较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑;这其实是一种乐观锁策略,一开始先认为没事儿,加不了就不加)
ABA?CAS也是有可能产生BUG的,就是ABA情况,在比较的时候即使比较相等,也是不能知道是否经过了修改又被修改的环节
如何解决?借鉴乐观锁,引入版本号,Compare的不仅仅是数据,还有版本号,数据相等但是版本号不同,依然不能成功
3.synchronized锁的实现与优化
3.1 如何优化
(1)锁消除优化
Vector所有方法都用synchronized修饰:
编译器+JVM判断出只有一个线程时,会消除掉所有锁操作以提升性能
(2)锁粗化优化
加锁——n++——解锁——n++——加锁——n++——解锁
过于频繁的加解锁,就是粒度太细了,所以可以JVM会适当优化放宽加锁粒度,加锁——n++——n++——n++——解锁
(3)锁升级(膨胀)——偏向锁——轻量级锁——重量级锁
铺垫知识:
关于Java对象的Hotspot版本(Hotspot是较新的JVM,它的实现语言是C++,这里也有对象的概念,但并不是Java中对象))的实现,对象头(对象的锁信息保存在对象的最开始)的问题
- 无锁态:当对象没有锁时,是无锁状态,对象头里暂存其他信息
- 偏向锁:记录了谁总在加锁,偏向锁存在的时间内(第一次被加锁一直到有其他线程来竞争)这个锁来加锁,我直接放行,随着其他线程来竞争——偏向锁失效,开始使用真正的锁——>轻量级锁(自适应的自旋锁)
- 轻量级锁:CAS++spin lock——不进入内核态,只在JVM的用户态解决锁的竞争问题。一个锁的持有时间不会很长,这个线程不需要交出CPU,所以性能较好
通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了. 也就是所谓的 "自适应"
适应性自旋锁,随着信用值(都能申请到)提高自旋等待时间延迟,多次失败后,就不再自旋——>重量级锁
- 重量级锁:OS提供的mutex锁,放弃CPU,进入阻塞状态,对象头里保存
执行加锁操作, 先进入内核态.
在内核态判定当前锁是否已经被占用
如果该锁没有占用, 则加锁成功, 并切换回用户态.
如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起.
等待被操作系统唤醒. 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.
sync锁的优化小结:
-
能消除尽量消除
-
看粒度是不是太细,尝试粗化
-
无锁态——>偏向锁——>轻量锁——>重量锁
3.2 什么是偏向锁?
偏向锁不是真的加锁, 而只是在锁的对象头中记录一个标记(记录该锁所属的线程).
如果没有其他线程参与竞争锁, 那么就不会真正执行加锁操作, 从而降低程序开销.
一旦真的涉及到其他的线程竞争, 再取消偏向锁状态, 进入轻量级锁状态.
3.3 Synchroonized锁的实现原理?
1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态
3. 实现轻量级锁的时候大概率用到的自旋锁策略
4. 是一种不公平锁
5. 是一种可重入锁
6. 不是读写锁
4.ConcurrentHashMap VS HashMap
4.1 前置知识:
(1)用于动态数据搜索;
(2)HashMap解决冲突的方式是拉链法
(3)明确HashMap的put过程:
(4)HashMap在线程安全中是不安全的;所以永远不要多线程使用HashMap,官方微微补救措施:链表插入的时候选择尾插而不是头插(多线程下,头插可能把链表变成环,然后死循环;尾插仍是错的,但不至于死循环)
(5)如何使其变成安全的?
方法一: Hashtable
- 最早期的版本,使用synchronized锁,性能差(很多不需要互斥的也限制互斥了)
- 如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
- size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
- 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低
方法二:java.util.concurrent.ConcurrentHashMap 分段锁
- 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁.
- 加锁的方式仍然是是用 synchronized, 但是不是锁整个对象, 而是 "锁桶" ,只针对同一个链表的操作进行互斥;只有两个线程访问的恰好是同一个哈希桶上的数据才会锁冲突, 大大降低了锁冲突的概率.
- 扩容方面:jdk1.7缺点:一旦扩容,只针对某个链表做互斥就无意义——>jdk1.8:当前遇到扩容的线程,只扩容和搬一个元素(搬什么呢,把老数组中的每个key-value重新计算下标放入新数组新位置,只搬移一个,其他元素交给同时间的其他线程干),期间新老数组同时存在
4.2 相关面试题
🤨🤨🤨ConcurrentHashMap的读是否要加锁,为什么?
答:
- 读操作没有加锁. 目的是为了进一步降低锁冲突的概率.
- 但是为了保证读到刚修改的数据, 搭配了 volatile 关键字,保证内存可见性
😅😅😅 介绍下 ConcurrentHashMap的锁分段技术?
答:
- 这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了.
- 简单的说就是把若干个哈希桶分成一个 "段" (Segment), 针对每个段分别加锁.而jdk1.8中使用 synchronized 锁直接锁每个链表头结点, 锁冲突概率低,
- 目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争
🤔🤔🤔 ConcurrentHashMap在jdk1.8做了哪些优化?
答:
- 取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象).
- 将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树.
😕😕😕Hashtable和HashMap、ConcurrentHashMap 之间的区别?
答:
- HashMap: 线程不安全. key 允许为 null
- Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null.
- ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用 CAS 机制. 优化了扩容方式. key 不允许为 null
5.线程 VS 进程
1. 进程是系统进行资源分配和调度的一个独立单位,线程是程序执行的最小单位。
2. 进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈。
3. 由于同一进程的各线程间共享内存和文件资源,可以不通过内核进行直接通信。
4. 线程的创建、切换及终止效率更高。
6.死锁
死锁(哲学家问题)——多个线程都在阻塞等待同一个资源,而这个资源是不可获取的,这些线程无限期的等待
存在的四个必要条件:
互斥——不可抢占——请求和占有——循环等待、
解决方法:破坏其中任意一个条件即可,相对而言,破坏循环等待最容易,就是进行锁排序,明确线程都得先加A锁再加B锁
7.线程状态
Java线程共有几种状态?状态之间怎么切换的?
NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态.
RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在 CPU 上运行/在即将准备运行 的状态.
BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状态.
WAITING: 调用 wait 方法会进入该状态.
TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态
8.小总
synchronized 开始是一个轻量级锁(用户态). 如果锁冲突比较严重, 就会变成重量级锁(重量级锁完全依赖于OS提供的Mutex互斥锁.
Synchronized 不是读写锁,读写锁(读与读之间是不互斥的,而Synchronized是无论读写都互斥)
Synchronized锁是不公平锁(不公平锁不会遵守先来先得原则)
Synchronized锁是可重入锁(可重入就是允许一个线程多次加同一个锁,不可重入可能导致死锁)
自旋锁VS挂起等待锁:挂起等待锁就是我如果加锁失败就去阻塞等待,而自旋锁是即使加锁失败我也会运行一些无用的指令来抢占CPU,一旦锁被释放,我就能立即得到锁,Synchronized锁的轻量级锁大概率就是这种自旋锁
悲观锁认为产生冲突的可能性很大,所以每次都是真正的加锁,只能我可以操作;而乐观锁很乐观,认为多个线程访问同一个共享变量冲突的概率不大,所以乐观锁会引入一个版本号只有提交版本号大于记录当前版本才能执行更新余额,根据版本号判断是否会冲突再决定加锁
CAS:Compare and Swap比较并交换(在进行写的操作时,先将‘我’知道的旧值和内存中的现在的值进行比较,如果一致,我就写入内存,否则,就放弃写入)相当于通过一个原子的操作, 同时完成 "读取内存, 比 较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑;这其实是一种乐观锁策略,一开始先认为没事儿,加不了就不加)
ABA?CAS也是有可能产生BUG的,就是ABA情况,在比较的时候即使比较相等,也是不能知道是否经过了修改又被修改的环节
如何解决?借鉴乐观锁,引入版本号,Compare的不仅仅是数据,还有版本号,数据相等但是版本号不同,依然不能成功
JVM 对 synchronized 锁的加锁过程:无锁——〉偏向锁——>轻量级锁(使用CAS实现)——>重量级锁
优化:消除锁(比如单线程不必要的锁),锁粗化(粗化加锁粒度),
Callable接口???
线程池——正式员工——>阻塞队列——>临时员工——>都用完了,就使用定义的拒绝策略(四种拒绝策略:直接以返回异常的形式拒绝,交由调用者执行,抛弃当前线程,抛弃最旧的线程)
信号量?本质是一个计数器,用于表示可用资源的个数,有新进入者,就信号量-1,又离开的,就信号量+1,当信号量为0,表明无资源可再用,后续-1操作就会进入阻塞等待状态,直到有新的+1操作
线程安全的集合类:Stack,Vector,HashTable
都只是简单的对所有方法进行了Synchronized加锁操作,所以极其不高效,少用
Map:对于HashTable,由于它极其效率不高,所以有了一个优化的ConcurrentHashMap类,优化如下:该类对读操作并不加锁;该类只对竞争相同“桶”的进行加锁;该类对扩容操作也进行了优化(每个操作搬一部分)
死锁(哲学家问题)——多个线程都在阻塞等待同一个资源,而这个资源是不可获取的,这些线程无限期的等待
存在的四个必要条件:
互斥——不可抢占——请求和占有——循环等待、
解决方法:破坏其中任意一个条件即可,相对而言,破坏循环等待最容易,就是进行锁排序,明确线程都得先加A锁再加B锁