[面试题] 多线程、高并发&JUC(上)
主要包括:多线程、并发编程知识、JUC等相关面试题。
文章目录
1、进程 vs 线程
- 进程是程序的一次启动执行,即“正在执行中的程序”。它是系统进行资源分配和调度的一个独立单位。
- 线程是“程序代码段”的一次顺序执行流程,是进程的一个实体。它是CPU调度的最小单位。
- 一个进程有一个或多个线程组成,一个进程至少有一个线程。
- 进程之间是相互独立的,但进程内部的各个线程之间并不完全独立;他们共享进程的方法区内存、堆内存、系统资源(文件句柄、系统信号等)。
- 线程上下文切换比进程上下文切换要快很多。
2、创建线程的几种方式?
- 继承Thread类,然后重写run()方法;
- 实现Runnable接口,然后重写run()方法;
- 实现Callable接口,重写call方法,创建FutureTask对象组合Callable实例;
- 使用线程池,创建线程池对象后让其调用execute()/submit()方法,方法中传入Runnable或Callable实例即可创建线程执行任务。
// execute方法 void execute(Runnable command); // submit方法 Future<?> submit(Runnable task); <T> Future<T> submit(Callable<T> task);
3、说说线程的生命周期
public enum State { // State是Thread内部枚举类
NEW, // 新建
RUNNABLE, // 可执行:包括操作系统的就绪和等待两种状态
BLOCKED, // 阻塞
WAITING, // 等待
TIMED_WAITING, // 限时等待
TERMINATED; // 终止
}
-
NEW状态
当线程创建成功,但还未调用start()方法时,Java线程处于NEW状态,对应操作系统线程生命周期的“新建状态”。 -
RUNNABLE状态
- 当线程调用了start()方法后,此时Java线程处于RUNNABLE状态,对应操作系统线程生命周期中的“就绪状态”,这时需要等待系统的调度,获得CPU的时间片;
- 当线程被系统选中,获得了CPU时间片,线程开始占用CPU并执行线程代码,这时对应操作系统线程生命周期的“运行状态”,但在Java线程状态依然是RUNNABLE。
-
TERMINATED状态
当处于RUNNABLE状态的线程执行完run()方法或者发生了运行时异常而没有被捕捉,则线程状态将变为TERMINATED,对应操作系统线程生命周期的“终止状态”。 -
BLOCKED状态
处于BLOCKED状态下的线程并不会占用CPU资源,以下情况会让线程进入阻塞状态:- 线程等待获取锁;
- 线程发起了阻塞式IO操作,包括磁盘IO、网络IO等,如:等待用户输入内容
-
WAITING状态
处于WAITING(无限等待)状态的线程需要被其他线程显式唤醒,才会进入就绪状态,如下方式可以进入WAITING状态:- Object.waite()方法,需要Object.notify()/notifyAll()方法唤醒;
- Thread.join()方法,需要被合入的线程执行完毕才能唤醒;
- LockSupport.park()方法,需要LockSupport.unpark(Thread)方法唤醒。
-
TIMED_WAITING状态
处于TIMED_WAITING(限时等待)状态的线程在指定时间内没有被唤醒,则该线程会被系统自动环境,进入就绪状态。以下方式将进入TIMED_WAITING状态:- Thread.sleep(time),限时结束唤醒
- Object.wait(time),可以调用Object.notify()/notifyAll()唤醒或限时结束唤醒
- LockSupport.parkNanos(time)/parkUntil(time)方法,可以调用LockSupport.unpark(Thread)方法唤醒或者限时结束唤醒
- Thread.join(time),限时结束唤醒
4、简述线程的常见操作
- sleep操作:让目前正在执行的线程休眠,让CPU去执行其他任务。
- interrupt操作
- 如果线程正处于阻塞状态,则马上退出阻塞,并抛出InterruptedException异常,线程就可以捕捉该异常做处理,然后让线程退出程序;
- 如果线程正处于运行状态,线程将不受任何影响,继续运行,仅仅是线程的中断标志被设置为true。所以,程序可以在适当的位置通过调用isInterrupted()方法来查看自己是否被中断,并执行退出操作。
- join操作:在A线程中调用B线程对象的join方法,当代码执行到此处时,需要A线程等待B线程执行结束才能继续往下执行,这就是线程的合并。
- yield操作:让目前正在执行的线程放弃当前的执行,让出CPU的执行权,使得CPU去执行其他线程。从操作系统线程生命周期来看,线程状态从执行状态变为就绪状态,即放弃本次获得的CPU时间片。
5、谈谈对线程池的认识
- 线程池负责线程的创建、维护和分配,对线程对象作统一的调度管理。
- 使用线程池有3个好处:
- 降低资源消耗。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性;通过复用已创建的线程,能降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程池提供了一种限制、管理资源的策略,维护一些基本的线程统计信息。还可以对线程资源进行统一的分配、调优和监控。
6、简述线程池的任务调度流程
- 提交任务时,先判断核心线程数(corePoolSize)是否已满:
- 如果还没满,则创建线程执行任务;
- 如果已经满了,则进入第2步判断队列是否已满
- 判断队列是否已满:
- 如果队列未满,则进入队列等待,待工作线程从任务队列中获取任务然后执行;
- 如果队列已满,则进入第3步判断最大线程数是否已满
- 判断最大线程数(maximumPoolSize)是否已满:
- 如果未满,则创建线程(非核心线程)执行任务;
- 如果已满,则只能执行任务拒绝策略。
7、介绍线程池实现类及其重要属性
- ThreadPoolExecutor是JUC线程池的核心实现类,继承自抽象类AbstractExecutorService。
- ThreadPoolExecutor类相关属性
// ThreadPoolExecutor构造方法,总共7个参数 public ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, TimeUnit unit, // 空闲线程存活时间间隔(时间 + 单位) BlockingQueue<Runnable> workQueue, // 任务阻塞队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 ){} // 线程工厂接口 public interface ThreadFactory { Thread newThread(Runnable r); } // 拒绝策略接口 public interface RejectedExecutionHandler { void rejectedExecution(Runnable r, ThreadPoolExecutor executor); }
- corePoolSize,设置核心线程数
- maximumPoolSize,设置最大线程数
- workQueue,任务阻塞队列,存储暂时无法处理的任务,常见的BlockingQueue接口的实现类包括:ArrayBlockingQueue/LinkedBlockingQueue/DelayQueue/SynchronousQueue
- keepAliveTime+unit,构成空闲线程存活时间,用于设置线程池内线程最大空闲时长;默认情况下,超过了该时间,非核心线程会被回收。如果需要将其应用于核心线程,可以调用allowCoreThreadTimeOut(true); 方法
- threadFactory,线程工厂,即创建线程实例的类,可以自定义线程工厂类用来更改线程的名称、线程组、优先级等
- handler
- 拒绝策略,即拒绝任务提交到线程池执行。
- 当线程池已经关闭或任务队列已满且maximumPoolSize已满时,任务会被拒绝。
- RejectedExecutionHandler接口的实现类包括:AbortPolicy(拒绝策略)、DiscardPolicy(抛弃策略)、DiscardOldestPolicy(抛弃最老任务策略)、CallerRunsPolicy(调用者执行策略)
8、为什么不建议使用Executors类创建线程池?
-
如果使用Executors类创建线程池,则有4种方式:
- newSingleThreadExecutor:创建只有一个线程的线程池
- newFixedThreadPool:创建固定大小的线程池
- newCachedThreadPool:创建一个不限制线程数量的线程池,任何提交的任务都将立即执行,但空闲线程会得到及时回收
- newScheduledThreadPool:创建一个可定期或延时执行任务的线程池
-
为什么不建议使用以上4种创建线程池的方式?
- newSingleThreadExecutor和newFixedThreadPool默认的任务队列是无界阻塞队列。当任务提交速度远大于执行速度时,会导致任务队列的无限扩大,很可能导致OOM;
- newCachedThreadPool和newScheduledThreadPool默认的最大核心线程数为Integer.MAX_VALUE,即可以无限创建线程。当任务提交速度远大于执行速度时,会造成大量的线程启动,很可能造成OOM。
-
以下是源码,探讨不推荐的原因,提供参考。
// Executors类 public class Executors { // newSingleThreadExecutor // new LinkedBlockingQueue<Runnable>(), 该阻塞队列对象是无界的, // 如果任务提交速度持续大于任务处理速度,就会造成队列中大量的任务等待, // 当队列很大时,很可能导致JVM出现OOM异常,使内存资源耗尽。 public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); } // newFixedThreadPool,不推荐理由与newSingleThreadExecutor相同,见上 public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); } // newCachedThreadPool // 最大线程数为Integer.MAX_VALUE,也就是说最大线程数不设上限,可以无限创建线程 // 如果任务提交的任务较多,就会造成大量的线程启动,很可能造成OOM异常,甚至导致CPU线程资源耗尽 public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); } // newScheduledThreadPool,不推荐理由与newCachedThreadPool相同,见上 // 这里提供了ScheduledThreadPoolExecutor类及其父类构造方法的源码,提供参考 public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } } // ScheduledThreadPoolExecutor类 public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor implements ScheduledExecutorService { public ScheduledThreadPoolExecutor(int corePoolSize) { // 调用父类构造方法,第二个参数是设置最大线程数 super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } } // ThreadPoolExecutor,线程池实现类 public class ThreadPoolExecutor extends AbstractExecutorService { public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, // 最大线程数 long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, Executors.defaultThreadFactory(), defaultHandler); } }
9、如何确定线程池的线程数?
- IO密集型任务
- 主要执行IO操作,执行IO操作的时间较长,CPU利用率不高
- 通常设置最佳线程数为:CPU核数的两倍
- CPU密集型任务/计算密集型任务
- 主要执行计算任务,CPU利用率较高
- 通常设置最佳线程数为:CPU的核数
- 混合型任务
- 既执行逻辑运算,有进行大量非CPU耗时操作(如:RPC调用、数据库访问、网络通信等);
- 通常将其最佳线程数设置为:(线程等待时间与线程CPU时间之比 + 1)* CPU核数
10、谈谈对ThreadLocal类的认识
-
如果程序创建了一个ThreadLocal实例,那么在访问该变量时,每个线程都有自己独立的本地值,从而起到线程隔离的作用,规避了线程安全问题。
-
ThreadLocal实例可以看成是线程专属的变量,不受其他线程干扰,保存着线程的专属数据。
-
ThreadLocal类的常用方法包括:set(T value) ,T get(),remove(),分别用于设置、获取、移除与当前线程绑定的本地值。
-
ThreadLocal主要用于线程隔离和跨函数数据传递,如传递请求过程中的用户id、session等。
-
ThreadLocal底层原理
- 底层内部结构是一个Map (key, value),但新旧版本的JDK实现有所差异。
- JDK8之前的版本,key是Thread实例,value是本地值,拥有者是ThreadLocal实例;
- JDK8开始,key是ThreadLocal实例,value是本地值,拥有者是Thread实例。
- 当threadLocal.set(T)时,获取当前线程对应的Map,将本地值存储其中;
- 当threadLocal.get()时,获取当前线程对应的Map,取出存储其中的本地值;
- 当threadLocal.remove()时,获取当前线程对应的Map,并将threadLocal对应的本地值移除。
- 这个Map就是ThreadLocal.ThreadLocalMap类,源码如下,提供参考:
static class ThreadLocalMap { // 存储多个(key, value) private Entry[] table; // 这里的Entry继承了弱引用类WeakReference static class Entry extends WeakReference<ThreadLocal<?>> { Object value; Entry(ThreadLocal<?> k, Object v) { // 调用父类构造方法使key成为弱引用对象 super(k); value = v; } } }
-
使用ThreadLocal的弊端及解决方案
- 原理:ThreadLocalMap中Entry的key使用了弱引用。在下次GC发生时,就会将没有被其他强引用之指向、仅被Entry的key所指向的ThreadLocal实例回收。同时将对应Entry中存储的key值设置为null。当ThreadLocal的get()/set(value)/remove()被调用时,ThreadLocalMap内部将清除这些key为null的Entry。
- 说明:内存泄露是指不再用到的内存没有及时释放归还系统。对于持续运行的服务进程必须及时释内存,否则内存占用率越来越高会,轻则会影响系统性能,重则导致进程崩溃甚至系统崩溃。
- 基于上述原理,使用ThreadLocal可能会发生内存泄露,其前提条件是:
- 线程长时间运行而没有被销毁;
- ThreadLocal引用已经被设置为null,但后续没有get/set/remove操作,导致key为null的Entry内存没有被释放
- 在开发过程中,推荐使用static final修饰 + 调用remove()方法,static fina修饰保证变量共享且不可改变,但使得Entry中的key永远不可能为null,可能导致内存泄露,所以可以配合调用remove()方法将threadLocal实例移除。