多线程进阶 - JavaEE初阶 - 细节狂魔

x33g5p2x  于2022-04-10 转载在 Java  
字(11.7k)|赞(0)|评价(0)|浏览(256)

前言

多线程基础,主要讲的是 线程的基础概念,基本编程用法。属于面试和日常工作中常见的。

而这一篇多线程进阶,主要讲的是关于面试的内容。
这一块内容其实在工作用的很少!
就是为了面试。
也就是俗称的 “八股文”,背一背就可以,不必纠结具体的细节。

常见锁策略

锁策略 和 普通程序员基本没有什么关系,和“实现锁”的人,才有关系、
这里所提到的“锁策略”,和 Java 本身没有关系,适用于所有和“锁”相关的情况。

1、悲观锁 VS 乐观锁

悲观锁:
它 预期 锁冲突的概率会很高。
什么意思呢?
就是它认为,只要它一加锁,就会出现锁冲突。

'乐观锁:预期冲突的概率很低。
就是说:它认为,它加锁没有那么容易就会出现锁冲突。

其实 悲观锁 和 乐观锁,在其背后所做的工作量是完全不同的。
那我们来思考一个问题:是悲观锁背后做的事情多,还是乐观锁背后做的事情多?
其实还是悲观锁的事情做得多。
举个例子
疫情期间,谁也不知道下一步疫情会不会更严重,
疫情一旦严重,吃饭都成问题。可能会买不到菜!

悲观锁,就是在认为下一时刻就会出现这样的问题!
为此,它去超市菜场买了大量的各种各样的生活用品 和 菜,屯在家里,以防不时之需。

乐观锁,就是认为在国家的管控下,疫情很难复发。为此它认为不需要屯那么多货。
即使疫情真的复发,也不会支持太久,菜也应该是能买到,物资完全够用。

更直观来说:悲观锁,所做的一切,是需要花费大量的钱财,买来的东西也需要空间来存放,还费时间。【放在代码中就是 执行效率低,还需要占用一定的空间。简单来说就需要花费大量的资源】

相比于乐观锁,悲观锁做的事情实在太多了。
要做的事情更多,就以为意味着要付出更多的成本和代价。

所以说:乐观锁做的事情很少,整体较轻量。我们就认为乐观锁更高效、
悲观锁做的事情很多,整体较重量。我们就认为 悲观锁更低效。

2、读写锁 vs 普通的互斥锁

对于普通的互斥锁,只有两个操作:加锁 和 解锁。
只要两个线程针对同一个对象进行加锁,就会产生 互斥 / 锁竞争。

对于读写锁来说,分成了三个操作:
1、加读锁:如果代码只是进行读操作,就加读锁
2、加写锁:如果代码中只是进行了修改操作,就加写锁。
这个时候,就相当于将 读锁 和 写锁 给分开了。
分开了有什么好处呢?
我们针对 读锁 和 读锁 之间,是不存在互斥关系的。这是因为多线程同时读取一个数据,是不会有线程安全的问题的,只有修改才会存在线程安全问题。
读锁 和 写锁 之间,写锁和写锁之间,才需要互斥。

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

多线程的情况也一样,很多场景都是读多写少。
那么这个时候,本来 读 和 读 之间,就没有线程安全问题,也就不需要互斥。
我们就直接让它们共享就行了。
这就是读写锁要起到的一个效果。
关键就是将读操作单独拿出来,让读与读操作之间,不用互斥了。
因此,我们的读取数据的速度就会变快了。

3、解锁

读写锁在Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写
锁.
Reentrant: 可重入
ReadWriteLock:读写锁

ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.

ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

3、重量级锁 vs 轻量级锁

这个两个锁 和 上面的悲观乐观两个锁,含义上有一定的重叠。
可以这么理解:上面的悲观和乐观锁,这是原因;而重量级和轻量级锁,这是结果。

重量级锁就是做了更多的事情,开销很大。
轻量级锁,做的事情很少,开销也就很小。

相信大家也不难发现:悲观锁对应的是 重量级锁,乐观锁对应的是 轻量级锁。
也就可以这么去认为:
悲观锁一般都是重量级锁。
乐观锁一般都是轻量级锁。
但是!这种说法并不绝对!这种对应关系并不是百分百正确的。
因为 悲观锁 和 乐观锁,表示的是一种“处理锁冲突的态度”。(原因)

