写点什么

三天吃透 Java 并发面试八股文

作者:Geek_Yin
  • 2025-06-08
    湖南
  • 本文字数:18471 字

    阅读完需:约 61 分钟

分享 50 道 Java 并发高频面试题。线程池线程池:一个管理线程的池子。为什么平时都是使用线程池创建线程,直接 new 一个线程不好吗?嗯,手动创建线程有两个缺点


不受控风险频繁创建开销大


为什么不受控?系统资源有限,每个人针对不同业务都可以手动创建线程,并且创建线程没有统一标准,比如创建的线程有没有名字等。当系统运行起来,所有线程都在抢占资源,毫无规则,混乱场面可想而知,不好管控。频繁手动创建线程为什么开销会大?跟 new Object() 有什么差别?虽然 Java 中万物皆对象,但是 new Thread() 创建一个线程和 new Object()还是有区别的。new Object()过程如下:


JVM 分配一块内存 M 在内存 M 上初始化该对象将内存 M 的地址赋值给引用变量 obj


创建线程的过程如下:


JVM 为一个线程栈分配内存,该栈为每个线程方法调用保存一个栈帧每一栈帧由一个局部变量数组、返回值、操作数堆栈和常量池组成每个线程获得一个程序计数器,用于记录当前虚拟机正在执行的线程指令地址系统创建一个与 Java 线程对应的本机线程将与线程相关的描述符添加到 JVM 内部数据结构中线程共享堆和方法区域


创建一个线程大概需要 1M 左右的空间(Java8,机器规格 2c8G)。可见,频繁手动创建/销毁线程的代价是非常大的。为什么使用线程池?


降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。提高响应速度。当任务到达时,可以不需要等到线程创建就能立即执行。提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完内存。


线程池执行原理?


线程池执行流程


当线程池里存活的线程数小于核心线程数 corePoolSize 时,这时对于一个新提交的任务,线程池会创建一个线程去处理任务。当线程池里面存活的线程数小于等于核心线程数 corePoolSize 时,线程池里面的线程会一直存活着,就算空闲时间超过了 keepAliveTime,线程也不会被销毁,而是一直阻塞在那里一直等待任务队列的任务来执行。当线程池里面存活的线程数已经等于 corePoolSize 了,这是对于一个新提交的任务,会被放进任务队列 workQueue 排队等待执行。当线程池里面存活的线程数已经等于 corePoolSize 了,并且任务队列也满了,假设 maximumPoolSize>corePoolSize,这时如果再来新的任务,线程池就会继续创建新的线程来处理新的任务,知道线程数达到 maximumPoolSize,就不会再创建了。如果当前的线程数达到了 maximumPoolSize,并且任务队列也满了,如果还有新的任务过来,那就直接采用拒绝策略进行处理。默认的拒绝策略是抛出一个 RejectedExecutionException 异常。


本文已经收录到大彬精心整理的大厂面试手册,手册包含计算机基础、Java 基础、多线程、JVM、数据库、Redis、Spring、Mybatis、SpringMVC、SpringBoot、分布式、微服务、设计模式、架构、校招社招分享等高频面试题,非常实用,有小伙伴靠着这份手册拿过字节 offer~ 【领取/点击】


线程池参数有哪些?ThreadPoolExecutor 的通用构造函数:arduino 代码解读复制代码 public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);


1、corePoolSize:当有新任务时,如果线程池中线程数没有达到线程池的基本大小,则会创建新的线程执行任务,否则将任务放入阻塞队列。当线程池中存活的线程数总是大于 corePoolSize 时,应该考虑调大 corePoolSize。2、maximumPoolSize:当阻塞队列填满时,如果线程池中线程数没有超过最大线程数,则会创建新的线程运行任务。否则根据拒绝策略处理新任务。非核心线程类似于临时借来的资源,这些线程在空闲时间超过 keepAliveTime 之后,就应该退出,避免资源浪费。3、BlockingQueue:存储等待运行的任务。4、keepAliveTime:非核心线程空闲后,保持存活的时间,此参数只对非核心线程有效。设置为 0,表示多余的空闲线程会被立即终止。5、TimeUnit:时间单位代码解读复制代码 TimeUnit.DAYSTimeUnit.HOURSTimeUnit.MINUTESTimeUnit.SECONDSTimeUnit.MILLISECONDSTimeUnit.MICROSECONDSTimeUnit.NANOSECONDS


