个人总结的一些关于安卓线程的八股,因为目前做的是安卓java原生,没做kotlin,所以用的很多东西都是java,学java的也可以参考一下。(由于本人是菜鸡,水平有限,如有错漏欢迎留言)
一、线程
1.1 线程是什么
这个问题还是比较好回答的,结合进程和协程一起梳理一下。
首先进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。同时进程也是操作系统分配资源的最小单位,这里资源指虚拟内存空间(含代码段、数据段、堆、栈等)、页表、文件描述符、IO缓冲区、进程控制块PCB等。
线程是CPU调度的最小单位,一个进程内可以有多个线程,这些线程共享进程的虚拟内存,所以全局变量、静态变量和动态分配的内存(如 malloc或 new创建的对象)都是共享的。另外进程打开的文件也是共享的,包括本地文件或者网络连接等。不过线程也有一些自己私有的东西,最重要的有两个吧:一是线程的栈空间,二是执行上下文。每个线程都拥有自己独立的栈空间,这里主要存储函数调用链、局部变量、参数和返回地址。因为每个线程的执行路径是独立的,所以必须有自己的栈来保证函数调用和返回的正确性。栈空间是线程安全的,其他线程无法直接访问。执行上下文是线程独立运行的"现场记录",包含了程序计数器 (PC) 和一系列寄存器的状态。程序计数器记录当前线程执行到哪条指令,寄存器则暂存临时的计算数据和地址。当线程被切换时,这些状态会被保存,以便下次恢复执行。
协程是相对比较新的概念,java中没有原生支持协程,不过kotlin是有协程的。协程是用户态的轻量级线程,程序控制的调度单元,依附于线程,是线程内部的任务流,cpu不直接调度协程。协程间的切换通常在一个线程之内进行,由线程自身在用户态控制协作式调度,非抢占,仅需保存/恢复少量寄存器状态,无内核介入,消耗极低。
1.2 为什么要有线程
经过上面梳理进程、线程、协程的区别之后,应该很容易看出来三者的关系,其实就是一级一级细分。那问题就是为什么需要细分。首先进程线程协程出现是有先后顺序的,由于计算机的性能和cpu数量在不断提升,原先单进程的程序无法合理利用好计算机性能,所以才出现线程,作用就是压榨计算机性能、也有些地方叫提高并发度。例如在处理计算密集型任务时利用多核并行加快处理速度,可能原先只用到一个cpu核心,需要算2组数据,使用两个线程之后就可以一个核心算一组,加快计算速度。(使用线程之后不可避免出现线程间的同步问题,这个也是多线程并发时必须要注意的点,也是经常问的)
这里也简单说一下协程,协程是比较晚出现的,同样是进一步提高并发度。在目前互联网时代,经常会有高并发的情况,服务器同时维持了几万、几十万的连接,如果都由线程处理,那就是好几万的线程,虽然线程共享了进程的资源,线程间切换的消耗没有进程切换那么大,但是同样涉及内核态切换,并且线程创建必须分配栈空间(一般是几M),数量多了也是很大开销,为了应对这种场景才有了协程。(协程栈空间往往几十到几百KB、协程切换在用户态由线程控制,开销很小)
二、多线程
2.1 安卓什么时候使用多线程
首先除了上文提到,为了提高并发度,引入了线程的概念,所以我们可以知道同时处理多个任务可以使用多线程。另外安卓app属于客户端,涉及刷新ui、处理用户输入,这些事情都是在主线程进行的,如果主线程挂掉那app也就挂掉了。如果我们把所有事情都放在主线程做会发生什么?因为安卓是基于消息机制的,所有要处理的事情/事件都被封装成一条一条的消息丢到消息队列,由handler在消息队列不断取出消息然后执行,如果有一条消息执行的太久,那刷新ui的消息就一直得不到执行,就会卡顿,甚至时间过长还会出现无响应(ANR),所以当一个事件消息(或者你可以简单理解为一个函数)需要执行较长时间,例如数据库操作、网络操作,那么需要放到子线程去做,这样主线程handler处理这条消息时,可以把耗时的操作丢到子线程,消息可以很快处理完返回,这样子就可以继续处理下一条消息,最后如果需要知道结果可以通过回调函数通知。
2.2 如何使用多线程
2.2.1 new Thread
java本身提供了创建一个线程的api,直接通过创建线程对象,并将要执行的任务封装为Runnable传入,调用线程的start函数即可开始运行。自动执行runnable的run方法。
优点:1.解耦,任务逻辑和线程本身分开,符合面向对象设计原则。 2.灵活,一个 Runnable 任务可以被多个线程执行,也可以被提交到线程池。3.不破坏继承体系,你的任务类可以继承任何其他类。
缺点:1.无法获取返回值,run() 方法没有返回值。2.无法直接抛出异常,run() 方法签名不允许抛出 throws 异常。3.资源开销大,每次 new Thread() 都会创建一个新的系统线程。如果频繁创建, 会导致性能下降和资源浪费, 甚至 OutOfMemoryError。4.管理困难,创建的线程缺乏统一的管理、 调度和生命周期控制。
安卓中的定位:基础,但不推荐直接使用。 适用于执行那些非常独立、数量极少、生命周期与应用不完全绑定的后台任务。 在绝大多数情况下, 有更好的替代方案。
java
Thread t = new Thread(new Runnable() {
@Override
public void run() {
int a = 1;
int b = a;
}
});
t.start();
2.2.2 通过继承Thread,然后重写run方法
这是另一种Java基础方式,将线程和任务绑定在一起,类即线程。
java
class MyThread extends Thread {
@Override
public void run() {
// ... 在这里执行耗时操作 ...
}
}
// 创建并启动
new MyThread().start();
优点:代码结构简单直观。
缺点:1.破坏单继承,Java类只能单继承,如果你的类已经继承了其他类, 就不能用这种方式, 这是它最大的硬伤。 2.强耦合,任务和线程绑定,不灵活。3.同样存在资源开销和管理困难的问题。
安卓中的定位:不推荐使用,了解即可
2.2.3 Callable + FutureTask (Runnable升级版)
一个可以返回结果并抛出异常的异步任务,算是解决Runnable的问题。(Callable的call提供抛异常能力,Futuretask实现RunnableFuture接口,RunnableFuture接口继承Runnable接口与Future接口,Future提供了获取返回结果与查询状态的能力)
java
// 1. 创建一个Callable任务
Callable<String> downloadTask = new Callable<String>() {
@Override
public String call() throws Exception {
// 模拟下载,耗时2秒
Thread.sleep(2000);
// 返回下载结果
return "下载完成的数据";
}
};
// 2. 用FutureTask包装Callable
FutureTask<String> futureTask = new FutureTask<>(downloadTask);
// 3. FutureTask可以被线程执行
new Thread(futureTask).start();
// 4. 在主线程中获取结果
try {
// futureTask.get() 是一个阻塞方法,会一直等待直到call()方法执行完毕并返回结果
String result = futureTask.get();
// ... 使用result更新UI ...
myTextView.setText(result);
} catch (ExecutionException | InterruptedException e) {
// 处理Callable中抛出的异常或线程中断异常
e.printStackTrace();
}
优点:1.可以获取异步任务的返回值。2.可以捕获和处理在子线程中抛出的异常。3.提供了任务状态查询(isDone(), isCancelled())和任务取消(cancel())的能力。
缺点:1.get() 方法会阻塞当前线程。如果在UI线程调用 get(),而任务又没完成,就会导致界面卡死(ANR) 。 2.仍然是手动管理线程,有资源开销和管理问题。
安卓中的定位:是 AsyncTask 的底层原理之一,但同样不推荐直接使用。 它提供了一种获取异步结果的思路,但直接使用过于繁琐且容易出错。 它的思想被更高级的工具吸收了。
(总结一下,在目前的实践中,以上几种"手动档"的创建线程方式都有自身的问题,在现代的安卓开发还是推荐用到线程池、协程等,但是这几种手动创建方式作为基础还是需要了解的)
2.3 线程的状态
在Thread类中定义了一个内部枚举,表示线程状态,通过getState方法可以获取线程状态
java
public static enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
private State() {
}
}
|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| New | 当线程创建后,未start前,处于NEW状态(新建),此时的线程只是一个普通的Java对象, 操作系统还没有为它分配任何CPU资源。 它仅仅在JVM的堆内存中占据了一块空间。 |
| RUNNABLE | 调用start()后,线程立即进入RUNNABLE状态,只要线程有资格运行,它就处于 RUNNABLE状态。JVM不区分"就绪"和"运行中",因为这取决于底层操作系统的调度,对Java层面来说是透明的。 |
| BLOCKED | 线程正在等待获取一个监视器锁(monitor lock),但该锁目前被其他线程持有。通常发生在线程试图进入一个 synchronized 同步代码块或方法时。此时线程会被阻塞(BLOCKED)。 |
| WAITTING | 线程正在无限期地等待另一个线程执行一个特定的动作(通知或中断)。处于此状态的线程不会被分配CPU时间,直到被显式唤醒。此时处于WAITTING状态(无限期等待)。当线程调用了没有超时参数的方法Object.wait()、Thread.join()、LockSupport.park()时会进入WAITTING,恢复条件是另一个线程调用了Object.notify() 或 Object.notifyAll()、 join()的目标线程执行完毕、另一个线程调用了LockSupport.unpark(waitingThread)、 线程被其他线程中断 (interrupt())。 |
| TIMED_WAITTING | 与WAITTING类似的有一个TIMED_WAITTING(限时等待)状态,它会在指定的时间后自动唤醒,或者被其他线程提前唤醒。 进入条件:调用了以下带有超时参数的方法:Thread.sleep(long millis)、Object.wait(long timeout)、Thread.join(long millis)、LockSupport.parkNanos(long nanos)、LockSupport.parkUntil(long deadline) 。恢复条件:等待时间结束、被其他线程通过 notify() / notifyAll() / unpark() 唤醒、被其他线程中断 (interrupt())。 |
| TERMINATED | 线程结束就进入终止状态(TERMINATED),当线程的 run() 方法已经执行完毕,或者因为一个未捕获的异常而提前退出,会进入此状态。 线程的生命周期已经结束,不能再通过调用 start() 方法来重新启动它(会抛出 IllegalThreadStateException),它在JVM中只是一个普通的 Thread 对象,等待垃圾回收。 |
BLOCKED vs WAITING / TIMED_WAITING 的关键区别:
|-------|---------------------------------------------------------------------------------------------------------------------|
| 原因不同 | BLOCKED 是被动的,因为抢不到锁。WAITING / TIMED_WAITING 是主动的,是线程自己调用了 wait(), sleep(), join() 等方法主动放弃CPU。 |
| 场景不同 | BLOCKED 只与 synchronized 关键字关联。WAITING / TIMED_WAITING 与 Object.wait(), Thread.sleep(), LockSupport.park() 等多种API关联。 |
| 是否释放锁 | 处于 BLOCKED 状态的线程不会释放它已经持有的任何锁。线程因调用 wait() 而进入 WAITING 状态时,会释放它在 wait() 对象上持有的锁。但如果调用 sleep() 或 join(),则不会释放任何锁。 |
三、线程池
3.1 为什么要有线程池、好处是什么
其实上文讲多线程的时候也说到一部分,首先是手动创建线程的几种方法都有自身的问题,不好管理,消耗大,复杂等等。使用线程池可以加快响应速度,节约创建线程时间;减少频繁创建和销毁线程导致的性能开销;避免短时间大量任务需要创建线程导致系统资源耗尽;方便统一管理线程等。
3.2 安卓中如何使用线程池
3.2.1 ThreadPoolExecutor
Java本身提供创建自定义线程池的api, ThreadPoolExecutor 构造函数,这是最灵活、最可控、最安全的方式。通过它, 你可以精确地配置线程池的每一个参数。
java
// 创建一个自定义的线程池
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maximumPoolSize, // 最大线程数
keepAliveTime, // 非核心线程的空闲存活时间
unit, // 存活时间的单位
workQueue, // 任务队列
threadFactory, // 线程工厂(用于创建新线程)
handler // 拒绝策略(当队列和线程池都满了之后的处理方式)
);
各参数的作用:
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| corePoolSize(核心线程数) | 作用:线程池中始终保持存活的线程数量,即使这些线程处于空闲状态(除非设置allowCoreThreadTimeOut=true)。 默认行为:核心线程不会被回收,适合处理突发任务时快速响应。 建议值:通常设为CPU核心数 + 1(如4核设备设为5),以平衡性能与资源占用。 |
| maximumPoolSize(最大线程数) | 作用:线程池允许创建的最大线程数量(包括核心线程和非核心线程)。 非核心线程:当任务队列满时,线程池会创建新线程(数量不超过maximumPoolSize - corePoolSize)处理任务,闲置超时后回收。 建议值:通常设为CPU核心数 × 2 + 1。 |
| keepAliveTime + unit(线程闲置超时和时间单位) | 作用:非核心线程(或核心线程若allowCoreThreadTimeOut=true)在空闲时的存活时间,超时后回收。 单位:通过unit指定(如TimeUnit.SECONDS)。 |
| workQueue(任务队列) | 作用:缓存待执行任务的阻塞队列,直接影响线程创建策略。常见类型: LinkedBlockingQueue: 无界队列(默认容量Integer.MAX_VALUE),任务过多可能导致OOM。 ArrayBlockingQueue:有界队列,需指定容量,队列满时触发非核心线程创建。 SynchronousQueue:不存储任务,直接移交线程处理(用于CachedThreadPool)。(此队列只有put和take操作,put之后必须等待其他线程take,过程中是阻塞的,take如果没有取到put的元素,也会阻塞等待) |
| threadFactory(线程工厂) | 作用:自定义线程创建逻辑(如命名线程、设置优先级)。 默认实现:Executors.defaultThreadFactory()。 |
| handler(拒绝策略) | 触发条件:当线程数达到maximumPoolSize且队列满时,处理新提交的任务。 常见策略: AbortPolicy(默认):抛出RejectedExecutionException。 CallerRunsPolicy:由提交任务的线程直接执行任务 |
向线程池提交任务:
java
// 方式一:使用 execute(Runnable) - 提交没有返回值的任务
for (int i = 0; i < 15; i++) {
final int taskId = i;
executorService.execute(() -> {
System.out.println("线程 " + Thread.currentThread().getName() +
" 正在执行任务 " + taskId);
try {
Thread.sleep(1000); // 模拟任务耗时
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
// 方式二:使用 submit(Callable) - 提交有返回值的任务
Future<String> future = executorService.submit(() -> {
System.out.println("线程 " + Thread.currentThread().getName() +
" 正在执行一个有返回值的任务...");
Thread.sleep(2000);
return "任务执行完成!";
});
// 通过Future对象获取任务结果 (get()方法会阻塞)
try {
String result = future.get();
System.out.println("获取到任务结果: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
3.2.2 Executors 类
Executors 类提供了一些静态方法来快速创建几种常见的线程池
java
// 1. 创建一个固定大小的线程池
//参数:核心线程数 = 最大线程数(固定),无超时机制,无界队列。
//场景:适合长期稳定的并发任务。
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);
java
// 2. 创建一个单线程的线程池
//参数:核心线程数 = 最大线程数 = 1,无界队列。
//场景:需顺序执行任务的场景,保证所有任务按提交顺序串行执行。
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
java
// 3. 创建一个可缓存的线程池
//参数:核心线程数=0,最大线程数=Integer.MAX_VALUE,超时60秒,队列SynchronousQueue
//场景:适合短时高频的轻量级任务
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
java
// 4. 创建一个支持定时和周期性任务的线程池
//参数:固定核心线程数,最大线程数 = Integer.MAX_VALUE,延迟队列。
//场景:延时任务
ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(5);
注: Executors 的 newFixedThreadPool 和 newCachedThreadPool 方法。因为它们内部使用的任务队列是无界的( LinkedBlockingQueue),当任务提交速度远大于处理速度时,可能导致大量任务堆积在队列中, 最终引发 OutOfMemoryError。大厂可能会考这个点,同理自己创建线程池时也要考虑到这个问题。
3.3 线程池中的管理
|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 任务提交流程 | 1.若当前线程数 < corePoolSize,立即创建核心线程执行任务。 2.若线程数 ≥ corePoolSize,任务入队等待。 3.若队列已满且线程数 < maximumPoolSize,创建非核心线程执行任务。 4.若线程数 ≥ maximumPoolSize,触发拒绝策略。 关键点:任务队列未满时,优先复用现有线程而非创建新线程。 |
| 线程复用机制 | 核心逻辑:线程执行完任务后,通过getTask()从队列获取新任务,若超时未获取则回收线程(非核心线程或允许超时的核心线程)。 优势:避免频繁创建/销毁线程的开销。 |
| 动态调整 | 非核心线程回收:通过keepAliveTime控制闲置线程的存活时间。 核心线程超时:若allowCoreThreadTimeOut=true,核心线程也会被回收。 |
非核心线程如何回收:
线程调用getTask获取任务,如果在 keepAliveTime时间内未能获取到新任务,返回null,当 getTask()返回 null时,当前线程会退出任务获取循环,随后执行清理工作并结束运行,线程随之被销毁。
四、多线程安全问题
4.1 多线程环境会有什么问题
单线程环境下,代码顺序执行,逻辑简单,也便于调试。但是为了利用好cpu性能,提高并发度,我们不得不引入多线程,优点明显,代价同样明显。
首先第一类是线程间运行顺序导致的问题,线程执行顺序不确定导致的数据错误、结果不可预测;
第二类是短时间并发量过高导致线程数量过多、任务过多导致系统资源耗尽;
第三类是为了处理线程运行顺序问题而引入线程同步中的各种问题;
|----------------|---------------------------|-------------------------------|
| 线程安全与数据竞争 | 数据不一致、结果不可预测、脏读 | 多个线程未同步地读写同一共享数据 |
| 死锁、活锁、饥饿 | 程序卡住、无进展、部分线程无法执行 | 线程间循环等待资源、相互谦让或优先级不合理导致资源无法获取 |
| **活动性问题(阻塞)** | 性能下降、响应缓慢 | 一个线程长时间持有锁,导致其他需要该锁的线程被挂起 |
| 性能开销 | 资源消耗大、CPU占用率高 | 线程创建销毁开销、频繁的上下文切换、缓存失效 |
| 运行顺序问题 | 执行结果与预期不符 | 线程由操作系统调度,执行顺序具有不确定性 |
| 资源管理问题 | 内存溢出(OOM)、服务崩溃 | 线程数量过多耗尽内存;或任务队列无限增长导致OOM |
| 线程间协作问题 | 逻辑错误、数据丢失(如异步任务失败) | 线程间通信或协调机制不当 |
| 特定技术问题 | 事务失效、调试困难、ThreadLocal数据错乱 | 数据库连接与线程绑定、异常难以复现、线程池中线程复用 |
(其中需要重点关注的是线程同步的内容,当多个线程需要访问同一份资源可能会引入线程同步)
4.2 线程安全之原子操作
4.2.1 原子操作的作用
原子操作解决了多线程环境下对单个状态变量进行快速、安全更新的问题。
适用场景:计数器(如网站访问量)、状态标志位(如开关控制)、序列号生成器等。
不适用场景:需要保护多个变量构成的复杂不变性条件,或者操作本身涉及多个步骤且需要作为整体原子执行时,原子类就不够用了,需要考虑使用锁机制。
4.2.2 AtomicReference更改对象引用的小问题
AtomicReference仅保证引用本身的原子性,即保证对引用变量(内存地址)的赋值操作是原子的,但不保证所引用对象内部字段的线程安全。如果多个线程同时修改引用对象内部的属性,仍然会产生数据竞争。
解决方案:可以考虑使用不可变对象(每次更新都创建一个新对象进行整体替换),或者对对象内部的访问使用其他同步机制
4.2.3 原子类的底层(CAS)
原子类的基石是 CAS(Compare-And-Swap/比较并交换)操作,CAS是一种无锁原子操作,用于实现多线程环境下的变量同步。它包含三个操作数:内存位置、预期原值、新值。操作逻辑为:1.读取:获取共享变量当前的值,作为"预期原值 A"。2.计算:在本地计算出新的值 B。3.写入:检查此刻共享变量的值是否还是原来的 A。如果是,说明没有其他线程修改过,就将新值 B 写入。如果不是,则操作失败,通常会重试(自旋) 整个步骤,直到成功。
根据这种逻辑,会有一个问题(ABA问题):变量值从A变成B再变回A,CAS会认为未修改,如果在使用场景中对于这种情况需要进行区分,可通过AtomicStampedReference(带版本号)解决;
缺点:若竞争激烈,CAS自旋会消耗大量CPU资源;
另外讲到CAS,可能会顺带被问AQS,区分一下二者。AQS是JUC包中同步组件的基础框架,用于构建锁(如ReentrantLock)、同步器(如Semaphore、CountDownLatch)等。它通过一个volatile int state(同步状态)和一个FIFO等待队列(CLH队列)管理线程对共享资源的访问。CLH队列:双向链表结构的等待队列,存储因竞争资源失败的线程节点(Node),节点包含线程信息、等待状态(如SIGNAL表示需要唤醒后继节点)
AQS 的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态;如果共享资源被占用,则将暂时获取不到锁的线程加入到队列中,等待被唤醒 。
其核心结构由三部分组成 :
同步状态 (state):一个 volatile int类型的变量,是 AQS 的灵魂。在不同同步器中,state的含义不同,ReentrantLock:表示锁的重入次数。Semaphore:表示可用许可的数量。CountDownLatch:表示计数器的值 。对 state的操作通过原子方法getState() , setState(), compareAndSetState() 保证线程安全 。
等待队列 (CLH 队列变体):一个双向链表结构,是实现阻塞锁的关键。当线程获取共享资源失败时,AQS 会将当前线程封装成一个 Node节点并加入队列尾部,同时阻塞该线程 。
条件队列 (ConditionObject):AQS 的内部类,实现了 Condition接口,用于支持 await/signal模式的线程间协作。每个 ConditionObject对象都维护了一个自己的单向链表(条件队列)
条件队列优点:
-
精确唤醒:每个Condition对象对应一个独立的等待队列,可以实现精确的线程唤醒,避免"惊群效应"。
-
多条件支持:一个锁可以关联多个Condition对象,支持复杂的线程协作场景。
-
中断处理:支持丰富的中断处理机制,包括可中断和不可中断的等待。
-
超时控制:提供带超时的等待方法,防止线程无限期阻塞
4.3 线程安全之Volatile
volatile关键字(轻量级同步机制), 有两个作用
内存可见性:当对一个 volatile变量执行写操作时,JVM 会向处理器发送一条带有 LOCK前缀的指令。这个指令主要有两个作用:1.将当前处理器缓存行的数据立即写回系统主内存;2.这个写回操作会使其他处理器中缓存了该内存地址的缓存行无效。这样,当其他线程需要读取该变量时,会发现缓存已失效,从而必须从主内存重新加载最新值。
取消编译优化:volatile通过内存屏障阻止编译器和处理器对volatile变量的读写操作进行重排序优化,确保程序执行顺序与代码编写顺序一致。
基于这种特性,用一个 volatile boolean变量作为线程运行或中断的标志。由于操作简单(直接赋值和判断),并能确保一个线程关闭标志后,其他线程能立刻看到;
另外一种使用场景是懒汉式单例,双重检查确保其他线程获取到的是一个完全初始化好的实例,而不是一个半成品。在这里,volatile的关键作用是防止指令重排序。
java
//new一个类时有三步,1.分配内存空间;2.初始化对象(调用构造函数);
//3.将instance引用指向分配的内存地址。由于指令重排序,可能出现instance提前指向未初始化的对象,
//其他线程调用时出现空指针。
//多线程环境,若未使用volatile修饰单例变量,当一个线程(如线程A)创建完单例实例后,
//另一个线程(如线程B)可能仍从自己的工作内存中读取到旧值(null),导致重复创建实例。
public class Singleton {
private static volatile Singleton instance; // 必须使用volatile
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
//(.class唯一,而且instance可能为null,null作为锁会抛空指针异常,所以用Singleton.class)
synchronized (Singleton.class) { // 加锁
if (instance == null) { // 第二次检查
instance = new Singleton(); // 实例化
}
}
}
return instance;
}
}
4.4 线程安全之Semaphore
Semaphore其实在操作系统中应该都学过,信号量机制。实际上就是这个东西。
其核心思想就是维护一定数量的一组资源,线程在拿资源前需要看是否有剩余资源,有资源的话线程会立即获取并继续执行(同时资源数量减一);如果没有资源可用,线程将被阻塞,直到有其他线程释放资源。
Semaphore实际上做的是管理资源,并不关联实际的物理许可对象,它只是管理一个计数器,所以使用时需要注意正确完成资源获取和释放。内部运行逻辑围绕许可证进行,代表资源数量。
|---------------|---------------------------------------------------------------------------------------------------------------------------------|
| 许可证(Permits) | Semaphore 内部维护一个计数器,表示可用许可证的数量。这个数量在创建 Semaphore 时设定。 |
| 获取许可(acquire) | 线程在执行前必须调用 acquire()方法获取许可证。如果许可证可用,线程会立即获取并继续执行(同时许可证数量减一);如果没有许可证可用,线程将被阻塞,直到有其他线程释放许可证。 |
| 释放许可(release) | 当线程使用完资源后,调用 release()方法归还许可证,使计数器增加,并唤醒一个等待的线程(如果有的话),通常需要将 release()调用放在 finally代码块中,以确保即使在执行过程中发生异常,许可证也能被释放,从而避免资源泄漏。 |
在创建 Semaphore 时,可以指定一个公平性参数(boolean fair)。当设置为 true时,它成为一个公平信号量,会按照线程请求许可证的顺序(FIFO)来分配许可,防止线程饥饿。当设置为 false时(默认值),它是非公平的。不保证请求的顺序,允许新的请求线程"插队",在吞吐量方面通常优于公平模式。
4.5 线程安全之ThreadLocal
ThreadLocal在几种线程安全手段中是比较另类的,其它手段都是通过一些机制使得执行结果和逻辑正确,而ThreadLocal它通过为每个线程创建变量的独立副本,使得每个线程都能独立地操作自己的副本,从而避免了多线程环境下的资源共享和同步问题,是一种典型的"以空间换时间"的线程安全策略。 属于是直接规避问题。
每个线程(Thread对象)内部都维护了一个名为 threadLocals的成员变量,其类型是 ThreadLocal.ThreadLocalMap。你可以将 ThreadLocalMap简单理解为一个线程私有的HashMap。当你调用 ThreadLocal实例的 set方法时,实际上是以当前操作的 ThreadLocal实例自身作为Key,以要存储的值作为Value,将这个键值对存入当前执行线程的 ThreadLocalMap中 。当你调用 get方法时,它会从当前线程的 ThreadLocalMap中,使用当前的 ThreadLocal实例作为 Key 来查找并返回对应的 Value 。这种设计确保了数据访问的隔离性:由于每个线程都有自己的ThreadLocalMap,所以一个线程永远无法读取或修改另一个线程存储的数据。
使用示例
java
public class ThreadLocalDemo {
// 推荐将 ThreadLocal 变量声明为 static,以避免创建大量 ThreadLocal 实例
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
// 方式1:通过重写 initialValue 方法设置初始值
private static ThreadLocal<Integer> threadLocalWithInitial =
new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return 0; // 初始值为0
}
};
// 方式2(推荐):使用 withInitial 工厂方法
private static ThreadLocal<Integer> anotherThreadLocal =
ThreadLocal.withInitial(() -> 0);
public static void main(String[] args) {
// 线程1
new Thread(() -> {
threadLocal.set("线程A的值");
// 输出:线程A读取: 线程A的值
System.out.println("线程A读取: " + threadLocal.get());
threadLocal.remove(); // 使用完毕后清理
}).start();
// 线程2
new Thread(() -> {
threadLocal.set("线程B的值");
// 输出:线程B读取: 线程B的值
System.out.println("线程B读取: " + threadLocal.get());
// 注意:如果在线程池等场景中,此处也必须调用 remove()
}).start();
// 主线程未设置值,获取初始值
// 输出:主线程读取: 0
System.out.println("主线程读取: " + anotherThreadLocal.get());
}
}
ThreadLocal 最需要警惕的问题是内存泄漏。
根源:ThreadLocal存储变量使用的是键值对,一个Entry包含key和value,其中key是弱引用,value是强引用,持有threadLocalMap的引用。当线程结束时,threadlocalmap可以回收,里面的所有entry也可以回收。当线程池环境下,由于线程复用,threadlocalmap一直存在,导致entry也一直存在,因为value想自动回收必须要threadlocalmap被回收,导致泄漏。
合理的解决是使用后进行remove,在代码的 finally块中显式调用 remove()方法,清理当前线程的 ThreadLocal变量 。尤其是在使用线程池时,线程会被复用,如果不清理,后续任务可能会读到之前任务设置的"脏数据";
将ThreadLocal声明为 static:这可以避免因创建多个 ThreadLocal实例而导致的内存浪费和潜在问题。
4.6 线程安全之 锁
4.6.1 锁的几个概念
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新时会判断此期间数据是否被更新,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁。
自旋锁,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需自旋,等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。线程自旋需消耗cpu的,如果一直获取不到锁,则线程长时间占用CPU自旋。
公平锁指的是锁的分配机制是公平的,通常先对锁提出获取请求的线程会先被分配到锁,加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得。对应的非公平锁允许"插队",可以概括为"先尝试,再排队",失败后才被加入到等待队列的末尾进行排队。
读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。
重量级锁(Mutex Lock)这种依赖于操作系统Mutex Lock所实现的锁我们称之为"重量级锁",操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高。
轻量级锁本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁(锁升级)。
偏向锁在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,在只有一个线程执行同步块时进一步提高性能。一旦出现多线程竞争的情况就必须撤销偏向锁。
可重入锁(递归锁),指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。
4.6.2 偏向锁
偏向锁是 Java 并发编程中synchronized锁机制的一种优化手段,在没有实际多线程竞争的情况下,如果同一个线程多次获得锁,那么后续的加锁操作可以避免昂贵的同步开销,从而提升性能。一旦出现多线程竞争,撤销成本较高。目前在jdk 15后废弃。
偏向锁的实现紧密依赖于 Java 对象的对象头,特别是其中的 Mark Word 部分,当一个可偏向的对象首次被线程加锁时,JVM 通过 CAS 操作将当前线程的 ID 写入对象的 Mark Word。如果成功,对象就进入"已偏向"状态,之后,只要该线程再次进入同步块,它会检查 Mark Word 中记录的线程 ID 是否与自己一致。如果一致,线程将直接执行同步代码,无需任何同步操作
当另一个线程也尝试获取这个已被偏向的锁时,需要解除偏向。JVM 会等待一个全局安全点,此时所有用户线程暂停,检查原持有锁的线程是否仍活跃或已退出同步块,随后,锁状态会根据竞争情况升级为轻量级锁(常见)或重量级锁(竞争激烈时)。
JVM 启动时默认延迟约4秒后才启用偏向锁。这是因为 JVM 自身初始化过程中会使用大量同步操作且存在竞争,此时开启偏向锁优化意义不大反而可能增加不必要的撤销开销;
如果一个对象已经处于偏向状态,这时程序调用了它的 hashCode()方法,会导致偏向锁立即被撤销。原因是偏向状态的 Mark Word 没有足够空间同时存储线程 ID 和哈希码
4.6.3 轻量级锁
轻量级锁的核心思想是乐观锁,它假设锁的竞争不激烈,大部分情况下线程可以快速获取到锁 。其加锁和解锁过程都依赖于 CAS 操作。轻量级锁在多个线程交替执行,竞争不激烈,且同步代码块执行时间非常短的场景适合使用。
当线程尝试进入同步块时,JVM 会先在当前线程的栈帧中创建一个锁记录,将锁对象的对象头(Mark Word)复制到锁记录的Displaced Mark Word字段中 。线程通过 CAS 操作尝试将锁对象的 Mark Word 替换为指向该锁记录的指针 。如果 CAS 成功,表示该线程成功获取了轻量级锁,锁标志位变为 00。如果 CAS 失败,说明存在竞争(另一个线程已经持有了该锁),当前线程会进入自旋状态,循环尝试获取锁。
· 线程执行完同步块后,会尝试通过 CAS 操作将 Displaced Mark Word替换回锁对象的 Mark Word 。如果 CAS 成功,表示没有发生竞争,锁被成功释放 。如果 CAS 失败,说明在持有锁期间有其他线程尝试竞争并自旋失败,导致锁已经膨胀为重量级锁。此时,JVM 会进入重量级锁的释放流程,唤醒等待队列中的线程。
当线程自旋超过一定次数(默认 10 次,可通过 JVM 参数调整)后仍未获取到锁;或者当有第三个或更多线程参与竞争时,JVM 可能直接认为竞争激烈,触发锁膨胀;一旦升级为重量级锁,锁对象头中的指针会指向一个操作系统级的互斥量(Monitor),未获取到锁的线程会被挂起,进入阻塞队列等待唤醒。
4.6.4 重量级锁
重量级锁是 Java 中 synchronized关键字在高度竞争环境下最终会采用的同步机制。它通过操作系统的互斥量(Mutex Lock)来实现,涉及线程的阻塞、唤醒以及上下文切换涉及用户态到内核态的转换,操作成本高 。重量级锁的核心是 Monitor(监视器),可以将其理解为一个特殊的房间。这个房间(临界区)一次只允许一个线程进入。
Monitor 主要包含以下关键组件:
_Owner:记录当前持有锁的线程。
_EntryList(或 ContentionList)(等待队列):当一个线程(假设为Thread A)已经持有锁,此时另一个线程(Thread B)尝试获取锁时,Thread B会被封装成 ObjectWaiter对象并放入这个队列中等待。这个队列中的线程都处于阻塞状态 。
_WaitSet:如果持有锁的线程(Thread A)调用了 wait()方法,那么该线程会释放锁并进入这个等待集合。当其他线程调用 notify()或 notifyAll()时,这些线程会被移动到 _EntryList 中,重新参与锁竞争 。
其基本工作流程是,当线程尝试进入同步块时,需要先进入Monitor。如果 _Owner 为 null,线程会通过 CAS 操作将自己设置为 _Owner。如果 _Owner 已被其他线程占用,当前线程则进入 _EntryList 阻塞等待。当持有锁的线程执行完同步代码块,释放锁时,会将 _Owner 置为 null,然后根据特定策略从 _EntryList 中唤醒一个或多个线程来竞争锁。
4.6.5 读写锁
在读操作远多于写操作的场景。它通过分离读、写锁,在保证数据一致性的前提下,最大限度地提高了程序的并发读取能力。
多个线程可以同时持有读锁并访问共享资源;如果一个线程已经持有写锁,其他所有尝试获取读锁或写锁的线程都会被阻塞。反之,如果一个或多个线程正持有读锁,尝试获取写锁的线程也会被阻塞,直到所有读锁释放;同一时刻只允许一个线程持有写锁。
Java中读写锁主要实现是ReentrantReadWriteLock,其提供了一些高级特性,支持非公平锁(默认,吞吐量高)和公平锁(按申请顺序获取,避免饥饿);允许读线程和写线程多次重复获取同一把锁;允许线程在持有写锁的情况下,再获取读锁,随后释放写锁。这样,写锁就"降级"成了读锁。
注意在持有读锁的情况下尝试获取写锁(锁升级),可能会导致死锁。因为当前线程在等待写锁,而写锁的获取需要等待其他所有读锁(包括自己持有的读锁)释放,这就形成了循环等待。
4.6.6 隐式锁synchronized
synchronized关键字是最常见的加锁手段,也叫隐式锁。在加锁的过程中会经历无锁 → 偏向锁 → 轻量级锁 → 重量级锁的升级过程。
特点:
|-----------------------------------------------------------------|
| 使用简单,但是只能锁定对象,没法做到灵活控制。 |
| 非公平锁。获取锁过程不支持中断,只能一直等待直到获取锁,不支持超时返回。 |
| 无法查询锁状态。 |
| 依赖于对象的wait()、notify()、notifyAll()方法,所有等待线程共享同一个等待队列,可能导致不必要的唤醒。 |
| synchronized的锁释放是自动的,由JVM保证即使发生异常也能正常释放锁,这避免了死锁。 |
| 可重入。 |
synchronized在不同地方使用的区别:
修饰函数,同一个实例的synchronized方法时会串行执行,不同实例的synchronized方法互不影响。
修饰静态方法,所有实例共享同一把锁,类加载时就初始化,全局唯一
修饰代码块可以指定锁住某个任意对象,精确控制同步范围,减少锁的持有时间
4.6.7 显式锁 ReentrantLock
ReentrantLock是一种基于 AQS实现的用户态锁,内部通过AQS的state变量记录锁状态和重入次数,竞争失败时线程会进入 AQS 的等待队列,可能自旋或阻塞,由 AQS 统一调度。
特点:
|---------------------------------------------------------|
| 需要开发者显式地创建锁对象、调用lock()方法获取锁,调用unlock()方法释放锁,使用灵活。 |
| 支持非公平和公平锁,ReentrantLock允许线程在等待锁的过程中响应中断,允许超时返回。 |
| 可以创建多个Condition对象,实现更精细的线程控制,允许按条件分组唤醒线程,减少不必要的唤醒。 |
| 支持查询锁状态,等待队列长度等。 |
| ReentrantLock需要手动释放锁,必须使用try-finally结构确保锁最终被释放,否则可能导致死锁 |
| |
由于jvm的优化,在性能上基本ReentrantLock和synchronized没有太大差距,在竞争激烈时可能ReentrantLock更优。如果不需要精细控制就用synchronized,否则使用ReentrantLock。