栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

多线程(进阶)面试重点

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

多线程(进阶)面试重点

一、常见的锁策略

锁策略,和普通程序猿基本没啥关系,和"实现锁"的人才有关系的
这里所提到的锁策略,和Java本身没关系,适用于所有和“锁"相关的情况

1.1、悲观锁和乐观锁

悲观锁:预期锁冲突(锁竞争)的概率很高
乐观锁:预期锁冲突(锁竞争)的概率很低

我们来想个问题:到底是悲观锁处理的事情多,还是乐观锁处理的事情多 举个例子:
现在处于疫情期间
A: 假设A保持一个乐观的态度,他说疫情很快就结束了,然后他就什么什么事情都不管,既来之,则安之
B: 假设B持有一个悲观的态度,他说下一波疫情还是很严重,于是他就去超市里面买了一大堆东西(大米,菜,酱油啥的),此时B就在后面做了很多的事情

所以悲观锁做的事情比较多
因此,乐观锁做的事情比较少,消耗掉资源比较少,成本低
悲观锁做的事情比较多,消耗的资源比较多,成本高

Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.

1.2、读写锁 VS 普通互斥锁

对于普通互斥锁来说,只有两个操作:加锁和解锁
如果多个线程(两个及两个以上)对同一个对象操作就会产生互斥

> 对于读写锁来说,分成了三个操作
1、加读锁:如果代码中只进行读操作,那就加读锁
2、加写锁: 如果代码中只进行写操作,那就加写锁
3、解锁

这个时候,就相当于将 读锁 和 写锁 给分开了。 针对 读锁 和 读锁
之间,是不存在互斥关系的,这是因为多个线程同时读操作不改变内存里面的数据,所以是线程安全的 读锁 和 写锁之间, 写锁 和
写锁之间,才需要互斥

所以,读写锁,我们就自然而然的就将 读锁 和 写锁 分开来写 而且,我们在很多实际场景中都是读操作多,写操作少。
我在讲数据库的时候,期间就提到过这件事。
数据库中的索引就适用于读多写少的情况,因为我们去针对一个有索引的表进行修改,这个操作本身就很低效,但是如果是查询(读操作),那就非常的高效。

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock /unlock 方法进行加锁解锁.

Synchronized 不是读写锁.

1.3、重量级锁 vs 轻量级锁

CPU 提供了 “原子操作指令”.
操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
JVM 基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类.

这和上面的乐观锁和悲观锁有理解上一定的重叠,相当于乐观锁和悲观锁是原因,而重量级锁和轻量级锁是结果
重量级锁:所做的事情多了,开销也就大了
轻量级锁:所做的事情少了,开销因为就小了

通常情况下:也可以认为,悲观锁就是重量级锁,乐观锁就是轻量级锁
但是!这种说法并不绝对!这种对应关系并不是百分百正确的。
因为 悲观锁 和 乐观锁,表示的是一种“处理锁冲突的态度”。(原因)
 
而重量级锁和轻量级锁,表示的是“处理锁冲突的结果”。
就是说:我们已经处理好锁冲突,已经把代码实现了。然后,发现这种实现方式有点“重”(开销有点大),或者说发现这种实现方式 “很轻”(开销很小)。

更具体的来说:
在使用的锁中,如果锁是基于内核的一些功能来实现的。【内核态】
比如:调用了操作系统提供的 mutex 接口,此时一般认为这是一个重量级锁。
因为操作系统的锁,会在内核中做很多的事情,开销也就很大,“重量”有点重。
 
如果锁是用户态去实现的,此时一般认为这是一个轻量级锁。一般认为 用户态的代码要更可控,也更高效。

1.4,自旋锁和挂起等待锁

挂起等待锁:往往就是通过内核的一些机制来实现的,往往较重,[重量级锁的一种典型实现]
自旋锁:往往就是通过用户态代码来实现的,往往教轻 [轻量级锁的一种典型实现]

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.
但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题

自旋锁伪代码:

while (抢锁(lock) == 失败) {}

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会
在极短的时间内到来.
一旦锁被其他线程释放, 就能第一时间获取到锁.

理解自旋锁 vs 挂起等待锁
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~ 挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意, 这个很长的时间间隔里,女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.

自旋锁是一种典型的 轻量级锁 的实现方式.

优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是 不消耗 CPU 的)

1.5、公平锁和非公平锁

公平锁:当多个线程等待一把锁的时候,谁先来的,谁就先获取锁(遵循先来后到的规则)
非公平锁:当多个线程等待一把锁的时候,哪一个线程获取到锁的概率是相同的(不遵守先来后到)

