热门八股-JUC
线程基础
1.进程和线程的区别
进程可以理解为一个正在运行的程序,比如启动一个JVM就是一个进程。线程是进程里面的执行单元,一个进程可以有多个线程。
常见的区别:
- 进程是资源分配最小单位,线程是CPU调度最小单位,属于进程
- 进程上下文切换开销大,线程切换开销小
- 进程之间相互隔离,同一个线程共享堆、方法区
- 一个进程崩溃通常不会影响其他进程,线程之间可以直接访问共享资源,会引发并发安全问题
2.Go的协程和Java线程的区别
Go goroutine是用户态轻量级协程,由Go的GMP模型调度。Java线程是操作系统内核线程,由OS操作系统内核调度。每一个Java线程直接映射操作系统的一个线程
- Java默认栈1M,开销大,无法大量创建。Go初始栈2KB,可以大量创建
- goroutine适合大量并发IO任务;Java也可以用线程池、NIO、虚拟线程来提高并发能力
3.线程有几种创建方式?各自优缺点?
1.继承Thread类
写法简单,直接调用start()启动。Java是单继承,任务和线程机制耦合在一起
2.实现Runnable接口
任务和线程解耦,适合资源共享。run没有返回值,不能抛出受检异常
3.实现Callable+FutureTask
call()有返回值可以抛出异常,可以拿到线程执行结果。get()获取结果会阻塞主线程
4.线程池ExecutorService
生产环境首选,线程复用,控制并发,减少创建销毁开销
4.线程的五大生命周期状态及流转过程?
JVM线程状态Thread.State一共6种枚举,面试常说五大生命周期:新建、就绪、运行、阻塞、终止。
- NEW:创建了线程对象,还没有调用start()
- READY:调用start()后,进入就绪状态
- RUNNING:线程正在执行run()里面的代码
- BLOCKED:阻塞等待获取synchronized锁
- WAITING:调用wait(),join(),无限等待
- TIMED_WAITING:sleep(time),wait(time),join(time),等待指定时间
- TERMINATED:线程执行完毕
流转过程:创建线程后是NEW,调用start()进入RUNNABLE(包括就绪和运行),竞争锁失败进入BLOCKED,调用等待类方法进入WAITING或TIMED_WAITING,任务执行完进入TERMINATED
5.什么是守护线程?和用户线程的区别?应用场景
守护线程:为用户线程提供服务的后台线程
只有存在用户线程,JVM就不会停止;当所有用户线程结束,无论守护线程是否执行完,JVM直接退出,守护线程强制终止
GC垃圾回收线程是典型的守护线程,main主线程是用户线程
应用场景:适合做后台监控、定时日志、GC、清理缓存
6.sleep()、wait()、yield()、join()区别
sleep():让线程休眠指定一段时间,不会释放锁
wait():当前线程等待,释放监视器锁,必须写在synchronized同步代码块里面
yield():提示调度器,礼让CPU,但不一定采纳,依然拥有锁
join():等待另一个线程执行完毕,底层就是调用wait(),会释放锁
7.为什么wait、notify要在synchronized里面执行?
wait、notify依赖对象监视器锁,必须放在synchronized里面,主要是为了防止信号丢失问题。
synchronized保证:条件判断和wait操作是受锁保护的,wait执行的时候,会释放监视器锁,被notify唤醒之后,还需要重新竞争锁。如果不在同步代码块中调用,会抛出IllegalMonitorStateException
wait一定要写在while循环里面判断条件,不要用if,否则会虚假唤醒。
8.什么是线程上下文切换?为什么会耗时间
上下文切换是CPU从一个线程切到另一个线程执行。切换时要保存当前线程的寄存器、程序计数器、栈等信息,再恢复另一个线程的信息。
耗时的原因:
- 需要保存、恢复CPU寄存器,内存读写慢于寄存器
- 线程切换会造成CPU高速缓存失效,缓存命中率降低
- 频繁阻塞。唤醒、抢锁会增加调度开销
锁机制和关键字
1.synchronized底层原理
synchronized是JVM层面的内置锁,它依赖对象头里的Mark Word和Monitor实现
当线程进入同步代码块时,会尝试获取对象的monitor;获取成功就执行,失败就阻塞等待。退出同步代码块时释放monitor。
修饰普通方法时,锁对象是当前实例this;修饰静态方法时,锁对象是当前类的Class对象;修饰代码块时,锁对象是括号里指定法对象。
2.synchronized锁升级流程:偏向锁->轻量级锁->重量级锁?
偏向锁:只有一个线程反复进入锁,偏向这个线程,减少CAS
轻量级锁:多个线程交替竞争,用CAS尝试获取锁,避免直接阻塞
重量级锁:竞争激烈时膨胀为Monitor,线程阻塞和唤醒由操作系统参与。
锁只会升级
3.偏向锁、轻量级锁、重量级锁各自原理和使用场景
偏向锁:底层CAS记录线程ID,适合单线程反复获取锁,几乎无竞争
轻量级锁:底层CAS+自适应自旋,适合线程交替执行,竞争不激烈
重量级锁:底层操作系统Monitor互斥锁,适合竞争激烈,线程阻塞挂起
4.synchronized修饰普通方法、静态方法、代码块,锁的对象分别是?
- 普通方法:当前实例对象this
- 静态方法:当前类的Class对象
- 同步代码块:synchronized(lock)里的lock对象
两个实例对象调用同一个普通同步方法,不互斥,因为它们的锁是不同的this
5.vloatile关键字的作用是什么?
vloatile主要有两个作用:
- 保证可见性:一个线程修改变量,其他线程可以及时看见
- 禁止指令重排序:通过内存屏障保证特点顺序
6.为什么vloatile不能保证原子性?
vloatile只能保证单次读写的可见性,不能保证复合操作原子性
比如count++分为读取、计算、写回。多个线程同时执行时,仍可能丢失更新。
7.什么是可重入锁,为什么需要可重入?
可重入锁:同一个线程已经持有锁,还可以再次获取同一把锁,不会把自己阻塞。
底层依靠计数器重入次数,每获取一次,计数器+1,释放一次-1;计数器归0才真正释放
如果锁不可重入自己已经持有锁,再次申请锁,线程把自己阻塞,直接死锁。
synchronized、ReentrantLock都是可重入锁
8.什么是公平锁、非公平锁?优缺点?
- 公平锁:严格按照线程请求锁的先后顺序分配锁
- 非公平锁:允许新来的线程可以直接插队竞争锁
公平锁优点是不会出现线程饥饿,缺点性能较差,吞吐量低
非公平锁优点性能高,吞吐量好,但可能出现线程饥饿
synchronized只能是非公平锁,ReentrantLock默认是非公平锁
9.悲观锁和乐观锁的区别,使用场景
悲观锁认为冲突经常发生,所以先加锁再操作。缺点有阻塞、死锁上下文切换开销。synchronized、ReentrantLock都属于悲观思路
乐观锁认为冲突不一定发生,先不加锁,提交更新时再检查有没有被其他人改过。CAS、版本号机制都属于乐观思路。
使用场景:
- 写多,冲突多:悲观锁更稳
- 读多,冲突少:乐观锁性能更好
10.CAS原理是什么?自旋、Unsafe类作用?
CAS全称CompareAndSwap,比较并交换。它有三个值:内存值V、期望值A、新值B。只有当V=A时,才把V改成B,否则失败
CAS一次可能失败,通过while循环不断重试CAS,线程不阻塞
在Java中,很多原子类底层通过Unsafe或VarHandle调用CPU原子指令完成CAS
11.CAS三大问题:ABA、循环耗时、只能保证单个变量原子性,怎么解决?
ABA:变量从A变成B,又改回A,CAS看不出它变过。解决方法加加版本号,同时校验数据和版本号。
循环耗时:竞争激烈时一直自旋会浪费CPU。解决方法是控制重试次数、退避策略,或在高竞争场景改用锁。
只能保证单个变量原子性:多个变量需要一起更新时,可以用锁,或者把多个字段封装成一个对象引用,通过AtomicReference更新整体。
12.什么是自旋锁,优缺点?
自旋锁:线程获取锁失败时,不阻塞,原地循环不断尝试抢锁。
优点:避免线程阻塞和上下文切换;锁持有时间很短的时候性能很好
缺点:锁占用时间长,会大量空循环消耗CPU
适用于锁持有时间短、并发竞争不激烈。
13.什么是死锁,死锁产生的条件是什么?
死锁:多个线程互相持有对方需要的锁,互相等待释放,全部阻塞,程序卡死
死锁四个必要条件,必须同时满足:
- 互斥:资源互斥,同一个时间只能一个线程占用
- 请求与保持:线程持有部分锁,不释放,继续申请其他的锁
- 不可剥夺:锁不能被其他线程强行抢走
- 循环等待:线程之间形成环形等待链条
破坏任意一个条件就可以避免死锁,比如固定加锁顺序,加超时时间,尽量减少锁嵌套
14.什么是偏向锁撤销、重偏向?
偏向锁撤销是指当另一个线程来竞争被偏向的锁时,JVM需要撤销原来的偏向状态。
重偏向是指如果一批对象总是从线程A转移到线程B使用,JVM可能把这批对象重新偏向线程B,减少后续撤销成本。
这是JVM锁优化里的细节,面试一般讲到“偏向锁适合无竞争,竞争出现会撤销并升级”
15.轻量级锁自旋次数自适应
自适应自旋是重量级锁的优化。JVM会根据历史抢锁记录,动态调整自旋循环次数:之前自旋成功就多旋几次,经常失败就少旋甚至不旋,避免空耗CPU
16.什么是锁粗化、锁消除?JVM优化手段?
锁粗化:如果连续多次对同一个锁反复加锁释放,JVM把多段小锁合成一把大锁,减少频繁加解锁带来的性能消耗
锁消除:如果JVM通过逃逸分析发现对象不会被多个线程共享,就可以直接去掉锁。
AQS机制
1.AQS是什么?核心设计思想?
AQS全称AbstractQueuedSyncheonizer,抽象队列同步器,是JUC里很多同步器的基础框架。
核心思想:用一个volatile int state表示同步状态,再用一个FIFO双向队列管理抢锁失败的线程
如果获取资源成功,线程继续执行;如果失败,就封装成节点加入等待队列,等待前驱节点释放资源后再被唤醒。
2.AQS底层结构:state状态、双向阻塞队列
- state:表示同步状态,比如锁是否被占用、重入次数、剩余许可数
- CLH变体双向队列:保存等待获取资源的线程节点
ReentrantLock:state=0,没有线程持有锁。>0线程持有锁、可重入
获取失败的线程会进入队列并挂起,释放锁时唤醒后继节点
3.AQS独占模式、共享模式的区别?
- 独占模式:同一时刻只能有一个线程获取资源。典型例子是ReentrantLock.
- 共享模式:同一时刻可以有多个线程获取资源。典型例子是Semaphore CountDownLatch ReadLock
区别在于:独占模式释放资源通常只唤醒一个后继节点;共享模式释放资源可能继续向后传播,唤醒多个等待线程。
4.AQS排队、唤醒线程的流程?
获取资源时:
- 先尝试获取资源
- 获取失败后,当前线程封装成Node加入AQS队列尾部
- 如果前驱节点是head,再次尝试获取锁
- 仍失败则挂起线程
释放资源时:
- 修复state
- 如果资源完全释放,唤醒后继节点
- 后继节点被唤醒后继续尝试获取资源
5.AQS为什么要用双向链表
双向链表方便节点入队、取消、唤醒后继节点
线程等待过程中可能被中断或超时取消,如果是单向链表,删除或跳过取消节点会比较麻烦。双向链表可以通过前驱和后继更方便维护队列
JUC常用工具类
1.ConcurrentHashMap工作流程
ConcurrentHashMap是线程安全的HashMap。JDK8中主要通过CAS+synchronized+分桶结构保证并发安全
写入流程:
- 根据key计算hash,定位桶位置
- 如果桶为空,用CAS放入新节点
- 如果桶不为空,对桶头节点加synchronized,再插入链表或红黑树
- 如果元素达到阈值,则触发扩容
读操作通常不加锁,通过volatile保证可见性,所以读性能比较好
2.ReentrantLock和synchronized的区别
synchronized是JVM层面锁,代码执行完毕或发生异常会自动释放锁
ReentrantLock是API锁,底层基于AQS实现需要手动lock,并且一定要在finally里面unlock
- synchronized只能是非公平锁,ReentrantLock既可以是公平锁也可以是非公平锁,默认非公平。
- ReentrantLock支持可中断获取锁
- ReentrantLock支持超时获取锁
- ReentrantLock支持多个队列Condition
- synchronized代码更简洁,不容易忘记释放锁
3.ReentrantLock可重入、可中断、可超时、公平锁怎么实现
可重入:内部用state记录次数,同一个线程再次获取锁时state+1,释放时-1
可中断:lockInterruptible()在等待锁时可以响应中断
可超时:tryLock(timeout,unit)在指定时间内获取不到锁就返回失败
公平锁:获取锁前先判断AQS队列里有没有排队更久的线程,有就排队
4.ConcurrentHashMapJDK7和JDK8底层原理是区别?
JDK7使用分段锁Segment,把整个Map分成16个小HashMap,锁粒度是Segment
JDK8取消Segment,改为数组+链表+红黑树,锁粒度为桶级别。空桶用CAS插入,非空桶用synchronized锁桶头节点
5.ConcurrentHashMap为什么JDK8放弃分段锁,改用CAS+synchronized
1.锁粒度更小,并发性能更好
2.synchronized经过锁升级优化,再配合CAS,可以让空桶插入无锁化,冲突时只锁一个桶
3.减少了内存开销
6.ConcurrentHashMap的扩容机制
JDK8 ConcurrentHashMap支持多线程协助扩容,当元素达到阈值,线程创建新数组。迁移时每个线程负责一段桶,迁移完成的桶会放置ForwardingNode,如果其他线程访问到它,会帮忙扩容。
这叫协助扩容,可以避免单个线程扩容时间过长。
7.BlockingQueue阻塞队列的核心作用
BlockingQueue是支持阻塞插入和阻塞获取的队列
当队列满时,生产者放入元素会阻塞;当队列为空时,消费者获取元素会阻塞。
安全实现生产者消费者模型,是线程池任务队列的核心组件。
8.常见阻塞队列的区别和场景
1.ArrayBlockingQueue基于数组实现,必须指定固定容量,有界队列,适合固定容量资源可控的场景
2.LinkedBlockingQueue单向链表结构,默认容量很大,也可以指定容量。适合高并发生产消费,需要提升吞吐
3.SynchronousQueue不存储任何元素,生产者必须等消费者直接接受,适合快速移交任务
4.PriorityBlockingQueue无界数组实现的优先级队列,元素必须实现Comparable接口或传入比较器,按优先级排序取出。适合任务有优先级区分
5.DelayQueue延迟队列,只有元素到期后才能被取出,适合定时任务、限时缓存
9.DelayQueue原理,延时任务应用场景
DelayQueue基于优先级堆实现,队首是最早到期的元素。元素必须实现Delayed接口。任务到期才能被取出,未到期则消费线程阻塞
多用于订单超时、优惠卷过期、延时重试、定时提醒
10.CompletableFuture的常用API?
CompletableFuture常用于异步编排
常见API:
- supplyAsync():异步执行,有返回值
- runAsync():异步执行,无返回值
- thenApply():处理上一步结果,并返回新结果
- thenAccept():消费结果,无返回值
- thenCompose():串行依赖,避免嵌套Future
- thenCombine():合并两个异步任务结果
- allOf():等待多个任务全部完成
- anyOf():任意一个任务完成即可
- exceptionally():异常兜底
- hendle():正常和异常都能处理
它比Future更强,因为支持链式调用,任务编排和异常处理
线程池
1.线程池七大核心参数含义
ThreadPoolExecutor七大参数:
- corePoolSize:核心线程数
- maximumPoolSize:最大线程数
- keepAliveTime:非核心线程空闲多久被回收
- unit:时间单位
- workQueue:任务队列
- threadFactory:线程工厂,用于创建线程
- handler:拒绝策略
2.线程池工作原理,执行流程
当调用execute()提交任务时:
1.运行线程数小于核心线程数,直接创建核心线程执行任务
2.核心线程满了,任务进入阻塞队列
3.队列满了,且线程数小于最大线程数,创建非核心线程执行
4.线程数达到最大值,且队列也满了,执行拒绝策略
3.线程池有哪些种类?
- FixedThreadPool:固定线程数量,空闲线程不会回收
- CacheThreadPool:线程数可动态扩展
- SingleThreadExecutor:单线程池
- ScheduledThreadPool:支持定时和周期任务
- WorkStealingPool:空闲线程窃取其他队列任务
不建议使用Executors创建线程池,推荐手动指定参数
4.四种拒绝策略分别是什么?适用场景
- AbortPolicy:默认策略,直接抛异常。适合必须感知任务失败的场景
- CallerRunsPolicy:让提交任务的线程自己执行。适合削峰,让提交速度慢下来
- DiscardPolicy:直接丢弃任务,不抛异常。适合允许丢弃的低价值任务
- DiscardOldestPolicy:丢弃队列中最老的任务,再尝试提交新任务。适合更关注新任务的场景
5.JDK内置四大线程池:特点、坑点
- FixedThreadPool:固定线程数量,默认使用无界队列,任务堆积可能OOM
- CacheThreadPool:使用SynchronousQueue,最大线程数接近无限。高并发下可能创建大量线程导致OOM
- SingleThreadExecutor:单线程串行执行,但也是无界队列,任务堆积有风险
- ScheduledThreadPool:支持定时和周期任务,但也要注意异常处理和任务堆积
所以阿里不推荐直接用Executors,而是手动创建ThreadPoolExecutor.
6.为什么阿里禁止使用Executors创建线程池
因为Executors的一些默认参数隐藏风险:
FixedThreadPool和SingleThreadExecutor使用无界队列,可能导致内存溢出。
CacheThreadPool最大线程数过大,可能创建过多线程。
默认线程名不清晰,不利于排查问题
手动创建线程池可以明确核心线程数、最大线程数、队列大小、线程名和拒绝策略,更加可控
7.核心线程数怎么合理设置?IO密集型、CPU密集型公式
CPU密集型任务主要消耗CPU,比如计算、加密、压缩。线程数一般设置为 CPU核心数+1
IO密集型任务会大量等待IO,比如数据库、RPC、文件读写、线程数可以更大,常见估算:CPU核心数*2 通用公式:CPU核心数*(1+等待时间/计算时间)
8.线程池空闲线程回收机制?
非核心线程:空闲时间超过keepAliveTime,就会被回收
默认情况下,核心线程不会因为空闲被回收,可以调用allowCoreThreadTimeOut(true)超时后也会回收。
9.线程池关闭shutdown()和shutdownNow()区别
shutdown()是温和关闭:不在接受新任务,但会继续执行队列已有任务.
shutdownNow()是立即关闭;尝试中断正在执行的任务,并返回队列中还没有执行的任务。
shutdownNow()只是发出中断信号,任务是否停止,取决于任务代码是否正确响应中断。
10.线程池任务提交execute()和submit()区别
execute()只能提交Runnable,没有返回值,任务异常会直接抛到线程的异常处理器。
submit()可以提交Runnable或Callable,返回Future,可以通过Future.get()获取结果或异常
submit()里的异常会被包装进Future,如果不调用get(),异常可能被忽略。
11.线程池异常怎么捕获?
- 任务内部try-catch
- 使用submit()后调用Future.get()。
- 自定义线程工厂,设置UncaughtExceptionHandle。
- 继承ThreadPoolExecutor,重写afterExecute()。
生产中建议任务内部做好异常兜底,并配合日志、监控和告警。
ThreadLocal
1.ThreadLocal原理,底层ThreadLocalMap结构
ThreadLocal用来保存线程本地变量,每个线程都有自己独立的一份数据
它是底层不是ThreadLocal自己存数据,而是每个Thread对象内部有一个ThreadLocalMap。
Thread->ThreadLocalMap->Entry(ThreadLocal,value)
ThreadLocal对象作为key,真正是值是value。不同线程访问同一个ThreadLocal,拿到的是各自线程里的value。
2.ThreadLocal为什么会内存泄露?
ThreadLocalMap的key是弱引用,value是强引用。
当ThreadLocal对象没有外部强引用时,key可能被GC回收,变成null。但value还被当前线程的ThreadLocalMap强引用着。如果线程一直不结束,比如线程池里的工作线程,value就可能长期无法被释放,造成内存泄露。
所以用完一定要调用remove()
3.弱引用在ThreadLocal里的作用
当外部不在引用ThreadLocal时,key可以被GC回收,避免key永远留在Map里
但弱引用只能解决key的回收,value仍然需要通过remove()清理
弱引用降低了key泄露风险,但不代表不会发生内存泄露
4.ThreadLocal有什么缺点?
- 可能内存泄露,需要手动remove()
- 在线程池中容易出现数据串用,因为线程会复用
- 默认不能跨线程传递值
- 过度使用会让数据流向不清晰,代码可读性变差
总结:
- volatile保证可见性和有序性,不保证复合操作原子性
- synchronized是JVM内置锁,锁对象可以是this,Class对象或指定对象
- CAS是乐观锁思想,靠比较并交换实现原子更新
- AQS用state表示同步状态,用FIFO队列管理等待线程
- ReentrantLock比syncheonized功能更丰富,支持可中断、超时、公平锁和多个条件队列
- ConcurrentHashMapJDK8+是数组+链表+红黑树,配合CAS和syncheonized
- 线程池执行顺序的核心线程、队列、最大线程、拒绝策略
- 生产环境不建议用Executors,因为默认队列或最大线程数可能不可控
- ThreadLocal的值在线程内部保存,线程池场景用完必须remove()
