欢迎光临
我们一直在努力

Java EE:3.多线程-进阶(第三弹):JUC的常见类

目录

 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:进程和线程的区别?

进程是包含线程的,每个进程至少有一个线程存在,即主线程

进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间

进程是系统分配资源的最小单位,线程是系统调度的最小单位

赞(0)
未经允许不得转载:171主机测评 » Java EE:3.多线程-进阶(第三弹):JUC的常见类
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址