文章导读
引言
本篇文章总结了多线程中面试频率比较高的考点,内容可能比较琐碎,但是如果能够坚持看完,注意总结积累,相信对面试会有很大帮助。多线程内容较多,用一篇文章写完可能篇幅过长,我打算用两篇文章来总结,本篇主要写的是多线程中辅助加锁的数据结构和指令,下一篇主要讲的是锁策略。
考点1. CAS 指令(重点)
一、什么是CAS
CAS(Compare-and-Swap)是一种用于实现多线程同步的原子指令。它涉及到三个操作数:内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值,这个操作是原子的。
CAS 操作包含三个关键动作:
- 比较(Compare):将内存位置的值与预期原值进行比较。
- 交换(Swap):如果比较相等,那么处理器会自动将该内存位置的值更新为新值。
- 原子性(Atomicity):上述整个比较和交换的操作是原子的,即该操作在执行过程中不会被其他线程的操作打断。
CAS 指令一般是基于硬件实现的,在 Intel 处理器中,它可以通过 LOCK 前缀的 CMPXCHG 指令来实现。在 Java 中,CAS 操作被广泛用于实现非阻塞算法,如原子变量类(java.util.concurrent.atomic
包下的类)中的 getAndIncrement
、compareAndSet
等方法都是基于 CAS 实现的。
二、CAS 的优点
- 非阻塞算法:CAS 允许线程在不进入阻塞状态的情况下进行并发操作,这有助于减少线程切换的开销,提高系统的并发性能。
- 无需使用锁:在多个线程竞争同一个资源时,传统的锁机制可能会导致线程阻塞,而 CAS 可以在不依赖锁的情况下实现线程间的同步。
三、CAS 的缺点
- 循环时间长开销大:如果 CAS 操作一直不成功,那么线程会一直处于自旋状态(返回失败并持续重试),这会增加 CPU 的负担。
- 只能保证一个共享变量的原子操作:当需要对多个共享变量进行操作时,CAS 就无法保证操作的原子性了。这种情况下,需要使用锁或其他同步机制来保证操作的原子性。
四、ABA问题
情景:
- 假设小明有存款1000元,他去ATM机上取100元,服务器产生了两个线程处理。线程1执行完存款变为900,线程2CAS指令比较失败,无法执行。
- 就在小明取钱的时候,小明的爸爸给小明的银行账户转了100元,产生了线程3。如果线程3是在线程1执行后才产生的,那么就会出现存款从 1000 -> 900 -> 1000 的过程,于是再执行线程2的CAS指令就会成功。本来小明只想取100元,现在取钱操作执行了两次,取出了200元!
- 假设小明有存款1000元,他去ATM机上取100元,服务器产生了两个线程处理。线程1执行完存款变为900,线程2CAS指令比较失败,无法执行。
定义:
在CAS操作中,线程会首先读取某个内存位置的值(我们称之为预期值A),然后执行CAS操作,尝试将该内存位置的值修改为新的值(我们称之为B),但前提是内存位置的值必须仍然是预期值A。如果在读取值和尝试修改值之间,有其他线程修改了该内存位置的值(比如从A改为了B,然后又改回了A),那么CAS操作会错误地认为该值没有变化,从而成功执行,这就会导致ABA问题。
在以上情景中,存款从1000变900再变成1000的过程所导致的取钱两次的BUG就是ABA问题。
解决方法:
给要修改的值, 引⼊版本号. 在 CAS ⽐较数据当前值和旧值的同时, 也要⽐较版本号是否符合预期。例如:
给存款引入版本号,每次执行线程时版本号加1.
版本号为1,线程1执行扣款成功,存款为900,版本号+1变为2,线程3执行存入成功,存款为1000,版本号+1变为3,线程2执行,版本号与之前读取的不同,执行失败。
五、相关面试题
- 讲解下你⾃⼰理解的 CAS 机制。
- ABA问题怎么解决?
忠告:相关面试题的答案我不会给出,读者应自己总结积累,盲目背诵答案已经过时,面试场上的八股文已被千变万化的情景题目所取代,只有自己总结积累经验和知识才能应对变化,才能让面试官青睐!
考点2. 信号量(semaphore)
一、基本概念
- 定义:信号量是一个非负整数,用于表示某种资源的数量。它有两个主要操作:P(等待)和V(释放)。
- 作用:实现任务之间的同步或临界资源的互斥访问,常用于协助一组相互竞争的任务来访问临界资源。
二、信号量的主要操作
P(等待)操作:
- 当一个进程或线程需要访问共享资源时,它会尝试执行P操作。
- 如果信号量的值大于0,表示资源可用,进程或线程可以继续访问资源,并将信号量的值减1。
- 如果信号量的值等于0,表示资源已被占用,进程或线程会被阻塞,直到信号量的值变为正数。
V(释放)操作:
- 当一个进程或线程完成对共享资源的访问时,它会执行V操作,将信号量的值加1。
- 如果有其他等待进程被阻塞,它们中的一个将被唤醒并获得对资源的访问权限。
代码示例:
public static void main(String[] args) { // 信号量为4,表明有四个资源待访问 Semaphore semaphore = new Semaphore(4); // 写一个线程访问访问资源 Thread t = new Thread(() -> { try { // accquire方法表示P操作 semaphore.acquire(); // do something ... Thread.sleep(1000); // release方法表示V操作 semaphore.release(); } catch(InterruptedException e){ e.printStackTrace(); } }); t.start(); }
三、信号量的应用
信号量在操作系统和并发编程中有着广泛的应用,包括但不限于:
- 进程同步:控制多个进程的执行顺序,保证数据的正确处理。
- 临界资源的互斥访问:保护共享资源,防止数据竞争和冲突。
- 生产者-消费者问题:在生产者-消费者模型中,通过信号量来控制资源的生产和消费。
- 线程池管理:控制线程池中的线程数量,以控制系统的负载。
- 顺序控制:确保多个任务按照特定的顺序执行。
综上所述,信号量是一种重要的同步机制,通过合理地控制信号量的值,可以实现对共享资源的互斥访问和同步操作,从而避免并发编程中的常见问题。
四、相关面试题
- 简单解释一下什么是信号量?
- 什么场景下会使用到信号量?
考点3、CountDownLatch 类
CountDownLatch
是 Java 并发包 java.util.concurrent
中的一个非常有用的同步辅助类,它允许一个或多个线程等待一组其他线程完成操作。这个类通过让一个或多个线程等待其他线程完成一组操作来协调线程。CountDownLatch
初始化时设置一个计数器(count),这个计数器代表等待完成的操作的数量。
一、主要用途
- 等待多个线程完成:
CountDownLatch
允许一个或多个线程等待其他一组线程完成它们的任务。例如,在启动多个线程进行并行计算时,你可能希望等待所有线程都完成计算后再继续执行后续操作。 - 性能优化:通过并行处理,可以提高应用程序的响应速度和吞吐量。
CountDownLatch
可以帮助在并行处理完成后同步后续操作。
二、主要方法
CountDownLatch(int count)
:构造函数,初始化计数器值为给定的count
值。void await()
:使当前线程在锁存器倒计数至零之前一直处于等待状态,除非线程被中断。void await(long timeout, TimeUnit unit)
:使当前线程在锁存器倒计数至零之前一直处于等待状态,或者从当前时间起已经过了指定的等待时间,或者线程被中断。void countDown()
:递减锁存器的计数,如果计数到达零,则释放所有等待的线程。
三、示例
下面是一个简单的示例,展示了如何使用 CountDownLatch
来等待一组线程完成它们的任务。
import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class CountDownLatchExample { public static void main(String[] args) throws InterruptedException { int taskCount = 5; ExecutorService executor = Executors.newFixedThreadPool(taskCount); CountDownLatch latch = new CountDownLatch(taskCount); for (int i = 0; i < taskCount; i++) { executor.submit(() -> { try { // 模拟任务执行 Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } latch.countDown(); // 完成任务,减少计数器 }); } // 等待所有任务完成 latch.await(); System.out.println("所有任务完成"); executor.shutdown(); } }
在这个示例中,我们创建了一个包含 5 个任务的线程池。每个任务完成后,都会调用 countDown()
方法来减少 CountDownLatch
的计数器。主线程通过调用 await()
方法等待,直到计数器的值达到 0,即所有任务都已完成。然后,主线程继续执行并打印出 “所有任务完成”。
考点4、Callable 接口
在Java中,Callable
接口是Java并发API的一部分,它位于 java.util.concurrent
包中。与 Runnable
接口不同,Callable
接口可以返回一个结果,并且可能抛出一个异常。这使得 Callable
接口非常适合用于那些需要返回值的并发任务。
Callable
接口的定义如下:
@FunctionalInterface public interface Callable<V> { /** * Computes a result, or throws an exception if unable to do so. * * @return computed result * @throws Exception if unable to compute a result */ V call() throws Exception; }
Callable 与 Runnable 的主要区别
- 返回值:
Runnable
接口的run
方法没有返回值,而Callable
接口的call
方法可以返回一个泛型类型的值。 - 异常处理:
Runnable
的run
方法不允许抛出受检查的异常(checked exceptions),而Callable
的call
方法可以。如果call
方法抛出了一个异常,这个异常将被封装在一个ExecutionException
中,这个异常是由Future.get()
方法抛出的。
使用场景
当你需要执行一个任务,并且这个任务完成后需要返回一个结果时,就可以使用 Callable
。例如,你可能需要从远程服务器获取数据,或者执行一些计算并返回结果。
示例
创建线程计算 1 + 2 + 3 + … + 1000,
使用 Run 版本:
创建⼀个类 Result,包含⼀个 sum 表示最终结果, lock 表⽰线程同步使⽤的锁对象。
main ⽅法中先创建 Result 实例, 然后创建⼀个线程 t. 在线程内部计算 1 + 2 + 3 + … + 1000.
主线程同时使⽤ wait 等待线程 t 计算结束. (注意, 如果执行到 wait 之前, 线程 t 已经计算完了, 就不必等待了)。
当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果.
public class Demo18 { static class Result { public int sum = 0; public Object lock = new Object(); } public static void main(String[] args) throws InterruptedException { Result result = new Result(); Thread t = new Thread() { @Override public void run() { int sum = 0; for (int i = 1; i <= 1000; i++) { sum += i; } synchronized (result.lock) { result.sum = sum; result.lock.notify(); } } }; t.start(); synchronized (result.lock) { while (result.sum == 0) { result.lock.wait(); } System.out.println(result.sum); } } }
可以看到,上述代码需要⼀个辅助类 Result,还需要使⽤⼀系列的加锁和 wait notify 操作,代码复杂,容易出错。
使用Callable版本:
- 创建⼀个匿名内部类,实现 Callable 接⼝。 Callable 带有泛型参数,泛型参数表⽰返回值的类型。
- 重写 Callable 的 call ⽅法,完成累加的过程,直接通过返回值返回计算结果.
- 把 callable 实例使⽤ FutureTask 包装⼀下。
- 线程的构造⽅法传⼊ FutureTask。 此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法完成计算,计算结果就放到了 FutureTask 对象中。
- 在主线程中调⽤ futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果。
public class Demo18 { public static void main(String[] args) throws InterruptedException, ExecutionException { Callable<Integer> callable = new Callable<Integer>() { @Override public Integer call() throws Exception { int sum = 0; for (int i = 1; i <= 1000; 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); } }
可以看到,使⽤ Callable 和 FutureTask 之后,代码简化了很多,也不必⼿动写线程同步代码了。
相关面试题
请你说说Callable 和 Runnable 的主要区别?
考点5、多线程下的数据结构
一、多线程环境使用ArrayList
- 普通的 ArrayList 线程并不安全,在使用时必须在可能发生冲突的地方加锁,操作复杂且容易发生死锁。
- (使用较多)使用
Collections.synchronizedList(new ArrayList)
,synchronizedList 是标准库提供的⼀个基于 synchronized 进行线程同步的方法,它是 Collections 类的一个静态方法,它的返回值是一个对关键方法加了锁的链表(List)。这样可以简化程序猿对代码的加锁操作,降低死锁的风险。 - (常考)使⽤
CopyOnWriteArrayList
,这是一个写时复制的容器。原理:
- 当我们往⼀个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出⼀个新的容器,然后新的容器里添加元素;
- 添加完元素之后,再将原容器的引用指向新的容器。
优点:
线程可以对CopyOnWrite原容器进行并发的读,而不需要加锁,因为原容器不会添加任何元素,读和写是不同的容器。在读多写少的场景下,性能很高,不需要加锁竞争。
缺点:
占用内存较多且新写的数据不能被第⼀时间读取到。
二、多线程环境下使用哈希表
HashMap 本身是线程不安全的,Java 又在 HashMap 的基础上封装了两个类:
- Hashtable
- ConcurrentHashMap
1、Hashtable
Hashtable 只是在 HashMap 的基础上,把关键方法加上了锁(synchronized),如:
public synchronized V get(Object key) public synchronized V put(K key, V value)
这无疑是给所有读写操作都加上了锁,线程想要访问同一个 Hashtable 的任何数据都会直接造成锁竞争(一把锁锁上整个Hash表,如图,一个每次只能有一个线程访问该表),一旦触发扩容,就只有在单个线程(触发扩容的线程)上进行,涉及大量的数据拷贝,效率非常低。
2、ConcurrentHashMap(重点)
ConcurrentHashMap
是 Java 并发包 java.util.concurrent
中的一个非常重要的类,用于在并发环境下替代传统的 HashMap
。它提供了比 Hashtable
更高的并发级别,因为 Hashtable
是同步的,这意味着在每一次访问时,整个表都需要被锁定,这大大降低了并发性能。ConcurrentHashMap
通过以下几个方面的优化和改进来提升并发性能:
分段锁(Segmentation Locking)(在 Java 8 之前):
- 在 Java 8 之前,
ConcurrentHashMap
使用分段锁的机制来减少锁的竞争。它将整个哈希表分为多个段(Segment),每个段都维护着自己的锁。这样,在并发环境中,只要多个线程访问的是不同的段,它们就可以并行地执行,从而减少了锁的争用。每个段内部都维护了一个哈希表,用于存储键值对。 - 当需要对某个键进行操作时,首先通过哈希码确定该键属于哪个段,然后只锁定该段进行操作,而不是锁定整个表。
- 在 Java 8 之前,
锁粒度细化(Fine-grained Locking)(在 Java 8 及以后):
- 从 Java 8 开始,
ConcurrentHashMap
放弃了分段锁的设计,转而采用了一种更为灵活的锁策略,即使用Node
数组加上链表或红黑树(在链表过长时)的方式来存储键值对,并通过synchronized
关键字或CAS
(Compare-And-Swap)操作来确保线程安全。 - 在 Java 8 的实现中,锁被细化到了每个桶(bucket)上,即每个数组元素。当多个线程访问不同的桶时,它们可以并行地执行。这进一步减少了锁的竞争,提高了并发性能。
- 读操作没有加锁(但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁。
- 从 Java 8 开始,
使用 CAS(Compare-And-Swap)操作:
- CAS 是一种无锁算法,用于实现线程间的同步,而不需要使用传统的锁。在
ConcurrentHashMap
的实现中,当尝试修改某个桶(或节点)时,会尝试使用 CAS 操作来更新该桶的状态。如果桶的状态在此期间没有被其他线程修改,则 CAS 操作成功,否则重试(CAS与版本号结合)。 - CAS 操作减少了锁的使用,从而提高了性能,但也可能导致更高的 CPU 使用率,因为需要不断重试直到成功为止。
- CAS 是一种无锁算法,用于实现线程间的同步,而不需要使用传统的锁。在
动态扩容:
- 定义:
ConcurrentHashMap
支持动态扩容,即当哈希表中的元素数量达到某个阈值时,会自动进行扩容操作,以避免哈希冲突和性能下降。与HashMap
类似,扩容操作涉及到重新计算每个元素的哈希码,并将其放置到新的哈希表中。但ConcurrentHashMap
的扩容操作是并发安全的,可以在不阻塞读操作的情况下进行。 - 原理:
- 发现需要扩容的线程,只需要创建⼀个新的数组,同时只搬几个元素过去。
- 扩容期间, 新老数组同时存在,后续每个来操作 ConcurrentHashMap 的线程,都会参与搬家的过程,每个操作负责搬运一小部分元素,搬完最后⼀个元素再把老数组删掉。
- 这个期间,插入的元素只往新数组里添加,查找需要同时查新数组和⽼数组。
- 定义:
红黑树优化:
- 在 Java 8 及以后的版本中,当某个桶中的链表长度超过一定阈值时(默认为 8),
ConcurrentHashMap
会将该链表转换为红黑树,以优化查找性能。这是因为红黑树在查找、插入和删除操作上的时间复杂度比链表更低(在平均和最坏情况下都是 O(log n)),可以进一步提高并发性能。
- 在 Java 8 及以后的版本中,当某个桶中的链表长度超过一定阈值时(默认为 8),
综上所述,ConcurrentHashMap
通过分段锁(在 Java 8 之前)、锁粒度细化(在 Java 8 及以后)、CAS 操作、动态扩容和红黑树优化等多种机制来优化和改进并发性能,使其成为 Java 中处理并发哈希表的首选数据结构。
相关面试题
- ConcurrentHashMap的读是否要加锁,为什么?
- 介绍下 ConcurrentHashMap的锁分段技术?
- ConcurrentHashMap在jdk1.8做了哪些优化?
- Hashtable和HashMap、ConcurrentHashMap 之间的区别?
考点五、其他常见面试题
以下的面试题的答案都在我之前的文章中,大家可以从中寻找答案,这里我就不再一一赘述。
谈谈 volatile关键字的用法?
参考文章 线程安全
Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域; ⽅法区, 堆区, 栈区, 程序计数器。 其中堆区这个内存区域是多个线程之间共享的。只要把某个数据放到堆内存中,就可以让多个线程都能访问到。
Java创建线程池的接⼝是什么?参数 LinkedBlockingQueue 的作用是什么?
参考文章 线程池的认识和使用
Java线程共有几种状态?状态之间怎么切换的?
Thread和Runnable的区别和联系?
参考文章 Thread类和线程的用法