前言👀~
上一章我们介绍了线程池的一些基本概念,今天接着分享多线程的相关知识,这些属于是面试比较常见的,大部分都是文本内容
常见的锁策略
乐观锁
悲观锁
轻量锁
重量级锁
自旋锁
挂起等待锁
可重入锁和不可重入锁
互斥锁
读写锁
公平锁和非公平锁
CAS(重要)
原子类
CAS操作的解释
ABA问题
synchronized原理(重要)
锁升级(重要)
锁消除
锁粗化
Callable 接口
ReentrantLock类
信号量semaphore
CountDownLatch
线程安全的集合类
多线程环境使用哈希表(重要)
Hashtable
ConcurrentHashMap
如果各位对文章的内容感兴趣的话,请点点小赞,关注一手不迷路,讲解的内容我会搭配我的理解用我自己的话去解释如果有什么问题的话,欢迎各位评论纠正 🤞🤞🤞
个人主页:N_0050-CSDN博客
相关专栏:java SE_N_0050的博客-CSDN博客 java数据结构_N_0050的博客-CSDN博客 java EE_N_0050的博客-CSDN博客
常见的锁策略
下面这些是"锁的一种特点",是"一类锁",不是一把具体的锁。其实就是根据场景来描述出此时Synchronized锁的特点或者说状态
乐观锁
这两个锁就是字面意思,但都是对后续锁冲突是否频繁给出的预测,根据实际场景进行预测,最终目的都是为了保证线程安全,避免在并发场景下的资源竞争问题
乐观锁:预测接下来锁冲突的概率小,就少做些工作称为"乐观锁",乐观锁认为多个线程访问同一个共享变量冲突的概率不大,线程可以不停地访问数据无需加锁也无需等待, 在访问的同时识别当前的数据是否出现访问冲突
乐观锁的实现:可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突也可以使用CAS
例子:就像你有问题问老师,乐观的人认为老师不忙肯定有时间,然后直接去找老师,老师如果确实没空就回去,有空就直接问(虽然没加锁, 但是能识别出数据访问冲突)
悲观锁
悲观锁:预测接下来锁冲突的概率大,就多做些工作称为"悲观锁",悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁,这样别的线程想拿这个数据就会阻塞直到上个线程解锁
悲观锁的实现:就是先加锁(比如借助操作系统提供的 mutex(互斥锁)), 获取到锁再操作数据. 获取不到锁就等待
例子:就像你有问题问老师,悲观的人认为老师比较忙,不一定有时间,然后你要发个消息给老师确认一下(相当于加锁)
Synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略
轻量锁
下面两个锁和乐观锁和悲观锁有关系,只不过一个是预测锁冲突的概率,一个是锁的开销以及效率问题
轻量级锁:不涉及到内核态,开销小执行速度快。乐观锁一般情况下也就是轻量级锁,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁
重量级锁
重量级锁 :涉及大量的内核态用户态切换,容易引发线程的调度,线程的调度这个操作需要线程上下文切换,开销大执行速度比轻量级锁慢。悲观锁一般情况下也就是重量级锁
但是上面这种说法也不绝对只是说一般情况,比如synchronized 开始是一个轻量级锁,如果锁冲突比较严重, 就会变成重量级锁
自旋锁
下面两个锁又和重量级锁和轻量级锁有关系
我们之前说过多个线程争取同一把锁失败后,会进入阻塞等待,等待操作系统调度,但实际上,大部分情况下抢锁失败,过一会锁就释放了没必要就放弃 CPU资源,这个时候使用自旋锁去解决这个问题
自旋锁:自旋锁是轻量级锁的一种具体表现,是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待(忙等),然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环但是如果自旋到一定程度会膨胀成重量级锁。就类似舔狗天天舔,哪天女神分手了就有机会了
优点:因为没有放弃cpu资源,如果锁的占有时间短, 一旦锁被释放就能第一时间获取到锁, 更高效
缺点:因为没有放弃cpu资源,如果锁的占有时间长,就会浪费cpu资源
挂起等待锁
挂起等待锁:挂起等待锁是重量级锁的一种具体表现,是指当一个线程在获取锁失败后,进入阻塞等待,此时被操作系统内核挂起就不占cpu资源了,等获取锁的线程释放之后,唤醒当前线程然后再次进行争夺
可重入锁和不可重入锁
之前讲过,简单提一下就是一个线程针对同一把锁加锁两次,不会出现死锁就是可重入锁,否则就是不可重入锁
互斥锁
如果两个线程争取同一个锁,不管两个线程是要对数据进行读还是写,都会产生锁冲突,synchronized 就是个典型的互斥锁;加锁就是单纯的加锁,进入代码块加锁,出了代码块解锁
读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两个线程争取同一个锁,就会产生锁冲突。所以读写锁因此而产生,读写锁是把加锁操作,分为读锁和写锁
ReadWriteLock是一个读写锁,它允许多个线程同时读共享数据,而写操作则是互斥的,就是如果没有线程没有进行写操作,那么多个线程就可以同时进行读取,提高性能。因为我们知道多线程下同时读取一个数据是没有安全问题!
public class Test {
public static void main(String[] args) {
//创建读写锁
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
//获取写加锁
lock.readLock().lock();
//释放读锁
lock.readLock().unlock();
//创建写锁
lock.writeLock().lock();
//释放写锁
lock.writeLock().unlock();
}
}
读写锁的约定:
1.读锁和读锁不会造成锁冲突
2.读锁和写锁会造成锁冲突
3.写锁和写锁也会造成锁冲突
读写锁最主要用在 "频繁读, 不频繁写" 的场景中,所以就是我们在读操作多的场景,通过使用读锁,提高效率,因为两个线程同用读锁不会造成锁冲突,可以并发执行
公平锁和非公平锁
我们之前好像说过多个线程争取一把锁,一个线程拿到锁后,其他线程进入阻塞等待,等这个线程解锁后,后续的线程想要获取这把锁的概率是均等的
公平锁:线程要想获取同一把锁,遵循先来后到的准则
非公平锁:就和我开头说的一样,线程要想获取同一把锁,每个线程获取这把锁的概率是均等的
操作系统内部的线程调度就是随机的,就属于"非公平锁",要想实现公平锁需要使用队列来记录线程的先后顺序
synchronized 属于哪种锁?(重要)
初始情况下,synchronized 会预测锁冲突的概率高不高,如果不高则以乐观锁的模式运行也就是轻量级锁,以自旋锁的方式实现
初始情况下,synchronized 会预测锁冲突的概率高不高,如果高则以悲观锁的模式运行也就是重量级锁,以挂起等待锁的方式实现
这个是不会变的,是可重入锁,不是读写锁,是非公平锁
面试题:
1.你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
2.介绍下读写锁?
3.什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
4.synchronized 是可重入锁么?
CAS(重要)
compare and swap 比较交换的是内存和寄存器,多线程的一个经典操作,CAS的本质是为了赋值,就比如说内存A,寄存器B,寄存器C,内存A的值要和寄存器C的值进行交换。先将内存A的值和寄存器B的值进行比较,如果相同则进行交换然后返回true,如果不相同直接返回false。交换的本质就是为了把寄存器C的值赋给内存A,我们更关注内存A中的情况
CAS其实是一个cpu指令,这些比较交换的过程是由一个cpu指令完成的,也就是单个的cpu指令,所以是原子的!我们可以使用CAS完成一些操作,进一步替代加锁。本来我们遇到的线程安全的问题,第一时间想到的是加锁,但是呢锁冲突严重的话会使其他线程进入阻塞等待,最终影响执行效率。此时引入CAS能保证操作的原子性,又没有阻塞等待,所以在一定程度上可以替代加锁。所以我们可以使用无锁编程(基于CAS实现线程安全的方式),但是CAS代码复杂不易理解,只适合特定的一些场景,如果在资源竞争激烈的情况下不如加锁方式通用。CAS本质是咱们的硬件设备cpu提供的指令,被操作系统封装提供成API,又被JVM封装提供成API给我们进行使用
总结:例如count++这个指令在cpu上可以拆分多步所以不算原子操作,在多线程下容易引发安全问题,有两种办法解决一种是加锁,另外一种则是CAS,这样这个count++通过CAS操作在cpu就变为原子操作了,也就是使用CAS操作实现count++的原子性,这样在硬件层面上,CAS 是由单个的 CPU 指令完成的,这意味着它是原子的,能够避免竞态条件。主要还是因为cpu上支持CAS操作,所以你可以这样写,如果不支持这样写就不能保证原子性了
原子类
比如之前说的count++这个操作分为三步,不属于原子操作,在多线程中容易引发安全问题,通过原子类的方法可以完成一些自增自减的操作并且解决线程安全问题,AtomicInteger,基于CAS的方法对int进行封装,此时++这个自增操作就基于CAS指令来完成,就属于原子操作
public class Test {
public static AtomicInteger count = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
count.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t1.join();
System.out.println(count);
}
}
输出结果,符合预期说明保证了线程安全,通过使用原子类提供的方法
点进这个方法会看到unsafe,java中对于偏底层的操作会放到unsafe进行归类,所以在使用的时候要注意
再进一步点进去可以看到源码中的CAS操作
结论:其实是通过这个AtomicInteger类提供的方法实现的,原子类里面的方法是基于CAS操作实现的
CAS操作的解释
理解CAS操作保证线程安全:解释一下在多线程下下面的代码的执行,t1线程初始value=0,然后进入方法,value进行赋值,oldvalue=0。然后t2线程初始value=0,然后进入方法,value进行赋值,oldvalue=0,进入while循环开始CAS操作,此时看作value看作是内存中的值,oldvalue看作是寄存器A的值,oldvalue+1看作是寄存器B的值,然后我们开始比较,内存中的值等于寄存器A中的值,然后内存中的和寄存器B中的值进行交换,此时CAS操作会返回true,接着判断条件true!=true得出false循环结束,我们直接返回oldvalue也就是0。接着回到t1线程,此时内存中的值也就是value为1,寄存器B中的值为0,然后进行比较,不同返回false,接着判断条件false!=true执行whlie循环里的代码,进行赋值把value的值赋值给oldvalue,接着再次while循环判断,此时内存中的值和寄存器B中的值相同,然后内存中的值和寄存器C中的值进行交换,返回true,判断条件true!=true得出false循环结束。此时内存中的值就是2
前面说线程不安全的原因,是对同一个变量进行读写操作的时候,穿插执行导致的。CAS操作让不同线程对同一变量进行读写操作的时候不穿插执行,核心的思路和加锁类似,区别加锁是让其他线程进入阻塞等待避免穿插,CAS则是通过重试的方式避免穿插。并且不会引起线程切换和上下文切换的开销,从而提高并发性能
ABA问题
属于是CAS的一个关键问题,CAS进行操作的关键,是以值未发生变化作为其他线程没有穿插执行的判断依据,但是这种判断不够严谨会有些极端的情况
举个例子描述这个问题,你要存款卡里100存个100,然后你点了两下确定,然后创建了两个线程,然后一个线程进行判断,内存中和期望值相同进行交换,内存中的值更新为200说明100存进去了,但是这个时候你女朋友刚好花了100,内存中的值变成100了。然后轮到另外一个线程执行的时候和期望值一样进行交互,内存中的值又更新为200,这时候就出现ABA问题了白嫖了
大部分情况下ABA问题没什么事情,但是对于账户余额这种可能会出现极端的问题,有增有减就可能出现ABA问题,只增或只减问题不大
解决办法:通过设置一个版本号,不仅要比较数值还要比较版本号,还是上面的例子,初始版本号为1,两个线程执行存款操作,t1线程存款完成,并且版本号没问题,版本号+1此时版本为2,接着就算有线程插入执行扣款我们版本再+1,此时版本号为3,接着轮到t2线程执行,即使钱是对的但是版本号不同就不执行存款操作了,此时数据就是正确的了
面试题:
1.讲解下你自己理解的 CAS 机制
2.ABA问题怎么解决?
synchronized原理(重要)
锁升级(重要)
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级,锁升级过程是单向的,不能降级。无锁->偏向锁->自旋锁->重量级锁
解释一下偏向锁:如果直接加锁效率会比较低并且有开销,所以在锁升级的过程中我们先尝试升级到偏向锁优化一下效率。偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程,如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销),如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入轻量级锁状态。偏向锁是运行时的事情,运行时多个线程调度执行的情况不确定,这个线程的锁可能有人竞争也可能没有人竞争
偏向锁这样的思想和饿汉模式的实现挺像的,饿汉模式在创建实例的时候会先进行判断当前实例有没有被创建,如果被创建了直接返回实例,没有则进行加锁并且创建实例,和偏向锁的道理差不多,以及记住不是加锁了线程就安全了,例如单例模式中的饿汉模式你加了锁还是会有线程安全的问题,还得加些逻辑来处理才能保证
锁消除
编译器在编译过程触发没在运行时进行优化的手段,编译器会针对你写的加锁代码进行优化,它会判断当前需不需要加锁,如果不需要就直接就把锁优化掉了,也就是你写的synchronized,例如单个线程你加锁,此时会给你优化掉,节省不必要的开销。但是编译器只有在把握很大的情况下,才会进行锁消除
之前说过StringBuffer和StringBuilder,一个带synchronized,一个不带synchronized。在单线程的时候,编译器就可以进行锁消除了
锁粗化
JVM对锁进行优化
先理解锁的粒度:synchronized里的代码多锁的粒度粗,代码少锁的粒度细
锁的粒度细有一定的好处,能够充分发挥多核cpu的性能,能够并发执行的逻辑就更多。但是缺点就是如果遇到频繁被加锁和解锁,多个线程也要获取这把锁会反复造成锁冲。开销会变大效率反而降低,不如锁的粒度粗来的直接。就像是你跟别人说事情一样,你本来可以一次性说完但是分多次说,别人恨不得扇你
实际上可能并没有其他线程来抢占这个锁,这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁
Callable 接口
这个接口是创建线程的一种方式,Callable和Runnbale都是用来描述一个任务,Callable适用于线程在执行完一段逻辑后,并且带返回值,Runnbale则不带返回值
public class Test {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int num = 0;
for (int i = 0; i < 1000; i++) {
num++;
}
return num;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}
Callable接口和FutureTask搭配使用,为什么要搭配FutureTask类?
例子:你去肯德基点餐(这里就是Callable),点完后给你一张小票(这里使用FutureTask来记录这个任务的结果),工作人员那也有一张小票根据这种小票给你做餐(线程根据FutureTask执行任务),做完了,它会喊小票上的号码,你在拿着你的小票去取餐,这个FutureTask的作用就是工作人员可以通过小票里的任务做餐,而你可以通过小票去获取到任务执行完后的结果。要如果没有小票,很多人都点餐,那通过什么方式取呢?
除了上面那种实现Callable接口还可以像下面这样实现,使用lambda表达式
public class Test {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<Integer> futureTask = new FutureTask<>(() -> {
int num = 0;
for (int i = 0; i < 1000; i++) {
num++;
}
return num;
});
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}
面试题:
介绍下 Callable 是什么?
ReentrantLock类
和synchronized一样都是可重入锁,早期synchronized效果不咋滴,没有像现在一样被优化过,所以提供了其他锁
优势:
1.加锁的时候有两种方式,一种是lock和synchronized一样如果锁冲突激烈的情况,其他线程会进入阻塞等待。一种是trylock尝试获取锁时,获取不到直接放弃获取,不会死等,有更多操作空间
public class Test1 {
public static int count;
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
lock.lock();
for (int i = 0; i < 1000; i++) {
count++;
}
lock.unlock();
});
Thread t2 = new Thread(() -> {
lock.lock();
for (int i = 0; i < 1000; i++) {
count++;
}
lock.unlock();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
public class Test1 {
public static int count;
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
try {
lock.lock();
for (int i = 0; i < 1000; i++) {
count++;
}
} finally {
lock.unlock();
}
});
Thread t2 = new Thread(() -> {
lock.lock();
for (int i = 0; i < 1000; i++) {
count++;
}
lock.unlock();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
2.提供了公平锁的实现,默认情况是非公平锁,可以通过构造方法传入参数进行设置为公平锁
ReentrantLock 和 synchronized 的区别:
1.都实现了线程安全,都是可重入互斥锁,默认情况是非公平锁,但是ReentrantLock 可以设置为公平锁
2.synchronized 是一个关键字,ReentrantLock 是标准库中的一个类
3.synchronized 自动解锁,ReentrantLock 需要手动解锁
4.synchronized 获取锁失败会死等,trylock尝试获取锁时,获取不到直接放弃获取
信号量semaphore
信号量本质上就是一个计数器,用来表示 "可用资源的个数",所以开发中使用申请资源的场景,可以使用信号量来实现
拿停车场举例子,停车场会有一个牌子实时记录当前车位,车进入+1,车出去-1,信号量就是这个牌子,每次申请一个可用资源则+1,称为P操作。每次取出一个可用资源则-1,称为V操作。英语中这个P用acquire表示,这个V用release表示。这里的+1和-1的操作都是原子的,所以多线程下使用是线程安全的。当信号量数值为0的情况,线程会出现阻塞等待。什么意思呢?就像我们停车场没车位,我们可以选择等,也可以选择找别的停车场,这里选择的是等,下面有代码演示
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
System.out.println("资源-1");
semaphore.acquire();
System.out.println("资源-1");
semaphore.acquire();
System.out.println("资源-1");
semaphore.release();
System.out.println("资源+1");
semaphore.acquire();
System.out.println("资源-1");
semaphore.acquire();
System.out.println("资源-1");
}
}
输出结果,进入阻塞等待,因为一共可获得资源只有3个,在获取第四个得时候会进入阻塞
还可以使用信号量来实现多线程对同一变量自增并且保证线程安全
public class Test2 {
public static int count;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
try {
semaphore.acquire();
count++;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
semaphore.release();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
try {
semaphore.acquire();
count++;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
semaphore.release();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
输出结果
CountDownLatch
下载一个文件,我们可以使用多线程来完成,把文件拆解成多个模块,一个模块交给一个线程去完成,让这些任务并发执行,怎么判断这些线程是否都完成了呢?就使用CountDownLatch去判断这个任务的执行进度
countDown方法,用来通知CountDownLatch任务其中之一完成,await方法,使用这个方法会进入阻塞等待,等所有任务完成才会往下走,下面是代码实现
public class Test1 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch count = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int finalI = i;
Thread thread = new Thread(() -> {
System.out.println("线程" + finalI);
count.countDown();
});
thread.start();
}
count.await();
System.out.println("结束");
}
}
输出结果
如果没完成则进入阻塞等待,我就创建9个线程去完成任务,但是我设置了10个要完成的任务
public class Test1 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch count = new CountDownLatch(10);
for (int i = 0; i < 9; i++) {
int finalI = i;
Thread thread = new Thread(() -> {
System.out.println("线程" + finalI);
count.countDown();
});
thread.start();
}
count.await();
System.out.println("结束");
}
}
输出结果
面试题:
线程同步的方式有哪些?
为什么有了 synchronized 还需要 juc 下的 lock?
AtomicInteger 的实现原理是什么?
信号量听说过么?之前都用在过哪些场景下?
解释一下 ThreadPoolExecutor 构造方法的参数的含义
线程安全的集合类
原来数据结构学的集合类,,大部分都不是线程安全的。vector、Stack、HashTable带有synchronized 所以线程安全,所以要想在多线程下使用这些集合类,需要考虑线程安全的问题,可以选择直接加锁,这种比较常见适用范围广一些,标准库也提供了一些搭配的组件保证线程安全
多线程环境使用 ArrayList:
1.可以直接加锁synchronized或ReentrantLock
2.Collections.synchronizedList(new ArrayList);
synchronizedList 的关键操作上都带有 synchronized,你创建出这个ArrayList相当于带了一个synchronized,这样使用的时候就可以保证线程安全了
3.CopyOnWriteArrayList:写时拷贝
就是多线程情况下,有两个线程使用ArrayList的时候可能会有读有写操作,对于读操作没什么问题,对于写操作,我们直接拷贝一个ArrayList出来,一个线程要修改的话,就修改这个拷贝出来的ArrayList,另外一个线程读的话还是读之前的ArrayList,当另外一个线程修改完成后,把之前的ArrayList给覆盖掉,也就是引用赋值。总结就是拷贝出一个ArrayList进行修改,没修改完之前读原本的ArrayList即可,修改完后把原先的ArrayList覆盖掉就行了。总结就是有线程进行修改操作的时候,拷贝出一个ArrayList进行修改,在未修改完之前读操作都是读取之前的ArrayList,修改完成后覆盖掉之前的ArrayList。在读多写少的场景下, 性能很高, 不需要加锁竞争。但是不适合多个线程同时修改,不然可能会混淆,但是有缺点,如果装的数据太多了,拷贝时间太久也占空间
特别适合服务器的配置更新,可以通过配置文件,来描述配置的详细内容,这个内容不会很大。配置好的内容会被读到内存中,然后由其他线程进行读取,修改这个配置内容,只由一个线程进行修改。就比如我们修改了配置文件,通过某个命令让服务器重新加载配置,可以使用写时拷贝的方式,这样效率也高也能保证线程安全
多线程环境使用哈希表(重要)
Hashtable
保证线程安全就是直接在一些关键的方法上加synchronized关键字,相当于给this加锁也就是给Hashtable加了锁,整个哈希表只有一把锁,其他线程使用同一个Hashtable的话会出现锁冲突,影响效率
ConcurrentHashMap
ConcurrentHashMap和HashMap的使用方法差不多
分段锁:java 8之前是多个链表共用一把锁,java 8之后则是ConcurrentHashMap基于分段锁的方式下实现的,每个链表有独立的锁,以链表的头节点作为锁对象
改进:
1.不考虑扩容的情况下,操作不同链表的时候,此时线程是安全的。但是在操作同一个链表的时候容易出现线程安全的问题,所以操作不同链表不需要加锁,操作同一链表的时候进行加锁即可。所以就是每个链表拥有独立的锁(链表的头节点作为锁对象),降低锁冲突的概率,提高效率
2.充分利用CAS特点,在一些只增只减的操作上,使用原子操作完成,比如我们会使用一个变量记录hash表中的元素个数,这里就不用加锁,使用CAS进行修改即可,又降低了锁冲突的概率,提高了效率
3.针对读操作没有进行加锁(但是使用了 volatile 保证从内存读取结果),读和读之间以及读和写之间都没有加锁,进一步降低锁冲突,来提高效率。对于读和写操作的时候,ConcurrentHashMap底层编码中处理了一些细节,在修改的时候会避免使用非原子的操作,要进行修改的话使用的是原子的操作(=),所以进行读的时候要么是之前读的值,要么是读到写后的值
4.ConcurrentHashMap对扩容进行优化,HashMap和Hashtable在扩容的时候会把所有的元素都拷贝一遍,数据多的情况下时间会有些久,导致用户体验不好。ConcurrentHashMap则不采取一次性拷贝,分为多次进行拷贝,避免出现这样的情况。扩容期间,新老数组同时存在,后续每个来操作 ConcurrentHashMap 的线程,都会参与拷贝的过程.,每个操作负责拷贝一小部分元素。如果要插入元素则插入新数组中,要删除元素需要去查找当前元素在新数组还是老数组,要查元素则是新老数组同时查,拷贝完成后再将老数组删除
面试题:
ConcurrentHashMap的读是否要加锁,为什么?
介绍下 ConcurrentHashMap的锁分段技术?
ConcurrentHashMap在jdk1.8做了哪些优化?
Hashtable和HashMap、ConcurrentHashMap 之间的区别?
以上便是多线程进阶的相关内容,多线程就讲解到这了,进阶内容大部分都是面试考的内容,所以好好理解理解,我们下一章再见💕