目录
4.JUC(java.util.concurrent)的常见类
4.1Callable接口
理解 Callable
理解 FutureTask
答疑
完善创建线程的写法
4.2ReentrantLock
答疑:这个跟 synchronized 哪个效率高??
synchronized vs ReentrantLock [经典面试题]
如何选择使用哪个锁?(课件内容)
4.3原子类
4.4线程池
4.5信号量Semaphore
理解信号量
答疑
4.6CountDownLatch
4.7相关面试题(课件内容)
阶段性小结
5.线程安全的集合类
5.1多线程环境使用ArrayList
5.2多线程环境使用队列
5.3多线程环境使用哈希表
1)Hashtable
2)ConcurrentHashMap
核心优化点1:把锁整个表=>锁桶
核心优化点2:使用 原子类 针对 size 进行维护
核心优化点3:针对哈希扩容的场景
5.4相关面试题(课件内容)
6.死锁
7.其他常见面试题(课件内容)
书接上文:Java EE:3.多线程-进阶(第二弹)~~
4.JUC(java.util.concurrent)的常见类
聊一下 JUC 中的一些组件
java.util.concurrent
就是一些和多线程相关的工具~~
闲聊:
如果在学校担任班干部/学生会/社团 担任一些职位,等到大二下学期/大三上学期,就要考虑把手里的事情逐渐交出去了,才有机会溜~~
赶紧去找实习~~
因为我们要明确:在计算机相关的秋招岗位中,这些学生会/班干部 经历,不是加分项!!
4.1Callable接口
理解 Callable
Callable接口和Runnable接口是并列关系
Runnable接口重写 run 方法,返回值是 void ,关注执行过程,不关注执行结果
相比之下,Callable接口重写 call 方法,返回值是泛型,我们可以根据需要往里传参数
闲聊:
泛型这个概念,很多语言都有~~
动态类型(像Python、JS、PHP……)不太需要泛型
而静态类型(像C++、Java……)才更需要~~
其实都叫做“泛型编程”,但是在C++中用到 template 关键字,也可以叫做“模板”
下面放到代码中理解一下
package thread;
import java.util.concurrent.Callable;
public class Demo40 {
public static void main(String[] args) {
//此处 Callable 只是定义了一个“带有返回值”的任务!
//并没有真的在执行,执行还是需要搭配 Thread 对象
Callable<Integer> callable=new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result=0;
for(int i=1;i<=100;i++){
result+=i;
}
return result;
}
};
}
}
但是我们给 Thread 传入 callable 的时候,发现这个代码编译不了👇

原因就是 Thread 的构造方法并没有提供 参数为 callable 对象的版本
传不了吗??不是的,我们只需要借助一点“外力”
需要在传递之前,先定义另一个类:FutureTask,泛型参数与之前的 Callable 泛型参数相同👇
package thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo40 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//此处 Callable 只是定义了一个“带有返回值”的任务!
//并没有真的在执行,执行还是需要搭配 Thread 对象
Callable<Integer> callable=new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result=0;
for(int i=1;i<=100;i++){
result+=i;
}
return result;
}
};
FutureTask<Integer> futureTask=new FutureTask<>(callable);
Thread t=new Thread(futureTask);
t.start();
//get 操作就是获取到 FutureTask 的返回值,这个返回值就来自于 Callable 的 call 方法
//get 可能会阻塞,如果当前线程执行完毕,get就拿到返回结果
//如果当前线程还没执行完毕,get 会一直阻塞
System.out.println(futureTask.get());
}
}

