
语言基础与并发
这一篇重点解决一个问题:为什么面试官明明在招 Android,却总是先问 Java / Kotlin / 并发。
原因很简单。高级工程师不是只会写页面,而是要能处理线程切换、状态一致性、性能瓶颈和复杂异步流程。语言基础不扎实,后面的优化和架构很容易停留在口号层面。
1. HashMap 为什么线程不安全?ConcurrentHashMap 为什么更适合并发?
参考答案
HashMap 的底层是数组加链表加红黑树。它在并发写入时线程不安全,核心原因不是"它没加锁"这么简单,而是多个线程可能同时触发扩容、链表插入或元素覆盖,导致数据丢失、覆盖甚至结构异常。
ConcurrentHashMap 的设计目标是让读尽可能无锁、写尽量降低锁粒度。JDK 1.8 以后它主要通过 CAS + synchronized 控制桶级别的并发更新,而不是像早期版本那样使用大段锁。这样能在保证线程安全的同时保留较高吞吐。
面试官继续追问什么
- 为什么
HashMap扩容时更容易暴露并发问题? ConcurrentHashMap为什么不允许key或value为null?synchronized放在桶节点上,为什么比整张表加锁更高效?
追问怎么答
HashMap扩容时会重算桶位置并迁移节点,多线程同时迁移更容易出现覆盖、丢失和结构异常,所以并发问题在扩容阶段更容易暴露。ConcurrentHashMap不允许null,是为了避免并发读场景下分不清"这个 key 不存在"还是"这个 key 存的值就是 null"。- 桶级别加锁只阻塞冲突桶的写入,其他桶仍可并发更新,所以吞吐明显好于整张表一把大锁。
项目中怎么回答
如果你的业务里有内存缓存、请求去重表、下载任务表、页面状态表,尽量结合它们举例。高级岗位更想听到你是否知道"哪里该用并发容器,哪里不该共享可变状态"。
直接套用句式
"我们项目里有一类共享状态,比如请求去重表/下载任务表/内存缓存,这类场景我会优先考虑线程安全容器;但如果状态只在单线程闭环里消费,我不会为了看起来并发安全就把所有东西都换成重型并发结构。"
2. volatile 能解决什么问题?为什么不能保证原子性?
参考答案
volatile 主要保证两件事:可见性和有序性。一个线程修改了变量,其他线程能更快看到最新值;同时编译器和 CPU 不会随意把它前后的指令重排到破坏语义的程度。
但 volatile 不能保证复合操作的原子性,比如 count++ 本质上是读取、加一、写回三步。多个线程同时执行时,即使每一步都可见,最终结果仍然可能丢失更新。
面试官继续追问什么
- 为什么双重检查单例要配合
volatile? - 什么场景下
volatile比加锁更合适? volatile和AtomicInteger的边界区别是什么?
追问怎么答
- 双重检查单例配合
volatile,是为了禁止"对象先赋值、后初始化完成"的指令重排,避免别的线程拿到半初始化对象。 volatile更适合状态通知类场景,比如停止标记、配置开关、是否已初始化这类只要求可见性的变量。AtomicInteger适合需要原子更新的计数和累加,volatile只能保证看见最新值,不能保证++这种复合操作安全。
项目中怎么回答
可以讲页面状态位、任务取消标记、单例初始化等场景。重点不是背概念,而是能说明什么时候只需要"状态通知",什么时候必须要"互斥修改"。
直接套用句式
"像页面销毁标记、停止开关、是否已初始化这种状态,我更关注的是其他线程能不能及时看到,所以 volatile 往往够用;但如果是计数、累加、复合写入,我就不会指望 volatile,而是会换成原子类或锁。"
3. synchronized 和 ReentrantLock 怎么选?
参考答案
synchronized 语法简单、可读性好,由 JVM 保证加锁和释放,适合大多数互斥场景。ReentrantLock 更灵活,支持可中断锁、超时尝试、公平锁以及多个条件队列,在复杂同步控制里更有优势。
如果只是保护一个简单临界区,优先 synchronized。如果你需要可中断等待、精细化唤醒或者更复杂的线程协作,就考虑 ReentrantLock。
面试官继续追问什么
- 什么叫可重入?
- 公平锁和非公平锁的区别与代价是什么?
- 为什么高级岗位不建议为了"更高级"就默认用
ReentrantLock?
追问怎么答
- 可重入就是同一个线程拿到某把锁后,可以再次进入同一把锁保护的代码,而不会把自己锁死。
- 公平锁按等待顺序分配,更"公平",但吞吐通常更差;非公平锁允许插队,性能往往更高,所以默认更常用。
ReentrantLock不是默认更高级,只是更灵活。大部分简单互斥场景用synchronized可读性更好、出错面更小。
项目中怎么回答
你可以结合磁盘缓存写入、数据库串行访问、任务队列调度这些实际问题来讲。高级面试更看你有没有"为场景选工具",而不是机械对比 API。
直接套用句式
"我一般不会先比 API,而是先看场景。如果只是简单互斥,我优先 synchronized;如果要可中断等待、超时控制或者更复杂的线程协作,我才会考虑 ReentrantLock。"
4. 线程池有哪些关键参数?为什么很多线上问题都和线程池配置有关?
参考答案
线程池最关键的几个参数是:
corePoolSize:核心线程数,通常长期保留。maximumPoolSize:最大线程数。workQueue:任务队列。keepAliveTime:非核心线程空闲多久回收。RejectedExecutionHandler:满载后的拒绝策略。
线上问题常出在两个地方。第一,线程数开太大,导致 CPU 争抢、上下文切换频繁;第二,队列开太大,导致任务堆积、超时、内存上涨,但表面上看不到明显报错。
面试官继续追问什么
CPU密集和IO密集线程池为什么配置不同?- 为什么"线程越多越快"是错的?
- 你如何判断当前线程池是线程数问题还是队列问题?
追问怎么答
CPU密集任务主要受核心数限制,线程太多只会增加切换成本;IO密集任务大量时间在等待,所以可以适当放更多线程提高利用率。- 线程越多不代表越快,因为线程会争抢
CPU、内存和锁,过多时上下文切换本身就会变成开销。 - 看任务执行耗时、排队时长、拒绝次数和线程活跃数。如果线程长期满但队列短,多半是线程数或任务耗时问题;如果队列持续堆积,更像消费速度跟不上。
项目中怎么回答
最好能讲一个真实例子,比如图片预加载、日志落盘、离线同步、首页并发任务编排。说明你怎么通过监控、埋点、耗时分布和拒绝次数去判断问题。
直接套用句式
"我在线程池问题上不太会只背参数,而是会结合真实链路看任务耗时、排队时长和活跃线程数。因为很多线上问题不是线程池不能用,而是线程数和队列配置跟业务类型不匹配。"
5. 协程为什么适合 Android?它和线程到底是什么关系?
参考答案
协程不是线程,它更像一种轻量级的异步任务抽象。线程是操作系统调度单位,协程由用户态调度器管理。协程的优势在于:写法接近同步代码、切换成本低、取消和作用域控制更自然,特别适合 Android 页面生命周期和异步链路。
但协程不是免费午餐。它只是在合适的挂起点让出执行权,不会自动让阻塞代码变成非阻塞。如果你在协程里直接做阻塞 IO 或长时间计算,问题一样会发生。
面试官继续追问什么
suspend函数为什么不能直接在普通函数里调用?- 协程挂起时到底保存了什么?
- 为什么协程也会导致内存泄漏或任务泄漏?
追问怎么答
suspend函数依赖协程上下文和调度器,普通函数没有这套运行时支撑,所以不能直接调用。- 协程挂起时本质上会保存当前执行状态、局部变量和后续恢复点,恢复后能从上次停下的位置继续跑。
- 如果协程作用域绑错、持有页面引用、页面销毁后任务不取消,协程一样会把对象和任务生命周期拖长,形成泄漏或"幽灵任务"。
项目中怎么回答
可以用页面数据加载、搜索联想、上传下载、重试控制这些例子。高级岗位更想知道你是否把协程当成了"结构化并发工具",而不是"新语法糖"。
直接套用句式
"我在项目里用协程,主要不是为了写法新,而是为了把任务归属、取消和异常收口做得更清楚。尤其在页面生命周期相关的请求和状态收集上,它比手写回调链更稳。"
6. launch、async、withContext 的区别是什么?
参考答案
launch用于启动一个不直接返回结果的协程,返回Job。async用于并发执行并返回结果,返回Deferred,通常要配合await()。withContext更像"切换上下文并等待结果返回",常用于明确地把任务切到IO或Default线程池执行。
面试时最好补一句:async 不是越多越好,如果任务之间并没有并发收益,只会增加调度复杂度和异常处理成本。
面试官继续追问什么
async的异常什么时候抛出?- 为什么
withContext常比嵌套launch更容易维护? - 页面销毁时,哪些协程应该取消?
追问怎么答
async的异常通常在await()时暴露,如果根本不await,就容易把异常和任务结果都"藏起来"。withContext会切线程并等待结果返回,流程是串起来的;嵌套launch容易把控制流拆散,异常和取消也更难收口。- 和页面生命周期绑定的请求、轮询、收集任务都应该在页面销毁或离开可见状态时取消,避免回调到失效 UI。
7. 协程取消是怎么传播的?为什么有时你调用 cancel() 却没停下来?
参考答案
协程取消依赖协作式取消。也就是说,任务需要在挂起点、检查点或者显式检查取消状态时才能响应。像 delay()、withContext()、大多数挂起函数都能感知取消;但如果你在协程里跑一个长时间的普通循环或阻塞调用,不主动检查 isActive,它就不会及时停下。
结构化并发的价值就在这里:父协程取消时,子协程默认一起取消,生命周期更容易统一管理。
面试官继续追问什么
SupervisorJob为什么不会像普通父子关系那样传播失败?CancellationException为什么通常不当成业务异常处理?- 你如何处理"页面退出但网络请求仍然回调 UI"的问题?
追问怎么答
SupervisorJob的设计目标就是隔离失败,一个子任务出错不应把兄弟任务全带崩,更适合页面里多个相对独立的异步任务。CancellationException在协程里更多代表正常取消信号,不是业务失败;如果把它当错误上报,噪音会很多。- 关键是把请求放到正确作用域里,让页面退出自动取消;同时 UI 更新前再检查生命周期状态,避免失效回调。
8. Flow、StateFlow、SharedFlow 怎么区分?
参考答案
Flow 默认是冷流,只有收集时才开始执行,适合一次性异步数据链路和流式转换。StateFlow 是热流,始终持有一个最新状态,适合页面状态建模。SharedFlow 也是热流,但更适合事件广播或多订阅分发,因为它不强制要求永远有一个当前值。
在现代 Android 架构里,一个很常见的组合是:
StateFlow表示页面状态SharedFlow表示一次性事件- 普通
Flow表示数据处理管道
面试官继续追问什么
- 为什么"一次性事件"不适合直接用
StateFlow? Flow的背压问题怎么理解?repeatOnLifecycle解决了什么问题?
追问怎么答
StateFlow会一直保留最新值,页面重建或重新订阅时可能把"跳转、Toast"这类一次性事件再发一次。- 背压可以理解为上游产出太快、下游处理太慢,结果就是堆积、延迟甚至资源浪费;
Flow相关操作符就是在帮助你控制这件事。 repeatOnLifecycle会在生命周期进入目标状态时开始收集、离开时自动取消,避免页面不可见时还在白白收流。
收尾建议
这一篇的重点不是把所有并发原语都背下来,而是建立一个判断框架:
- 这个问题是可见性问题,还是互斥问题?
- 是 CPU 瓶颈、IO 瓶颈,还是调度问题?
- 是该共享状态,还是该避免共享?
- 是该并发执行,还是该串行收敛?
把这些问题答清楚,你的回答就会明显区别于只会背八股的候选人。
相关推荐
《Java Synchronized 和 ReentrantLock》
《Java 线程池深入理解》