而重量级锁和轻量级锁,表示的是“处理锁冲突的结果”。
就是说:我们已经处理好锁冲突,已经把代码实现了。然后,发现这种实现方式有点“重”(开销有点大),或者说发现这种实现方式 “很轻”(开销很小)。

这么说吧,因为我们对这件事很悲观,所以,我们做了更多的操作。
也就是 悲观在前,实现后重量更重 在后。

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

如果锁是用户态去实现的,此时一般认为这是一个轻量级锁。
一般认为 用户态的代码要更可控,也更高效。
关于内核态 和 用户态,可以参考多线程基础篇 的 案例四 线程池这篇博客

4、挂起等待锁 vs 自旋锁

挂起等待锁:往往就是通过内核的一些机制来实现的,往往较重。
挂起等待锁,是重量级锁的一种典型实现。

自旋锁:往往就是通过 用户态代码来实现的,往往较轻。
自旋锁,是轻量级锁的一种典型实现

小结

想必大家会发现,除了读写锁比较好理解之外,1,3,4 这三种锁策略,翻来覆去好像都是讲得同一件事。
其实也确实如此。只不过就是说法,越来越详细。
其中悲观和乐观锁的说法是最笼统的。
而重量级锁 和 轻量级锁 的 说法稍微详细了一点点。
最后的挂起等待所 和 自旋锁 的说法,就涉及到更具体的实现了。

所以上面的 1,3,4 策略,这三组锁策略,其实之间的描述是非常接近的。只不过就是把抽象的概念,一步步的变得详细,更狭义。
我们当前可以将它当做是同一个东西。
但是在面试的时候,这些锁策略都会随机的出现。

上述的这些锁策略之间,彼此并不是完全没有没有联系,而是有着千丝万缕的关系。
所以大家也不要去把它们分割开来,去看待。
其实它们就是说的就是同一件事。
只不过是一件事,站在不同的角度,不同的范围来去进行描述。

5、公平锁 和 非公平锁

这两个锁的概念容易混淆。请注意理解
公平锁:多个编程等待一把锁的时候,谁是先来的,谁就能获取到这把锁。(遵守先来后到)
基础篇说到多个线程在竞争同一把锁,只有一个线程能够占用这把锁,其余的线程只能等待。那么,当我们占用锁的线程释放锁的时候,等待的线程,谁先来获取到这把锁。

非公平锁:多个线程在等待一把锁的时候,不遵守先来后到。

每个等待的线程获取到锁的概率都是均等的。
容易搞混的地方就在这,可能有些碰头认为“均等的”是公平的。
但是此处约定的是,遵守先来后到,才是公平的。
均等,反而是不公平的。

对于操作系统来说,本身线程之间的调度就是随机的(机会均等的),操作系统提供的 mutex 这个锁,就属于非公平锁。

但是有人可能会有疑问:线程之间不是存在优先级嘛?优先级难道不会影响调度吗?
是的,会影响。所以,我们这里考虑的是优先级相等的情况下。

其实开发中很少会手动修改线程的优先级。
改了之后,在宏观上的效果并不明显。

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

6、可重入锁 和 不可重入锁

一个线程,针对一把锁,咔咔连续加锁两次。
如果死锁,就是不可重入锁。
反之如果不死锁,就是可重入锁。
死锁,这个在多线程基础篇的 重点解析 synchronized 关键字 中的 可重入 讲过了这个。

总结

大家要明白 锁策略不止这六种,但是这六种属于面试中高频出现的6种锁策略。

拓展一 :synchronized 与 锁策略的对应关系

前面讲过一把锁 synchronized。
如果我们把这把锁,往上面的策略里面套会是怎样的结果?
1、synchronized 既是一个乐观锁,也是一个悲观锁。
synchronized 会根据锁竞争的激烈程度,自适应。

2、synchronized 不是读写锁,只是一个普通互斥锁、
3、synchronized 既是一个重量级锁,也是一个轻量级锁。

synchronized 会根据锁竞争的激烈程度,自适应。

4、轻量级锁的部分是基于自旋锁来实现的,重量级锁的部分是基于挂起等待锁来实现的。
5、非公平锁
6、可重入锁

相关面试题

1、 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁。

乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.

悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.

乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.

2、 介绍下读写锁?

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

3、 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

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

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

4、 synchronized 是可重入锁么?

是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.【一个线程,针对一把锁的情况】
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增.【解锁,就只直接计数自减,为0就解锁成功了】

CAS

什么是CAS?

CAS: compare and swap(比较 与 交换)
注意颜色,细品