可见,与 Runnable 不同
Runnable 可以直接填写到 Thread 的构造方法中的
但是 Callable 必须借助 FutureTask 多包一层才能传入进去(FutureTask 就负责这个等待结果出来的工作)
理解 FutureTask
打个比方:吃麻辣烫的时候~~
你先夹菜,把菜筐交给服务员,在后厨给你煮了,也会给你一个号码牌,你后续凭号码牌取餐~~
Thread 本身不提供获取结果的方法的,就需要凭借 FutureTask 对象来拿到结果
答疑
Q1:不提供是因为用的少吗??
是为了解耦合,我们希望 Thread 就是线程,能和任务这个概念剥离开,更不希望关心任务是啥样的任务(是否有返回值)
Q1追问:那 Runnable 咋就行呢?
Runnable 的返回值是 void
Q2:Executor 和上面的 ExecutionExecption 有关系吗?
应该是没关系的,Exector 一般指的是线程池,而 ExecutionExecption 是上述代码执行过程中 get 方法可能抛出的异常
如果不使用 Callbale ,还是基于原来的 Runnable 来实现,其实代码就会变得更加复杂一些,需要我们额外去定义一些有关联的变量,内聚性就不如 Callable 的那么集中👇
package thread;
//直接使用 Runnable 实现1+2+……+100的功能
public class Demo41 {
private static int total=0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable=new Runnable() {
@Override
public void run() {
int sum=0;
for(int i=1;i<=100;i++){
sum+=i;
}
total=sum;
}
};
Thread t=new Thread(runnable);
t.start();
t.join();//等待线程执行完毕
System.out.println("total="+total);
}
}
所以以后我们涉及得到一个明确的返回值的时候,就可以考虑用 Callable 来代替 Runnable,但其实相比之下,整体来说,Runnable 用的还是更多一些吧~~
答疑:为什么写 Callable 的时候启动线程不写 t.join(),写 Runnable 的时候启动线程就要写 t.join()?
因为 Callable 的任务执行结果已经存在 FutureTask 里了,启动线程前已经执行完了
而 Runnable 的任务没有返回值,线程启动才会开始计算,自然要用 t.join() 等待线程结束
完善创建线程的写法
1.继承 Thread(定义单独的类/匿名内部类)
2.实现 Runnable(定义单独的类/匿名内部类)
3.实现 Callable(定义单独的类/匿名内部类)
4.lambda
5.线程池,ThreadFactory
闲聊:模拟面试
模拟面试的时候,一般问问题,都是从浅入深的问,真实面试也是如此,不是要难倒你,而是要摸清你的水平~~
简单的问题回答好,会更深入的问
如果回答的不好,会换个其他话题,继续从浅入深的问~~
这就导致很多同学面试之后,对于自己的评价,会存在明显的误判~~
所有的问题都答上了,我是不是稳了??大概率是凉了
回答过程中,一部分答上了,一部分没答上,反而更稳~~
尤其是当你连续面试多场,面一场挂一场~~
最好是录音=>发给比特老师听听~~
不要发到公开的平台上,否则即使你面试通过,被人家公司发现,可能认为你违反公司规章制度,取消你的 offer!!不追究法律责任就算不错的了
因为面试,可能涉及到 公司机密~~
之前有个Java12班有个同学,前面的问题回答的都勉勉强强~~面试官语气很低沉,想快速结束了,然后让他反问,他通过一个神奇的问题,一波翻盘,扭转局势,拿到 offer
“假如给我发了 offer,大概需要我啥时候去入职呢?我这边最快的话下周左右就可以入职”
毕竟公司招人就是找人干活,你意向强烈,面试官还是挺看好的,毕竟这也与他自身利益相关
4.2ReentrantLock
可重入互斥锁,和 synchronized 是并列关系,属于更经典风格的锁(使用 lock / unlock 实现加锁解锁)
ReentrantLock:”瑞鹌鹑闰特lock“(给面试官读的时候千万别读错~~)

还是拿之前的例子,此处的代码明显是有线程安全问题的👇
package thread;
public class Demo42 {
private static int count=0;
public static void main(String[] args) {
Thread t1=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<50000;i++){
count++;
}
}
});
Thread t2=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<50000;i++){
count++;
}
}
});
t1.start();
t2.start();
try{
t1.join();
t2.join();
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("Count="+count);
}
}

之前我们是通过 synchronized 加锁保证线程安全,这次我们通过 ReentrantLock 来实现👇
package thread;
import java.util.concurrent.locks.ReentrantLock;
public class Demo42 {
private static int count=0;
public static void main(String[] args) {
ReentrantLock locker=new ReentrantLock();
Thread t1=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<50000;i++){
locker.lock();//加锁
count++;
locker.unlock();//解锁
}
}
});
Thread t2=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<50000;i++){
locker.lock();//加锁
count++;
locker.unlock();//解锁
}
}
});
t1.start();
t2.start();
try{
t1.join();
t2.join();
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("Count="+count);
}
}

