多线程多线程面试常见基础内容
Posted 吞吞吐吐大魔王
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程多线程面试常见基础内容相关的知识,希望对你有一定的参考价值。
文章目录
- 1. CAS
- 2. synchronized 工作原理
- 3. Callable 接口
- 4. JUC(java.util.concurrent)的常见类
- 5. 线程安全的集合
- 6. 死锁
- 7. 其它常见面试题
1. CAS
1.1 CAS 介绍
CAS(CompareAndSwap),字面意思是比较并替换,是一个单个的 CPU 指令,CAS 操作有3个操作数:
- 内存地址 V
- 旧的预期值 A
- 即将要更新的目标值 B
更新一个变量的时候,只有当变量的预期值 A 和内存地址 V 当中的实际值相同时,才会将内存地址 V 对应的值修改为 B。整个比较并替换的操作是一个原子操作。
由于 CAS 这个机制,就给实现线程安全版本的代码提供了一个新的思路,之前是通过加锁来把多个指令打包成一个整体,来实现线程安全。使用 CAS 来实现修改操作,则就能保证线程是安全的。
1.2 CAS 的应用
1.2.1 实现原子类
Java 标准库提供了 java.util.concurrent.atomic
包,该包里面的类都是基于 CAS 方式来实现的。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iqZCLM5a-1661619116805)(C:/Users/bbbbbge/Pictures/接单/1661434530281.png)]
例如通过 AtomicInteger 创建的变量,通过两个线程对他并发的进行自增操作各5000次,结果则会是10000,而不会出现小于10000的bug。
public class Demo20
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args)
Thread t1 = new Thread(() ->
for(int i=0; i<5000; i++)
count.getAndIncrement();
);
t1.start();
Thread t2 = new Thread(() ->
for(int i=0; i<5000; i++)
count.getAndIncrement();
);
t2.start();
try
t1.join();
t2.join();
catch (InterruptedException e)
e.printStackTrace();
System.out.println(count.get());
1.2.2 实现自旋锁
通过 CAS 可以实现自旋锁,实现思路如下:
- 在锁中创建一个线程变量来记录当前持有该锁的线程,如果该所没有被持有,则会 null。
- 通过 CAS 操作来判断当前锁是否被持有,即:
- 内存地址 V 的值就是线程变量。
- 旧的预期值 A 就是预期的线程变量的值。
- 即将要更新的目标值 B 就是要获取该锁的线程。
- 如果该锁的持有线程不为 null,则获取锁的线程自旋等待。
- 如果该锁的持有线程为 null,则将该所的持有线程设置为获取锁的线程。
1.3 CAS 的缺点
CAS 虽然能高效的解决原子操作问题,但是仍存在一些问题:
-
循环时间长,开销大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给 CPU 带来很大的压力。
-
不能保证代码块的原子性。
CAS 机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用 synchronized 了。
-
存在 ABA 问题
1.4 ABA 问题
CAS 的使用流程通常如下:
- 首先从内存地址 V 读取值
- 判断读取的值是否等于 A
- 如果等于,则将地址 V 中的值修改为 B
CAS 修改完成的前提就是第一步中读取的值和预期的旧值是相同的(相同是为了保证在这次操作期间,并没有其它线程修改这个内存的值),但当一个线程准备进行 CAS 操作时,如果有其它线程对内存中该变量的值进行了修改,并且在这期间该变量被修改回了原来的值,那么当第一个线程通过 CAS 操作时,就会误认这个变量是没有被修改过。在某些场景下如果出现这种情况则会出现很大的影响。
ABA 场景:
如果现在一个人的账户有100元,从 ATM 取50,出现极端情况机器卡了,就点了两次取钱,那么就会通过两个线程来完成这件事。两个线程都通过 CAS 的读取操作读取到了内存中余额为100,并且第一个线程通过 判断和修改操作将余额改成了50,但此时有人给这个人突然转帐了50,即这个人的余额又变成了100,此时第二个线程又会进行判断和修该操作,将这个人的余额再次改成50。那么这个人就会一次取款,就拿出了100。
这个漏洞称为 CAS 操作的 ABA 问题。
解决方式:
-
给 CAS 的变量引入一个版本号(或时间戳),每次修改的时候,让版本号递增即可,只要当前修改的操作的预期的旧版本号和原版本号不同,则说明内存中的值是被修改过的。
-
Java 并发包为了解决这个问题,也提供了一个带有标记的原子引用类
AtomicStampedReference<E>
,这个类可以对某个类进行包装,在内部提供了版本管理的功能,它可以通过控制变量值的版本来保证 CAS 的正确性。
因此,在使用 CAS 前要考虑清楚 ABA 问题是否会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
2. synchronized 工作原理
2.1 synchronized 的特性
- synchronized 是自适应的锁,开始是乐观锁,锁冲突激烈了就变成悲观锁。
- synchronized 不是读写锁。
- synchronized 是自适应锁,开始是轻量级锁,锁冲突激烈了就变成重量级锁。
- synchronized 内部的轻量级死锁是基于自旋的方式实现的,重量级锁是基于挂起等待的方式实现的。
- synchronized 是非公平锁。
- synchronized 是可重入锁。
2.2 锁升级
JVM 将 synchronized 加锁的过程分为无锁、偏向锁、轻量级锁和重量级锁四种状态。会根据锁冲突的激烈情况,进行依次升级。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tYqfYvWD-1661619116807)(C:/Users/bbbbbge/Pictures/接单/1661446421873.png)]
-
偏向锁:第一个尝试加锁的线程,优先进入偏向锁状态
偏向锁不是真的加锁,只是给对象头做一个偏向锁的标记,记录这个锁属于哪个线程。如果后续没有其它线程来竞争该锁,那么就不回真的加锁,避免了加锁解锁的开销。如果后续有其它线程来竞争该锁,由于该锁的锁对象中已经记录了该锁当前属于哪个新线程,则其它线程无法进行加锁,而当前的偏向锁状态也会取消,会升级成轻量级锁。
-
轻量级锁:随着其它线程来竞争,偏向锁状态消失,进入轻量级锁状态(自适应的自旋锁)
这里的轻量级锁采用 CAS 来实现一个自旋锁。由于自旋锁是会让 CPU 空转的,会浪费资源,所以当空转的时间或次数多了,就不会继续自旋,而是升级成重量级锁。
-
重量级锁:如果竞争进一步激烈,自旋不能快速获取到锁状态,就会升级成重量级锁(内核提供的 mutex)。
- 执行加锁操作,先进入内核态。
- 在内核态中判定当前锁是否已经被占用。
- 如果该锁没有被占用,则加锁成功,别切回到用户态。
- 如果该锁被占用了,则加锁失败,此时线程进入阻塞队列中,直到被操作系统唤醒。
2.3 消除锁
锁消除属于一种编译器的优化机制,编译器会智能的分析当前代码是否有必要加锁,如果认为没有必要,就会自动的把代码中的锁给去除。
示例:例如在单线程的环境下使用 StringBuffter
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
stringBuffer.append("d");
由于 StringBuffer 中自带了锁,所以每 append 一次都会涉及到加锁和解锁,如果在单线程中,由于没有其它线程的竞争,则没有必要进行加锁和解锁,消耗资源。所以编译器就会自动的把以上代码的锁给干掉。
2.4 锁粗化
锁粗化属于一种编译器的优化机制,在开发中,使用细粒度锁是期望释放锁的时候其它线程能够使用该锁,但在某些场景中可能并没有其它线程来抢占这个锁,这种情况下,编译器就会自动把锁粗话,避免繁忙的申请和释放锁。
锁的粒度表示当前这个锁对应的代码范围,锁覆盖的代码越多,则认为粒度越粗,反之越细。
3. Callable 接口
3.1 Callable 介绍
-
Callable 是一个接口,描述了一个任务,通过重写 call 方法来完成该任务的内容。和 Runnable 不同的是 Callable 关注任务的返回值,返回值的类型为 Callable 的泛型参数。
-
Callable 通常需要搭配 FutureTask 来使用,FutureTask 用来保存 Callable 的返回结果。
-
Callable 往往是在另一个线程中执行的,什么时候执行结束并不确定。
-
FutureTask 负责等待 Callable 的结果。
-
FutureTask 是可取消的异步任务,提供 Future 的基础实现,并实现了 Runnable 接口。 FutureTask 包含了取消与启动计算的方法,查询计算是否完成以及检索计算结果的方法。只有在计算完成才能检索到结果,调用
get()
方法时如果任务还没有完成将会阻塞调用线程至到任务完成。一旦计算完成就不能重新开始与取消计算,但可以调用runAndReset()
重置状态后再重新计算。 -
FutureTask 实现了 RunnableFuture 接口,而 RunnableFuture 接口扩展自 Future
3.2 Callable 的用法
- 通过 Callable 接口创建一个对象,泛型参数为返回值类型。
- 重写 Callable 的 call 方法,并在 call 方法中编写代码。
- 将 Callable 示例通过 FutureTask 包装。
- 创建线程,并在线程的构造方法中传入 FutreTask,此时该线程就会执行 FutureTask 内部的 Callable 的 call 方法,返回结果就存放到了 FutureTask 中。
- 在主线程中调用 FutureTask 的 get 方法就能过阻塞等待新线程执行完 call 方法中的内容,并获取到 FutureTask 中存放的结果。
示例代码:计算 1—100的和
public class Demo21
public static void main(String[] args) throws ExecutionException, InterruptedException
Callable<Integer> callable = new Callable<Integer>()
@Override
public Integer call() throws Exception
int sum = 0;
for (int i = 1; i <= 100; i++)
sum += i;
return sum;
;
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
4. JUC(java.util.concurrent)的常见类
4.1 ReentrantLock
Java 标准库中提供了 ReentrantLock 类,这个类是一个可重入互斥锁。和 synchronized 不同的是,它通过以下方法进行加和解锁操作:
方法 | 说明 |
---|---|
lock() | 加锁,如果获取不到锁就死等 |
trylock(超时时间) | 加锁,如果获取不到锁,就等待一定的时间之后就放弃加锁 |
unlock() | 解锁 |
ReentrantLock 需要搭配 try-finally 一起使用,否则如果加锁的代码出现问题,导致抛出异常,那么可能导致无法执行到解锁操作。
示例代码如下:
public class Demo22
private static int count = 0;
public static void increase()
count++;
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(()->
lock.lock();
try
for (int i = 0; i < 50000; i++)
increase();
finally
lock.unlock();
);
t1.start();
Thread t2 = new Thread(()->
lock.lock();
try
for (int i = 0; i < 50000; i++)
increase();
finally
lock.unlock();
);
t2.start();
t1.join();
t2.join();
System.out.println(count);
ReentrantLock 和 synchronized 的区别:
- synchronzied 是一个关键字,是 JVM 内部实现的。ReentrantLock 是标准库的一个类,在 JVM 外实现的。
- synchronized 使用时不需要手动加锁解锁。ReentrantLock 使用时需要手动加锁和解锁。
- synchronized 在申请锁失败时会四等。ReentrantLock 可以通过 trylock 方法进行有时间限制的尝试获取锁。
- synchronized 是非公平锁。ReentrantLock 默认是非公平锁,但是可以通过构造方法传入一个 true 开启公平锁模式。
- synchronized 是通过 Object 的 wati 和 notify 方法来实现等待和唤醒,每次唤醒的是一个随机等待的线程。ReentrantLock 搭配 Condition 类实现等待和唤醒,可以更精确的控制唤醒某个指定的线程。
4.2 Semaphore
Semaphore 通常我们叫它信号量, 可以用来控制同时访问特定资源的线程数量,本质上是一个计数器。通过协调各个线程,以保证合理的使用资源。
Semaphore 内部维护了一组虚拟的许可,许可的数量可以通过构造函数的参数指定。
- 访问特定资源前,必须使用 acquire 方法获得许可,如果许可数量为0,该线程则一直阻塞,直到有可用许可。
- 访问资源后,使用 release 释放许可。
Semaphore 和 ReentrantLock类似,获取许可有公平策略和非公平许可策略,默认情况下使用非公平策略。
Semaphore 的 PV(P 申请资源, V 释放资源)操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用。
理解信号量:
可以把信号量想象成停车场可用车位的计数器,当车位有100个时,表示有100个可用资源。
- 当有车占用一个车位时,相当于申请了一个可用资源,可用车位就-1(这个称为信号量的 P 操作)。
- 当有车离开车位时,相当于释放了一个可用资源,可用车位+1(这个称为信号量的 V 操作)。
- 如果计数器的值已经为0,还尝试申请资源,就会阻塞等待,直到有其它线程释放资源。
示例代码:
public class Demo23
public static void main(String[] args)
Semaphore semaphore = new Semaphore(4);
for(int i=0; i<20; i++)
Thread thread = new Thread(()->
try
semaphore.acquire();
System.out.println("申请资源成功");
Thread.sleep(1000);
semaphore.release();
System.out.println("释放资源成功");
catch (InterruptedException e)
e.printStackTrace();
);
thread.start();
使用场景:
通常用于那些资源有明确访问数量限制的场景,常用于限流 。
比如:数据库连接池,同时进行连接的线程有数量限制,连接不能超过一定的数量,当连接达到了限制数量后,后面的线程只能排队等前面的线程释放了数据库连接才能获得数据库连接。
4.3 CountDownLatch
CountDownLatch 是 java.util.concurrent
包中的一个类,它主要用来协调多个线程之间的同步,起到一个同步器的作用。总的来说,CountDownLatch 让一个或多个线程在运行过程中的某个时间点能停下来等待其他的一些线程完成某些任务后再继续运行。
类似的任务可以使用线程的 join() 方法实现,在等待时间点调用其他线程的 join() 方法,当前线程就会等待 join 线程执行完之后才继续执行,但 CountDownLatch 实现更加简单,并且比 join 的功能更多。
使用方式:
- CountDownLatch 的构造方法会接收一个 int 型参数作为计数器,例如想让 N 个任务执行完后才能继续执行,则参数设为 N。
- 到达预期的线程通过调用 CountDownLatch 的 countDown 方法,计数器N的值就会减1.
- 需要等待的线程会调用 CountDownLatch 中的 await 方法,该线程就会阻塞直到计数器的值减为0。
示例代码:
public class Demo24
public static void main(String[] args) throws InterruptedException
CountDownLatch countDownLatch = new CountDownLatch(10);
for(int i=0; i<10; i++)
Thread t = new Thread(()->
System.out.println(Thread.currentThread().getName() + " 执行完了任务!");
countDownLatch.countDown();
);
t.start();
countDownLatch.await();
System.out.println("任务都执行完毕!");
不足:
CountDownLatch 是一次性的,不可能重新初始化或者修改其内部计数器的值,当 CountDownLatch 使用完毕后,它不能再次被使用。
5. 线程安全的集合
平常使用的简单的集合类,大部分都是线程不安全的,但也有几个是线程安全的:
- Vector
- HashTable
- Stack
不过以上线程安全的集合类只是内部粗暴的加上了 synchronized,但并不是所有场景都适用,而以下将会介绍一些更加好用的线程安全的集合类。
5.1 多线程环境使用 ArrayList
多线程环境下有以下几种方式安全的使用 ArrayList:
-
使用 synchronzied 或者 ReenactmentLock 等同步机制
-
通过 Collections.synchronizedList(new ArrayList) 就能得到线程安全的 ArrayList
该方式在以下两种场景下线程是不安全的:
- 使用迭代器 Iterator 进行遍历列表时
- 使用 for-each 遍历列表时
对于以上线程不安全的场景要手动加锁
-
使用 CopyOnWriteArrayList
- CopyOnWriteArrayList 是 ArrayList的 线程安全版本,是写时复制的容器。
- CopyOnWriteArrayList 是在有写操作的时候会 copy 一份数据,然后写完再设置成新的数据。而读操作的时候就直接读就可以。
- CopyOnWrite 容器能进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
- CopyOnWriteArrayList 适用于读多写少的并发场景。
- CopyOnWriteArrayList 容器运用了读写分离的思想。
- CopyOnWrite容 器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器。
5.2 多线程环境使用队列
-
ArrayBlockingQueue
基于数组实现的阻塞队列
-
LinkedBlockingQueue
基于链表实现的阻塞队列
-
PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
-
TransferQueue
最多只包含一个元素的阻塞队列
5.3 多线程环境使用哈希表
HashMap 本身是线程不安全的,在多线程环境下可以使用以下线程安全的哈希表:
- Hashtable(不推荐)
- ConcurrentHashMap(推荐)
Hashtable 就是把关键方法加上了 synchronized 关键字,这相当与直接针对 Hashtable 对象本身加锁。
- 如果多个线程访问同一个 Hashtable 就会直接产生锁冲突。
- 类如 size 属性也是通过 synchronized 来控制同步,效率比较慢。
- 一旦触发扩容,该线程就要完成整个扩容过程,涉及到大量的元素拷贝,效率较低。
ConcurrentHashMap 相比于 Hashtable 做出了如下优化:
- 读操作没有加锁,而是使用了 volatile 保证了内存可见性,可以降低锁冲突的概率。
- 写操作依旧是使用 synchronized 进行加锁,但是不是锁整个对象,而是锁桶(用每个链表的头节点作为锁对象),大大降低了锁冲突的概率,锁粒度变细了。
- 通过 CAS 特性对类如 size 属性进行读取和更新,避免了重量级锁的情况。
- 优化了扩容方式:如果某次 put 操作触发扩容,则不会直接将所有的元素拷贝到新的数组中,而是在创建了新的数组后,将少量的元素拷贝过去。在旧数组的元素完全拷贝到新数组前,两个数组都是同时存在的。当后续操作 ConcurrentHashMap 时,操作的线程会继续将就数组的少量元素拷贝到新数组,直到就数组的所有元素都拷贝到新数组后,就数组就被删除。而操作的线程如果时进行插入操作,则只加入到新数组中,而查找操作,则会同时查找旧数组和新数组,如果两个数组都存在则以新数组的为准。
在 jdk1.8 之前 ConcurrentHashMap 采用锁分段技术,即把若干哈希桶分成一个段,针对每个段分别加锁。而 jdk1.8 则不采用锁分段,而是直接在每个哈希桶分配了一个锁,这样锁的粒度就会更细,降低了锁冲突的概率。
除此之外,jdk1.8 相比于之前,还将 ConcurrentHashMap 的数组+链表的实现方式改进成了数组+链表/红黑树的方式,当链表较长的时候(大于等于8)就转换成红黑树。
数据结构 | 区别 |
---|---|
HahsMap | 线程不安全;key 允许为 null |
Hashtable | 线程安全,使用 synchronized 锁 Hashtable 对象,效率降低;key 不允许为 null |
ConcurrentHashMap | 线程安全,使用 synchronized 锁每个链表头节点,锁冲突概率低,且充分利用 CAS 机制和优化了扩容;key 不允许为 null |
6. 死锁
6.1 死锁介绍
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或全部都在等待某个资源被释放。由于线程被无期限的阻塞,因此程序不能正常终止。
6.2 死锁产生的条件
死锁产生的四个必要条件:
- 互斥使用,即当资源被一个线程使用时,别的线程不能使用。
- 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由占有者主动释放。
- 请求和保持,即当资源请求者在请求其它的资源的同时保持对原有资源的占有。
- 环路等待,即存在一个等待队列:P1 占有 P2 需要的资源,P2 占有 P3 需要的资源,P3 占有 P1 需要的资源。这样就形成了一个等待环路。
以上四个条件都成立时,便形成了死锁。当打破以上任意一个条件,死锁就会消失。
在以上四个条件中,破坏循环等待时最容易实现的,方式如下:
通过锁排序,假设有 N 个线程尝试获取 M 把锁,就可以针对 M 把锁进行编号(1、2、…、M)。N 个线程尝试获取锁的时候,都会按照固定的编号由小到大顺序来获取锁,这样就可以避免环路等待,来避免死锁。
可能产生环路等待的代码:
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread()
@Override
public void run()
synchronized (lock1)
synchronized (lock2)
// do something...
;
t1.start();
Thread t2 = new Thread()
@Override
public void run()
synchronized (lock2)
synchronized (lock1)
// do something...
;
t2.start();
不会产生环路等待的代码:
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread()
@Override
public void run()
synchronized (lock1)
synchronized (lock2)
// do something...
;
t1.start();
Thread t2 = new Thread()
@Override
public void run()
synchronized (lock1)
synchronized (lock2)
// do something...
t2.start();
7. 其它常见面试题
-
volatile关键字的用处
volatile 能够保证内存可见性,强制从主内存中读取数据。此时如果有其他线程修改被 volatile 修饰的变量,可以第一时间读取到最新的值。
-
Java 多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域:方法区, 堆区, 栈区, 程序计数器。其中堆区这个内存区域是多个线程之间共享的。只要把某个数据放到堆内存中,就可以让多个线程都能访问到。
-
Java 创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
创建线程池主要有两种方式:
- 通过 Executors 工厂类创建创建方式比较简单,但是定制能力有限。
- 通过 ThreadPoolExecutor 创建,创建方式比较复杂,但是定制能力强。
LinkedBlockingQueue 表示线程池的任务队列。用户通过 submit 或 execute 向这个任务队列中添 加任务,再由线程池中的工作线程来执行任务。
-
在多线程下,如果对一个数进行叠加,该怎么做?
-
使用 synchronized/ReentrantLock 加锁
-
使用 AtomInteger 原子操作
-
-
Servlet 是否是线程安全的?
Servlet 本身是工作在多线程环境下,如果在 Servlet 中创建了某个成员变量,此时如果有多个请求到达服务器,服务器就会多线程进行操作,是可能出现线程不安全的情况的。
-
Thread 和 Runnable 的区别和联系
Thread 类描述了一个线程,Runnable 描述了一个任务。在创建线程的时候需要指定线程完成的任务,可以直接重写 Thread 的 run 方法,也可以使用 Runnable 来描述这个任务。
-
多次 start 一个线程会怎么样?
第一次调用 start 可以成功调用,后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常。
-
synchronized 加在一个类的两个非静态方法上,两个线程分别同时用这个方法,请问会发生什么?
synchronized 加在非静态方法上, 相当于针对当前对象加锁。
- 如果这两个方法属于同一个实例,线程1能够获取到锁,并执行方法。线程2会阻塞等待,直到线程1执行完毕释放锁,线程2才能获取到锁,并执行方法的内容。
- 如果这两个方法属于不同实例, 两者能并发执行,互不干扰。
-
进程和线程的区别
进程是包含线程的,每个进程至少有一个线程存在,即主线程。进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间。进程是系统分配资源的最小单位,线程是系统调度的最小单位。
以上是关于多线程多线程面试常见基础内容的主要内容,如果未能解决你的问题,请参考以下文章