对于操作系统来说,本身线程之间的调度就是随机的(机会均等的),操作系统提供的mutex这个锁,就是属于非公平锁
考虑到相同优先级的情况.
实际开发中很少会手动修改线程的优先级(改了之后在宏观上的体会并不明显)

要想实现公平锁反而要付出更多的代价,(得整个队列,来把这些参与竞争的线程给排一排先来后到)

1.6、可重入锁和不可重入锁

一个线程针对同一个对象加两把锁,如果不会死锁,那就是可重入锁,
如果会死锁你,那就是不可重入锁

总结

锁策略不止以上说的这些,还有很多,这只是常见的一部分

1.7、常见的面试题

1) 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁 认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加锁.
乐观锁 认为多个线程访问同一个共享变量冲突的概率不大,并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.
悲观锁 的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁 的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突. (实现细节参考上面的图).

2) 介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要用在 “频繁读, 不频繁写” 的场景中.

3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
自旋锁,如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.

相比于挂起等待锁,
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源

4) synchronized 是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁
的线程就是持有锁的线程, 则直接计数自增

5)synchronized
1.既是一个乐观锁,也是一个悲观锁. (根据锁竞争的激烈程度,自适应)
⒉.不是读写锁,只是一个普通互斥锁.
3.既是一个轻量级锁,也是一个重量级锁(根据锁竞争的激烈程度,自适应)
4.轻量级锁的部分基于自旋锁来实现.重量级的部分基于挂起等待锁来实现.
5.非公平锁.
6.可重入锁.

二、CAS 2.1、什么是CAS

CAS: 全称Compare and swap,字面意思:”比较并交换“,
一个 CAS 涉及到以下操作:

此处所谓的 CAS ,指的是 CPU 提供了一个单独的 CAS 指令,通过这一条CPU指令,就可以完成上述伪代码要做的所有事情(比较并且交换)。
我们此处讨论的CAS,其实讨论的就是这一条CPU的指令。

另外,这个代码很明显是线程不安全的。

其实我们代码中很多的功能,都是属于既可以让硬件实现,也可以是软件实现。
就像刚才这段比较交换逻辑,这就相当于硬件直接实现出来了,通过这一条指令,封装好,让我们直接使用。

CAS最大意义
就是让我们写这种多线程安全的代码的时候,提供了一个新的思路和方向!! !
CAS就和锁就不一样了! !
前面讲过的办法就是加锁和解锁,把线程不安全的代码放在锁里,来保证线程安全。这是一种办法。现在我们又看到了另外一种:通过CAS来去进行实现,
关键就在CAS本身操作就只有一条指令,那么它就是原子的,是线程安全的。

2.2、CAS 如何帮我们解决一些线程安全问题? 2.2.1、基于CAS 能够实现“原子类”(这是工作经常用到的)

Java标准库里提供了一组原子类,针对所常用多一些int, long, int array…进行了封装,可以基于CAS的方式进行修改,并且线程安全

2.2.2、CAS的应用 1)实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是
AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作

接下来看一下伪代码是怎么实现这个++操作

2)实现自旋锁

2.2.3、如何理解 CAS中的 ABA 问题?- 面试主要问的问题

ABA问题:就是CAS中的关键【先比较,在交换】
而这里的比较,其实是在比较 当前值 和 旧值 是不是相同的。
把这两个值相同的情况,就视为中间没有发生过改变。

但是这里的结论存在这漏洞。
当前值 和 旧值 相同,可能是中间确实没改变过。
也有可能是改变了,但是变回来了。
【最终的值虽然和旧值相同,但是它确实改变了】
而当前就很草率的决定,只要值相同就没有发生改变。
这样的漏洞,在大多数情况下,其实没有影响。
但是,在极端情况也会引起bug。
这种问题,就被称为 ABA 问题。
所谓的ABA 指的是:本来旧的值A,当前值也是A。
结果你不知道当前的A,它是一直都是A,还是从A变成了B,再从B又变回了A。(中途你不知道值改变过,但是这个值确实改变过的)
所以这就叫ABA问题。

举个例子:我现在去买手机,有可能买到新的手机,也有可能买到翻新机,但是我并不知道我买的是新手机还是翻新机

如何处理这种ABA问题

在 Java 标准库中提供了 AtomicStampedReference 类. 这个类可以对某个类进行包装, 在内部就提供了上面描述的版本管理功能.

2.2.4、CAS相关的面试题

1) 讲解下你自己理解的 CAS 机制
全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作,
同时完成 “读取内存, 比 较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑.

2) ABA问题怎么解决?
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当 前版本号比之前读到的版本号大,
就认为操作失败

三、Synchronized 原理 基本特点

基本特点
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 实现轻量级锁的时候大概率用到的自旋锁策略
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁
加锁的过程

加锁的过程
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级