答疑:这个跟 synchronized 哪个效率高??
上古时期,主要使用 ReentrantLock ,后来 synchronized 优化做的越来越好,所以优先使用 synchronized
之前也探讨过,这种写加锁解锁的方式,很容易把解锁给忘记、或者中间抛出异常后解不到锁,因此最好写成这样👇
locker.lock();//加锁
try{
count++;
}finally {
locker.unlock();//解锁
}
当然,这也只是权宜之计,还是没有 synchronized 更香~~
虽然我们优先使用 synchronized ,但是 ReentrantLock 还有有一些它的独到之处~~
synchronized vs ReentrantLock [经典面试题]
1)
synchronized 是关键字(内部实现是 JVM 通过 C++ 实现的)
ReentrantLock 是标准库的类(纯Java代码)
2)
synchronized 通过代码块控制加锁解锁
ReentrantLock 需要 lock / unlock 方法,需要注意 unlock 不被调用的问题
3)
synchronized 加不到锁会阻塞等待,用起来更简单粗暴
ReentrantLock 除了提供 lock / unlock 之外,还提供了一个方法:tryLock(),使得 ReentrantLock 使用起来更灵活,这个tryLock()有两个特性:
①不会阻塞!!
加锁成功,返回 true,加锁失败,返回 false,需要调用者判定返回值,决定接下来咋做~~
前面的加锁,都是追不到,但是“愿意等”,甘当备胎
为啥要当备胎??一定要先学会爱自己,才能爱别人~~
追不到,就放弃~~
当然,这就导致写代码时考虑的更多~~
②提供了设置超时时间的版本
等待时间达到超时时间再返回 true / false
4)
synchronized 就是非公平锁
而 ReentrantLock(默认时非公平锁)提供了公平锁的实现

设置为 true 就是公平锁,设置为 false 就是非公平锁
5)
synchronized 搭配的通知等待机制是 Object 的 wait / notify ,每次唤醒一个随机等待的线程
ReentrantLock 搭配的等待通知机制,是 Condition 类,相比 wait – notify 来说功能更强大一些,可以更精确控制唤醒某个线程
如何选择使用哪个锁?(课件内容)
1)锁竞争不激烈时,使用 synchronized 效率更高,自动释放更方便
2)锁竞争激烈时,使用 ReentrantLock ,搭配 trylock 更灵活控制加锁行为,而不是死等
3)如果需要使用公平锁,使用 ReentrantLock
4.3原子类
关于原子类,之前已经在 CAS 部分介绍过了,内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多,原子类有以下几个:
①AtomicInteger(高频)
②AtomicLong(高频)
③AtomicBoolean
④AtomicIntegerArray
⑤AtomicReference
⑥AtomicStampedReference
// 以 AtomicInteger 举例,常见方法有
1 addAndGet(int delta); i += delta;
2 decrementAndGet(); –i;
3 getAndDecrement(); i–;
4 incrementAndGet(); ++i;
5 getAndIncrement(); i++;
之前我们说过了,应用的场景,比如数据统计,统计服务器收到了多少个请求之类的~~
4.4线程池
参考:Java EE:2.多线程-初阶(第八弹):多线程案例-线程池
4.5信号量Semaphore
信号量,用来表示“可用资源的个数”,本质上就是一个计数器
能够协调多个进程之间的资源分配
也能协调多个线程之间的资源分配(我们主要研究的✔)
理解信号量
比如:开车找停车位~~
如何区分停车场是否有空闲车位?
电子指示牌:剩余N个空位
此处的计数器就会在你开进去之后 -1
后续开出来,计数器 -1
如果计数器为0,说明停满了
要么等,要么放弃
信号量表示的就是“可用资源的个数”
申请一个资源,计数器就会 -1=>P操作
释放一个资源,计数器就会 +1=>V操作
计数器为0,继续申请,就会阻塞等待
这就是PV操作,通常我们用英文 acquire 和 release 分别表示申请和释放操作
闲聊:为啥叫 PV 操作??
因为提出信号量的大佬,叫迪杰斯特拉(也是提出算法中图的最短路径 Dijkstra 的人),他是个荷兰人,PV就是荷兰语的单词首字母,P←Passeren,意味着进入临界区域,V←vrijgeven,意味着放弃临界区域
下面用代码来理解一下信号量👇
①4个可用资源,申请4次
package thread;
import java.util.concurrent.Semaphore;
public class Demo43 {
//理解信号量
public static void main(String[] args) throws InterruptedException {
//指定可用资源的个数是“4”
Semaphore semaphore=new Semaphore(4);
semaphore.acquire();//可能发生阻塞,所以要抛异常
System.out.println("进行一次 P 操作");
semaphore.acquire();
System.out.println("进行一次 P 操作");
semaphore.acquire();
System.out.println("进行一次 P 操作");
semaphore.acquire();
System.out.println("进行一次 P 操作");
}
}
可以发现,能够正常运行👇

