天天看点

Java多线程大厂面试题与答案

目录

线程

线程与进程的区别是什么?

线程状态图

多线程会带来哪些性能问题?

JMM内存模型

什么是JMM内存模型?

什么是指令重排序?有什么好处?

什么是内存可见性问题?

主内存和工作内存的关系是什么?

什么是 happens-before 关系?

volatile

volatile的作用是什么?

volatile如何保证可见性?

volatile是否可以保证原子性?

synchronized

synchronized有几种使用方式?

synchronized的底层实现原理是什么?

JVM对synchronized做了哪些优化?

描述锁升级的过程

wait和notify为什么需要在synchronized里面?

wait/notify 和 sleep 方法的区别是什么?

为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?

synchronize与volatile的区别是什么?

AQS

什么是AQS,内部组成有哪些?

AQS的底层结构具体是怎样的?

AQS解决了哪些问题?

AQS中state的应用有哪些?

Lock

Lock和synchronized的区别?

描述Lock的加锁的全流程

公平锁与非公平锁的区别,如何实现的?

对比悲观锁,乐观锁的优点和缺点都有哪些?

线程池

线程池的核心参数有哪些?

线程池执行任务的流程是什么?

拒绝策略有哪些?

ForkJoinPool有什么特点?

ForkJoinPool与ThreadPoolExecutor区别是什么?

JDK提供的线程池用到了哪些阻塞队列?

CPU核心数和线程数的关系是什么?

队列

队列常见api的区别?

有哪些常见的阻塞队列?

ArrayBlockingQueue的实现原理是什么?

阻塞队列和非阻塞队列在实现上有哪些区别?

多线程工具类

CountDownLatch与CyclicBarrier的区别是什么?

Future

Future与CompletableFuture区别?

FutureTask的实现原理是什么?

ThreadLocal

ThreadLocal与Thread的关系是什么?

ThreadLocal与Synchronized的区别是什么?

ThreadLocal为什么可能产生内存泄漏,如何避免?

子线程如何共享主线程的ThreadLocal变量?

线程

线程与进程的区别是什么?

  • 进程指的是应用程序在操作系统中执行的副本(系统分配资源的最小单位),线程是程序执行的最小单位;
  • 进程使用独立的数据空间,而线程共享进程的数据空间。

线程状态图

Java多线程大厂面试题与答案

多线程会带来哪些性能问题?

  • 调度开销,一般线程数往往大于CPU核心数,这样操作系统再执行线程时就会出现上下文切换,从而产生一定性能开销;
  • 协作开销,为了保证线程之间共享变量的线程安全,有可能会禁用编译器和CPU的重排序等优化,还可能会频繁的将工作内存刷新到主内存,主内存再同步给工作内存,这些开销都是单线程下不存在的。

JMM内存模型

什么是JMM内存模型?

  • JMM 是和多线程相关的一组规范,需要各个 JVM 的实现来遵守 JMM 规范;
  • JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。

什么是指令重排序?有什么好处?

  • 重排序是指编译器、JVM 或者 CPU 为了提高执行效率,对于实际指令执行的顺序进行调整;
  • 重排序通过减少执行指令,从而提高整体的运行速度。

什么是内存可见性问题?

  • 共享变量的值已经被第 1 个线程修改了,但是其他线程却看不到。

主内存和工作内存的关系是什么?

  • 所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;
  • 线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;
  • 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。

什么是 happens-before 关系?

  • 如果第一个操作和第二个操作之间满足 happens-before 关系,那么我们就说第一个操作对于第二个操作一定是可见的;

volatile

volatile的作用是什么?

  • 保证内存可见性以及多线程之间操作的有序性

volatile如何保证可见性?

  • volatile变量修饰的共享变量,在进行写操作的时候会多出一个lock前缀的汇编指令,当对其进行写操作时,JVM就会向处理器发送一条Lock前缀的指令,把这个变量所在的缓存行的数据写回到系统内存。然后处理器会根据MESI缓存一致性协议来保证多CPU下的各个高速缓存中的数据的一致性。

