赞
踩
本篇文章总结了多线程中面试频率比较高的考点,内容可能比较琐碎,但是如果能够坚持看完,注意总结积累,相信对面试会有很大帮助。多线程内容较多,用一篇文章写完可能篇幅过长,我打算用两篇文章来总结,本篇主要写的是多线程中辅助加锁的数据结构和指令,下一篇主要讲的是锁策略。
CAS(Compare-and-Swap)是一种用于实现多线程同步的原子指令。它涉及到三个操作数:内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值,这个操作是原子的。
CAS 操作包含三个关键动作:
CAS 指令一般是基于硬件实现的,在 Intel 处理器中,它可以通过 LOCK 前缀的 CMPXCHG 指令来实现。在 Java 中,CAS 操作被广泛用于实现非阻塞算法,如原子变量类(java.util.concurrent.atomic
包下的类)中的 getAndIncrement
、compareAndSet
等方法都是基于 CAS 实现的。
情景:
定义:
在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问题怎么解决?
忠告:相关面试题的答案我不会给出,读者应自己总结积累,盲目背诵答案已经过时,面试场上的八股文已被千变万化的情景题目所取代,只有自己总结积累经验和知识才能应对变化,才能让面试官青睐!
P(等待)操作:
V(释放)操作:
代码示例:
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(); }
信号量在操作系统和并发编程中有着广泛的应用,包括但不限于:
综上所述,信号量是一种重要的同步机制,通过合理地控制信号量的值,可以实现对共享资源的互斥访问和同步操作,从而避免并发编程中的常见问题。
- 简单解释一下什么是信号量?
- 什么场景下会使用到信号量?
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,即所有任务都已完成。然后,主线程继续执行并打印出 “所有任务完成”。
在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;
}
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版本:
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 的主要区别?
Collections.synchronizedList(new ArrayList)
,synchronizedList 是标准库提供的⼀个基于 synchronized 进行线程同步的方法,它是 Collections 类的一个静态方法,它的返回值是一个对关键方法加了锁的链表(List)。这样可以简化程序猿对代码的加锁操作,降低死锁的风险。CopyOnWriteArrayList
,这是一个写时复制的容器。
原理:
优点:
线程可以对CopyOnWrite原容器进行并发的读,而不需要加锁,因为原容器不会添加任何元素,读和写是不同的容器。在读多写少的场景下,性能很高,不需要加锁竞争。
缺点:
占用内存较多且新写的数据不能被第⼀时间读取到。
HashMap 本身是线程不安全的,Java 又在 HashMap 的基础上封装了两个类:
Hashtable 只是在 HashMap 的基础上,把关键方法加上了锁(synchronized),如:
public synchronized V get(Object key)
public synchronized V put(K key, V value)
这无疑是给所有读写操作都加上了锁,线程想要访问同一个 Hashtable 的任何数据都会直接造成锁竞争(一把锁锁上整个Hash表,如图,一个每次只能有一个线程访问该表),一旦触发扩容,就只有在单个线程(触发扩容的线程)上进行,涉及大量的数据拷贝,效率非常低。
ConcurrentHashMap
是 Java 并发包 java.util.concurrent
中的一个非常重要的类,用于在并发环境下替代传统的 HashMap
。它提供了比 Hashtable
更高的并发级别,因为 Hashtable
是同步的,这意味着在每一次访问时,整个表都需要被锁定,这大大降低了并发性能。ConcurrentHashMap
通过以下几个方面的优化和改进来提升并发性能:
分段锁(Segmentation Locking)(在 Java 8 之前):
ConcurrentHashMap
使用分段锁的机制来减少锁的竞争。它将整个哈希表分为多个段(Segment),每个段都维护着自己的锁。这样,在并发环境中,只要多个线程访问的是不同的段,它们就可以并行地执行,从而减少了锁的争用。每个段内部都维护了一个哈希表,用于存储键值对。锁粒度细化(Fine-grained Locking)(在 Java 8 及以后):
ConcurrentHashMap
放弃了分段锁的设计,转而采用了一种更为灵活的锁策略,即使用 Node
数组加上链表或红黑树(在链表过长时)的方式来存储键值对,并通过 synchronized
关键字或 CAS
(Compare-And-Swap)操作来确保线程安全。使用 CAS(Compare-And-Swap)操作:
ConcurrentHashMap
的实现中,当尝试修改某个桶(或节点)时,会尝试使用 CAS 操作来更新该桶的状态。如果桶的状态在此期间没有被其他线程修改,则 CAS 操作成功,否则重试(CAS与版本号结合)。动态扩容:
ConcurrentHashMap
支持动态扩容,即当哈希表中的元素数量达到某个阈值时,会自动进行扩容操作,以避免哈希冲突和性能下降。与 HashMap
类似,扩容操作涉及到重新计算每个元素的哈希码,并将其放置到新的哈希表中。但 ConcurrentHashMap
的扩容操作是并发安全的,可以在不阻塞读操作的情况下进行。红黑树优化:
ConcurrentHashMap
会将该链表转换为红黑树,以优化查找性能。这是因为红黑树在查找、插入和删除操作上的时间复杂度比链表更低(在平均和最坏情况下都是 O(log n)),可以进一步提高并发性能。综上所述,ConcurrentHashMap
通过分段锁(在 Java 8 之前)、锁粒度细化(在 Java 8 及以后)、CAS 操作、动态扩容和红黑树优化等多种机制来优化和改进并发性能,使其成为 Java 中处理并发哈希表的首选数据结构。
- ConcurrentHashMap的读是否要加锁,为什么?
- 介绍下 ConcurrentHashMap的锁分段技术?
- ConcurrentHashMap在jdk1.8做了哪些优化?
- Hashtable和HashMap、ConcurrentHashMap 之间的区别?
以下的面试题的答案都在我之前的文章中,大家可以从中寻找答案,这里我就不再一一赘述。
谈谈 volatile关键字的用法?
参考文章 线程安全
Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域; ⽅法区, 堆区, 栈区, 程序计数器。 其中堆区这个内存区域是多个线程之间共享的。只要把某个数据放到堆内存中,就可以让多个线程都能访问到。
Java创建线程池的接⼝是什么?参数 LinkedBlockingQueue 的作用是什么?
参考文章 线程池的认识和使用
Java线程共有几种状态?状态之间怎么切换的?
参考文章
线程安全
Thread类和线程的用法
Thread和Runnable的区别和联系?
参考文章 Thread类和线程的用法
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。