6、ThreadFactory:每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。arduino 代码解读复制代码 public class MyThreadFactory implements ThreadFactory {private final String poolName;


public MyThreadFactory(String poolName) {    this.poolName = poolName;}
public Thread newThread(Runnable runnable) { return new MyAppThread(runnable, poolName);//将线程池名字传递给构造函数,用于区分不同线程池的线程}
复制代码


}


7、RejectedExecutionHandler:当队列和线程池都满了的时候,根据拒绝策略处理新任务。代码解读复制代码 AbortPolicy:默认的策略,直接抛出 RejectedExecutionExceptionDiscardPolicy:不处理,直接丢弃 DiscardOldestPolicy:将等待队列队首的任务丢弃,并执行当前任务 CallerRunsPolicy:由调用线程处理该任务


线程池大小怎么设置?如果线程池线程数量太小,当有大量请求需要处理,系统响应比较慢,会影响用户体验,甚至会出现任务队列大量堆积任务导致 OOM。如果线程池线程数量过大,大量线程可能会同时抢占 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了执行效率。CPU 密集型任务(N+1) : 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,多出来的一个线程是为了防止某些原因导致的线程阻塞(如 IO 操作,线程 sleep,等待锁)而带来的影响。一旦某个线程被阻塞,释放了 CPU 资源,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。I/O 密集型任务(2N) : 系统的大部分时间都在处理 IO 操作,此时线程可能会被阻塞,释放 CPU 资源,这时就可以将 CPU 交出给其它线程使用。因此在 IO 密集型任务的应用中,可以多配置一些线程,具体的计算方法:最佳线程数 = CPU 核心数 * (1/CPU 利用率) = CPU 核心数 * (1 + (IO 耗时/CPU 耗时)),一般可设置为 2N。


最全面的 Java 面试网站


线程池的类型有哪些?适用场景?常见的线程池有 FixedThreadPool、SingleThreadExecutor、CachedThreadPool 和 ScheduledThreadPool。这几个都是 ExecutorService 线程池实例。FixedThreadPool 固定线程数的线程池。任何时间点,最多只有 nThreads 个线程处于活动状态执行任务。arduino 代码解读复制代码 public static ExecutorService newFixedThreadPool(int nThreads) {return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());}


使用无界队列 LinkedBlockingQueue(队列容量为 Integer.MAX_VALUE),运行中的线程池不会拒绝任务,即不会调用 RejectedExecutionHandler.rejectedExecution()方法。maxThreadPoolSize 是无效参数,故将它的值设置为与 coreThreadPoolSize 一致。keepAliveTime 也是无效参数,设置为 0L,因为此线程池里所有线程都是核心线程,核心线程不会被回收(除非设置了 executor.allowCoreThreadTimeOut(true))。适用场景:适用于处理 CPU 密集型的任务,确保 CPU 在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。需要注意的是,FixedThreadPool 不会拒绝任务,在任务比较多的时候会导致 OOM。SingleThreadExecutor 只有一个线程的线程池。csharp 代码解读复制代码 public static ExecutionService newSingleThreadExecutor() {return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());}


使用无界队列 LinkedBlockingQueue。线程池只有一个运行的线程,新来的任务放入工作队列,线程处理完任务就循环从队列里获取任务执行。保证顺序的执行各个任务。适用场景:适用于串行执行任务的场景,一个任务一个任务地执行。在任务比较多的时候也是会导致 OOM。CachedThreadPool 根据需要创建新线程的线程池。csharp 代码解读复制代码 public static ExecutorService newCachedThreadPool() {return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());}


如果主线程提交任务的速度高于线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。使用没有容量的 SynchronousQueue 作为线程池工作队列,当线程池有空闲线程时,SynchronousQueue.offer(Runnable task)提交的任务会被空闲线程处理,否则会创建新的线程处理任务。适用场景:用于并发执行大量短期的小任务。CachedThreadPool 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。ScheduledThreadPoolExecutor 在给定的延迟后运行任务,或者定期执行任务。在实际项目中基本不会被用到,因为有其他方案选择比如 quartz。使用的任务队列 DelayQueue 封装了一个 PriorityQueue,PriorityQueue 会对队列中的任务进行排序,时间早的任务先被执行(即 ScheduledFutureTask 的 time 变量小的先执行),如果 time 相同则先提交的任务会被先执行(ScheduledFutureTask 的 squenceNumber 变量小的先执行)。执行周期任务步骤:


线程从 DelayQueue 中获取已到期的 ScheduledFutureTask(DelayQueue.take())。到期任务是指 ScheduledFutureTask 的 time 大于等于当前系统的时间;


执行这个 ScheduledFutureTask;


修改 ScheduledFutureTask 的 time 变量为下次将要被执行的时间;


把这个修改 time 之后的 ScheduledFutureTask 放回 DelayQueue 中(DelayQueue.add())。