volatile是否可以保证原子性?

  • volatile是一种轻量级的同步机制,它主要有两个特性:
    • 保证共享变量对所有线程的可见性;
    • 禁止指令重排序优化;
  • 同时需要注意的是,在多线程场景下,如果仅仅是赋值操作,volatile可以保证原子性,但是像num++这种复合操作(取值、计算、赋值),volatile无法保证其原子性。

synchronized

synchronized有几种使用方式?

  • 类、方法、代码块

synchronized的底层实现原理是什么?

  • 每个Object对象中都内置了一个Monitor监视器,通过指令Monitor.enter和Monitor.exit进行加锁和释放锁,加锁失败的线程会被加入到一个同步队列中,当锁被释放时再重新竞争锁。

JVM对synchronized做了哪些优化?

  • 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

描述锁升级的过程

  • 偏向锁升级轻量级锁:当一个对象持有偏向锁,一旦第二个线程访问这个对象,如果产生竞争,偏向锁升级为轻量级锁。
  • 轻量级锁升级重量级锁:一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

wait和notify为什么需要在synchronized里面?

  • wait方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列,而这些操作都和监视器是相关的,所以wait必须要获得一个监视器锁。
  • 而对于notify来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里,所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。

wait/notify 和 sleep 方法的区别是什么?

  • wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求;
  • 在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁;
  • sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复;
  • wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。

为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?

  • 因为 Java 中每个对象都有一把称之为 monitor 监视器的锁,锁信息保存在对象头中,wait/notify/notifyAll 都是锁级别的操作,所以把它们定义在 Object 类中是最合适;
  • 如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。

synchronize与volatile的区别是什么?

  • volatile 可以看作是一个轻量版的 synchronized,比如一个共享变量如果自始至终只被各个线程赋值和读取,而没有其他操作的话,那么就可以用 volatile 来代替 synchronized 或者代替原子变量,足以保证线程安全;
  • volatile无法保证对“i++”一类复合操作(包括取值、计算、赋值)的原子性和互斥性,它保证了变量间的可见性,并禁用了指令重排序;
  • synchronize没有禁用指令重排序,这也是单例double check模式,对象必须用volatile修饰的原因。

AQS

什么是AQS,内部组成有哪些?

  • AQS提供了一个FIFO双向队列,可以看做是一个用来实现锁以及其他需要同步功能的框架。
  • AQS主要由三部分组成:
    • 第一个是 state,它是一个数值,在不同的类中表示不同的含义,往往代表一种状态;
    • 第二个是一个FIFO的队列,该队列用来存放阻塞状态的线程;
    • 第三个是“获取/释放”的相关方法,需要利用 AQS 的工具类根据自己的逻辑去实现。

AQS的底层结构具体是怎样的?

  • 底层是由head节点、tail节点、双向链表组成的双向队列;
  • head与tail节点主要负责节点的出队与入队,时间复杂度O(1);
  • 之所以使用双向链表而不是单向链表,是因为AQS考虑到高并发的场景下,节点的状态时刻都有可能发生变化,当前节点的一些动作需要依赖前序节点的状态,例如:
    • 只有当前节点的prev节点为head时,才有资格参与锁竞争;
    • 当前节点进入阻塞之前需要判断该节点的prev节点的状态是否为SIGNAL(节点的线程释放或被取消会通知后继节点)。

AQS解决了哪些问题?

  • 状态的原子性管理;
  • 线程的阻塞与解除阻塞;
  • 队列的管理。

AQS中state的应用有哪些?

  • 对于ReentrantLock,持有锁的线程每次lock重入,state+1,每次unlock,state -1,只有state = 0才表示彻底释放锁,其它线程才可以获取;
  • 对于Semaphore,acquire 方法代表获取许可,此时能不能获取许可取决于state的值是否足够,如果足够state值会减掉对应的许可数量,如果不够则会进入阻塞,release方法代表释放许可,state值会增加直到定义的上限值;
  • 对于CountDownLatch,await方法会判断state值是否为0,不为0则进入阻塞等待,直到其它线程通过countDown方法将state减为0才会执行;
  • 对于CyclicBarrier,线程调用await方法state会+1,如果state值小于初始设置的阈值,线程阻塞等待,直到state累加等于该阈值,所有等待的线程会一起释放,同时state会清0。

