5 - JUC 篇
5.1 线程基础
5.1.1 进程、线程与协程?
- 概念:
- 进程:程序执行的独立实例,拥有自己的内存空间。有多个状态,如运行、等待、就绪和终止。
- 线程:进程中的一个实体,是CPU调度的最小单位。同一进程的线程共享进程资源,如内存。
- 协程:轻量级线程,用于非阻塞的异步编程。可以在等待操作时挂起,节省资源,提高效率。
- 区别:
- 进程是正在运行程序的 实例,进程中包含了线程,每个线程执行不同的 任务。
- 不同的进程使用 不同 的内存空间,在当前进程下的所有线程可以 共享 内存空间。
- 线程比进程更轻量,线程上下文切换成本一般上要比进程上下文切换低。
- 协程比线程更轻量,适合处理大量并发的异步任务。
5.1.2 并行与并发的区别?
并发:
- 并发是指多个任务在同一个时间段内进行,它们可能在不同的处理器上,也可能在同一个处理器上快速切换。
- 在多线程环境中,即使只有一个CPU核心,线程也可以通过 时间片轮转 实现并发执行。
并行:
- 并行是指多个任务同时进行,且每个任务都在不同的处理器上执行。
- 并行需要多个处理器或核心,这样多个任务可以真正地 同时进行,而不是通过时间片轮转。
区别:
- 并发更侧重于任务的重叠执行,而并行侧重于任务的真正同时执行。
- 并发可能涉及I/O操作和CPU计算的交替,而并行则通常涉及多个计算任务的并发处理。
5.1.3 线程创建的方式有哪些?
直接使用 Thread 类
Thread t = new Thread(() -> { log.debug("running"); }, "t1");
使用 Runnable 接口配合 Thread
Runnable r = () -> { log.debug("running"); }; Thread t = new Thread(r, "t2");
使用 Callable 接口配合 Thread
FutureTask<String> ft = new FutureTask<>(() -> { log.debug("running"); return "Hello from Callable"; }); Thread t = new Thread(ft, "t3");
使用 ThreadPoolExecutor 线程池
ExecutorService e = Executors.newFixedThreadPool(4); e.submit(() -> { log.debug("running"); });
5.1.4 Runnable 和 Callable 有什么区别?
- Runnable 接口
run
方法没有返回值。且异常只能在内部消化,不允许抛出异常。- Callable 接口
call
方法有返回值,需要FutureTask
获取结果。且允许抛出异常。
5.1.5 线程的 run() 和 start() 有什么区别?
start()
:只能被调用一次,用来启动线程。run()
:可以被调用多次,用来执行封装的代码。
5.1.6 线程的生命周期 / 六种状态
线程的六种状态包括新建、可运行、运行、阻塞、等待、计时等待和终止状态。
- 线程从 新建 状态开始,通过调用
start()
方法进入 可运行 状态,等待操作系统分配时间片。- 获得时间片后,线程进入 运行 状态执行任务。
- 在运行过程中,线程可能因为等待锁、调用
wait()
方法或sleep()
方法而进入 阻塞、等待 或 计时等待 状态。- 当线程完成任务或因异常结束时,它将进入 终止 状态。
5.1.7 wait 和 sleep 方法的不同?
wait 的调用必须获取 对象锁,而 sleep 则不用。
wait 是 Object 的成员方法,执行后 会释放 对象锁(其他人可以用)。
sleep 是 Thread 的静态方法,执行后 不会释放 对象锁(其他人也用不了)。
5.1.8 新建三个线程,如何保证它们按顺序执行?
通过在创建线程的顺序中使用
join()
方法,可以确保线程按照创建的顺序执行。Thread t1 = new Thread(Runnable1); Thread t2 = new Thread(Runnable2); Thread t3 = new Thread(Runnable3); t1.start(); t1.join(); // 等待t1执行完成 t2.start(); t2.join(); // 等待t2执行完成 t3.start();
5.1.9 notify() 和 notifyAll() 有什么区别?
notifyAll
:唤醒所有 wait 的线程notify
:随机唤醒一个 wait 线程
5.1.10 如何停止一个正在运行的线程?
- 使用
interrupt()
:线程的中断状态是一个标志,可以通过Thread.interrupt()
设置,Thread.isInterrupted()
检查。- 使用标志变量:设置一个共享的标志变量,线程定期检查这个变量,如果变量被设置,则退出运行。
- 使用
Future.cancel()
:如果线程是ExecutorService
管理的,可以使用Future.cancel()
方法来取消任务。注意: 直接调用
stop()
方法来停止线程是不安全的,因为它可能导致资源泄露和不一致的状态。
5.2 线程安全
5.2.1 Synchronized 原理及锁升级?
1. Synchronized 底层原理
- Synchronized 对象锁采用 互斥 的方式,让同一时刻至多只有一个线程能持有对象锁。
- 它的底层由 monitor 实现,monitor 是 JVM 级别的对象( C++实现),内部有三个属性:
owner
关联的是当前获得锁的线程entrylist
关联的是处于阻塞状态的线程队列waitset
关联的是处于等待状态的线程队列2. 锁升级
重量级锁:当多个线程竞争激烈时,
synchronized
会退化为重量级锁。重量级锁通过操作系统的互斥锁来实现,涉及到用户态和内核态的切换,以及进程上下文切换,会带来较高的性能开销。
轻量级锁:在竞争不激烈的情况下,
synchronized
会使用轻量级锁。轻量级锁主要通过CAS操作来尝试获取锁,这是一种无锁的非阻塞算法,它避免了较高的开销,因此在低竞争环境下性能较好。
偏向锁:当一个线程多次获取同一个锁时,JVM会将这个锁偏向这个线程,但在多线程竞争时会升级为轻量级锁或重量级锁。
即在该线程再次获取锁时,会检查对象头的
mark word
中的线程ID,如果一致,则无需进行CAS操作。
5.2.2 谈一谈 JVM(Java Virtual Machine)?
- JVM 是一个抽象计算机,它提供了一个运行时环境,允许 Java 字节码在任何平台上运行。
- JVM 包括类加载器、运行时数据区和执行引擎。
- 类加载器负责加载类文件,运行时数据区包括堆、栈、方法区等,执行引擎则负责执行字节码。
5.2.3 谈一谈 CAS(Compare-And-Swap)?
- CAS 基于乐观锁的思想,是一种无锁的非阻塞算法,用于实现多线程下的原子操作。
- 它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。
- 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。
5.2.4 谈一谈 AQS(AbstractQueuedSynchronizer)?
- AQS 是一个用于构建锁和其他同步器的框架,它提供了一套可扩展的原子操作。
- AQS 使用一个整数状态来表示同步状态,通过内置的FIFO队列来管理线程。
- AQS 提供了
acquire
、release
、tryAcquire
和tryRelease
等方法,允许自定义同步器实现自己的同步逻辑。
5.2.5 ReentrantLock 的实现原理?
ReentrantLock
是一个可重入的互斥锁,它基于 AQS 实现。- 它支持公平锁和非公平锁两种模式,公平锁会按照线程等待的顺序获取锁,非公平锁则可能造成饥饿。
ReentrantLock
提供了lock()
、unlock()
、tryLock()
等方法,允许更灵活的锁定操作。
5.2.6 Synchronized 和 Lock 的区别?
- 语法层面
- Synchronized 是 关键字,源码在 jvm 中,用 c++ 语言实现。自动释放锁。
- Lock 是 接口,源码由 jdk 提供,用 java 语言实现。手动释放锁。
- 功能层面
- Lock 提供了 Synchronized 不具备的功能,例如公平锁、可打断、可超时、多条件变量。
- Lock 有适合不同场景的实现,如
ReentrantLock
,ReentrantReadWriteLock
(读写锁)。
- 性能层面
- 在没有竞争时,Synchronized 的性能一般,但轻量级锁、偏向锁也不赖。
- 在竞争激烈时,Lock 的性能更好。
5.2.7 死锁的必要条件?
线程是程序执行的最小单位,锁用于同步线程间的资源访问。一个线程需要同时获取多把锁,就容易发生死锁。
死锁的四个必要条件:互斥、占有和等待、不可剥夺、环路等待。
如何避免:避免循环等待,确保资源有序分配等。
5.2.8 如何进行死锁诊断?
jps
:jdk 自带工具,检查进程的状态信息。jstack
:jdk 自带工具,检查线程的堆栈信息,查看日志。jconsole
、VisualVM
:可视化工具,也可以检查死锁问题。
5.2.9 请谈谈你对 volatile 的理解?
- 保证线程间的可见性。用 volatile 修饰共享变量,能够防止编译器的优化,让一个线程对共享变量的修改对另一个线程可见。
- 禁止进行指令重排序。用 volatile 修饰共享变量,会加入不同的屏障,阻止其他读写操作越过屏障。
5.2.10 并发问题的根本原因?
- 并发问题的根本原因:
- 没有保证原子性:一个线程在 CPU 中操作不可暂停,也不可中断。解决:
synchronized
、lock
。- 没有保证可见性:让一个线程对共享变量的修改对另一个线程可见。解决:
volatile
、synchronized
、lock
。- 没有保证有序性:因为处理器为了提高运行效率,对代码进行了优化而无序。解决:
volatile
。- volatile 的理解:
- 保证线程间的可见性。volatile 修饰共享变量能防止编译器的优化,让一个线程对共享变量的修改对另一个线程可见。
- 禁止进行指令重排序。volatile 修饰共享变量会加入不同的屏障,阻止其他读写操作越过屏障。
5.2.11 Synchronized 用于静态方法与普通方法的区别?
1. 锁的对象:
- 对于普通方法,锁的对象是调用该方法的实例对象(即 this)。
- 对于静态方法,锁的对象是当前类的 Class 对象。
2. 影响范围:
- 对于普通方法,锁的范围是实例对象级别的,即每个实例对象有自己的锁。
- 对于静态方法,锁的范围是类级别的,即所有该类的实例对象共享同一个锁。
3. 性能影响:
- 静态方法的锁通常比普通方法的锁开销更小,因为它锁定的是类而不是实例对象。
5.2.12 乐观锁和悲观锁的区别?
乐观锁:
乐观锁假设冲突很少发生,通常通过检测在操作过程中数据是否被其他线程修改来实现。
它适用于 读多写少 的场景,常见的实现方式是使用版本号或时间戳。
悲观锁:
悲观锁假设冲突经常发生,通常在操作数据时直接加锁。
它适用于 写操作频繁 的场景,可以防止数据不一致,但可能导致线程阻塞和性能下降。
5.3 线程池
5.3.1 线程池创建及执行流程?
ThreadPoolExecutor 创建线程池的核心参数:
corePoolSize
:核心线程数目
maximumPoolSize
:最大线程数目 = 核心线程数目 + 非核心线程数目
keepAliveTime
:生存时间,生存时间内没有新任务,此线程资源会释放
unit
:生存时间单位,如秒、毫秒等
workQueue
:阻塞队列,当没有空闲核心线程时,新来任务会加入到此队列排队
threadFactory
:线程工厂,可以定制线程对象的创建,例如设置线程名字
handler
:拒绝策略,当所有线程都在忙,阻塞队列也放满时,会触发拒绝策略线程池的执行原理:
5.3.2 线程池的 BlockingQueue?如何体现阻塞?
有界队列是指队列有固定的大小,当队列满了之后,新的任务将不能被提交,除非队列中的任务被消费。
无界队列则没有固定的大小限制,理论上可以无限增长,直到内存耗尽。
LinkedBlockingQueue ArrayBlockingQueue 默认无界,支持有界 强制有界 底层是链表 底层是数组 是懒惰的,创建节点的时候添加数据 提前初始化 Node 数组 入队会创建新 Node Node 需要提前创建好 两把锁(头尾) 一把锁
- 线程提交任务到队列时:如果已满,
put()
或offer()
方法会阻塞当前线程,直到队列中有空间可用。- 线程从队列获取任务时:如果为空,
take()
或poll()
方法会阻塞当前线程,直到队列中有任务可用。
5.3.3 如何确定核心线程数?
并发较高、任务执行时间短:
CPU 核数 + 1
,减少线程上下文的切换。并发不高、任务执行时间长:
- IO 密集型的任务:
CPU核数 * 2 + 1
。- 计算密集型任务:
CPU核数 + 1
。
5.3.4 线程池的种类有哪些?
newFixedThreadPool
:**定长 **的线程池,可控制线程最大并发数,超出的线程会在队列中等待。newSingleThreadExecutor
:**单线程 **化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按 FIFO 顺序执行。newCachedThreadPool
:可缓存 的线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。newScheduledThreadPool
:可执行 延迟任务 的线程池,支持定时及周期性任务执行。
5.3.5 为什么不建议用 Executors 创建线程池?
线程池不建议使用 Executors 创建,而是通过 ThreadPoolExecutor,可以规避资源耗尽的风险。分析源码:
- FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为
Integer.MAXVALUE
,可能堆积 大量请求,从而导致 OOM。- CachedThreadPool:允许的创建线程数量为
Integer.MAXVALUE
,可能创建 大量线程,从而导致 OOM。
5.3.6 线程池的拒绝策略有哪些?
线程池的拒绝策略就是当线程池忙不过来,任务太多,队列都满了,它怎么处理新来的任务。Java里有几个招儿:
AbortPolicy
:这个策略就是直接说“不”,如果新任务来了,它就直接抛出一个异常,告诉你“不行,我忙不过来了”。
CallerRunsPolicy
:这个策略比较温和,它会告诉提交任务的线程说“你等等,我先帮你跑这个任务”。但是,如果线程池已经关了,它就不管了。这个策略可能会让程序变慢,但是如果真的需要每个任务被处理,可以用这个。
DiscardPolicy
:这个策略就是“无视”,新任务来了,它看都不看,直接扔掉,不处理。
DiscardOldestPolicy
:这个策略有点“喜新厌旧”,它会看队列里哪个任务最老,然后把老任务扔掉,给新任务腾地方。
5.3.7 线程池的作用?
- 资源优化:线程池通过重用已经创建的线程来执行新的任务,避免了频繁创建和销毁线程的开销,从而优化了系统资源的使用。
- 提高性能:线程池减少线程创建和销毁的开销,因为创建和销毁是耗时的过程,线程池可以提高程序的响应速度和吞吐量。
- 控制并发:线程池限制程序中同时运行的线程数量,避免线程过多而系统过载和资源竞争,从而提高系统稳定性和响应能力。
- 提高线程管理的简便性:线程池提供了统一的线程管理机制,使得线程的创建、调度、执行和销毁更加方便和高效。
- 异常和任务管理:线程池集中处理线程执行中出现的异常,并且对任务进行调度和优先级管理,使得任务执行更加有序和可控。
5.4 使用场景
5.4.1 线程池使用场景?
- 资源管理: 线程池可以限制并发线程的数量,有效管理资源。
- 提高响应速度: 线程池可以减少线程创建和销毁的开销,快速响应任务请求。
- 提高线程的可管理性: 线程池提供了线程的统一管理,包括线程的创建、销毁和监控。
- 控制并发级别: 在需要控制并发任务数量的场景下,线程池可以限制同时运行的线程数量。
5.4.2 如何控制某个方法允许并发访问线程的数量?
在多线程中提供了一个工具类 Semaphore,信号量。在并发的情况下,可以控制方法的访问量:
- 创建
Semaphore
对象,可以给一个容量。acquire()
可以请求一个信号量,此时信号量个数 - 1。release()
可以释放一个信号量,此时信号量个数 + 1。
5.4.3 谈谈 ThreadLocal?
ThreadLocal
实现了资源对象的线程隔离,让每个线程各用各的,避免争用 引发线程安全问题。
- 就像每个线程都把物品放在私人储物柜里,别的线程看不到也拿不到,就不会因为大家都想用同一样东西而打架。
ThreadLocal
实现了线程内的 资源共享。每个线程内有一个ThreadLocalMap
类型的成员变量:
- 调用
set
方法,以 ThreadLocal 作为 key,以资源对象作为 value 存入。- 调用
get
方法,以 ThreadLocal 作为 key,以资源对象作为 value 取出。- 内存泄漏 问题:key 是弱引用,会被 GC 释放内存。而 value 是强引用,不会释放。建议主动调用
remove
释放。
- 就像如果有人吃完了饭,但是忘记把自己的餐具放回原位,这些餐具就会一直占用空间,这就是所谓的“内存泄漏”。
- 在
ThreadLocal
里,如果你不再需要某个线程的资源,但是没有手动清理,那么这些资源就会一直占用内存。- 因为 key(储物柜号码)是弱引用,可能会被垃圾回收器清理掉,但是 value(物品)是强引用,不会被自动清理。
5.4.4 Java 中有哪些类型的锁?
锁的应用场景:
对象锁(Synchronized)适用于保护单个对象实例的访问。
类锁(Synchronized)适用于控制对类级别资源的访问,如静态变量。
重入锁(ReentrantLock)适用于需要复杂同步控制的场景。
读写锁(ReentrantReadWriteLock)适用于读操作频繁且写操作较少的场景。
自旋锁(Spin Lock)适用于锁持有时间短且竞争激烈度低的场景。
锁的性能比较:
Synchronized
经过 JVM 优化,性能较高,但可能导致线程阻塞。
ReentrantLock
提供更多功能,但性能略低于Synchronized
。
ReentrantReadWriteLock
适用于读多写少,提高读操作并发性。
SpinLock
适用于锁保护时间短,避免上下文切换开销,但可能导致CPU消耗。