什么是进程、线程、协程?
- 进程:进程是指在系统中正在运行的一个应用程序,程序一旦运行就是进程。进程是系统进行资源分配的独立实体, 且每个进程拥有独立的地址空间。例如运行在电脑上的钉钉、QQ 就是一个进程,一个进程可以包含数百个线程。
- 线程:线程是操作系统进行运算的最小调度单位,一个进程中可以包含多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。线程拥有独立的内存空间,当线程需要获取其他线程的数据就需要线程通讯,线程下面还有更轻量的协程,一个线程可以包含数百个协程,go 语言中 Goroutine 就是协程的实现。
- 协程:是一种基于线程之上,但又比线程更加轻量级的线程(协程又被称为 Fiber,即纤程),这种由开发者写程序来管理的轻量级线程叫做用户空间线程,具有对内核来说不可见的特性。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切换回来的时候,恢复先前保存的寄存器上下文和栈。由于协程的暂停完全由程序控制,发生在用户态上;而线程的阻塞状态是由操作系统内核来进行切换,发生在内核态上。因此,协程的开销远远小于线程的开销,也就没有了 ContextSwitch 上的开销。例如 Golang 的 goroutine 和 JDK19 的虚拟线程都属于协程的经典例子。
什么是并行和并发?
多线程的优缺点?
多线程的优点:
- 发挥 CPU 多核的优势,CPU 资源率利用更好。得益于现代计算机的蓬勃发展,现代计算机大多数采用多核架构(例如 4 核、8 核、16 核处理器),由于线程是基本的调度单位,如果在程序中只有一个线程,那么最多同时只能在一个核心处理器上运行,其他核心处理器处于空闲状态,无法发挥 CPU 多核优势。例如在 4 核的处理器上运行单线程任务,CPU 空闲率为 75%,其余三个核心处理器都处于空闲状态,所以单线程无法发挥 CPU 多核优势。
- 易于建模。使用多线程可以将一个复杂且异步的工作流进一步分解为一组简单且同步的工作流,每个工作流在一个单独的线程运行,并在特定的同步位置进行交互。
- 防止阻塞(异步或并行执行),提高性能。程序执行效率来看,单核 CPU 并不能发挥多线程的优势,反而在单核 CPU 运行的多线程导致线程上下文的切换,从而影响执行效率。假设单核 CPU 使用单线程执行某个任务,如果一旦该线程发生阻塞就会影响后续任务的执行效率,而使用多线程并行执行任务能解决线程的阻塞。
多线程的缺点:
- 增加资源消耗。线程在运行的时,需要从计算机里获取一些资源,除了 CPU,线程还需要一些内存来维持它本地的堆栈,还需要占用操作系统中的一些资源来管理线程。多线程也会增加上下文切换的开销,当 CPU 从执行一个线程切换到另外一个线程的时候,它需要存储当前线程的本地数据,程序指针等,然后载入另一个线程的本地数据,程序指针等,最后才开始执行,这种切换称为"上下文切换"CPU 会在一个上下文中执行一个线程,然后切换到另外一个上下文中执行另一个线程。上下文的切换非常耗费系统资源,如果没有必要,应该减少上下文切换的发生。频繁地线程上下文切换,有时候多线程的执行效率远不如单线程。
- 多线程可能会造成线程安全问题。多个线程共享同一全局资源时,可能会发生数据安全问题,操作后的数据与预期数据不符,这就是线程安全问题。线程安全问题的解决办法有同步机制(synchronized)、线程本地存储(ThreadLocal)、加锁等方案。
Java 创建线程有那几种方式?
Java 进程中每一个线程都对应着一个 Thread 实例,线程的描述信息保存在 Thread 的实例属性上,用于 JVM 进行线程管理和调度。Java 提供了四种方式用于创建线程:
- 继承 Thread 类重写 run()创建线程。由于 Java 不支持多继承(Java 类仅能继承一个类),继承 Thread 类创建线程的方式会限制类的继承,在开发环境中和生产环境都不推荐使用。
- 实现 Runnable 接口重写 run()创建线程。
- 实现 Callable 接口重写 call()或 FutureTask 创建线程。
- 基于线程池创建线程。线程池可以复用线程,减少因创建或销毁线程带来的开销,且可以更好管理线程,推荐使用线程池创建线程。
停止线程的方式有哪些?
通过 Thread 的 interrupt()关闭线程。Thread 类提供了 interrupt()用于中断线程,通过 Thread 的 isInterrupted()方法可以判断线程是否处于中断状态。如果该线程在调用 Object 类的 wait()方法时被阻塞,或者在调用该类的 join()、sleep()方法中被阻塞,则其中断状态将被清除,并将抛出 InterruptedException 异常。
通过 stop()、suspend()、resume()停止线程。JDK 提供了一系列管理线程,如 start()、stop()、resume()、suspend()、destroy(),除了 start()方法外,其他方法都被标记为已废弃,使用这些已废弃的方法可能导致操作不安全问题,JDK 推荐使用 interrupt()终止线程。stop()、resume()、suspend()废弃原因如下:
- stop()用于立即停止线程,调用 stop()方法会立刻停止 run()方法中执行的任务(包括在 catch 或 finally 语句中的逻辑),并抛出 ThreadDeath 异常(通常情况下此异常不需要显式的捕获),因此可能会导致一些清理性的工作被中断,例如关闭文件或断开数据库连接等操作。调用 stop()方法会立即释放该线程所持有的所有的锁,导致数据得不到同步,从而出现数据不一致的问题。
- suspend()用于挂起线程,如果线程处于存活状态,则该该线程被挂起,并且在调用 resumed()前被阻塞。使用 suspend 容易导致死锁。如果目标线程在监视器上持有一个锁,以保护挂起的关键系统资源,则在目标线程恢复之前,任何线程都不能访问该资源。如果将恢复目标线程的线程在调用 resume 之前试图锁定此监视器,则会导致死锁。
- resume()用于恢复被挂起的线程,由于此方法仅用于恢复被挂起的线程,所以很容易死锁。
通过 volatile+标志位停止线程。使用 volatile+标志位停止线程在某些特殊的情况下(例如线程被长时间阻塞),无法及时感知线程被中断,因此 volatile+标志位停止线程并不能关闭线程的实时性。
Runnable 与 Callable 的区别?
Callable 接口与 Runnable 接口创建线程的区别在于:
- 重写方法不同。Runnable 接口创建线程需要重写 run(),Callable 接口创建线程需要重写 call()。
- 方法返回值不同。Runnable 接口的 run()无返回值,Callable 接口的 call()有返回值,返回值类型取决于 Callable 接口的泛型参数。
- call()内部支持抛出异常,run()内部不支持抛出异常。
Thread 的 start()与 run()的区别?
- start():start()是 Thread 类提供用于启动线程的方法,当调用 start() 方法时,会启动一个新的线程,并在新线程中执行 run() 方法。start() 方法负责启动新线程,然后立即返回,不会等待新线程执行完成。
- run():run()是 Thread 类提供用于定义执行任务的方法,当调用 run() 方法,它将在当前线程(调用 run 方法的线程)中执行,而不会启动新线程。
wait()和 sleep()的区别?
wait() 和 sleep() 是两种不同的方法,用于在编程中实现暂停或延迟的效果,两者区别如下:
- wait():wait()是 Object 类的方法,用于等待当前线程,实现线程之间的协作。wait 通常与 notify() 和 notifyAll() 一起使用,用于线程之间的等待和通知机制。当调用 wait()时,它会释放对象的锁,让其他线程可以获取锁并执行。等待的线程会进入等待池,直到被其他线程通过 notify() 或 notifyAll() 唤醒。注意:wait() 必须在同步块内调用,因为它要求当前线程释放对象的锁。
- sleep():sleep()是 Thread 类提供的静态方法,用于休眠当前线程,调用 sleep() 不会释放线程所持有的任何锁。
什么是线程池?
由于 Java 线程的创建、销毁都非常昂贵,需要 JVM 和 OS 配和完成大量的工作:
必须为线程堆栈分配和初始化大量内存块。其中包含至少 1MB 的栈内存。
需要进行系统调用,以便在 OS 中创建和注册本地线程。 为了解决在高并发情况下频繁创建线程导致的资源开销较大问题,Java 采用池化技术提供了线程池,使用线程池具有如下好处:
降低创建线程带来的资源开销,提升性能。线程池能独立负责线程的创建、维护和分配。在执行大量异步任务时,应将任务交给线程池调度,线程池会尽可能使用空闲的线程去执行异步任务,最大限度的对已创建的线程进行复用,从而提升性能。注意: 在实际开发应禁止显式的创建线程,而是通过线程池创建线程,减少创建线程带来的资源开销。
易于线程管理。每个 Java 线程池都会保持一些基本的线程信息,例如完成的任务数量、空闲时间等,以便于对线程进行有效的管理,使得调度更为高效。
创建线程池的四种方式?
Executors 是一个静态工厂类,它通过静态工厂方法返回 ExecutorService、ScheduledExecutorService 等线程池实例对象,这些静态工厂方法提供了创建线程池的快捷方式。Executors 工厂类提供如下四种方式创建线程池:
- newSingleThreadExecutor():创建只有一个线程的线程池。
- newFixedThreadExecutor():创建固定大小的线程池。
- newCachedThreadExecutor():创建一个不限制线程数量的线程池,任何提交的任务都将立即执行,但空闲线程会得到及时回收。
- newScheduleThreadExecutor():创建一个可定期或延时执行任务的线程池。
线程池的参数有哪些?
Executors 线程池工厂类虽然提供了创建线程池的便捷方式(其内部也基于 ThreadPoolExecutor,定时任务基于 ScheduledThreadPoolExecutor),但 Executors 存在如下问题:
- newFixedThreadPool()和 newSingleThreadPool()线程池内部处理的队列为无界队列,允许的队列长度为
Integer.MAX_VALUE,在处理大量任务时会导致任务堆积,从而导致 OOM(内存溢出)。 - newCachedThreadPool()和 newScheduleThreadExecutor()允许的创建线程数量为
Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM(内存溢出)。
在实际开发中,因根据实际情况基于 ThreadPoolExecutor 自定义线程池,ThreadPoolExecutor 提供了多个构造方法重载,签名如下:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)- corePoolSize 和 maximumPoolSize:corePoolSize 用于设置线程池核心线程数量,maximumPoolSize 用于设置最大线程数量。线程池执行器会根据 corePoolSize 和 maximumPoolSize 自动维护线程池中的工作线程,工作流程如下:
- 当线程池接收新任务,并且当前的工作线程少于 corePoolSize 时,即使其他工作线程处于空闲状态,线程池也会创建一个新的线程处理任务,直到线程数为 corePoolSize。
- 如果当前工作线程数多于 corePoolSize,但小于 maximumPoolSize,那么只有任务排队队列已满时才会创建新的线程。通过设置 corePoolSize 和 maximumPoolSize 相同数量,可以创建一个固定大小的线程池。
- 当 maximumPoolSize 被设置为无界值(如 Integer.MAX_VALUE)时,线程池可以接收任意数量的并发任务。
- corePoolSize 和 maximumPoolSize 可以通过 setCorePoolSize()和 setMaximumPoolSize()设置。
- keepAliveTime:线程构造器的 keepAliveTime(空闲线程存活时间)参数用于设置池内线程最大 Idle(空闲)时间(或者说包活时间) ,如果超过该时间,默认情况下空闲和非核心线程会被回收。Idle 超时策略仅适用与存在超过 corePoolSize 线程的情况。但若调用了 allowCoreThreadTimeOut(boolean) 方法,并且传入的参数为 true,则 keepAliveTime 参数所设置的 Idle 超时策略也将被应用于核心线程。
- unit:线程 keepAliveTime(存活时间)的时间单位。
- workQueue:BlockingQueue(阻塞队列)的实例用于暂时存储异步任务。如果线程池的核心线程都在忙,那么所接收到的目标任务缓存在阻塞队列中。
- threadFactory:线程工厂。线程工厂用于创建线程。
- handler:线程拒绝策略。
线程池的 submit()与 execute()的区别?
线程池提供的 execute()和 submit()都可以向线程池中提交任务,两者区别如下:
- execute()仅支持 Runnable 接口实例,而 submit()既支持 Runnable 接口又支持 Callable 接口。
- execute()无返回值,submit()返回一个 Future 对象,通过 Future 的 get()可以获取任务的执行结果。
- execute()不支持声明异常,submit()支持支持声明异常,submit()返回的 Future 对象在调用 get()获取执行结果时,可以捕获异步执行过程中所抛出的受检异常和运行时异常。submit()源码最终还是调用 execute()执行任务。
线程池的执行原理?
什么是线程安全问题?
线程安全是指多个线程并发的访问某个资源(Java 对象)时,无论系统如何调度这些线程,也无论这些线程如何交替操作,这个对象都能表现出一致的、正确的行为,那么表示对这个对象的操作是线程安全的。即多线程情况下的执行结果与单线程情况的执行结果一致,如果不一致则表示出现了线程安全问题。线程安全与线程的原子性、可见性、有序性三个特性相关,想要保证线程安全,必须要确保线程的原子性、可见性、有序性,只要有一个未得到保证,就可能会出现线程安全问题。
线程的三个特性?
线程的三个特性包括原子性、可见性、有序性,只要有一个未得到保证,就可能会出现线程安全问题。
- 原子性: 原子性是指一个操作是不可中断的,要么操作全部执行成功,要么操作全部执行失败。在多线程环境中,原子性保证了对共享变量的操作是一个不可分割的单元,不存在线程间的干扰。一般通过同步机制(如锁)和原子类来保证线程的原子性。
- 可见性:可见性是指当一个线程修改了共享变量的值时,其他线程能够立即看到这个修改。在多核处理器或多处理器系统中,线程之间的可见性问题可能由于各个线程的工作内存与主内存之间的数据同步延迟而导致。一般通过 volatile 关键字、synchronized 关键字、以及显式的读写锁等机制以确保线程的可见性。
- 有序性:有序性是指程序执行的顺序按照代码的先后顺序执行。在多线程环境中,编译器和处理器为了优化性能会对指令进行重排序,可能导致代码的执行顺序与预期不一致。一般通过使用同步机制(如锁)或者通过 volatile 关键字来禁止指令重排序,以确保线程的有序性。
volatile 的实现原理?
volatile 是 Java 中的关键字,用于修饰变量,具有特定的并发语义。使用 volatile 关键字修饰的变量具有以下两个主要特性:
- 保证线程的可见性,但不保证线程的原子性。JMM (Java Memory Model,即 Java 内存模型) 是一种规范,定义了 Java 中多线程并发访问共享内存的行为规则。在 JMM 中,主要主内存(Main Memory)和线程本地内存(Thread Local Memory)两个内存区域。主内存是所有线程共享的内存区域,包含了所有的共享变量,而线程本地内存是线程私有的内存区域,存储了该线程私有的变量副本。线程可见性是指当多个线程对共享变量进行修改时,由于缓存、编译器优化、指令重排序等原因导致其他线程不可见,从而无法获取最新值,出现数据不一致性问题。volatile 关键字可以保证线程的可见性,当一个线程对 volatile 变量进行写操作时,该操作会立即刷新到主内存中,当其他线程读取这个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,并从主内存中读取共享变量,因此它们会看到最新写入的值,而不会使用线程本地缓存中的旧值。volatile 保证了写操作对于所有线程都是可见的,解决了多线程之间的数据可见性问题,但是无法保证复合操作的原子性。volatile 通常搭配 CAS 解决线程安全问题。
- 禁止指令重排序。指令重排序是编译器和处理器为了优化性能而对指令序列进行排序的一种手段。指令重排序遵循不会对有数据依赖关系的操作进行重排序和重排序不能影响单线程下的执行结果两个规则。volatile 关键字可以禁止编译器和处理器对被标记变量的读写操作进行重排序,这样可以确保 volatile 变量的写操作在它的后续读操作之前被执行,防止了指令重排序导致的意外行为。使用 volatile 修饰共享变量,在编译时,JVM 会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,volatile 禁止指令重排序遵循如下规则:
- 写操作先行规则(Write-Read Ordering):在单个线程内,对 volatile 变量的写操作先于后续对这个变量的读操作。
- 读操作先行规则(Read-Read Ordering):在单个线程内,对 volatile 变量的读操作先于后续对这个变量的读操作。
- 写-读操作组合规则:在单个线程内,对 volatile 变量的写操作先于后续对其他变量的读写操作。
- 锁规则(Lock Ordering):在多线程环境下,如果一个线程先于其他线程获取锁,并对 volatile 变量进行写操作,那么其他线程在获取同一把锁时,一定能看到前一个线程对 volatile 变量的写操作。
什么是死锁?
在多线程场景下,死锁是指两个或多个线程因为互相持有对方所需的资源而陷入无限等待的状态,导致程序无法继续执行下去。死锁通常发生在系统资源有限、线程互斥、线程持有资源并等待其他线程释放资源的情况下。产生死锁的四个必要条件如下:
- 互斥条件:至少有一个资源是不能被共享的,一次只能被一个线程或进程占用。
- 占有且等待条件:一个线程或进程可以持有至少一个资源,并请求获取其他资源。
- 不可抢占条件:已经分配给线程的资源不能被强制性地抢占,只能由持有它的线程主动释放。
- 环路等待条件:存在一个等待循环,即若干线程之间形成环路,每个线程都在等待下一个线程所持有的资源。 当以上四个条件同时满足时,就有可能发生死锁。解决死锁问题通常需要破坏其中一个或多个必要条件,以防止死锁的发生。
Java知识库