Lock

Lock和synchronized的区别?

类别 synchronized Lock
存在层次 Java的关键字,在jvm层面上 是一个接口
锁的释放

1、以获取锁的线程执行完同步代码,释放锁;

2、线程执行发生异常,jvm会让线程释放锁。

必须在finally中释放锁,不然容易造成线程死锁
锁的获取 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待。 Lock有多种获取锁的方式,如lock、tryLock
锁状态 无法判断,只能阻塞

可以判断;

tryLock();

tryLock(long time, TimeUnit unit);

可避免死锁。

锁类型 可重入,非公平,不可中断

可重入,可公平(两者皆可)

可中断:lockInterruptibly();

功能 功能单一

API丰富;

tryLock();

tryLock(long time, TimeUnit unit);

可避免死锁。

描述Lock的加锁的全流程

  • 当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取,转而被构造成为尾节点并加入AQS同步队列,这个过程通过CAS来保证的线程安全。
  • 同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。
  • 设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。

公平锁与非公平锁的区别,如何实现的?

  • 非公平锁在获取锁的时候,会先通过CAS进行抢占,而公平锁则不会;
  • 公平锁会优先从同步队列中去唤醒,这样就保证了先到先得的顺序;
  • 非公平锁的效率更高,因为唤醒线程的过程是比较耗时的,非公平锁会利用这部分时间完成其它任务,但有可能造成锁饥饿。

对比悲观锁,乐观锁的优点和缺点都有哪些?

  • 乐观锁优点:
    • 悲观锁需要遵循下面三种模式:一锁、二读、三更新,即使在没有冲突的情况下,执行也会非常慢;
    • 乐观锁本质上不是锁,它只是一个判断逻辑,资源冲突少的情况下,它不会产生任何开销;
  • 乐观锁缺点:
    • 在并发量比较高的情况下,有些线程可能会一直尝试修改某个资源,但由于冲突比较严重,一直更新不成功,这时候,就会给 CPU 带来很大的压力(并发量大可以考虑通过分段锁的方式优化,例如LongAdder,或者直接使用悲观锁);
    • 无法解决ABA问题,意思是指在 CAS 操作时,有其他的线程现将变量的值由 A 变成了 B,然后又改成了 A,当前线程在操作时,发现值仍然是 A,于是进行了交换操作。大部分场景下ABA问题不会给业务带来影响,可以通过引入版本号的方式解决。

线程池

线程池的核心参数有哪些?

public ThreadPoolExecutor(int corePoolSize, // 核心线程数
                          int maximumPoolSize, // 最大线程数
                          long keepAliveTime, // 临时线程等待时间
                          TimeUnit unit, // 时间单位
                          BlockingQueue<Runnable> workQueue, // 阻塞队列
                          RejectedExecutionHandler handler) // 拒绝策略
           

线程池执行任务的流程是什么?

Java多线程大厂面试题与答案

拒绝策略有哪些?

  • AbortPolicy(默认):丢弃任务并抛出RejectedExecutionException异常;
  • DiscardPolicy:丢弃任务,但是不抛出异常;
  • DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入到队列中;
  • CallerRunsPolicy:提交任务的主线程调用任务的run()方法,绕过线程池直接执行(这种方法不会发生数据丢失,并且可以延缓任务提交的速度,缓解线程池压力)。

ForkJoinPool有什么特点?

  • 它每个线程都有一个自己的双端队列来存储分裂出来的子任务,避免了公共队列的阻塞;
  • 采用工作窃取模式,空闲线程 t1 可以帮助繁忙线程 t0 完成工作,这也是队列设计为双端队列的目的,t0是按LIFO的顺序处理任务,而t1 在steal t0任务时会按照FIFO的顺序;
  • ForkJoinPool 非常适合用于递归的场景,例如树的遍历、最优路径搜索等场景。