②3个可用资源,申请4次
package thread;
import java.util.concurrent.Semaphore;
public class Demo43 {
//理解信号量
public static void main(String[] args) throws InterruptedException {
//指定可用资源的个数是“4”
Semaphore semaphore=new Semaphore(3);
semaphore.acquire();//可能发生阻塞,所以要抛异常
System.out.println("进行一次 P 操作");
semaphore.acquire();
System.out.println("进行一次 P 操作");
semaphore.acquire();
System.out.println("进行一次 P 操作");
semaphore.acquire();
System.out.println("进行一次 P 操作");
}
}
可以发现,此时仅有3个完成了申请,第4次申请就进入了阻塞状态👇

此时我们通过 jconsole.exe 可以观察到,正好在第4次申请处发生阻塞👇

信号量的一个特殊情况:初始值为 1 的信号量
要么取值为 1,要么取值为 0 =>二元信号量
二元信号量 等价于 “锁”,换句话说,普通的信号量,就相当于 锁 的更广泛的推广
如果是普通的 N 的信号量,就可以限制,同时有多少个线程来执行某个逻辑~~
下面用二元信号量来代替锁解决线程安全问题👇
package thread;
import java.util.concurrent.Semaphore;
//通过信号量解决线程安全问题
public class Demo44 {
private static int count=0;
public static void main(String[] args) {
Semaphore semaphore=new Semaphore(1);
Thread t1=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<50000;i++){
try{
semaphore.acquire();//相当于加锁操作
count++;
semaphore.release();//相当于解锁操作
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
});
Thread t2=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<50000;i++){
try{
semaphore.acquire();//相当于加锁操作
count++;
semaphore.release();//相当于解锁操作
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
});
t1.start();
t2.start();
try{
t1.join();
t2.join();
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("Count="+count);
}
}