适用场景:周期性执行任务的场景,需要限制线程数量的场景。一个项目使用多个线程池还是一个线程池?项目中如果有多个场景需要使用线程池,那么最好的方式是:每一个业务场景使用独立的线程池。不要让所有的场景共用一个线程池。1)独立的线城池之间互相不影响彼此的任务作业,更有利于保证本任务的独立性和完整性,更符合低耦合的设计思想 2)如果所有的场景共用一个线程池,可能会出现问题,比如有任务 A、任务 B、任务 C 这三个任务场景共用一个线程池。当任务 A 请求量剧烈增加的时候就会导致任务 B 和任务 C,没有可用的线程,可能出现迟迟获取不到资源的情况。比如任务 A 同时有 3000 个线程请求,此时就可能会导致 任务 B 和任务 C 分配不到资源或者分配到很少的线程资源。注:1.JDK 自带的类使用了很多的线程池; 2.很多开源框架使用了大量的线程池; 3.自己的应用也会创建多个线程池; 4.多少个线程池,每个线程池提供多少线程,必须经过详细的测试 execute 和 submit 的区别 execute 只能提交 Runnable 类型的任务,无返回值。submit 既可以提交 Runnable 类型的任务,也可以提交 Callable 类型的任务,会有一个类型为 Future 的返回值,但当任务类型为 Runnable 时,返回值为 null。execute 在执行任务时,如果遇到异常会直接抛出,而 submit 不会直接抛出,只有在使用 Future 的 get 方法获取返回值时,才会抛出异常 execute 所属顶层接口是 Executor,submit 所属顶层接口是 ExecutorService,实现类 ThreadPoolExecutor 重写了 execute 方法,抽象类 AbstractExecutorService 重写了 submit 方法。进程线程进程是指一个内存中运行的应用程序,每个进程都有自己独立的一块内存空间。线程是比进程更小的执行单位,它是在一个进程中独立的控制流,一个进程可以启动多个线程,每条线程并行执行不同的任务。线程的生命周期初始(NEW) :线程被构建,还没有调用 start()。运行(RUNNABLE) :包括操作系统的就绪和运行两种状态。阻塞(BLOCKED) :一般是被动的,在抢占资源中得不到资源,被动的挂起在内存,等待资源释放将其唤醒。线程被阻塞会释放 CPU,不释放内存。等待(WAITING) :进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。超时等待(TIMED_WAITING) :该状态不同于 WAITING,它可以在指定的时间后自行返回。终止(TERMINATED) :表示该线程已经执行完毕。


图片来源:Java 并发编程的艺术


讲讲线程中断?线程中断即线程运行过程中被其他线程给打断了,它与 stop 最大的区别是:stop 是由系统强制终止线程,而线程中断则是给目标线程发送一个中断信号,如果目标线程没有接收线程中断的信号并结束线程,线程则不会终止,具体是否退出或者执行其他逻辑取决于目标线程。线程中断三个重要的方法:1、java.lang.Thread#interrupt 调用目标线程的 interrupt()方法,给目标线程发一个中断信号,线程被打上中断标记。2、java.lang.Thread#isInterrupted()判断目标线程是否被中断,不会清除中断标记。3、java.lang.Thread#interrupted 判断目标线程是否被中断,会清除中断标记。arduino 代码解读复制代码 private static void test2() {Thread thread = new Thread(() -> {while (true) {Thread.yield();


        // 响应中断        if (Thread.currentThread().isInterrupted()) {            System.out.println("Java技术栈线程被中断,程序退出。");            return;        }    }});thread.start();thread.interrupt();
复制代码


}


创建线程有哪几种方式?


通过扩展 Thread 类来创建多线程通过实现 Runnable 接口来创建多线程实现 Callable 接口,通过 FutureTask 接口创建线程。使用 Executor 框架来创建线程池。


继承 Thread 创建线程代码如下。run()方法是由 jvm 创建完操作系统级线程后回调的方法,不可以手动调用,手动调用相当于调用普通方法。java 代码解读复制代码/**


  • @author: 程序员大彬

  • @time: 2021-09-11 10:15*/public class MyThread extends Thread {public MyThread() {}

  • @Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println(Thread.currentThread() + ":" + i);}}

  • public static void main(String[] args) {MyThread mThread1 = new MyThread();MyThread mThread2 = new MyThread();MyThread myThread3 = new MyThread();mThread1.start();mThread2.start();myThread3.start();}}


Runnable 创建线程代码:typescript 代码解读复制代码/**


  • @author: 程序员大彬

  • @time: 2021-09-11 10:04*/public class RunnableTest {public static void main(String[] args){Runnable1 r = new Runnable1();Thread thread = new Thread(r);thread.start();System.out.println("主线程:["+Thread.currentThread().getName()+"]");}}


class Runnable1 implements Runnable{@Overridepublic void run() {System.out.println("当前线程:"+Thread.currentThread().getName());}}


实现 Runnable 接口比继承 Thread 类所具有的优势:


可以避免 java 中的单继承的限制线程池只能放入实现 Runable 或 Callable 类线程,不能直接放入继承 Thread 的类