ForkJoinPool与ThreadPoolExecutor区别是什么?

  • ForkJoinPool中的每个线程都会有一个队列,而ThreadPoolExecutor只有一个队列,并根据queue类型不同,细分出各种线程池;
  • ForkJoinPool能够使用数量有限的线程来完成非常多的具有父子关系的任务,ThreadPoolExecutor中根本没有什么父子关系任务;
  • ForkJoinPool在多任务,且任务分配不均是有优势,但是在单线程或者任务分配均匀的情况下,效率没有ThreadPoolExecutor高。

JDK提供的线程池用到了哪些阻塞队列?

  • LinkedBlockingQueue:FixedThreadPool 和 SingleThreadExector 的默认队列,容量为 Integer.MAX_VALUE,可以认为是无界队列,不会生成多于核心线程数的线程;
  • SynchronousQueue:CachedThreadPool的默认队列,是一种没有容量的阻塞队列。与FixedThreadPool正好相反,CachedThreadPool为了尽可能创建新的线程执行任务,它的最大线程数是 Integer.MAX_VALUE,队列容量为0;
  • DelayedWorkQueue:ScheduledThreadPool的默认队列,可以周期性执行任务或延迟一定时间执行任务,DelayedWorkQueue会按照延迟的时间长短对任务排序,内部数据结构是堆(二叉树)。

CPU核心数和线程数的关系是什么?

  • 如果是CPU密集型任务,例如加密、解密、编译、压缩、计算等任务,一般可以考虑线程数为CPU核心数的1~2倍,具体还应该参考压测结果;
  • 如果是IO密集型任务,可参考公式:线程数 = CPU 核心数 *(1 + 线程平均等待时间 / 线程平均工作时间)。

队列

队列常见api的区别?

Java多线程大厂面试题与答案

有哪些常见的阻塞队列?

  • ArrayBlockingQueue
    • 最典型的有界队列,其内部是用数组存储元素的,不会扩容,利用 ReentrantLock 实现线程安全;
  • LinkedBlockingQueue
    • 内部用链表实现的 BlockingQueue,容量默认就为整型的最大值 Integer.MAX_VALUE,一般称为无界队列;
  • SynchronousQueue
    • 容量为0的传递队列,存数据会阻塞,知道有人来存,同理取数据也会阻塞,直到有人来存;
  • PriorityBlockingQueue
    • 无界的优先级阻塞队列(有初始容量,可扩容),可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则,内部的数据结构是堆;
  • DelayQueue
    • 无界的延迟队列,DelayQueue 内部使用了 PriorityQueue 的能力来进行排序。

ArrayBlockingQueue的实现原理是什么?

  • ArrayBlockingQueue 实现并发同步的原理就是利用 ReentrantLock 和它的两个 Condition,读操作和写操作都需要先获取到 ReentrantLock 独占锁才能进行下一步操作;
  • 进行读操作时如果队列为空,线程就会进入到读线程专属的 notEmpty 的 Condition 的队列中去排队,等待写线程写入新的元素;
  • 同理,如果队列已满,这个时候写操作的线程会进入到写线程专属的 notFull 队列中去排队,等待读线程将队列元素移除并腾出空间。

阻塞队列和非阻塞队列在实现上有哪些区别?

  • 阻塞队列最主要是利用了 ReentrantLock 以及它的 Condition 来实现,而非阻塞队列则是利用 CAS 方法实现线程安全。

多线程工具类

CountDownLatch与CyclicBarrier的区别是什么?

  • 作用对象不同:CyclicBarrier 要等固定数量的线程都到达了栅栏位置才能继续执行,而 CountDownLatch 只需等待数字倒数到 0,也就是说 CountDownLatch 作用于事件,但 CyclicBarrier 作用于线程;
  • 可重用性不同:CountDownLatch 在倒数到 0  并且触发门闩打开后,就不能再次使用了,除非新建一个新的实例;而 CyclicBarrier 可以重复使用,并不需要重建实例。CyclicBarrier 还可以随时调用 reset 方法进行重置,如果重置时有线程已经调用了 await 方法并开始等待,那么这些线程则会抛出 BrokenBarrierException 异常。
  • 执行动作不同:CyclicBarrier 有执行动作 barrierAction,而 CountDownLatch 没这个功能。