所以,当我们使用 synchronized 进行加锁的时候,它会根据实际情况来进行逐步升级的。
如果当前没有线程跟它竞争,它就始终保持在偏向锁的状态。
如果有其他现场称跟它竞争,它会升级成一个自旋锁/轻量级锁。
【如果锁竞争就保持轻微的情况下,它就会一直抱着一个 自旋锁的状态】
如果锁竞争进一步加剧,它就会进一步的升级到 重量级锁。
 
synchronized 就有这样的一个自适应的过程。
【ps:能自动升级,也能自动降级】

这一张图来源于:细节狂魔https://blog.csdn.net/DarkAndGrey/article/details/123962694?spm=1001.2014.3001.5502

3.1、synchronized 几个典型的优化手段 3.1.1、锁消除

编译器+JVM 判断锁是否可消除.,如果可以,就直接消除(你写了一段加锁的代码,编译器如果认加不加所的效果是一样的,并且线程安全的,编译器就会将锁给消除)
什么是 “锁消除”

有些代码,明明不用加锁,结果你给加上锁了.
编译器就会发现这个加锁好像没啥必要,,就直接把锁给去掉了
有的时候加锁操作并不是很明显,稍不留神就做出了这种错误的决定
StringBuffer, Vector…在标准库中进行了加锁操作
在单个线程中用到了上述的类,就是单线程进行了加锁解锁~

3.1.2、锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化

举个栗子理解锁粗化
滑稽老哥当了领导, 给下属交代工作任务:
方式一: 打电话, 交代任务1, 挂电话.
打电话, 交代任务2, 挂电话.
打电话, 交代任务3, 挂电话.

方式二: 打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案.

可以看到, synchronized 的策略是比价复杂的, 在背后做了很多事情, 目的为了让程序猿哪怕啥都不懂,
也不至于写出特别慢的程序.

相关面试题

1) 什么是偏向锁?
偏向锁不是真的加锁, 而只是在锁的对象头中记录一个标记(记录该锁所属的线程). 如果没有其他线
程参与竞争锁, 那么就不会真正执行加锁操作, 从而降低程序开销. 一旦真的涉及到其他的线程竞
争, 再取消偏向锁状态, 进入轻量级锁状态.

四、java中的JUC(这是一个包)

JUC: java.util.concurrent
concurrent :并发 与多线程相关的操作,都在这个包里

4、Callable 接口 4.1、Callable 接口的用法

Callable是一个interface .
相当于把线程封装了一个"返回值",方便程序猿借助多线程的方式计算结果.

代码示例:创建线程计算1+2+3+ …+ 1000,不使用Callable版本 创建一个类Result,包含一个sum表示最终结果,
lock表示线程同步使用的锁对象,main方法中先创建Result实例,然后创建一个线程t,在线程内部计算1+2+3+ …+ 1000
主线程同时使用wait等待线程t计算结束.(注意,如果执行到wait之前,线程t已经计算完了,就不必等待了).
·当线程t计算完毕后,通过notify唤醒主线程,主线程再打印结果.

可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复 杂, 容易出错.

此时我们使用Callable按版本的看一下

代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本 创建一个匿名内部类, 实现Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型. 重写 Callable 的 call 方法,

完成累加的过程. 直接通过返回值返回计算结果. 把 callable 实例使用 FutureTask 包装一下. 创建线程,
线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法,
完成计算. 计算结果就放到了 FutureTask 对象中. 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕.
并获取到 FutureTask 中的结果

总程序

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;


public class Dome4 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable callable = new Callable() {
            int sum = 0;
            @Override
            public Integer call() throws Exception {
                for (int i = 0; i < 1000; i++) {
                    sum++;
                }
                return sum;
            }
        };
        FutureTask futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();

        System.out.println(futureTask.get());
//下面这种方式也可以打印sum的值        
//        int result = 0;
//        try {
//            result = futureTask.get();
//        } catch (InterruptedException e) {
//            e.printStackTrace();
//        } catch (ExecutionException e) {
//            e.printStackTrace();
//        }
//        System.out.println(result);
    }
}

**

理解 Callable
Callable 和 Runnable 相对, 都是描述一个 “任务”.
Callable描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务. Callable 通常需要搭配 FutureTask 来使用.
FutureTask 用来保存 Callable 的返回结果. 因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.

FutureTask 就可以负责这个等待结果出来的工作.
理解 FutureTask 想象去吃麻辣烫. 当餐点好后, 后厨就开始做了.
同时前台会给你一张 “小票” . 这个小票就是 FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没

**

相关面试题

介绍下 Callable 是什么
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作

4.2、JUC(java.util.concurrent) 的常见类–ReentrantLock