Callable 创建线程代码:java 代码解读复制代码/**


  • @author: 程序员大彬

  • @time: 2021-09-11 10:21*/public class CallableTest {public static void main(String[] args) {Callable1 c = new Callable1();

  • }


}


class Callable1 implements Callable<Integer> {


@Overridepublic Integer call() throws Exception {    int sum = 0;
for (int i = 0; i <= 100; i++) { sum += i; } return sum;}
复制代码


}


使用 Executor 创建线程代码:typescript 代码解读复制代码/**


  • @author: 程序员大彬

  • @time: 2021-09-11 10:44*/public class ExecutorsTest {public static void main(String[] args) {//获取 ExecutorService 实例,生产禁用,需要手动创建线程池 ExecutorService executorService = Executors.newCachedThreadPool();//提交任务 executorService.submit(new RunnableDemo());}}


class RunnableDemo implements Runnable {@Overridepublic void run() {System.out.println("大彬");}}


什么是线程死锁?线程死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去。如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方持有的资源,所以这两个线程就会互相等待而进入死锁状态。


死锁下面通过例子说明线程死锁,代码来自并发编程之美。csharp 代码解读复制代码 public class DeadLockDemo {private static Object resource1 = new Object();//资源 1private static Object resource2 = new Object();//资源 2


public static void main(String[] args) {    new Thread(() -> {        synchronized (resource1) {            System.out.println(Thread.currentThread() + "get resource1");            try {                Thread.sleep(1000);            } catch (InterruptedException e) {                e.printStackTrace();            }            System.out.println(Thread.currentThread() + "waiting get resource2");            synchronized (resource2) {                System.out.println(Thread.currentThread() + "get resource2");            }        }    }, "线程 1").start();
new Thread(() -> { synchronized (resource2) { System.out.println(Thread.currentThread() + "get resource2"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread() + "waiting get resource1"); synchronized (resource1) { System.out.println(Thread.currentThread() + "get resource1"); } } }, "线程 2").start();}
复制代码


}


代码输出如下:arduino 代码解读复制代码 Thread[线程 1,5,main]get resource1Thread[线程 2,5,main]get resource2Thread[线程 1,5,main]waiting get resource2Thread[线程 2,5,main]waiting get resource1


线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000)。让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。线程死锁怎么产生?怎么避免?死锁产生的四个必要条件:


互斥:一个资源每次只能被一个进程使用


请求与保持:一个进程因请求资源而阻塞时,不释放获得的资源


不剥夺:进程已获得的资源,在未使用之前,不能强行剥夺


循环等待:进程之间循环等待着资源


避免死锁的方法:


互斥条件不能破坏,因为加锁就是为了保证互斥一次性申请所有的资源,避免线程占有资源而且在等待其他资源占有部分资源的线程进一步申请其他资源时,如果申请不到,主动释放它占有的资源按序申请资源


线程 run 和 start 的区别?


当程序调用 start()方法,将会创建一个新线程去执行 run()方法中的代码。run()就像一个普通方法一样,直接调用 run()的话,不会创建新线程。一个线程的 start() 方法只能调用一次,多次调用会抛出 java.lang.IllegalThreadStateException 异常。run() 方法则没有限制。


线程都有哪些方法?start 用于启动线程。getPriority 获取线程优先级,默认是 5,线程默认优先级为 5,如果不手动指定,那么线程优先级具有继承性,比如线程 A 启动线程 B,那么线程 B 的优先级和线程 A 的优先级相同 setPriority 设置线程优先级。CPU 会尽量将执行资源让给优先级比较高的线程。interrupt 告诉线程,你应该中断了,具体到底中断还是继续运行,由被通知的线程自己处理。当对一个线程调用 interrupt() 时,有两种情况:


如果线程处于被阻塞状态(例如处于 sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个 InterruptedException 异常。


如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true。不过,被设置中断标志的线程可以继续正常运行,不受影响。


interrupt() 并不能真正的中断线程,需要被调用的线程自己进行配合才行。join 等待其他线程终止。在当前线程中调用另一个线程的 join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。yield 暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。sleep 使线程转到阻塞状态。millis 参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,线程自动转为 Runnable 状态。volatile 底层原理 volatile 是轻量级的同步机制,volatile 保证变量对所有线程的可见性,不保证原子性。


当对 volatile 变量进行写操作的时候,JVM 会向处理器发送一条 LOCK 前缀的指令,将该变量所在缓存行的数据写回系统内存。由于缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。


来看看缓存一致性协议是什么。缓存一致性协议:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量时,就会从内存重新读取。


volatile 关键字的两个作用:


保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。禁止进行指令重排序。


指令重排序是 JVM 为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止处理器重排序。插入一个内存屏障,相当于告诉 CPU 和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个 volatile 字段进行写操作,Java 内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。


synchronized 的用法有哪些?


修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁


synchronized 的作用有哪些?原子性:确保线程互斥的访问同步代码;可见性:保证共享变量的修改能够及时可见;有序性:有效解决重排序问题。synchronized 底层实现原理?synchronized 同步代码块的实现是通过 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor 的持有权(monitor 对象存在于每个 Java 对象的对象头中, synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因)。其内部包含一个计数器,当计数器为 0 则可以成功获取,获取后将锁计数器设为 1 也就是加 1。相应的在执行 monitorexit 指令后,将锁计数器设为 0 ,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止 synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。volatile 和 synchronized 的区别是什么?


volatile 只能使用在变量上;而 synchronized 可以在类,变量,方法和代码块上。volatile 至保证可见性;synchronized 保证原子性与可见性。volatile 禁用指令重排序;synchronized 不会。volatile 不会造成阻塞;synchronized 会。


ReentrantLock 和 synchronized 区别


使用 synchronized 关键字实现同步,线程执行完同步代码块会自动释放锁,而 ReentrantLock 需要手动释放锁。synchronized 是非公平锁,ReentrantLock 可以设置为公平锁。ReentrantLock 上等待获取锁的线程是可中断的,线程可以放弃等待锁。而 synchonized 会无限期等待下去。ReentrantLock 可以设置超时获取锁。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。ReentrantLock 的 tryLock() 方法可以尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回 true,否则返回 false。


wait()和 sleep()的异同点?相同点:


它们都可以使当前线程暂停运行,把机会交给其他线程任何线程在调用 wait()和 sleep()之后,在等待期间被中断都会抛出 InterruptedException


不同点:


wait()是 Object 超类中的方法;而 sleep()是线程 Thread 类中的方法对锁的持有不同,wait()会释放锁,而 sleep()并不释放锁唤醒方法不完全相同,wait()依靠 notify 或者 notifyAll、中断、达到指定时间来唤醒;而 sleep()到达指定时间被唤醒调用 wait()需要先获取对象的锁,而 Thread.sleep()不用


Runnable 和 Callable 有什么区别?


Callable 接口方法是 call(),Runnable 的方法是 run();Callable 接口 call 方法有返回值,支持泛型,Runnable 接口 run 方法无返回值。Callable 接口 call()方法允许抛出异常;而 Runnable 接口 run()方法不能继续上抛异常。


线程执行顺序怎么控制?假设有 T1、T2、T3 三个线程,你怎样保证 T2 在 T1 执行完后执行,T3 在 T2 执行完后执行?可以使用 join 方法解决这个问题。比如在线程 A 中,调用线程 B 的 join 方法表示的意思就是 :A 等待 B 线程执行完毕后(释放 CPU 执行权),在继续执行。代码如下:csharp 代码解读复制代码 public class ThreadTest {


public static void main(String[] args) {
Thread spring = new Thread(new SeasonThreadTask("春天")); Thread summer = new Thread(new SeasonThreadTask("夏天")); Thread autumn = new Thread(new SeasonThreadTask("秋天"));
try { //春天线程先启动 spring.start(); //主线程等待线程spring执行完,再往下执行 spring.join(); //夏天线程再启动 summer.start(); //主线程等待线程summer执行完,再往下执行 summer.join(); //秋天线程最后启动 autumn.start(); //主线程等待线程autumn执行完,再往下执行 autumn.join(); } catch (InterruptedException e) { e.printStackTrace(); }}
复制代码


}


class SeasonThreadTask implements Runnable{


private String name;
public SeasonThreadTask(String name){ this.name = name;}
@Overridepublic void run() { for (int i = 1; i <4; i++) { System.out.println(this.name + "来了: " + i + "次"); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } }}
复制代码


}


运行结果:makefile 代码解读复制代码春天来了: 1 次春天来了: 2 次春天来了: 3 次夏天来了: 1 次夏天来了: 2 次夏天来了: 3 次秋天来了: 1 次秋天来了: 2 次秋天来了: 3 次


守护线程是什么?守护线程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。在 Java 中垃圾回收线程就是特殊的守护线程。线程间通信方式 1、使用 Object 类的 wait()/notify() 。Object 类提供了线程间通信的方法:wait()、notify()、notifyAll(),它们是多线程通信的基础。其中,wait/notify 必须配合 synchronized 使用,wait 方法释放锁,notify 方法不释放锁。wait 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了 notify(),notify 并不释放锁,只是告诉调用过 wait()的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放,调用 wait() 的一个或多个线程就会解除 wait 状态,重新参与竞争对象锁,程序如果可以再次得到锁,就可以继续向下运行。2、使用 volatile 关键字。基于 volatile 关键字实现线程间相互通信,其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。3、使用 JUC 工具类 CountDownLatch。jdk1.5 之后在 java.util.concurrent 包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch 基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。4、基于 LockSupport 实现线程间的阻塞和唤醒。LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。ThreadLocal 线程本地变量。当使用 ThreadLocal 维护变量时,ThreadLocal 为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程。ThreadLocal 原理每个线程都有一个 ThreadLocalMap(ThreadLocal 内部类),Map 中元素的键为 ThreadLocal,而值对应线程的变量副本。


调用 threadLocal.set()-->调用 getMap(Thread)-->返回当前线程的 ThreadLocalMap<ThreadLocal, value>-->map.set(this, value),this 是 threadLocal 本身。源码如下:scss 代码解读复制代码 public void set(T value) {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null)map.set(this, value);elsecreateMap(t, value);}


ThreadLocalMap getMap(Thread t) {return t.threadLocals;}


void createMap(Thread t, T firstValue) {t.threadLocals = new ThreadLocalMap(this, firstValue);}


调用 get()-->调用 getMap(Thread)-->返回当前线程的 ThreadLocalMap<ThreadLocal, value>-->map.getEntry(this),返回 value。源码如下:ini 代码解读复制代码 public T get() {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null) {ThreadLocalMap.Entry e = map.getEntry(this);if (e != null) {@SuppressWarnings("unchecked")T result = (T)e.value;return result;}}return setInitialValue();}


threadLocals 的类型 ThreadLocalMap 的键为 ThreadLocal 对象,因为每个线程中可有多个 threadLocal 变量,如 longLocal 和 stringLocal。csharp 代码解读复制代码 public class ThreadLocalDemo {ThreadLocal<Long> longLocal = new ThreadLocal<>();


public void set() {    longLocal.set(Thread.currentThread().getId());}public Long get() {    return longLocal.get();}
public static void main(String[] args) throws InterruptedException { ThreadLocalDemo threadLocalDemo = new ThreadLocalDemo(); threadLocalDemo.set(); System.out.println(threadLocalDemo.get());
Thread thread = new Thread(() -> { threadLocalDemo.set(); System.out.println(threadLocalDemo.get()); } );
thread.start(); thread.join();
System.out.println(threadLocalDemo.get());}
复制代码


}


ThreadLocal 并不是用来解决共享资源的多线程访问问题,因为每个线程中的资源只是副本,不会共享。因此 ThreadLocal 适合作为线程上下文变量,简化线程内传参。ThreadLocal 内存泄漏的原因?每个线程都有⼀个 ThreadLocalMap 的内部属性,map 的 key 是 ThreaLocal,定义为弱引用,value 是强引用类型。垃圾回收的时候会⾃动回收 key,而 value 的回收取决于 Thread 对象的生命周期。一般会通过线程池的方式复用线程节省资源,这也就导致了线程对象的生命周期比较长,这样便一直存在一条强引用链的关系:Thread --> ThreadLocalMap-->Entry-->Value,随着任务的执行,value 就有可能越来越多且无法释放,最终导致内存泄漏。解决⽅法:每次使⽤完 ThreadLocal 就调⽤它的 remove()⽅法,手动将对应的键值对删除,从⽽避免内存泄漏。ThreadLocal 使用场景有哪些?场景 1ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。这种场景通常用于保存线程不安全的工具类,典型的使用的类就是 SimpleDateFormat。假如需求为 500 个线程都要用到 SimpleDateFormat,使用线程池来实现线程的复用,否则会消耗过多的内存等资源,如果我们每个任务都创建了一个 simpleDateFormat 对象,也就是说,500 个任务对应 500 个 simpleDateFormat 对象。但是这么多对象的创建是有开销的,而且这么多对象同时存在在内存中也是一种内存的浪费。可以将 simpleDateFormat 对象给提取了出来,变成静态变量,但是这样一来就会有线程不安全的问题。我们想要的效果是,既不浪费过多的内存,同时又想保证线程安全。此时,可以使用 ThreadLocal 来达到这个目的,每个线程都拥有一个自己的 simpleDateFormat 对象。场景 2ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。比如 Java web 应用中,每个线程有自己单独的 Session 实例,就可以使用 ThreadLocal 来实现。AQS 原理 AQS,AbstractQueuedSynchronizer,抽象队列同步器,定义了一套多线程访问共享资源的同步器框架,许多并发工具的实现都依赖于它,如常用的 ReentrantLock/Semaphore/CountDownLatch。AQS 使用一个 volatile 的 int 类型的成员变量 state 来表示同步状态,通过 CAS 修改同步状态的值。当线程调用 lock 方法时 ,如果 state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state 加 1。如果 state 不为 0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。arduino 代码解读复制代码 private volatile int state;//共享变量,使用 volatile 修饰保证线程可见性


同步器依赖内部的同步队列(一个 FIFO 双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态(独占或共享 )构造成为一个节点(Node)并将其加入同步队列并进行自旋,当同步状态释放时,会把首节点中的后继节点对应的线程唤醒,使其再次尝试获取同步状态。


ReentrantLock 是如何实现可重入性的?ReentrantLock 内部自定义了同步器 sync,在加锁的时候通过 CAS 算法,将线程对象放到一个双向链表中,每次获取锁的时候,检查当前维护的那个线程 ID 和当前请求的线程 ID 是否 一致,如果一致,同步状态加 1,表示锁被当前线程获取了多次。源码如下:ini 代码解读复制代码 final boolean nonfairTryAcquire(int acquires) {final Thread current = Thread.currentThread();int c = getState();if (c == 0) {if (compareAndSetState(0, acquires)) {setExclusiveOwnerThread(current);return true;}}else if (current == getExclusiveOwnerThread()) {int nextc = c + acquires;if (nextc < 0) // overflowthrow new Error("Maximum lock count exceeded");setState(nextc);return true;}return false;}


锁的分类公平锁与非公平锁按照线程访问顺序获取对象锁。synchronized 是非公平锁,Lock 默认是非公平锁,可以设置为公平锁,公平锁会影响性能。scss 代码解读复制代码 public ReentrantLock() {sync = new NonfairSync();}


public ReentrantLock(boolean fair) {sync = fair ? new FairSync() : new NonfairSync();}


共享式与独占式锁共享式与独占式的最主要区别在于:同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。悲观锁与乐观锁悲观锁,每次访问资源都会加锁,执行完同步代码释放锁,synchronized 和 ReentrantLock 属于悲观锁。乐观锁,不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。乐观锁最常见的实现就是 CAS。适用场景:


悲观锁适合写操作多的场景。乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。


乐观锁有什么问题?乐观锁避免了悲观锁独占对象的问题,提高了并发性能,但它也有缺点:


乐观锁只能保证一个共享变量的原子操作。长时间自旋可能导致开销大。假如 CAS 长时间不成功而一直自旋,会给 CPU 带来很大的开销。ABA 问题。CAS 的原理是通过比对内存值与预期值是否一样而判断内存值是否被改过,但是会有以下问题:假如内存值原来是 A, 后来被一条线程改为 B,最后又被改成了 A,则 CAS 认为此内存值并没有发生改变。可以引入版本号解决这个问题,每次变量更新都把版本号加一。


什么是 CAS?CAS 全称 Compare And Swap,比较与交换,是乐观锁的主要实现方式。CAS 在不使用锁的情况下实现多线程之间的变量同步。ReentrantLock 内部的 AQS 和原子类内部都使用了 CAS。CAS 算法涉及到三个操作数:


需要读写的内存值 V。进行比较的值 A。要写入的新值 B。


只有当 V 的值等于 A 时,才会使用原子方式用新值 B 来更新 V 的值,否则会继续重试直到成功更新值。以 AtomicInteger 为例,AtomicInteger 的 getAndIncrement()方法底层就是 CAS 实现,关键代码是 compareAndSwapInt(obj, offset, expect, update),其含义就是,如果 obj 内的 value 和 expect 相等,就证明没有其他线程改变过这个变量,那么就更新它为 update,如果不相等,那就会继续重试直到成功更新值。CAS 存在的问题?CAS 三大问题:


ABA 问题。CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA 问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从 A-B-A 变成了 1A-2B-3A。


JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,原子更新带有版本号的引用类型。


循环时间长开销大。CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来非常大的开销。


只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证操作的原子性的。


Java 从 1.5 开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。原子类基本类型原子类使用原子的方式更新基本类型


AtomicInteger:整型原子类 AtomicLong:长整型原子类 AtomicBoolean :布尔型原子类


AtomicInteger 类常用的方法:java 代码解读复制代码 public final int get() //获取当前的值 public final int getAndSet(int newValue)//获取当前的值,并设置新的值 public final int getAndIncrement()//获取当前的值,并自增 public final int getAndDecrement() //获取当前的值,并自减 public final int getAndAdd(int delta) //获取当前的值,并加上预期的值 boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)public final void lazySet(int newValue)//最终设置为 newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。


AtomicInteger 类主要利用 CAS (compare and swap) 保证原子操作,从而避免加锁的高开销。数组类型原子类使用原子的方式更新数组里的某个元素


AtomicIntegerArray:整形数组原子类 AtomicLongArray:长整形数组原子类 AtomicReferenceArray :引用类型数组原子类


AtomicIntegerArray 类常用方法:java 代码解读复制代码 public final int get(int i) //获取 index=i 位置元素的值 public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValuepublic final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增 public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减 public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值 boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update)public final void lazySet(int i, int newValue)//最终 将 index=i 位置的元素设置为 newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。


引用类型原子类


AtomicReference:引用类型原子类 AtomicStampedReference:带有版本号的引用类型原子类。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。AtomicMarkableReference :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来


为什么要使用 Executor 线程池框架呢?


每次执行任务都通过 new Thread()去创建线程,比较消耗性能,创建一个线程是比较耗时、耗资源的调用 new Thread()创建的线程缺乏管理,可以无限制的创建,线程之间的相互竞争会导致过多占用系统资源而导致系统瘫痪直接使用 new Thread()启动的线程不利于扩展,比如定时执行、定期执行、定时定期执行、线程中断等都不好实现


如何停止一个正在运行的线程?


使用共享变量的方式。共享变量可以被多个执行相同任务的线程用来作为是否停止的信号,通知停止线程的执行。使用 interrupt 方法终止线程。当一个线程被阻塞,处于不可运行状态时,即使主程序中将该线程的共享变量设置为 true,但该线程此时根本无法检查循环标志,当然也就无法立即中断。这时候可以使用 Thread 提供的 interrupt()方法,因为该方法虽然不会中断一个正在运行的线程,但是它可以使一个被阻塞的线程抛出一个中断异常,从而使线程提前结束阻塞状态。


什么是 Daemon 线程?后台(daemon)线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这个线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。必须在线程启动之前调用 setDaemon()方法,才能把它设置为后台线程。注意:后台进程在不执行 finally 子句的情况下就会终止其 run()方法。比如:JVM 的垃圾回收线程就是 Daemon 线程,Finalizer 也是守护线程。SynchronizedMap 和 ConcurrentHashMap 有什么区别?SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访问 map。JDK1.8 ConcurrentHashMap 采用 CAS 和 synchronized 来保证并发安全。数据结构采用数组+链表/红黑二叉树。synchronized 只锁定当前链表或红黑二叉树的首节点,支持并发访问、修改。 另外 ConcurrentHashMap 使用了一种不同的迭代方式。当 iterator 被创建后集合再发生改变就不再是抛出 ConcurrentModificationException,取而代之的是在改变时 new 新的数据从而不影响原有的数据 ,iterator 完成后再将头指针替换为新的数据 ,这样 iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变。怎么判断线程池的任务是不是执行完了?有几种方法:1、使用线程池的原生函数 isTerminated() ;executor 提供一个原生函数 isTerminated()来判断线程池中的任务是否全部完成。如果全部完成返回 true,否则返回 false。2、使用重入锁,维持一个公共计数。所有的普通任务维持一个计数器,当任务完成时计数器加一(这里要加锁),当计数器的值等于任务数时,这时所有的任务已经执行完毕了。3、使用 CountDownLatch。它的原理跟第二种方法类似,给 CountDownLatch 一个计数值,任务执行完毕后,调用 countDown()执行计数值减一。最后执行的任务在调用方法的开始调用 await()方法,这样整个任务会阻塞,直到这个计数值为零,才会继续执行。这种方式的缺点就是需要提前知道任务的数量。4、submit 向线程池提交任务,使用 Future 判断任务执行状态。使用 submit 向线程池提交任务与 execute 提交不同,submit 会有 Future 类型的返回值。通过 future.isDone()方法可以知道任务是否执行完成。什么是 Future?在并发编程中,不管是继承 thread 类还是实现 runnable 接口,都无法保证获取到之前的执行结果。通过实现 Callback 接口,并用 Future 可以来接收多线程的执行结果。Future 表示一个可能还没有完成的异步任务的结果,针对这个结果可以添加 Callback 以便在任务执行成功或失败后作出相应的操作。举个例子:比如去吃早点时,点了包子和凉菜,包子需要等 3 分钟,凉菜只需 1 分钟,如果是串行的一个执行,在吃上早点的时候需要等待 4 分钟,但是因为你在等包子的时候,可以同时准备凉菜,所以在准备凉菜的过程中,可以同时准备包子,这样只需要等待 3 分钟。Future 就是后面这种执行模式。Future 接口主要包括 5 个方法:


get()方法可以当任务结束后返回一个结果,如果调用时,工作还没有结束,则会阻塞线程,直到任务执行完毕 get(long timeout,TimeUnit unit)做多等待 timeout 的时间就会返回结果 cancel(boolean mayInterruptIfRunning)方法可以用来停止一个任务,如果任务可以停止(通过 mayInterruptIfRunning 来进行判断),则可以返回 true,如果任务已经完成或者已经停止,或者这个任务无法停止,则会返回 false。isDone()方法判断当前方法是否完成 isCancel()方法判断当前方法是否取消


最后给大家分享 200 多本计算机经典书籍 PDF 电子书,包括 C 语言、C++、Java、Python、前端、数据库、操作系统、计算机网络、数据结构和算法、机器学习、编程人生等,感兴趣的小伙伴可以自取:【领取/点击】


用户头像

Geek_Yin

关注

还未添加个人签名 2022-08-19 加入

还未添加个人简介

评论

发布
暂无评论
三天吃透Java并发面试八股文_编程_Geek_Yin_InfoQ写作社区