可以发现,信号量也可以达到“锁”的效果,其实信号量比锁的用途更广泛一些
毕竟锁只能针对一个线程,只能允许一个线程拿到锁资源
而信号量则可以根据需要允许任意多个线程去执行一些相关的逻辑
答疑
Q1:信号量也会发生竞争吗?
会产生阻塞,当然也会竞争
Q2:PV 会 wait、notify 吗?用一次阻塞,另一个就唤醒
信号量和 wait、notify 没关系~~
信号量和 wait、notify 在操作系统层面就是两种不同的机制,后来Java又封装了
和线程 1 释放锁,线程2 被系统唤醒,拿到锁~~类似的
但是线程2 被系统唤醒取决于系统,不像咱们 notify 主动唤醒~~
这个主动唤醒和被动唤醒,还是不太一样的,还是需要自己体会~~
4.6CountDownLatch
这个不太好翻译,网上有人翻译成“锁存器”,但是感觉这个翻译不太贴切,后续我们就不再强行翻译了,直接拿英文说吧~~
CountDownLatch 最主要的用途就是搭配多线程
我们知道,使用多线程,经常把一个大的任务拆分成多个子任务,使用多线程执行这些子任务,从而提高程序的效率
那么我们如何衡量,这多个子任务都完成了呢?或者如何衡量,整个任务都完成了呢?
这就可以使用 CountDownLatch 来实现
1)构造方法指定 参数,描述拆分了多少个任务→指定任务个数
2)每个任务执行完毕之后,都调用一次 CountDownLatch 方法
3)看 指定任务数 是否和 CountDownLatch 的调用次数匹配
比如一共10个任务,当一共调用了10次 CountDownLatch 时→说明任务全都完成了
4)主线程中调用 CountDownLatch 中的 await 方法,等待所有任务执行完毕
当 CountDownLatch 调用次数达到 指定任务数时,await 就会返回,否则就会阻塞等待
举个具体点的例子:运动会的跑步比赛
发令枪响,比赛开始
所有的选手都到达终点,比赛结束
而每个选手撞线→CountDownLatch
答疑:那其中一个抛异常,不 try-finaily 会结束吗?
这个就不叫任务结束了,这个叫异常终止了,抛异常,不处理,意味着整个进程结束了
下面用代码切身感受一下👇
package thread;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Demo45 {
public static void main(String[] args) throws InterruptedException {
// 现在把整个任务拆成 10 个部分,每个部分视为是一个 "子任务"。
// 可以把这 10 个子任务丢到线程池中,让线程池执行。
// 当然也可以安排 10 个独立的线程执行。
// 构造方法中传入的 10 表示任务的个数。
CountDownLatch latch = new CountDownLatch(10);
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10; i++) {
int id = i;
executor.submit(() -> {
System.out.println("子任务开始执行:" + id);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("子任务结束执行:" + id);
latch.countDown();
});
}
// 这个方法阻塞等待所有的任务结束
//此处的 a 可理解为 all
latch.await();
System.out.println("所有任务执行完毕");
}
}

答疑
Q1:为什么退出代码不是0?
因为线程池里面有前台线程,我们只需要在最后一行加上👇就可以了
executor.shutdown();

