【八股必备】多线程面试题2
第一部分线程基础与概念篇1. 线程模型面试官先来个基础题Java程序里的线程和操作系统线程是什么关系是一回事吗候选人好的。在绝大多数情况下比如我们常用的Windows、Linux系统Java线程与操作系统的内核线程是一一对应的关系。这被称为11线程模型。当我们通过Java代码创建一个线程new Thread()并调用start()方法时JVM的底层会通过平台相关的调用比如在Linux上它会调用pthread_create这个系统API来创建一个内核线程。这个内核线程负责执行Java线程中的run()方法。操作系统负责所有线程的调度它会分配给每个线程时间片然后在CPU核心上执行。所以Java里的线程调度、上下文切换这些底层的开销最终都是落在操作系统内核身上的。2. 线程创建方式面试官Java里创建线程有哪些方式你能聊聊它们的优缺点吗候选人Java中创建线程主要有四种方式继承Thread类重写run()方法。实现Runnable接口实现run()方法然后作为任务传递给Thread对象。实现Callable接口配合FutureTaskCallable的call()方法有返回值还能抛出异常。使用线程池通过ExecutorService.submit()提交任务这是企业级开发中最推荐的。面试官追问为什么说线程池是最推荐的Runnable和Callable你平时怎么选候选人 先说Runnable和Callable。它们其实都是任务Runnable是Java 1.0就有的它的run()方法既没返回值也不能抛出受检异常适合那种只管执行、不需要知道结果的场景比如打日志。而Callable是Java 5引入的它的call()方法可以返回一个结果比如执行一个计算任务后把结果返回给主线程还可以用Future来获取这个结果或处理异常。至于为什么最推荐线程池核心原因就两点资源控制和性能。降低开销线程的创建和销毁是需要时间和系统资源的。线程池可以复用一堆已经创建好的线程避免了频繁创建和销毁带来的性能损耗。提高响应速度任务来了如果线程池里有现成的空闲线程可以立刻执行不用等新线程创建。管理能力线程池能帮助我们有效控制系统资源。如果不加限制地创建线程在高并发下系统资源会迅速耗尽导致服务器OOM内存溢出。线程池可以限制最大线程数起到保护作用。3. 线程状态面试官Java线程有哪几种状态能描述一下它们之间的流转过程吗候选人Java线程在生命周期中一共定义了6种状态定义在Thread.State这个枚举类里。NEW新建线程刚被创建出来比如new Thread()但还没调用start()。RUNNABLE可运行调用了start()之后线程就进入这个状态。这个状态其实包含了两个子状态就绪等待CPU调度和运行中正在CPU上执行。在Java层面不管是等CPU时间片还是正在运行都统一叫RUNNABLE。BLOCKED阻塞线程在竞争synchronized隐式锁时如果没抢到锁就会进入这个状态被阻塞在锁池外面直到它拿到了锁才会重新变为RUNNABLE。WAITING等待线程调用了Object.wait()、Thread.join()或LockSupport.park()这些方法后会主动进入无限期等待状态。这时候它需要等其他线程做一个特定的动作比如notify或unpark才能被唤醒然后回到RUNNABLE状态。TIMED_WAITING超时等待类似于WAITING但线程调用的是带有超时参数的方法比如Thread.sleep(long)、Object.wait(long)、LockSupport.parkNanos()等。等时间一到线程就会自动醒来变成RUNNABLE。TERMINATED终止线程的run()方法正常执行完毕或者中途抛出了一个未捕获的异常线程生命周期就结束了。为了更直观可以想象一个流程图NEW → (start) → RUNNABLE ←→ BLOCKED/WAITING/TIMED_WAITING (相互切换) → (run()结束) → TERMINATED。4.sleep()vswait()面试官sleep()和wait()有什么区别这题看着简单但我希望你能把细节说透。候选人好的我分别从几个核心维度来对比一下。所属类不同sleep()是Thread类的静态方法所以任何时候都可以直接Thread.sleep()调用。wait()是Object类的实例方法必须通过某个对象实例来调用比如lock.wait()。锁的释放不同这是最本质的区别。sleep()方法不会释放任何持有的锁它就只是让自己暂停执行抱着锁睡大觉。而wait()方法会释放它持有的那个对象锁然后才进入等待队列这样其他线程就有机会拿到锁执行同步代码。使用前提不同sleep()可以在任何地方用。wait()必须在同步代码块或同步方法里用也就是必须先拿到那个对象的监视器锁否则会抛IllegalMonitorStateException。唤醒方式不同sleep()是定时自动苏醒时间到了就自然回到就绪态。wait()需要依靠其他线程在同一个锁对象上调用notify()或notifyAll()来唤醒如果没有被唤醒理论上它会一直等下去当然也可以调用带超时的wait(long)。面试官追问既然wait()会释放锁那它释放CPU吗BLOCKED和WAITING状态又有什么区别候选人关于CPU会的。只要线程不再是RUNNABLE状态无论是sleep、wait还是被阻塞操作系统都会收回分配给它的CPU时间片让其他线程得以执行。BLOCKEDvsWAITING这两个状态有本质不同。触发方式不同BLOCKED是被动的线程想进入synchronized代码块但锁被别人占着被JVM强制塞进了阻塞状态。而WAITING是线程主动调wait()、join()等方法进入的等待状态。唤醒机制不同BLOCKED状态的线程不需要别人显式唤醒它只等着持有锁的线程释放锁然后由系统自动去竞争锁。WAITING状态的线程必须由其他线程通过notify/notifyAll或unpark显式唤醒否则会一直等下去。第二部分锁与线程安全篇5.synchronized的实现与优化面试官synchronized在JDK 1.6之后做了很多优化被称为“锁升级”或“锁膨胀”能具体讲讲这个过程吗候选人好的。在JDK 1.6之前synchronized是 heavyweight lock直接依赖操作系统的互斥量Mutex Lock涉及用户态和内核态的切换开销很大。1.6之后引入了锁升级机制目的是尽可能地用开销小的方式解决并发问题。整个升级过程是单向的无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁HotSpot作者发现大多数情况下锁不仅不存在多线程竞争而且总是由同一线程多次获得。所以当一个线程第一次获得锁时会在对象头和栈帧的锁记录里存储这个线程的ID。以后这个线程再进入同步块时只需要检查这个偏向锁是否还指向自己是的话就直接进入无需CAS操作。这相当于给这个线程开了个“专用通道”。轻量级锁如果另一个线程也开始竞争这个偏向锁偏向锁会被撤销升级为轻量级锁。这个阶段其他线程会通过自旋循环CAS操作来尝试获取锁而不是立即挂起。自旋就是让线程在那儿“空转”一会看能不能等到持有锁的线程快速释放。这样做是为了避免线程挂起和恢复带来的昂贵内核态切换开销。重量级锁如果自旋次数太多比如超过10次或者自旋期间又有新的线程加入竞争锁就会升级为重量级锁。此时没抢到锁的线程都会被操作系统挂起并阻塞进入BLOCKED状态等待操作系统的调度。这时候的性能开销就最大了。这个过程本质上就是“用CAS操作和自旋尽可能地避免真正的线程阻塞和操作系统内核态切换”。面试官追问那JVM还有哪些其他的锁优化手段候选人除了锁升级还有三种重要的优化锁消除JVM通过逃逸分析如果发现一个对象只在一个线程内部使用根本不会被其他线程访问到那编译器就会把这个对象上的所有sychronized锁操作都消除掉因为它根本没有必要存在。锁粗化如果JVM检测到一系列连续的加锁、解锁操作都是针对同一个对象比如在一个循环里反复加锁解锁它会把这些操作合并成一个范围更大的锁也就是把锁放到循环外面从而减少频繁获取和释放锁带来的性能消耗。自适应自旋JVM会“学习”上次自旋等待同一个锁的结果。如果上次自旋成功拿到锁JVM就会认为这次自旋成功概率也高让线程多自旋一会如果上次没成功就减少自旋次数甚至直接跳过自旋避免白白浪费CPU。6.synchronizedvsReentrantLock面试官既然synchronized已经这么优秀了那ReentrantLock还有存在的必要吗聊聊它们之间的区别。候选人当然有必要。ReentrantLock是后来JUC包提供的API层面的锁它在灵活性上远超synchronized。它们的主要区别体现在以下几个方面维度synchronizedReentrantLock关键字/类Java关键字JVM层面实现JUC包下的一个类API层面实现锁的获取与释放自动。进入同步块自动获取退出自动释放手动。必须显式lock()和unlock()unlock()推荐放在finally里公平性非公平锁默认非公平但构造时可指定为公平锁中断响应不支持。线程在等待锁时不能被中断支持。通过lockInterruptibly()可以响应中断避免死锁超时获取锁不支持支持。通过tryLock(long timeout, TimeUnit unit)条件变量只有一个等待队列通过wait/notify通信可创建多个Condition实现更精细的线程间协调总结一下如果是简单的同步需求代码里没有特别复杂的协作逻辑用synchronized就挺好它简洁且不易出错。但如果需要实现公平锁、可中断锁、带超时的锁或者需要多个等待队列来协调线程比如生产者消费者模型一个Condition唤醒生产者一个唤醒消费者就必须用ReentrantLock。7.volatile的作用面试官volatile关键字有什么用它为什么不能保证线程安全候选人volatile主要有两个核心作用保证可见性当一条线程修改了一个volatile变量的值新值会立即刷新回主内存。其他线程读取这个变量时会强制从主内存重新读取而不是读自己工作内存里的缓存副本。这保证了所有线程看到的变量值是最新的。禁止指令重排序编译器和处理器为了提高性能可能会对指令进行重新排序。volatile关键字会通过插入内存屏障指令来禁止它前后的特定类型指令重排序确保程序执行的顺序符合代码编写的逻辑。典型的例子就是单例模式的双重检查锁instance new Singleton()这行代码如果不加volatile可能因为指令重排导致其他线程拿到一个未初始化完整的对象。至于为什么不能保证线程安全因为volatile不保证原子性。比如count这个操作它包含“读-改-写”三步。假设两个线程同时读到的count都是0然后各自加1最后写回内存的结果可能是1而不是2。volatile只能保证读的时候是最新值但无法保证这个“读-改-写”的过程不被其他线程打断。要保证复合操作的原子性还是得靠synchronized、Lock或者AtomicInteger。8. AQS原理面试官你刚才多次提到JUC包那你知道JUC的基石是什么吗聊聊AQS。候选人JUC包的基石是AQS全称AbstractQueuedSynchronizer。它是一个抽象类为我们构建锁和同步器如ReentrantLock、Semaphore、CountDownLatch等提供了一个强大的框架。AQS的核心思想是如果请求的共享资源空闲就把当前请求的线程设置为工作线程如果资源被占就用一套线程阻塞等待以及被唤醒时锁分配的机制来管理。这套机制主要依靠三个核心组件state状态一个用volatile修饰的int类型变量用于表示同步状态。比如在ReentrantLock里state表示锁被重入的次数在CountDownLatch里它表示还需要倒计数的次数。对state的修改必须保证线程安全AQS提供了getState、setState以及基于Unsafe类的compareAndSetStateCAS操作。CLH队列FIFO双向队列这是一个虚拟的、先进先出的双向队列。当线程获取同步状态失败时AQS会将当前线程和等待状态等信息封装成一个Node节点并把它加入到这个队列的尾部同时阻塞该线程。当同步状态释放时会唤醒队列头部的节点通常是首节点让其再次尝试获取state。模板方法AQS使用了模板方法设计模式。它定义好了获取和释放同步状态的流程比如acquire、release方法但将具体的获取/释放逻辑比如tryAcquire、tryRelease留给子类去实现。这样不同的子类就可以定义不同的同步语义。简单说AQS帮我们搞定了线程的排队、阻塞和唤醒这些最复杂、最容易出错的底层操作我们只需要通过CAS操作state并实现自己的获取/释放逻辑就能轻松造出一个同步器。9. CAS原理与问题面试官你提到CAS能详细说说它是什么有什么优缺点吗候选人CAS全称Compare-And-Swap比较并交换是一种乐观锁技术。它包含三个操作数内存地址V、期望值A、新值B。当且仅当V上的值等于A时才用新值B去更新V上的值这个过程是原子性的由硬件层面如CPU的cmpxchg指令保证。如果V上的值不等于A说明值已被其他线程修改操作就失败通常我们会进入自旋重试逻辑。CAS的优点很明显它避免了线程的阻塞和唤醒也就是避免了用户态和内核态的切换因此在低并发争用的情况下性能比synchronized这种悲观锁要好。但它也存在三个著名的缺点ABA问题线程1读到值是A随后线程2把值改成B又改回A线程1再CAS时发现内存值还是A就认为它没变过然后修改成功。但中间其实已经变过了。解决方案是用带版本号的原子类比如AtomicStampedReference通过比对版本号来识别ABA。循环时间长开销大如果并发争用激烈CAS一直失败线程就会一直在那里自旋空转白白消耗CPU资源反而成为性能瓶颈。只能保证一个共享变量的原子操作对一个变量进行CAS操作没问题但要对多个变量进行原子操作CAS就无能为力了。这时候可以用锁或者把这些变量封装成一个对象用AtomicReference来操作这个对象。第三部分并发工具与线程池篇10.CountDownLatchvsCyclicBarrier面试官CountDownLatch和CyclicBarrier都能实现线程等待它们有什么区别候选人虽然它们都用于线程间的协调但设计目标和用法有本质区别。我画个表格可能更清晰特性CountDownLatchCyclicBarrier核心概念一个或多个线程等待其他线程完成操作一组线程互相等待直到都到达某个公共屏障点计数方式计数器只能减减到0就不能再重置计数器可以重置通过reset()方法可循环使用参与者计数器的线程和等待的线程可以是不同的群体所有线程既是参与者又是等待者使用场景一个线程等待N个线程完成。比如主线程等待N个子线程加载完数据再汇总N个线程相互等待齐头并进。比如多线程计算每个线程算一部分等到齐后再进行下一步合并主要方法countDown()减1await()阻塞等待await()使线程到达屏障并阻塞等待举个例子CountDownLatch百米赛跑。发令员一个线程等所有运动员多个线程都准备就绪执行countDown才打响发令枪await之后继续。CyclicBarrier团队游戏。张三、李四、王五约好在地铁站集合各自到达后执行await必须三个人都到齐了才能一起出发去爬山执行后续任务。这个屏障点可以重复使用比如爬山回来再约好集合去吃烧烤。11. 线程池参数设置面试官线程池的核心参数有哪些如果让你来设计一个线程池你会怎么设置这些参数候选人线程池的核心构造参数有7个corePoolSize核心线程数。即使线程空闲也会保留在线程池里的数量。maximumPoolSize最大线程数。线程池允许创建的最大线程数量。keepAliveTimeunit空闲线程存活时间。当线程数超过核心线程数时多余的空闲线程能存活多久。workQueue工作队列。用于存放等待执行的任务的阻塞队列。threadFactory线程工厂。用于创建新线程一般用来给线程起个有意义的名字方便排查问题。handler拒绝策略。当队列和线程池都满了的时候对新提交的任务采取的处理策略。至于参数设置没有万能公式必须根据任务特性来而且最终的参数一定要经过压测验证。我一般会从以下几个维度来分析和设置首先明确服务器配置比如我们生产环境是4核8G的容器。其次分析任务类型CPU密集型任务主要消耗CPU如大量计算。线程数不宜过多避免频繁上下文切换。一般设为CPU核数 1。IO密集型任务大部分时间在等待IO如数据库访问、RPC调用。CPU其实很空闲可以多配点线程。一般设为2 * CPU核数。混合型如果IO等待时间和CPU计算时间都能估算有一个公式线程数 CPU核数 * (1 等待时间 / 计算时间)。接着考虑任务队列有界队列比如ArrayBlockingQueue必须用有界队列这是为了防止任务无限制堆积耗尽内存导致OOM。队列大小需要结合任务的延迟容忍度来设置。比如允许任务排队1秒每个任务平均执行100ms那队列大小设为10左右就比较合理。最后明确拒绝策略AbortPolicy默认的直接抛异常适合对数据一致性要求极高的场景。CallerRunsPolicy让提交任务的线程自己执行这个任务。这是一种简单的降级策略通过让提交者亲自执行来减缓任务提交的速度起到“慢一点提交”的反馈调节作用。面试官追问我刚才听你提到了CallerRunsPolicy能详细解释一下它的工作机制和优缺点吗候选人当然。CallerRunsPolicy的机制是当线程池的任务队列满了且当前线程数已达到maximumPoolSize时再有新任务提交它不会丢弃任务也不会抛异常而是直接在提交任务的线程中执行这个任务。优点绝不让任务丢失这是一种最保守的拒绝策略确保所有任务都能被执行。自然的背压机制假设主线程在循环提交任务如果线程池满了主线程自己就要去执行任务。这意味着主线程被“拖住”了提交任务的速度就会自动慢下来从而给线程池处理积压任务的时间。这是一种非常优雅的、从执行者到提交者的反压机制。缺点 如果主线程本身就是个对响应时间要求很高的Web服务器线程比如Tomcat线程让它在处理请求的过程中又去执行一个后台的、可能很慢的任务就会大大延长这个HTTP请求的响应时间甚至可能导致服务器连接超时。所以CallerRunsPolicy常用于对任务执行可靠性要求高且对提交线程的响应时间不敏感的后台批处理或定时任务场景。第四部分实战场景篇12. 死锁问题面试官能说说死锁是怎么产生的吗假如线上出现了死锁你会怎么排查和解决候选人死锁是指两个或两个以上的线程在执行过程中因争夺资源而造成的一种互相等待的现象如果没有外力干预它们会一直阻塞下去。死锁产生必须同时满足四个必要条件互斥条件资源同时只能被一个线程占有。请求与保持条件一个线程占有了至少一个资源但又去请求其他被占用的资源并且不释放自己已占有的。不剥夺条件资源只能由持有者自己释放其他线程不能强行剥夺。循环等待条件存在一个线程等待链比如线程A等线程B的资源线程B等线程A的资源。排查思路如果线上怀疑有死锁我们可以用JDK自带的工具快速定位。jps命令找到我们Java应用的进程ID。jstack命令jstack pid打印出该进程的线程堆栈信息。分析日志jstack的输出最后会直接提示“Found one Java-level deadlock”并且会非常清晰地指出哪些线程持有了哪些锁又在等待哪些锁。解决方案破坏循环等待条件最常用且有效的办法。规定所有线程获取多个锁的顺序必须一致。比如线程A和B都需要锁1和锁2那就约定必须先拿到锁1才能去拿锁2。这样就不会出现A等B、B等A的循环。破坏请求与保持条件可以尝试让线程一次性申请所有需要的资源申请不到就一个都不拿。使用带超时的锁比如用ReentrantLock的tryLock(long timeout, TimeUnit unit)方法如果在指定时间内获取不到锁就主动放弃并回滚释放已持有的资源。13. 单例模式与并发面试官写一个线程安全的单例模式并解释为什么在双重检查锁中要加volatile。候选人在白板上写出代码javapublic class Singleton { // 使用 volatile 禁止指令重排序 private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { // 第一次检查避免不必要的同步 if (instance null) { // 类锁保证只有一个线程能进入 synchronized (Singleton.class) { // 第二次检查防止在锁等待期间实例被另一个线程创建 if (instance null) { instance new Singleton(); // 问题可能出现在这一行 } } } return instance; } }为什么需要volatile关键在于instance new Singleton();这行代码在JVM层面并不是一个原子操作它大致可以分解为三个步骤memory allocate();// 1. 分配对象的内存空间ctorInstance(memory);// 2. 初始化对象执行构造函数instance memory;// 3. 将instance引用指向刚分配的内存地址如果不加volatile处理器或编译器可能会对步骤2和3进行指令重排序。比如可能发生这样的情况先执行了步骤3instance引用已经不为空了但步骤2的初始化还没完成对象是个半成品。这时候另一个线程A执行getInstance()在第一次检查if (instance null)时发现instance不为空就直接返回了instance。然后线程B拿着这个未完全初始化的对象去调用它的方法程序就会报错。volatile通过内存屏障禁止了这种重排序保证了对instance的赋值一定是在对象完全初始化之后。这样就确保了线程A拿到的永远是一个完整的、可用的对象。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2453936.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!