3. Java多线程&并发
3. Java多线程&并发
📄 1. 线程1
📄 2. 多线程2
📄 3. JMM(Java内存模型)[^13]
📑 4. 并发安全[^15]
📑 锁[^16]
- 📄 AQS[^17]
- 📄 CAS[^18]
- 📄 Java 原子性的方法3
📄 5. ThreadLocal4
📄 6. 并发工具类5
📄 7. 线程池[^19]
📄 8. Future[^29]
# 1. 线程
⭐️1.1 什么是线程和进程?线程和进程的区别?
进程是程序的一次执行过程,是系统运行程序的基本单位。
线程与进程相似,是进程划分成的更小的运行单位,多个线程可以共享同一个进程的资源,如内存(堆和方法区)。
线程和进程最大的不同在于:进程基本上是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。
线程执行开销小,但不利于资源的管理和保护;而进程正相反。
扩展:
程序计数器:
字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了
虚拟机栈:
- 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
本地方法栈:
- 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
堆和方法区:
- 是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
1.2 Java 线程和操作系统的线程的区别?
-
JDK 1.2
及以后,Java
线程改为基于原生线程(Native Threads
)实现,也就是说JVM
直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。 -
JDK 1.2
之前,Java
线程是基于绿色线程(Green Threads
)实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。由于绿色线程和原生线程比起来在使用时有一些限制(比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核) - 现在的 Java 线程的本质其实就是操作系统的线程。
1.3 创建线程的方式?
一般来说,创建线程有很多种方式,例如继承
Thread
类、实现Runnable
接口、实现Callable
接口、使用ExecutorService
线程池、基于ThreadGroup线程组、使用FutureTask类、使用CompletableFuture
类等等。不过,这些方式其实并没有真正创建出线程。准确点来说,这些都属于是在 Java 代码中使用多线程的方法。 严格来说,Java 就只有一种方式可以创建线程,那就是通过
new Thread().start()
创建。不管是哪种方式,最终还是依赖于new Thread().start()
。1.3.1 runnable 和 callable 有什么区别?
- Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
- Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
- Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛
3.2 线程的 run()和 start()有什么区别?start 方法时会执行 run 方法,那怎么不直接调用 run方法?
- start(): 调用 start() 会创建一个新的线程,并异步执行 run() 方法中的代码。
- run(): 直接调用 run() 方法只是一个普通的同步方法调用,所有代码都在当前线程中执行,不会创建新线程。没有新的线程创建,也就达不到多线程并发的目的。
⭐️1.4 线程的生命周期和状态?
- new: 初始,线程被创建出来但没有被调用
start()
。 - runnable: 可运行,线程被调用了
start()
后,线程处于等待运行(就绪)或正在运行的状态。 - blocked:阻塞,等待获取锁。
- waiting:等待,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
- timed_waiting:超时等待,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。T
- terminated:终止,表示该线程已经运行完毕
在操作系统层面,线程有 ready(就绪) 和 running(运行中) 状态;而在 JVM 层面,只能看到 runnable 状态,所以 Java 系统一般将这两个状态统称为 runnable 状态 。
线程创建之后它将处于 new 状态,调用
start()
方法后开始运行,线程这时候处于 ready 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 running 状态。如果线程获取锁失败后,由 runnable 进入 Monitor 的阻塞队列 blocked,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的 blocked 线程,唤醒后的线程进入 runnable 状态。
如果线程获取锁成功后,但由于条件不满足,调用了
wait()
方法,此时从 runnable 状态释放锁 waiting 状态,当其它持锁线程调用notify()
或notifyAll()
方法,会恢复为 runnable 状态。还有一种情况是调用 sleep(long) 方法也会从runnable 状态进入 timed_waiting 状态,不需要主动唤醒,超时时间到自然恢复为 runnable 状态。
1.4.1 BLOCKED 和 WAITING 的区别?
- BLOCKED是锁竞争失败后被被动触发的状态,WAITING是人为的主动触发的状态
- BLCKED的唤醒时自动触发的,而WAITING状态是必须要通过特定的方法来主动唤醒
1.5 什么是线程上下文切换?
线程上下文切换是指 CPU 从一个线程切换到另一个线程执行时的过程。
在线程切换的过程中,CPU 需要保存当前线程的执行状态,并加载下一个线程的上下文。
之所以要这样,是因为 CPU 在同一时刻只能执行一个线程,为了实现多线程并发执行,需要不断地在多个线程之间切换。
为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转的方式,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会让出 CPU 让其他线程占用。
线程上下文切换的发生时机, 通常有四种情况会发生线程上下文切换:
第一种是时间片耗尽,操作系统为每个线程分配了一个时间片,当线程的时间片用完后,操作系统会强制切换到其他线程,这是为了保证多个线程能够公平地共享 CPU 资源。
第二种是线程主动让出 CPU,当线程调用了某些方法,如 Thread.sleep()、Object.wait() 或 LockSupport.park()等,会使线程主动让出 CPU,导致上下文切换。
第三种是调用了阻塞类型的系统中断,比如:线程执行 I/O 操作时,由于 I/O 操作通常需要等待外部资源,线程会被挂起,会触发上下文切换。
第四种是被终止或结束运行。
线程上下文切换的过程,分为四步:
第一步是保存当前线程的上下文,将当前线程的寄存器状态、程序计数器、栈信息等保存到内存中。
第二步是根据线程调度算法,如:时间片轮转、优先级调度等,选择下一个要运行的线程。
第三步是加载下一个线程的上下文,从内存中恢复所选线程的寄存器状态、程序计数器和栈信息。
第四步是 CPU 开始执行被加载的线程的代码。
线程上下文切换所带来的影响:
线程上下文切换虽然能够实现多任务并发执行,但它也会带来 CPU 时间消耗、缓存失效以及资源竞争等问题。为了减少线程上下文切换带来的性能损失,可以采取减少线程数量、使用无锁数据结构等方式进行优化。
1.6 线程有哪些常用的调度方法?
比如说 start 方法用于启动线程并让操作系统调度执行;sleep 方法用于让当前线程休眠一段时间;wait 方法会让当前线程等待,notify 会唤醒一个等待的线程
wait()
:Object
的方法。使当前线程进入阻塞状态,释放对象锁,直到另一个线程调用共享对象的notify()
或notifyAll()
方法;或者其他线程调用 线程A 的interrupt()
方法,导致线程 A 抛出InterruptedException
异常。
wait()
是Object
的类的方法,针对的是要操作的对象而不是线程
join()
:等待指定线程执行完成,当前线程会被阻塞,直到被join()
的线程执行结束
notify()
:唤醒在此对象监视器上等待的单个线程。如果有多个线程在等待,则随机选择一个唤醒。
notifyAll()
:唤醒在此对象监视器上等待的所有线程。
yield()
:让当前线程让出 CPU 使用权,回到就绪状态。但是线程调度器可能会忽略。
interrupt()
:发送中断线程的请求,如果线程在wait()
、sleep()
或join()
方法中被阻塞,会抛出InterruptedException
sleep()
:Thread
的方法,使当前线程暂停执行指定的时间进入TIMED_WAITING
状态,会让出 CPU 时间片,但不会释放对象锁,可以被interrupt()
方法中断,抛出InterruptedException
。到时间自动苏醒。
sleep()
是Thread
类的静态本地方法,让线程暂停执行而不涉及对象。
1.7 线程间的通信方式?
线程之间传递信息的方式有多种,比如说使用
volatile
和synchronized
关键字共享对象、使用wait()
和notify()
方法实现生产者-消费者模式、使用Exchanger
进行数据交换、使用Condition
实现线程间的协调等。volatile 和 synchronized
多个线程可以通过 volatile 和 synchronized 关键字访问和修改同一个对象,从而实现信息的传递。
关键字 volatile: 可以用来修饰成员变量,告知程序任何对该变量的访问均需要从共享内存中获取,并同步刷新回共享内存,保证所有线程对变量访问的可见性。
关键字 synchronized: 可以修饰方法,或者同步代码块,确保多个线程在同一个时刻只有一个线程在执行方法或代码块。
wait() 和 notify() [^2]
一个线程调用共享对象的
wait()
方法时,它会进入该对象的等待池,释放已经持有的锁,进入等待状态。一个线程调用
notify()
方法时,它会唤醒在该对象等待池中等待的一个线程,使其进入锁池,等待获取锁。
Condition 也提供了类似的方法,
await()
负责阻塞、signal()
和signalAll()
负责通知。通常与锁 ReentrantLock 一起使用,为线程提供了一种等待某个条件成真的机制,并允许其他线程在该条件变化时通知等待线程
Exchanger
Exchanger
是一个同步点,可以在两个线程之间交换数据。一个线程调用exchange()
方法,将数据传递给另一个线程,同时接收另一个线程的数据。CompletableFuture[^3]
CompletableFuture 是 Java 8 引入的一个类,支持异步编程,允许线程在完成计算后将结果传递给其他线程。
↩︎
# 2. 多线程
2.1 并行与并发的区别?
- 并行:多核 CPU 上的多任务处理,多个任务在同一时间真正地同时执行。
- 并发:是单核 CPU 上的多任务处理,多个任务在同一时间段内交替执行,通过时间片轮转实现交替执行,用于解决 IO 密集型任务的瓶颈。
2.2 同步和异步的区别?
- 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
- 异步:调用在发出之后,不用等待返回结果,该调用直接返回
2.3 为什么要使用多线程?
先从总体上来说:
- 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。
- 从当代互联网发展趋势来说: 利用好多线程机制可以大大提高系统整体的并发能力以及性能。
再深入到计算机底层来探讨:
- 单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。
- 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 核心上执行,
2.4 单核 CPU 支持 Java 多线程吗?
支持 。操作系统通过时间片轮转的方式,将 CPU 的时间分配给不同的线程。尽管单核 CPU 一次只能执行一个任务,但通过快速在多个线程之间切换,可以让用户感觉多个任务是同时进行的。
2.5 单核 CPU 上运行多个线程效率一定会高吗?
- 如果任务是 CPU 密集型的,那么开很多线程会影响效率。
- 如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。
2.6 使用多线程可能带来什么问题?
并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
2.7 如何理解线程安全和不安全?
线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。
- 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
- 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。
2.8 怎么保证线程安全?
- 为了保证线程安全,可以使用 synchronized 关键字[^5] 对方法加锁,对代码块加锁。线程在执行同步方法、同步代码块时,会获取类锁或者对象锁,其他线程就会阻塞并等待锁。
- 如果需要更细粒度的锁,可以使用 ReentrantLock 并发重入锁[^6]等。
- 如果需要保证变量的内存可见性,可以使用 volatile 关键字[^7]。
- 对于简单的原子变量操作,还可以使用 Atomic 原子类[^8]。
- 对于线程独立的数据,可以使用 ThreadLocal [^9]来为每个线程提供专属的变量副本。
- 对于需要并发容器的地方,可以使用 ConcurrentHashMap、CopyOnWriteArrayList[^10] 等
2.9 什么是线程死锁?
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
产生死锁的四个必要条件:
- 互斥:该资源任意一个时刻只由一个线程占用。
- 请求与保持:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待:若干线程之间形成一种头尾相接的循环等待资源关系
2.10 如何预防和避免线程死锁?
预防死锁:
破坏死锁的产生的必要条件:
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
避免死锁:
在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。↩︎
# Java 原子性的方法
Java 有哪些保证原子性的方法?
Atomic 开头的原子类,synchronized 关键字,ReentrantLock 锁等。
原子操作类了解多少?
原子操作类是基于 CAS + volatile 实现的,底层依赖于
Unsafe
类,最常用的有AtomicInteger
、AtomicLong
、AtomicReference
等。AtomicInteger 的源码读过吗?
有读过。
AtomicInteger
是基于volatile
和 CAS 实现的,底层依赖于Unsafe
类。核心方法包括getAndIncrement
、compareAndSet
等。↩︎1
2
3public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}# 5. ThreadLocal
5.1 ThreadLocal 是什么?
ThreadLocal
是一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24public class ThreadLocalExample {
// 初始化为0
private static ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);
public static void main(String[] args) {
// 任务为 将 value + 1
Runnable task = () -> {
int value = threadLocal.get();
value += 1;
threadLocal.set(value);
System.out.println(Thread.currentThread().getName() + " Value: " + threadLocal.get());
};
Thread thread1 = new Thread(task, "Thread-1");
Thread thread2 = new Thread(task, "Thread-2");
thread1.start(); // 输出: Thread-1 Value: 1
thread2.start(); // 输出: Thread-2 Value: 1
// 可见线程1和线程2不会互相影响,得到的结果都是 0 + 1 = 1
//清除本地内存中的本地变量
threadLocal.remove();
}
}
5.2 ThreadLocal 的实现原理?
从上面
ThreadLocal
类 源代码可以看出,ThreadLocal
类中ThreadLocal中有一个内部类叫做ThreadLocalMap
,类似于HashMap
,ThreadLocalMap
中有一个属性table
数组,这个是真正存储数据的位置。ThreadLocal
类的set
或get
方法调用的时候,实际上我们调用的也是ThreadLocalMap
类对应的get()
、set()
方法。
5.3 ThreadLocal 内存泄露问题是怎么导致的?
ThreadLocalMap
的Key
是 弱引用,但Value
是强引用。弱引用:在下一次垃圾回收发生时,无论内存是否充足,弱引用指向的对象都会被回收
强引用:只要强引用存在,被引用的对象就不会被垃圾回收。只有当变量不再引用对象时(如设为
null
或变量离开作用域),对象才可能被回收如果一个线程一直在运行,并且
value
一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。5.3.1 如何解决内存泄露问题?
使用完
ThreadLocal
后,及时调用remove()
方法释放内存空间。remove()
方法会将当前线程的ThreadLocalMap
中的所有key
为null
的Entry
全部清除,这样就能避免内存泄漏问题。5.3.2 为什么 key 要设计成弱引用?
弱引用的好处是,当内存不足的时候,JVM 能够及时回收掉弱引用的对象。
key
是弱引用,当 JVM 进行垃圾回收时,只要发现了弱引用对象,就会将其回收。一旦
key
被回收,ThreadLocalMap
在进行set
、get
的时候就会对key
为null
的Entry
进行清理。
5.4 如何跨线程传递 ThreadLocal 的值?
InheritableThreadLocal
:InheritableThreadLocal
是 JDK1.2 提供的工具,继承自ThreadLocal
。使用InheritableThreadLocal
时,会在创建子线程时,令子线程继承父线程中的ThreadLocal
值,但是无法支持线程池场景下的ThreadLocal
值传递。
TransmittableThreadLocal
:TransmittableThreadLocal
(简称 TTL) 是阿里巴巴开源的工具类,继承并加强了InheritableThreadLocal
类,可以在线程池的场景下支持ThreadLocal
值传递
5.5 InheritableThreadLocal的原理了解吗?
在
Thread
类的定义中,每个线程都有两个ThreadLocalMap
:1
2
3
4
5
6
7public class Thread {
/* 普通 ThreadLocal 变量存储的地方 */
ThreadLocal.ThreadLocalMap threadLocals = null;
/* InheritableThreadLocal 变量存储的地方 */
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}普通
ThreadLocal
变量存储在threadLocals
中,不会被子线程继承。
InheritableThreadLocal
变量存储在inheritableThreadLocals
中,当new Thread()
创建一个子线程时,Thread
的init()
方法会检查父线程是否有inheritableThreadLocals
,如果有,就会拷贝InheritableThreadLocal
变量到子线程:↩︎1
2
3
4
5
6
7
8private void init(ThreadGroup g, Runnable target, String name, long stackSize) {
// 获取当前父线程
Thread parent = currentThread();
// 复制 InheritableThreadLocal 变量
if (parent.inheritableThreadLocals != null) {
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}# 6. 并发工具类
6. 1 Semaphore 有什么用?
Semaphore
——信号量,用于控制同时访问某个资源的线程数量,类似限流器,确保最多只有指定数量的线程能够访问某个资源,超过的必须等待。1
2
3
4
5
6// 初始共享资源数量
final Semaphore semaphore = new Semaphore(5);
// 获取1个许可
semaphore.acquire();
// 释放1个许可
semaphore.release();超过限制数量后的线程都会阻塞。当初始的资源个数为 1 的时候,
Semaphore
退化为排他锁。
Semaphore
有两种模式:。- 公平模式: 调用
acquire()
方法的顺序就是获取许可证的顺序,遵循 FIFO; - 非公平模式: 抢占式的。
Semaphore
对应的两个构造方法如下:1
2
3
4
5
6
7public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。
Semaphore
通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。6.1.1 Semaphore 的原理是什么?
Semaphore
的实现原理主要基于AQS 框架,它默认使用 AQS 的state
值作为许可数量permits
。当线程调用
acquire()
时,实际上是尝试减少state
的值。如果state
大于0,则减1并成功获取许可;如果state
为0,则线程会被放入AQS的等待队列中阻塞。
release()
方法则增加state
的值,并根据公平性策略唤醒等待队列中的线程。在公平模式下,AQS会严格按照FIFO顺序唤醒等待的线程;而在非公平模式下,新到达的线程可能会直接尝试获取许可,不管是否有线程在等待。
Semaphore的精妙之处在于复用了AQS的框架,只需要重写tryAcquireShared和tryReleaseShared这两个方法,就实现了复杂的同步逻辑和线程调度。
6.2 CountDownLatch 有什么用?
CountDownLatch
允许count
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
CountDownLatch
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当CountDownLatch
使用完毕后,它不能再次被使用。6.1.2 CountDownLatch 的原理是什么?
CountDownLatch
的实现原理也是基于AQS框架,它使用AQS的state
变量来表示计数器count
的值。当线程调用
await()
方法时,会检查当前state是否为0。如果不为0,表示计数器还未归零,线程会被放入AQS的等待队列中阻塞。当其他线程调用
countDown()
方法时,会将state
值以 CAS 的操作来减1,并检查state
是否变为0。如果变为0,则会唤醒所有在await()
方法上等待的线程。
CountDownLatch
的特点是计数器只能减不能增,且计数器一旦归零就不能被重置,这使得它是一次性的。
6.3 CyclicBarrier 有什么用?
CyclicBarrier
的字面意思是可循环使用(Cyclic
)的屏障(Barrier
)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时再共同执行。6.3.1 CyclicBarrier 的原理是什么?
CyclicBarrier
内部维护了一个计数器count
和一个ReentrantLock
锁,以及一个Condition
条件变量。当创建CyclicBarrier
时,我们会设置参与的线程数parties
,表示屏障拦截的线程数量。当调用
CyclicBarrier
对象调用await()
方法时,实际上调用的是dowait(false, 0L)
方法,会将count
减1,表示又有一个线程到达了屏障点。然后会判断计数器是否为0:如果不为0,说明还有线程未到达,当前线程就在条件变量上等待
如果为0,说明所有线程都已到达,这时会执行以下步骤:
- 唤醒所有在条件变量上等待的线程
- 重置计数器为初始值(因此
CyclicBarrier
可以重复使用) - 执行可选的屏障动作(如果创建时提供了)
- 创建新的"代"(Generation)对象,用于区分不同的等待周期
6.4 Exchanger 了解吗?
Exchanger
——交换者,用于在两个线程之间进行数据交换。支持双向数据交换,比如说线程 A 调用
exchange(dataA)
,线程 B 调用exchange(dataB)
,它们会在同步点交换数据,即 A 得到 B 的数据,B 得到 A 的数据。如果一个线程先调用
exchange()
,它会阻塞等待,直到另一个线程也调用exchange()
。
6.5 能说一下 ConcurrentHashMap 的实现吗?
ConcurrentHashMap
是HashMap
的线程安全版本。JDK 7 采用的是分段锁,整个
Map
会被分为若干段,每个段都可以独立加锁。不同的线程可以同时操作不同的段,从而实现并发。JDK 8 使用了一种更加细粒度的锁——桶锁,再配合 CAS +
synchronized
代码块控制并发写入,以最大程度减少锁的竞争。对于读操作,
ConcurrentHashMap
使用了volatile
变量来保证内存可见性。对于写操作,
ConcurrentHashMap
优先使用 CAS 尝试插入,如果成功就直接返回;否则使用synchronized
代码块进行加锁处理。
6.6 JDK 7 中 ConcurrentHashMap 的 put 和 get 流程?
put
流程和 HashMap 非常类似,只不过是先定位到具体的段,再通过 ReentrantLock 去操作而已。一共可以分为 4 个步骤:计算
key
的 hash,定位到段,段如果是空就先初始化;使用
ReentrantLock
进行加锁,如果加锁失败就自旋,自旋超过次数就阻塞,保证一定能获取到锁;遍历段中的键值对
HashEntry
,key
相同直接替换,key
不存在就插入。释放锁。
get
就更简单了,先计算key
的 hash 找到段,再遍历段中的键值对,找到就直接返回value
。
get
不用加锁,因为是value
是volatile
的,所以线程读取 value 时不会出现可见性问题。
6.7 JDK 8 中 ConcurrentHashMap 的 put 和 get 流程?
put
流程:计算
key
的 hash,定位到数组只能的位置,如果数组为空采用 CAS 就先初始化,确保只有一个线程在初始化数组。如果桶为空,直接 CAS 插入节点。如果 CAS 操作失败,会退化为
synchronized
代码块来插入节点。插入的过程中会判断桶的哈希是否小于 0(
f.hash >= 0
),小于 0 说明是红黑树,大于等于 0 说明是链表。补充:在
ConcurrentHashMap
的实现中,红黑树节点TreeBin
的 hash 值固定为 -2。如果链表长度超过 8,转换为红黑树
在插入新节点后,会调用
addCount()
方法检查是否需要扩容。
get
流程:
get
也是通过key
的 hash 进行定位,如果该位置节点的哈希匹配且键相等,则直接返回值。如果节点的哈希为负数,说明是个特殊节点,比如说如树节点或者正在迁移的节点,就调用
find
方法查找。否则遍历链表查找匹配的键。如果都没找到,返回
null
。
6.8 为什么 ConcurrentHashMap 在 JDK 1.7 中要用 ReentrantLock,而在 JDK 1.8 要用 synchronized?
JDK 1.7 中的
ConcurrentHashMap
使用了分段锁机制,每个Segment
都继承了ReentrantLock
,这样可以保证每个Segment
都可以独立地加锁。而在 JDK 1.8 中,
ConcurrentHashMap
取消了Segment
分段锁,采用了更加精细化的锁——桶锁,以及 CAS 无锁算法,每个桶都可以独立地加锁,只有在 CAS 失败时才会使用synchronized
代码块加锁,这样可以减少锁的竞争,提高并发性能。哈希表的本质是一个数组,这个数组中的每个元素就被称为一个"桶"
6.9 ConcurrentHashMap 怎么保证可见性?
ConcurrentHashMap
中的Node
节点中,value
和next
都是volatile
的,这样就可以保证对value
或next
的更新会被其他线程立即看到.
6.10 为什么 ConcurrentHashMap 比 Hashtable 效率高?
Hashtable
在任何时刻只允许一个线程访问整个Map
,是通过对整个Map
加锁来实现线程安全的。比如get
和put
方法,是直接在方法上加的 synchronized 关键字。1
2
3
4
5
6
7public synchronized V put(K key, V value) {
if (value == null) throw new NullPointerException();
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % table.length;
...
return oldValue;
}而
ConcurrentHashMap
在 JDK 8 中是采用 CAS +synchronized
实现的,仅在必要时加锁。比如说 put 的时候优先使用 CAS 尝试插入,如果失败再使用
synchronized
代码块加锁。
get
的时候是完全无锁的,因为value
是volatile
变量 修饰的,保证了内存可见性。
6.11 能说一下 CopyOnWriteArrayList 的实现原理吗?
CopyOnWriteArrayList
是ArrayList
的线程安全版本,适用于读多写少的场景。它的核心思想是写操作时创建一个新数组,修改后再替换原数组,这样就能够确保读操作无锁,从而提高并发性能。内部使用
volatile
变量来修饰数组array
,以读操作的内存可见性。写操作的时候使用
ReentrantLock
来保证线程安全。缺点就是写操作的时候会复制一个新数组,如果数组很大,写操作的性能会受到影响。
6.12 能说一下 BlockingQueue 吗?
BlockingQueue
是 JUC 包下的一个线程安全队列,支持阻塞式的“生产者-消费者”模型。当队列容器已满,生产者线程会被阻塞,直到消费者线程取走元素后为止;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
BlockingQueue
的实现类有很多,比如说 ArrayBlockingQueue、PriorityBlockingQueue 等[^11]。[^12]
6.13 阻塞队列是如何实现的?
阻塞队列使用
ReentrantLock
+Condition
来确保并发安全。以
ArrayBlockingQueue
为例,它内部维护了一个数组,使用两个指针分别指向队头和队尾。
put
的时候先用ReentrantLock
加锁,然后判断队列是否已满,如果已满就阻塞等待,否则插入元素。↩︎- 公平模式: 调用