Q2:可以把线程池所有线程都设置为后台线程吗?
之前讲过了,在创建线程池时(ThreadPoolExecutor有一个参数ThreadFactory),自己写一个ThreadFactory,创建线程,并且设置成后台就行,就是麻烦了点
闲聊:其实上述讲的也不全是八股文吧,毕竟像原子类、CountDownLatch在工作中都很实用,但是用途不是特别广泛,仅是针对特定场景的解决方案
4.7相关面试题(课件内容)
Q1:线程同步的方式有哪些?
synchronized、ReentrantLock、Semaphore等都可以用于线程同步
Q2:为什么有了 synchronized 还需要 JUC 下的 lock?
参考上述 synchronized vs ReentrantLock 内容
Q3:AtomicInteger 的实现原理是什么?
基于 CAS 机制,伪代码如下:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
执行过程参考上述 CAS的应用 部分
Q4:信号量听说过么?之前都用在过哪些场景下?
信号量,用来表示“可用资源的个数”,本质上就是一个计数器
使用信号量可以实现“共享锁”,比如某个资源允许3个线程同时使用,那么就可以使用P操作作为加锁,V操作作为解锁,前三个线程的P操作都能顺利返回,后续线程再进行P操作就会阻塞等待,直到前面的线程执行了V操作
Q5:解释一下 ThreadPoolExector 构造方法的参数的含义
参考:Java EE:2.多线程-初阶(第八弹):多线程案例-线程池
阶段性小结
CAS
原子类
自旋锁
ABA问题
JUC其他组件
Callable FutureTask
ReentrantLock lock / unlock
1.synchronized 关键字,JVM,C++;ReentrantLock Java标准库,Java
2.synchronized 不需要手动释放锁;ReentrantLock 需要手动释放锁,finally
3.ReentrantLock trylock 不阻塞
4.ReentrantLock 提供了公平锁的实现
5.提供更强的等待通知功能
原子类 AtomicInteger
Semaphore 信号量
P操作,计数器 -1
V操作,计数器 +1
计数器减为0,再进行P操作就会阻塞
5.线程安全的集合类
原来的集合类,大部分都是线程不安全的
JUC中就涉及到了一些跟线程安全相关的集合类~~
5.1多线程环境使用ArrayList
1.自行加锁[推荐]
需要分析清楚,要把哪些代码打包到一起,成为一个“原子”操作
2.Collections.synchronizedList(new ArrayList),类似一个“套壳”操作,返回的 List 的各种关键方法都是带有 synchronized[不推荐,太粗暴了,毕竟加锁有代价]
类似于 Vector、Hashtable、StringBuffer,直接加 synchronized 了
答疑:所有方法都有 synchronized 吗??
至少 public 方法都有~~
3.使用 CopyOnWriteArrayList
这是编程中的一种常见思想方法→写时拷贝
我们前面说过,线程不安全一个很重要的原因是多线程修改同一个变量
如果多线程修改不同变量,或者多线程读取变量,都不涉及线程安全问题
CopyOnWriteArrayList 就是类似这个思路,并不去加锁,而是通过写时拷贝的方式应对线程安全问题👇
CopyOnWriteArrayList保证线程安全
这个过程就类似于妹子化妆~~
有的妹子,素颜就很好看,画了妆也好看
但是,不要看画了一半的状态~~(有可能比较惊悚)
但是很明显上述方案也有非常明显的缺点
1)数组特别大的时候,非常低效!!
2)如果多个线程同时修改,也容易出现问题
还是上面动画的例子
如果一开始是1、2、3、4
线程1修改成100、200、3、4
线程2修改成1、2、300、400
那最后的引用指向1、2、300、400时,前面修改的100、200就丢了~~
因此,这种方案只适合于特定场景(比如读多写少),性能很高,不需要加锁竞争~~
比如:服务器进行 重新加载 配置 的时候~~
配置:很多程序提供很多功能,按需 开启/按需设置
比如像黑马喽这样的游戏,必然“设置”菜单:设置画质、设置声音、设置按键……
服务器正在运行,但是需要修改配置
正常来说,修改配置文件之后,不能立即生效的,需要重启一下服务器
但是这个时间可能就很长,因此很多服务器就提供了 配置重加载(reload)
可以理解为:配置就是被读取到服务器的内存中,以 数组/哈希 存储
服务器代码中的其他逻辑就会读取这些 数据/哈希 中的值(不会修改)
此时,程序员手动修改配置文件之后,手动触发 reload 功能
服务器就会创建新的 数组/哈希 ,加载新的配置
加载完毕之后,使用新配置 代替 旧配置~~
这个过程就能保证,要么读到的是 旧配置、要么读到的是 新配置,都是完整的配置,不会出现读到那种修改了一半的配置文件~~
闲聊:学习C++的同学在 std::string、Linux创建子进程……涉及到写时拷贝,研究的更深入一些,而我们Java只需了解即可
5.2多线程环境使用队列
重点了解前三个即可:
1.ArrayBlockingQueue:基于数组实现的阻塞队列
2.LinkedBlockingQueue:基于链表实现的阻塞队列
3.PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列
4.TransferQueue:最多只包含一个元素的阻塞队列
5.3多线程环境使用哈希表
HashMap 线程不安全
Hashtable 线程安全的(给各种 public 方法都加 synchronized)[不推荐,因为有更好的替代品👇]
ConcurrentHashMap
接下来讨论的内容都是经典面试题
为什么选择 ConcurrentHashMap ,而不推荐用 Hashtable??
因为 ConcurrentHashMap 是按照桶级别进行加锁,而不是给整个哈希加一个全局锁,能够有效降低锁冲突的概率,效率更高
先回顾一下哈希表的构造:
我们把每个key映射成一个数组下标放进哈希表中👇

但是当两个不同的 key 映射成同一个下标的时候会引起哈希冲突
解决哈希冲突有两种方案:
1.线性探测
把后来重复的放到下一个位置,但实际工作上基本上不用
2.使用链表
每个哈希表中的元素都是一个链表,有重复的就放链表里,而且实现起来也不复杂👇

如果链表太长了,咋办??
1)扩容:引入更多的哈希桶,来使每个链表变短
2)链表=>红黑树:把链表进化成红黑树
接下来就涉及到一个关键的问题,如果多线程使用哈希表
1)Hashtable
按照 Hashtable 来说,是给 public 方法上加锁=>在 this 上加锁=>而 this 指向了整个哈希表
此时,任意两个线程,访问任意的两个不同元素,都会产生锁竞争,进而阻塞

