Java 并发编程面经
1、并行跟并发有什么区别?
- 并行是多核 CPU 上的多任务处理,多个任务在同一时间真正地同时执行。
- 并发是单核 CPU 上的多任务处理,多个任务在同一时间段内交替执行,通过时间片轮转实现交替执行,用于解决 IO 密集型任务的瓶颈。
1.1 你是如何理解线程安全的?
如果一段代码块或者一个方法被多个线程同时执行,还能够正确地处理共享数据,那么这段代码块或者这个方法就是线程安全的。可以从三个要素来保证线程安全:
- 原子性:一个操作要么完全执行,要么完全不执行,不会出现中间状态。可以通过同步关键字 synchronized 或原子操作,如 AtomicInteger 来保证原子性。
- 可见性:当一个线程修改了共享变量,其他线程能够立即看到变化。可以通过 volatile 关键字来保证可见性。
- 有序性:要确保线程不会因为死锁、饥饿、活锁等问题导致无法继续执行。
2、说说进程和线程的区别?
进程是我们在电脑上启动的一个个应用。是操作系统分配资源的最小单位。线程是进程中的独立执行单元。多个线程可以共享同一个进程的资源,如内存;每个线程都有自己独立的栈和寄存器。
2.1 如何理解协程?
协程被视为比线程更轻量级的并发单元,可以在单线程中实现并发执行,由我们开发者显式调度。
2.2 线程间是如何进行通信的?
原则上可以通过消息传递和内存共享两种方法来实现。Java 采用的是内存共享的并发模型。也就是 Java 内存模型,简写为 JMM,共享变量存储在主内存中,每个线程的本地内存存储共享变量的副本。两个线程通信前,线程 A 把本地内存 A 中的共享变量副本刷新到主内存中。随后线程 B 到主内存中读取线程 A 刷新过的共享变量,同步到自己的共享变量副本中。
3、说说线程有几种创建方式?
有三种,分别是继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。
- 继承 Thread 类:需要重写父类 Thread 的 run() 方法,并且调用 start() 方法启动线程。存在多继承问题。
- 实现 Runnable 接口:需要重写 Runnable 接口的 run() 方法,然后创建 Thread 对象,参数为 Runnable 对象,最后调用 start() 方法启动线程。避免了多继承问题。
- 实现 Callable 接口:需要重写 Callable 接口的 call() 方法,然后创建 FutureTask 对象,参数为 Callable 对象;接着创建 Thread 对象,参数为 FutureTask 对象,最后调用 start() 方法启动线程。优点是可以获取线程的执行结果。
4、调用 start 方法时会执行 run 方法,那怎么不直接调用 run方法?
调用 start() 会创建一个新的线程,并异步执行 run() 方法中的代码。直接调用 run() 方法是同步方法调用,所有代码都在当前线程中执行,不会创建新线程。没有新的线程创建,也就达不到多线程并发的目的。
5、线程有哪些常用的调度方法?
- start 方法用于启动线程。
- stop 方法用于停止线程。
- sleep 方法用于让线程休眠。
- wait 方法用于让线程等待。
- notify 会唤醒一个等待的线程。
- yield 方法会让线程让出 CPU 使用权,回到就绪状态。
- interrupt 方法用于通知线程停止,但不会直接终止线程。
6、线程有几种状态?
6 种。new 代表线程被 new 出来但还没有开始执行;runnable 代表调用了 start 方法后线程处于就绪或运行状态,由操作系统调度;blocked 代表线程被阻塞,等待获取锁;waiting 代表线程正等待其他线程的通知;timed_waiting 代表线程会等待一段时间,超时后自动恢复;terminated 代表线程执行完毕,生命周期结束。
7、什么是线程上下文切换?
线程上下文切换是指 CPU 从一个线程切换到另一个线程执行时的过程。在线程上下文切换的过程中,CPU 需要保存当前线程的执行状态,并加载下一个线程的上下文。之所以要这样,是因为 CPU 在同一时刻只能执行一个线程,为了实现多线程并发执行,需要不断地在多个线程之间切换。CPU 资源的分配采用了时间片轮转的方式。
7.1 线程可以被多核调度吗?
多核处理器提供了并行执行多个线程的能力。每个核心可以独立执行一个或多个线程,操作系统的任务调度器会根据策略和算法,如优先级调度、轮转调度等,决定哪个线程何时在哪个核心上运行。
8、守护线程了解吗?
守护线程是一种特殊的线程,它的作用是为其他线程提供服务。JVM 启动时会调用 main 方法,main 方法所在的线程就是一个用户线程。在 JVM 内部,同时还启动了很多守护线程,比如垃圾回收线程。
8.1 守护线程和用户线程有什么区别?
守护线程是否结束并不影响 JVM 退出,而只要有一个用户线程还没结束,正常情况下 JVM 就不会退出。
9、线程间有哪些通信方式?
线程之间传递信息的方式有多种,比如说使用 volatile 和 synchronized 关键字共享对象、使用 wait() 和 notify() 方法实现生产者-消费者模式、使用 Exchanger 进行数据交换、使用 Condition 实现线程间的协调等。
9.1 简单说说 volatile 和 synchronized 的使用方式?
多个线程可以通过 volatile 和 synchronized 关键字访问和修改同一个对象,从而实现信息的传递。
关键字 volatile 可以用来修饰成员变量,告知程序任何对该变量的访问均需要从共享内存中获取,并同步刷新回共享内存,保证所有线程对变量访问的可见性。关键字 synchronized 可以修饰方法,或者同步代码块,确保多个线程在同一个时刻只有一个线程在执行方法或代码块。
9.2 wait() 和 notify() 方法的使用方式了解吗?
一个线程调用共享对象的 wait() 方法时,它会进入该对象的等待池,释放已经持有的锁,进入等待状态。
一个线程调用共享对象的 notify() 方法时,它会唤醒在该对象等待池中等待的一个线程,使其进入锁池,等待获取锁。
Condition 也提供了类似的方法,await() 负责阻塞、signal() 和 signalAll() 负责通知。
通常与锁 ReentrantLock 一起使用,为线程提供了一种等待某个条件成真的机制,并允许其他线程在该条件变化时通知等待线程。
9.3 Exchanger 的使用方式了解吗?
Exchanger 是一个同步点,可以在两个线程之间交换数据。一个线程调用 exchange() 方法,将数据传递给另一个线程,同时接收另一个线程的数据。
9.4 CompletableFuture 的使用方式了解吗?
CompletableFuture 是 Java 8 引入的一个类,支持异步编程,允许线程在完成计算后将结果传递给其他线程。
10、请说说 sleep 和 wait 的区别?
sleep 会让当前线程休眠,不需要获取对象锁,属于 Thread 类的方法;wait 会让提前获得对象锁的线程等待,调用后释放锁,属于 Object 类的方法。
- 所属类不同:sleep() 方法属于 Thread 类,wait() 方法属于 Object 类。
- 锁行为不同:如果一个线程在持有某个对象锁时调用了 sleep() 方法,它在睡眠期间仍然会持有这个锁。而线程执行 wait() 方法时,它会释放持有的对象锁,因此其他线程也有机会获取该对象的锁。
- 使用条件不同:sleep() 方法可以在任何地方被调用。wait() 方法必须在同步代码块或同步方法中被调用,这是因为调用 wait() 方法的前提是当前线程必须持有对象的锁。
- 唤醒方式不同:调用 sleep 方法后,线程会进入 TIMED_WAITING 状态,即在指定的时间内暂停执行。而调用 wait 方法后,线程会进入 WAITING 状态,直到有其他线程在同一对象上调用 notify 或 notifyAll 方法。
11、怎么保证线程安全?
线程安全是指在并发环境下,多个线程访问共享资源时,程序能够正确地执行,而不会出现数据不一致的问题。为了保证线程安全,可以使用 synchronized 关键字对方法加锁,对代码块加锁。线程在执行同步方法、同步代码块时,会获取类锁或者对象锁,其他线程就会阻塞并等待锁。
- 如果需要更细粒度的锁,可以使用 ReentrantLock 并发重入锁等。
- 如果需要保证变量的内存可见性,可以使用 volatile 关键字。
- 对于简单的原子变量操作,还可以使用 Atomic 原子类。
- 对于线程独立的数据,可以使用 ThreadLocal 来为每个线程提供专属的变量副本。
- 对于需要并发容器的地方,可以使用 ConcurrentHashMap、CopyOnWriteArrayList 等。
11.1 有个int的变量为0,十个线程轮流对其进行 ++ 操作(循环10000次),结果大于 10 万还是小于等于 10 万,为什么?
在这个场景中,最终的结果会小于 100000,原因是多线程环境下,++ 操作并不是一个原子操作,而是分为三步:
- 读取到变量的值。
- 将读取到的值加 1。
- 将结果写回变量。
这样的话,就会有多个线程读取到相同的值,然后对这个值进行加 1 操作,最终导致结果小于 100000。可以通过 synchronized 关键字为 ++ 操作加锁。或者使用 AtomicInteger 的 incrementAndGet() 方法来替代 ++ 操作,保证变量的原子性。
11.2 场景:有一个 key 对应的 value 是一个json 结构,json 当中有好几个子任务,这些子任务如果对 key 进行修改的话,会不会存在线程安全的问题?
会,在单节点环境中,可以使用 synchronized 关键字或 ReentrantLock 来保证对 key 的修改操作是原子的。在多节点环境中,可以使用分布式锁 Redisson 来保证对 key 的修改操作是原子的。
11.3 说一个线程安全的使用场景?
单例模式。在多线程环境下,如果多个线程同时尝试创建实例,单例类确保只创建一个实例,并提供全局访问点。
12、能说一下 Hashtable 的底层数据结构吗?
与 HashMap 类似,Hashtable 的底层数据结构也是一个数组加上链表的方式,然后通过 synchronized 加锁来保证线程安全。
13、ThreadLocal 是什么?
ThreadLocal 是一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离。
使用 ThreadLocal 通常分为四步:创建、设置值、获取值和删除值。在 Web 应用中,可以使用 ThreadLocal 存储用户会话信息,这样每个线程在处理用户请求时都能方便地访问当前用户的会话信息。
13.1 ThreadLocal 有哪些优点?
每个线程访问的变量副本都是独立的,避免了共享变量引起的线程安全问题。
14、ThreadLocal 怎么实现的呢?
当我们创建一个 ThreadLocal 对象并调用 set 方法时,其实是在当前线程中初始化了一个 ThreadLocalMap。ThreadLocalMap 是 ThreadLocal 的一个静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象,value 是线程的局部变量,这样就相当于为每个线程维护了一个变量副本。Entry 继承了 WeakReference,它限定了 key 是一个弱引用,弱引用的好处是当内存不足时,JVM 会回收 ThreadLocal 对象,并且将其对应的 Entry.value 设置为 null,这样可以在很大程度上避免内存泄漏。
15、ThreadLocal 内存泄露是怎么回事?
ThreadLocalMap 的 Key 是 弱引用,但 Value 是强引用。如果一个线程一直在运行,并且 value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。解决内存泄漏问题很简单,使用完 ThreadLocal 后,及时调用 remove() 方法释放内存空间,remove() 方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除,这样就能避免内存泄漏问题。总结一下,在 ThreadLocal 被垃圾收集后,下一次访问 ThreadLocalMap 时,Java 会自动清理那些键为 null 的 entry,这个过程会在执行 get()、set()、remove()时触发。
15.1 你了解哪些 ThreadLocal 的改进方案?
阿里的 TransmittableThreadLocal,不仅实现了子线程可以继承父线程 ThreadLocal 的功能,并且还可以跨线程池传递值。
16、ThreadLocalMap 的源码看过吗?
有研究过,ThreadLocalMap 虽然被叫做 Map,但它并没有实现 Map 接口,是一个简单的线性探测哈希表。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // 这里的 Key 是 WeakReference
value = v;
}
}
private Entry[] table; // 存储 ThreadLocal 变量的数组
private int size; // 当前 Entry 数量
private int threshold; // 触发扩容的阈值
}
底层的数据结构也是数组,数组中的每个元素是一个 Entry 对象,Entry 对象继承了 WeakReference,key 是 ThreadLocal 对象,value 是线程的局部变量。
17、ThreadLocalMap 怎么解决 Hash 冲突的?
开放寻址法。如果计算得到的槽位 i 已经被占用,ThreadLocalMap 会采用开放地址法中的线性探测来寻找下一个空闲槽位:如果 i 位置被占用,尝试 i+1。如果 i+1 也被占用,继续探测 i+2,直到找到一个空位。如果到达数组末尾,则回到数组头部,继续寻找空位。
17.1 为什么要用线性探测法而不是 HashMap 的拉链法来解决哈希冲突?
ThreadLocalMap 设计的目的是存储线程私有数据,不会有大量的 Key,所以采用线性探测更节省空间。链地址法还需要单独维护一个链表,甚至红黑树,不适合 ThreadLocal 这种场景。
18、ThreadLocalMap 扩容机制了解吗?
与 HashMap 不同,ThreadLocalMap 并不会直接在元素数量达到阈值(默认:2/3 数组长度)时立即扩容,而是先清理被 GC 回收的 key,然后在填充率达到 3/4 时进行扩容。清理过程会遍历整个数组,将 key 为 null 的 Entry 清除。扩容时,会将数组长度翻倍,然后重新计算每个 Entry 的位置,采用线性探测法来寻找新的空位,然后将 Entry 放入新的数组中。
19、父线程能用 ThreadLocal 给子线程传值吗?
不能。因为 ThreadLocal 变量存储在每个线程的 ThreadLocalMap 中,而子线程不会继承父线程的 ThreadLocalMap。可以使用 InheritableThreadLocal 来解决这个问题。
19.1 InheritableThreadLocal的原理了解吗?
在 Thread 类的定义中,每个线程都有两个 ThreadLocalMap:
普通 ThreadLocal 变量存储在 threadLocals 中,不会被子线程继承。
InheritableThreadLocal 变量存储在 inheritableThreadLocals 中,当 new Thread() 创建一个子线程时,Thread 的 init() 方法会检查父线程是否有 inheritableThreadLocals,如果有,就会拷贝 InheritableThreadLocal 变量到子线程:
20、说一下你对 Java 内存模型的理解?
Java 内存模型是 Java 虚拟机规范中定义的一个抽象模型,用来描述多线程环境中共享变量的内存可见性。
每个线程都有自己的共享变量副本,可以避免多个线程同时修改共享变量导致的数据冲突。
21、说说什么是指令重排?
指令重排是指 CPU 或编译器为了提高程序的执行效率,改变代码执行顺序的一种优化技术。从 Java 源代码到最终执行的指令序列,会经历 3 种重排序:编译器重排序、指令并行重排序、内存系统重排序。
22、happens-before 了解吗?
Happens-Before 是 Java 内存模型定义的一种保证线程间可见性和有序性的规则。
如果操作 A Happens-Before 操作 B,那么:
- 操作 A 的结果对操作 B 可见。
- 操作 A 在时间上先于操作 B 执行。
换句话说,如果 A Happens-Before B,那么 A 的修改必须对 B 可见,并且 B 不能重排序到 A 之前。
22.1 你知道哪些 Happens-Before 规则?
- 程序顺序规则:单线程内,代码按顺序执行。
- 监视器锁定规则:比如 synchronized 释放锁后,获取锁的线程能够看到最新的数据。
- volatile 变量规则:写 volatile 变量 Happens-Before 读 volatile。
- 传递性规则:A Happens-Before B 且 B Happens-Before C,则 A Happens-Before C。
- 线程启动规则:线程 A 执行操作 ThreadB.start(),该线程启动操作 happens-before 于线程 B 中的任意操作。
- 线程终止规则:线程的所有操作 Happens-Before Thread.join()。
23、as-if-serial 了解吗?
As-If-Serial 规则允许 CPU 和编译器优化代码顺序,但不会改变单线程的执行结果。它只适用于单线程,多线程环境仍然可能发生指令重排,需要 volatile 和 synchronized 等机制来保证有序性。
24、volatile 了解吗?
有两个作用。第一,保证可见性,线程修改 volatile 变量后,其他线程能够立即看到最新值;第二,防止指令重排,volatile 变量的写入不会被重排序到它之前的代码。
24.1 volatile 怎么保证可见性的?
当线程对 volatile 变量进行写操作时,JVM 会在这个变量写入之后插入一个写屏障指令,这个指令会强制将本地内存中的变量值刷新到主内存中。
当线程对 volatile 变量进行读操作时,JVM 会插入一个读屏障指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。
24.1 volatile 怎么保证有序性的?
JVM 会在 volatile 变量的读写前后插入 “内存屏障”,以约束 CPU 和编译器的优化行为。
StoreStore 会禁止所有写操作的重排,LoadLoad 会禁止所有读操作的重排,StoreLoad 会禁止 volatile 的读写重排,LoadStore 会禁止 volatile 读与后续普通写操作重排。
24.2 volatile 和 synchronized 的区别?
- volatile 关键字用于修饰变量,确保该变量的更新操作对所有线程是可见的,即一旦某个线程修改了 volatile 变量,其他线程会立即看到最新的值。
- synchronized 关键字用于修饰方法或代码块,确保同一时刻只有一个线程能够执行该方法或代码块,从而实现互斥访问。
24.3 volatile 加在基本类型和对象上的区别?
- 当 volatile 用于基本数据类型时,能确保该变量的读写操作是直接从主内存中读取或写入的。
- 当 volatile 用于引用类型时,能确保引用本身的可见性,即确保引用指向的对象地址是最新的。但是,volatile 并不能保证引用对象内部状态的线程安全。如果需要保证引用对象内部状态的线程安全,需要使用 synchronized 或 ReentrantLock 等锁机制。