可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 也是可重入锁.
“Reentrant” 这个单词的原意就是 “可重入”

ReentrantLock 的用法:
lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
unlock(): 解锁

ReentrantLock 主要提供了2个方法:
1、lock:加锁
2、unlock:解锁

ReentrantLock 和 synchronized 的区别:
1、synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准 库的一个类, 在 JVM 外实现的(基于 Java 实现).
2、synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏unlock.
3、synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock的方式等待一段时间就 放弃
4、synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个true 开启 公平锁模式.
5、更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

如何选择使用哪个锁?
锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
如果需要使用公平锁, 使用 ReentrantLock

4.3、JUC(java.util.concurrent)的常见类–信号量 Semaphore

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器

理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源. 当有车开进去的时候,
就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作) 当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就+1 (这个称为信号量的 V 操作) 如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.

Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.

回过头来,信号量表示的是可用资源的个数。
那么,它和锁有什么关系?
锁可以视为“二元信号量”:可用资源就一个,计数器的取值,非0即1(二元)

想一下锁是怎么工作?
多个线程竞争同一把锁,一个线程获取到,其他线程进行等待。
这就相当于 可用资源只有一个(锁),加锁操作就是在申请,解锁操作就是在释放。
同理,这里的信号量其实就是一个更广义的锁了。
信号量就把锁推广到一般情况:可用资源更多的时候,如果处理的?

代码示例

创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果

public class Dome6 {
    public static void main(String[] args) {
        //创建4个信号量
        Semaphore semaphore = new Semaphore(4);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    System.out.println("申请资源");
                    semaphore.acquire();//申请资源
                    Thread.sleep(1000);
                    System.out.println("申请到了资源,并且释放资源");
                    semaphore.release();//释放资源
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        for (int i = 0; i < 20; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
	}
}
4.4、JUC(java.util.concurrent)的常见类–CountDownLatch

同时等待 N 个任务执行结束。如果多个线程同时申请到了锁,想要释放锁的话,要等待所有的锁都执行完任务之后才能释放锁

例如:假设有一场跑步比赛,有5人参加比赛,裁判认为只要5个人都冲过了终点,此时比赛才算结束

在CountDownLatch中,主要提供两个方法:
1、CountDown:给每个线程里面去调用,就表示达到终点了。
2、await:是给等待线程去调用的,当所有的任务都达到终点了,await就从、阻塞中返回,就表示任务完成,

public class Dome8 {
    public static void main(String[] args) {
        // 构造方法的参数表示有几个选手参赛
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++){
            Thread t = new Thread( () -> {
                try {
                    Thread.sleep(3000);
                    System.out.println(Thread.currentThread().getName() + "到达了终点");
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            });
            t.start();
        }
        // 裁判 需要等待所有的线程到达
        // 当这些线程没有执行完的时候,await 就阻塞等待
        // 所有的线程都执行完了,await 才返回
        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("比赛结束");
    }
}

五、线程安全的集合类

原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.

5.1、多线程环境使用 ArrayList

多线程环境使用 ArrayList

1 ) 自己使用同步机制 (synchronized 或者 ReentrantLock)
前面做过很多相关的讨论了. 此处不再展开.

  1. Collections.synchronizedList(new ArrayList);
    synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
    synchronizedList 的关键操作上都带有 synchronized
  1. 使用 CopyOnWriteArrayList
    CopyOnWrite容器即写时复制的容器。 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,
    复制出一个新的容器,然后新的容器里添加元素, 添加完元素之后,再将原容器的引用指向新的容器。
    这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会 添加任何元素。
    所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争.

缺点:

  1. 占用内存较多.
  2. 新写的数据不能被第一时间读取到.
5.2、多线程环境使用队列

多线程环境使用队列

  1. ArrayBlockingQueue
    基于数组实现的阻塞队列
  2. LinkedBlockingQueue
    基于链表实现的阻塞队列
  3. PriorityBlockingQueue
    基于堆实现的带优先级的阻塞队列
  4. TransferQueue
    最多只包含一个元素的阻塞队列
5.3、多线程环境使用哈希表

HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
1、Hashtable (不推荐使用)
2、ConcurrentHashMap(推荐使用)
至于为什么不推荐使用 HashTable ,而是推荐 ConcurrentHashMap 类。
这就需要了解 HashTable 内部的构造,
HashTable 与 HashTableConcurrentHashMap 的区别。

HashTable 是如何保证线程安全的?
很简单就是给关键方法进行进行加锁操作。

这相当于直接针对 Hashtable 对象本身加锁.
如果多线程访问同一个 Hashtable 就会直接造成锁冲突. size 属性也是通过 synchronized 来控制同步,
也是比较慢的. 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低.

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/895299.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号