Future

Future与CompletableFuture区别?

  • 通过Future接收异步任务时,主线程需要通过get()方法去阻塞轮询获取结果,如果是多个任务,则需要等到所有任务完成之后才能做后续操作;
  • 而通过CompletableFuture接收异步任务时,无需等待所有任务全部完成,每个任务都可以通过thenAccept、thenApply、thenCompose等方式将前面的结果交给另一个异步事件来处理,最后还可以通过allOf或anyOf等方法来汇总结果。

FutureTask的实现原理是什么?

  • 当我们通过Future接收异步任务时,底层是通过其实现类FutureTask的run方法来执行任务的,run方法主要完成了以下流程:
    • 检查线程的状态,执行用户定义的call方法;
    • 执行结束后,设置返回值或异常,并更新线程状态,然后唤醒队列中阻塞等待获取结果的线程;
  • 当其它线程通过future.get获取结果时:
    • 首先根据状态判断任务是否完成,如果已经完成则直接返回;
    • 如果没有完成则会阻塞当前线程,并将其加入到阻塞队列(如果没有指定阻塞时间则一直阻塞知道任务完成唤醒);
    • 当任务完成时,阻塞的线程会被唤醒,拿到结果后返回;

ThreadLocal

ThreadLocal的作用与使用场景是什么?

  • ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。
  • ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。

ThreadLocal与Thread的关系是什么?

一个 Thread 里面只有一个ThreadLocalMap ,而在一个 ThreadLocalMap 里面却可以有很多的 ThreadLocal,每一个 ThreadLocal 都对应一个 value。因为一个 Thread 是可以调用多个 ThreadLocal 的,所以 Thread 内部就采用了 ThreadLocalMap 这样 Map 的数据结构来存放 ThreadLocal 和 value。

ThreadLocal与Synchronized的区别是什么?

  • ThreadLocal 是通过让每个线程独享自己的副本,避免了资源的竞争。
  • synchronized 主要用于临界资源的分配,在同一时刻限制最多只有一个线程能访问该资源。
  • ThreadLocal 并不是用来解决共享资源的多线程访问的问题,因为每个线程中的资源只是副本,并不共享。因此ThreadLocal适合作为线程上下文变量,简化线程内传参。

ThreadLocal为什么可能产生内存泄漏,如何避免?

  • 通过ThreadLocalMap的源码可以看到,Entry中的key被定义为弱引用类型,当发生GC时,key会被直接回收,无需手动清理。
  • 而value属于强引用类型,被当前的Thread对象关联,所以说value的回收取决于Thread对象的生命周期。
    • 如果说一个线程执行完毕,线程Thread随之被释放,那么value便不存在内存泄漏的问题。
    • 然而,我们一般会通过线程池的方式来复用Thread对象来节省资源,这就会导致一个Thread对象的生命周期会非常长,随着任务的执行,value就有可能越来越多且无法释放,最终导致内存泄漏。
  • 因此,我们在使用完ThreadLocal变量后,要手动调用remove()方法来清理ThreadLocalMap(一般在finally代码块中)。

子线程如何共享主线程的ThreadLocal变量?

  • 因为ThreadLocal变量保存在当前线程的成员变量ThreadLocalMap中,新创建子线程后无法再次使用父线程的ThreadLocal变量;
  • 为了解决子线程复用主线程ThreadLocal的问题,Thread类中还有另一个ThreadLocalMap:inheritableThreadLocals,里面保存的是InheritableThreadLocal,它是ThreadLocal的子类,Thread类初始化时会默认从父线程继承inheritableThreadLocals;
  • 因此我们可以用InheritableThreadLocal代替ThreadLocal实现父子线程共享线程变量的问题。

继续阅读