但其实我们分析发现:
①如果修改的两个元素,在不同的链表上,本身就不涉及线程安全问题(属于修改不同变量)
②如果修改同一个链表上的两个元素,可能有线程安全问题(看这俩元素是否相邻)
如果不相邻,不涉及线程安全问题(因为两元素没什么联系,还属于修改不同变量)
如果相邻,就涉及线程安全问题了(比如把这俩元素插入到同一个元素后面,next 指向谁?就可能产生竞争)
2)ConcurrentHashMap
核心优化点1:把锁整个表=>锁桶
相比之下,ConcurrentHashMap 会给哈希表每个位置加锁(因为不是你一个类只能有一个锁对象,因此我们可以任意的创建,任意的指定的)

针对不同的锁对象加锁,不会产生锁竞争(不会阻塞)
答疑:影响哈希性能吗??
①时间开销
再怎么优化,肯定不会高于 HashMap,但咱干的事儿也多,所以是跟 Hashtable 比,锁冲突降低了,时间肯定更快,因为锁开销最大地方就是 阻塞,阻塞一次就耽误老事儿了
实际开发中,用到的Hash表很可能是比较大的(桶有很多很多个),即使多线程访问上述的哈希表,同一时刻,两个线程恰好访问同一个链表的可能性,概率就比较低!!因此速度还是很快的
②空间开销
其实我们没必要真的去额外创建那么多锁对象
因为Java任意一个对象都可以作为锁对象
直接使用每个链表的头节点作为 synchronized 的锁对象就行了
所以空间上基本上没有开销~~
闲聊:其实上述桶方案是从Java8时期开始引入的,在这之前,ConcurrentHashMap采取了“分段锁”方案,就是将这些链表分成几组,每个组安排一个锁~~
思路还是一样的,只是不够彻底,所以这种方案没过多长时间就被pass掉了,变成了上述每个链表一把锁
核心优化点2:使用 原子类 针对 size 进行维护
其实有同学仔细观察会发现,还存在一些问题:
在一个链表上插入元素,需要修改哈希表长度 size
在另一个上插入元素,也需要修改哈希表长度 size
而这个 size 是整个哈希表的 size,我们上述的锁桶的策略并不能解决这个线程安全问题
难道我们要再额外弄个锁吗?没必要
我们有更好的方法,利用之前讲过的 原子类 来对 size 进行修改,避免进行加锁操作
核心优化点3:针对哈希扩容的场景
扩容操作,意味着需要创建更大的数组,把旧哈希中的所有元素搬运到新的哈希中(元素很多,耗时很长)
单线程操作还好,在多线程情况下就不科学了:
假设某个插入操作,触发了扩容~~就需要进行搬运了~~
搬运过程中,就需要进行更长时间的加锁了,需要将整个表锁住,等搬运结束后再释放锁
那这不就与前面的“锁桶”优化冲突了吗??前面全白努力了~~
此时我们的优化策略:“化整为零”,确保每个操作加锁的时间不要太长
由于一口气进行所有的搬运,比较耗时
因此把整个的搬运拆成多次来完成
一旦触发“扩容”,不是通过一次 put 来完成的
而是通过多次的 put / get 等操作来完成
①发现需要扩容的线程,只需要创建一个新的数组,同时只搬几个元素过去
②扩容期间,新老数组同时存在
③后续每个来操作 ConcurrentHashMap 的线程,都会参与搬家的过程,每个操作负责搬运一小部分元素
④搬完最后一个元素再把老数组删掉
⑤这个期间,插入只往新数组加,查找需要同时查新数组和老数组
比如1000w个元素,操作一次,只搬运其中的 百分之几 这种~~
后续再进行搬运操作,每次再触发一部分搬运~~
这样的话原本搬运需要消耗100ms,每次操作一次加锁时间 1ms,分100次完成搬运
参考资料:ConcurrentHashMap源码分析(JDK8版本)
5.4相关面试题(课件内容)
Q1:ConcurrentHashMap 的读是否要加锁,为什么?
读操作没有加锁,目的是为了进一步降低锁冲突的概率
为了保证读到刚修改的数据,搭配了 volatile 关键字
Q2:介绍下 ConcurrentHashMap 的锁分段技术?
这个是 Java1.7 中采取的技术,Java1.8中已经不再使用了,简单的说就是把若干个哈希桶分成一个“段”,针对每个段分别加锁
目的也是为了降低锁竞争的概率,当两个线程访问的数据恰好在同一个段上的时候,才触发锁竞争
Q3:ConcurrentHashMap 在 JDK1.8做了哪些优化?
取消了分段锁,直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头节点对象作为锁对象)
将原来 数组+链表 的实现方式改进成 数组+链表/红黑树 的方式,当链表较长的时候(≥8个元素)就转换成红黑树
Q4:Hashtable 和 HashMap、ConcurrentHashMap 之间的区别?
HashMap:线程不安全,key允许为null
Hashtable:线程安全,使用 synchronized 锁 Hashtable 对象,效率较低,key不允许为 null
ConcurrentHashMap:线程安全,使用 synchronized 锁每个链表头节点,锁冲突概率低,充分利用 CAS 机制,优化了扩容方式,key 不允许为 null
6.死锁
参考:Java EE:2.多线程-初阶(第四弹):synchronized 锁+内存可见性中的死锁部分
7.其他常见面试题(课件内容)
Q1:谈谈 volatile 关键字的用法?
volatile 能够保证内存可见性
强制从主内存中读取数据,此时如果有其他线程修改被 volatile 修饰的变量,可以第一时间读取到最新的值
Q2:Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域:
方法区,堆区,栈区,程序计数器
其中堆区这个内存区域是多个线程之间共享的
只要把某个数据放到堆内存中,就可以让多个线程都访问到
Q3:Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
创建线程池主要有两种方式:
①通过 Executor 工厂类创建,创建方式比较简单,但是定制能力有限
②通过 ThreadPoolExecutor 创建,创建方式比较复杂,但是定制能力强
LinkedBlockingQueue 表示线程池的任务队列,用户通过 submit / execute 向这个任务队列中添加任务,再由线程池中的工作线程来执行任务
Q4:Java线程共有几种状态?状态之间怎么切换的?
NEW:new,安排了工作,还未开始行动。新创建的线程,还没有调用 start 方法时处于这个状态
RUNNABLE:runnable,可工作的。又可以分成正在工作中和即将开始工作,调用 start 方法之后,并正在 CPU 上运行/在即将准备运行 的状态
BLOCKED:blocked,使用 synchronized 的时候,如果锁被其他线程占用,就会阻塞等待,从而进入该状态
WAITING:waiting,调用 wait 方法会进入该状态
TINMED_WAITING:timed_waiting,调用 sleep 方法或者 wait(超时时间)会进入该状态
TERMINATED:terminated,工作完成了。当线程 run 方法执行完毕后,会处于这个状态
Q5:在多线程下,如果对一个数进行叠加,该怎么做?
使用 synchronized / ReentrantLock 加锁
使用 AtmoicInteger 原子操作
Q6:Servlet是否是线程安全的?
Servlet 本身是工作在多线程环境下
如果在 Servlet 中创建了某个成员变量,此时如果有多个请求到达服务器,服务器就会多线程进行操作,是可能出现线程不安全的情况的
Q7:Thread 和 Runnable 的区别和联系?
Thread 类描述了一个线程
Runnable 类描述了一个任务
在创建线程的时候需要指定线程完成的任务,可以直接重写 Thread 的 run 方法,也可以使用 Runnable 来描述这个任务
Q8:多次 start 一个线程会怎么样?
第一次调用 start 可以成功调用
后续再调用 start 会抛出 java.lang.illegalThreadStateException 异常
Q9:有 synchronized 两个方法,两个线程分别同时用这个方法,请问会发生什么?
synchronized 加在非静态方法上,相当于针对当前对象加锁
如果这两个方法属于同一个实例:
线程1能够获取到锁,并执行方法
线程2会阻塞等待,直到线程1执行完毕,释放锁,线程2获取到锁之后才能执行方法内容
如果这两个方法属于不同实例:
两者能并发执行,互不干扰
Q10:进程和线程的区别?
进程是包含线程的,每个进程至少有一个线程存在,即主线程
进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间
进程是系统分配资源的最小单位,线程是系统调度的最小单位