再来看个例子

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

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

下面再来看一个例子,来加深我们对 CAS 的理解

CAS 如何帮我们解决一些线程安全问题?

这里我们主要讲两个场景

1、基于CAS 能够实现“原子类”

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

小拓展:原子类的一些其他基础方法,让一个原子类像一个普通的整数一样进行运算。而且,运算过程还是线程安全的。

虽然原子类放在进阶内容,但是不可否认这个原子类其实是一个工作中高频使用的东西。

原子类背后具体是怎么实现的?

2、基于 CAS 能够实现“自旋锁”

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

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

如何处理ABA问题

引入一个“版本号”,这个版本号,只能变大,不能变小。
在修改变量的时候,比较就不是比较变量本身了,而是版本号了。

版本号不一定,非的是加,也可以是减,只要一直是往着一个方向进行就可以了。
另外,这里不一定非得“版本号”,也可以使用“时间戳”,日期时间肯定是一直往前走的。
所以使用 时间戳也是没有问题的。
拓展:
这种基于 版本号 的方式 来进行多线程数据的控制,也是一种乐观锁的典型实现。
1、数据库里
在数据库里面,并发的去通过事务来访问表的时候,这也会涉及到类似加锁的一些多线程操作

2、版本管理工具(SVN)
它就是通过版本号来进行多人开发的协同。
如果别人改过了,就需要先去进行一个拉去数据,再重新提交。

相关面试题

1、 讲解下你自己理解的 CAS 机制

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

2、ABA问题怎么解决?

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

synchronized 中的 锁优化的机制

这也是属于我们编译器优化,以及说 JVM ,操作系统,它们的一些优化策略所涉及到一些小细节。
这些东西,其实说白了:如果我们不需要去实现 JVM 和 编译器,就并不需要去理解。
但奈何,现在面试都卷到这个份上,那么我们还是得学习一下。

基本特点 - 前面讲过的

结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
1、 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2、 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
3、 实现轻量级锁的时候大概率用到自旋锁策略
4、 是一种不公平锁
5、 是一种可重入锁
6、 不是读写锁
7、实现重量级锁的时候大概率会用到 挂起等待锁。
要知道 Java的版本是非常多的。
在这些版本变迁的过程中, 很多地方都有了不少的变化。
我们 主要以 JDK 1.8 / Java8 和 Java11 为主。
因为这是企业常用的两个版本。

synchronized 几个典型的优化手段

下面这几个,其实也是属于编译器、操作系统、JVM、CPU,它们之间来相互配合完成的一些具体的优化手段。

1、锁膨胀/锁升级

体现了 synchronized 能够 “自适应” 这样的能力。

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

synchronized 就有这样的一个自适应的过程。
【ps:能自动升级,也能自动降级】

2、锁粗化

有锁粗化,也就有锁细化。
此处的粗细指的是“锁的粒度”。
粒度:加锁代码涉及到的范围。
加锁代码的范围越大,认为锁的粒度就 越粗。
加锁代码的范围越小,认为锁的粒度就 越细。

锁粗化,就是把 频繁反复的去进行加锁,合并成一次加锁。

3、锁消除

有些代码,明明不用加锁,结果你给加上锁了。
编译器在编译的时候,发现这个锁好像没有存在的必要,就直接把锁给去掉了。
就比如你当前的代码是处于单线程的情况,你还咔咔的顿加锁操作。
这个时候,编译器就会你创建的锁,都去掉。

有的人可能会有疑问:单线程的代码,有谁会去加锁的?
其实有时候加锁操作并不是很明显,稍不留神就可能会做出这种错误的决定。

不过呢,我们的编译器很难给力,会把我们就把多余的锁进行删除去掉。
保证了我们代码的执行效率。

Java中的 JUC 包

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

callable接口

Callable 是一个 interface . 也是一种创建线程的方式。
谈到创建多线程,就会想到Runnable 接口。
但是Runnable 有个问题:不适合于 让线程计算出一个结果,这样的代码。

例如:像创建一个线程,让这个线程计算 1+2+3+…+1000
如果要基于 Runnable 来实现,就很麻烦。

而 Callable 就是要解决 Runnable 不方便返回结果的这个问题。
下面我们来根据实际代码,来看一下 Callable 是解决这个问题的。

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

public class Test2 {
    public static void main(String[] args) {
        // 通过 Callable 来描述一个任务
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for(int i = 1;i <= 1_000;i++){
                    sum+=i;
                }
                return sum;
            }
        };
        // 为了让线程执行 callable 中的任务
        // 光使用构造方法还不够,还需要一个辅助的类: FutureTask
        FutureTask task = new FutureTask(callable);

        // 创建线程,来完成这里的计算工作
        Thread t = new Thread(task);
        t.start();

        // 凭着task(小票)来获取 call方法的结果(自己的麻辣烫)
        // get 操作,
        // 如果线程的任务没有执行完,get就会先陷入阻塞
        // 一直阻塞到,任务完成了,得出结果为止。
        try {
            System.out.println(task.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

ReentrantLock

ReentrantLock其实就是可重入锁
我们都知道 synchronized 也是一个可重入锁。
现在又蹦出一个 ReentrantLock。
有的人可能会有疑问:这就是既生瑜何生亮啊!
为什么有了 synchronized,还需要 ReentrantLock呢?
这是因为 ReentrantLock 可以做到一些 synchronized 实现不了的功能。
也就是说 ReentrantLock 提供了 一些 synchronized 没有的功能。

ReentrantLock 的 基础用法

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

ReentrantLock把加锁和解锁两个操作给分开了。
这里就和 synchronized 的 差别就很大了。
synchronized 是 加锁和解锁两个操作 给整合在了一起。

那么,加锁和解锁两个操作,是分开好,还是合在一起好?
如果大家多敲一点代码,就能很明显的感觉到 还是合在一起。【synchronized 胜出】

这是因为 分开的做法不太好用。很容易,最后忘记解锁操作 unlock。
一旦没有 unlock ,就容易出现死锁。

通常为了保证 unlock 的执行,我们是像下面这样去写的。

另外,当多个线程竞争同一把锁的时候,就会阻塞。
【这一点和 synchronized一样】

ReentrantLock 和 synchronized 区别

1、synchronized 是一个关键字,ReentrantLock 是一个标准库的类。
关键字就意味着:其背后的逻辑是 JVM 内部实现的。(C++代码实现的)
类:背后的逻辑是 Java代码实现的

2、synchronized 不需要手动释放锁,出了代码块,锁就自然释放了。
ReentrantLock 必须要手动释放锁,要谨防忘记释放。

(重点)3、synchronized 如果竞争锁失败,就会阻塞等待。
ReentrantLock 除了会阻塞等待,还有一手:trylock【失败了,就直接返回】
trylock给我们加锁操作增添了一份灵活性,并不需要完全去进行阻塞死等,可以根据我们的需要,来选择等还是不等,还是说等,以及等多久、
所以 trylock 给了我们更加灵活的回旋余地。
这是synchronized 所不具备的!

(重点)4、synchronized 是一个非公平锁。
而 ReentrantLock 提供了 非公平锁 和 公平锁 两个版本!!!
在构造方法中,通过参数来指定 当前是公平锁,还是非公平锁。

5、基于 synchronized 衍生出来的等待机制,是 wait 和notify。功能是相对有限的。
基于 ReentrantLock 衍生出来的等待机制,是 Condition 类(又可称为条件变量)。
功能上要更丰富一些。

在日常开发中,绝大部分情况下,synchronized 就够用了
之所以 ReentrantLock 会存在,这是由于历史的原因。
因为早期的 synchronized 的功能,并没有现在这么强大。
所以,我们使用 ReentrantLock 对其功能进行扩充。
随着发展,synchronized 的功能已经足够强大了!
所以,除了极个别的情况下,会使用到 ReentrantLock 。

信号量 - Semaphore

Semaphore 是一个更广义的锁。
锁是信号量里第一种特殊情况,叫做“二元信号量”。
举个例子:开车
开车经常会遇到一个情况,停车。
停车肯定不能乱停,只能停在指定地点。
比如:停车场
当我们开到停车场的时候,我们如何去判断 停车场是否有空位,车能否停进去?
也很简单,停车场入口,一般都有一个牌子。
上面写着“当前空闲 xx 个车位”。
每次有一辆车开进去,车位数 -1
每次有一辆车开出来,车位数 +1;
此时,我们认为这个牌子就是信号量,它描述了可用资源(车位)的个数。
每次申请一个可用资源,计数器就 -1(称为 P 操作)
每次释放一个可用资源,计数器就 +1(称为 V 操作)
当信号量的计数已经是 0 了,再次进行 P 操作,就会阻塞等待。
【牌子显示空闲车位为 0,没位置停车了,那就只能等了。】

这里的 P 和 V 是哪个英文单词的缩写?
很遗憾,这里没有对应的英文单词。
因为提出“信号量”的人,叫做“迪杰斯特拉”(数学家)。
数据结构中的图 里面有一个算法叫做“迪杰斯特拉算法”,能够计算两点之间的距离。这也是他提出的。

注意!他是一个芬兰人,P 和 V 是芬兰语的单词缩写。
意思在英文中对应的单词,P:acquire 申请,V:release 释放
即 P 和 V 操作,又被称为 acquire 和 release 操作。

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

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

在实际开发中,并不会经常用到信号量。
纯粹只是为了应付面试。

CountDownLatch

我们可以把它理解为 “终点线”。

下面我们来使用 CountDownLatch 来实现一下,上述的比赛

import java.util.concurrent.CountDownLatch;

public class Test5 {
    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("比赛结束");
    }
}

线程安全的集合类

这里面其实也算是 JUC 的一部分。
但是还是单独拿出来,讲讲的好。

其实在多线程基础篇我们谈到过哪些集合类是线程安全的。

在这里,我们在进行强调一下,Vector、Stack、HashTable 是线程安全的,但是不推荐使用,其他剩余类都是线程不安全的。

多线程环境使用 ArrayList

1、 自己使用同步机制 (synchronized 或者 ReentrantLock)
2、使用标准库里面提供的一个 套壳操作: Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized

第二种方法没有第一种方法灵活,因为并不是所有的方法都涉及到加锁。
但是,第二种方法,就属于无脑加锁的哪一种。
3、 使用 CopyOnWriteArrayList : 一个支持写实拷贝的List。
意思就是在修改的时候,会创建一份副本出来。
CopyOnWrite容器即写时复制的容器。
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,
复制出一个新的容器,然后新的容器里添加元素,
添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
1、 占用内存较多.
2、新写的数据不能被第一时间读取到

多线程环境使用队列

1、 ArrayBlockingQueue:基于数组实现的阻塞队列
2、 LinkedBlockingQueue:基于链表实现的阻塞队列
3、 PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列
4、TransferQueue:最多只包含一个元素的阻塞队列

除了第4个,我都是讲过的,在多线程基础篇都是用到过的。

多线程使用哈希表 - 面试最爱考

首先我们要明白 HashMap 这个类 本身 线程并不安全。不能直接在多线程中使用
解决方法:
1、使用 HashTable 类 【不推荐使用】
2、使用 ConcurrentHashMap 类 【推荐使用】
至于为什么不推荐使用 HashTable ,而是推荐 ConcurrentHashMap 类。
这就需要了解 HashTable 内部的构造,
HashTable 与 HashTableConcurrentHashMap 的区别。
HashTable 是如何保证线程安全的?
很简单就是给关键方法进行进行加锁操作。

上述这种直接对方法进行加锁的操作。
其实就是在针对 this 来进行加锁。
当有多个线程 来访问这个 HashTable 的时候,无论是什么样的操作,什么样的数据,都会出现锁竞争。
这样的设计就会导致锁竞争的概率非常大,效率就比较低!
知识点:HashTable - 哈希表

由此得出以下结论:
1、ConcurrentHashMap 为了减少了锁冲突,给每个链表的头节点上进行加锁。(锁桶)
2、ConcurrentHashMap 只是针对 写操作加锁了,读操作没有加锁,而只是使用了 volatile 关键字,来避免“内存可见性”的问题。
3、ConcurrentHashMap 中更广泛的使用了 CAS,进一步提高效率。
(比如维护 size【元素个数】 操作)
4、ConcurrentHashMap 针对扩容,进行了巧妙的化整为零。
如果元素多了,链表的长度就很长,就会影响到 哈希表的效率。
就需要扩容,增加数组的长度。
扩容就需要创建一个更大的数组,然后把之前旧的元素都给搬运过去。【非常耗时】

对于HashTable来说,只要你这次put触发了扩容,就一口气全部搬运完。这样就会导致这次put非常卡顿。
对于ConcurrentMap来说,每次操作只搬运一点点,通过多次操作完成整个搬运的过程。
同时维护一个新的 HashMap 和 一个旧的, 查找的时候即需要查旧的也要查新的。
插入的时候直插入新的。
这个时候,我们就可以保证 Hash表 能正常工作的同时 完成这样的一个逐渐搬运的过程。
直到搬运完毕,再来销毁旧的。

相关文章

微信公众号

最新文章

更多