面灵AI→

字节剪映 CapCut 客户端一面凉经:IPC、Android 内存分区与 TCP 全链路

轮次
一面
结果
收到感谢信
时间
2026-10
来源
牛客网

《面试题目》

  1. 核对专业、年级、可实习时长。
  2. 你做了不少项目,挑一个在开发过程中觉得最难的问题,讲一下你是怎么一步步定位和解决的?
  3. 进程间通信(IPC)有哪些方式?
  4. 简历里写了 AI 相关实践,有用过 MCP 吗?
  5. 知道 MCP 底层是通过哪种进程间通信方式来调用工具或访问外部数据的吗?
  6. 平时使用 Cursor、PE 和开发 Agent 时,有深入了解过整个 Agent 的底层架构或执行流程吗?
  7. 回到刚才说的 IPC,共享内存和 Socket 分别适合用在什么业务场景?
  8. 从应用层到系统层,有了解或使用过 Native Buffer、DirectBuffer 或者 ByteBuffer 吗?
  9. 平时在 Android / Kotlin 开发中是怎么分配内存的?
  10. 现在有一张图片,把它加载转换成 Bitmap,里面有大量的像素数据,这些像素内容到底存放在哪块内存上?
  11. Android 整体分哪几块内存区域?
  12. 了解 JNI 或者 C/C++ 吗?客户端调用底层 C++ 代码是怎么调的?
  13. 你之前用过第三方 SDK 的 .so 库,它底下对外暴露的是什么接口?
  14. 底层的 C++ 代码在运行时,它申请的内存是放在哪个区的?归在哪里管理?
  15. 为什么 Bitmap 像素放在 Native 层可以实现 Kotlin 层与 Native 层的快速共享访问?
  16. 什么是缺页中断?
  17. 讲一下什么是 TCP?有哪些核心特性?
  18. 详细讲一下 TCP 的三次握手和四次挥手过程。
  19. 为什么建立连接需要三次握手,而不是两次?
  20. 为什么断开连接需要四次挥手?
  21. IP 地址前三个字节和最后一个字节分别代表什么意思?
  22. IP 地址后面通常会跟一个端口号,什么叫端口?端口的作用是什么?
  23. 什么是子网掩码?起什么作用?
  24. 假设你在家里连着 Wi-Fi 玩手机,在 App 里点击了一下「购买」按钮,服务端会响应并下发数据:请完整讲一下从你点击 App 按钮开始,整个网络数据包的流转过程是怎样的?
  25. 你的手机连的是家里 Wi-Fi,处于局域网内,当数据包从公网服务器返回时,它是怎么穿过公网找到你家的路由器,再从路由器精准发给你这部手机的?
  26. 数据包到了你的手机网卡之后,手机上跑着那么多应用,操作系统是怎么知道该把这个包丢给你的这个 App 程序的?
  27. 有了解过 WebSocket 和 SSE 这两种连接协议吗?
  28. Android 里用过 OkHttp 的拦截器,有深入了解过它里面的框架源码实现吗?
  29. 简历里写了性能优化,讲一下 LRU 缓存是什么?你在项目里用它做了什么?
  30. LRU 的底层是用什么数据结构实现的?为什么这么设计?
  31. 你在项目里设置的 LRU 缓存最大上限是怎么定的?依据是什么?每个缓存对象大概占多大内存?
  32. 假设在一个无限滑动的 RecyclerView 图片列表场景里,不仅有 View 池化,也有图片的 LRU 缓存:如果用户不断往下滑,数据持续增长,且不同用户的手机设备内存大小完全不同,你的池化大小和缓存上限该怎么合理设定?
  33. 如果用户快速滑动列表,甚至当前手机系统内存已经非常紧张(Low Memory)了,而你的 LRU 缓存还在占着内存,这时候应该怎么设计动态释放与回收策略?
  34. 平时用过 Git 做代码管理吗?
  35. 讲一下 git rebase 的作用是什么?
  36. 什么时候用 git rebase,什么时候用 git merge?这两者的核心区别是什么?
  37. 询问学校成绩排名如何,数据结构、操作系统等专业课大概考多少分?
  38. 算法:反转链表。

《参考解析》

这道面经的结构很典型:面试官用一条主线串起好几层。IPC → MCP 的底层 → 共享内存 vs Socket 的选型 → Native Buffer 与 Bitmap 的内存位置 → JNI → 缺页中断。这条线的本质是「数据在进程和层之间怎么流动」,准备时按这条线串起来比孤立地背每个知识点有效得多。

Android 的 IPC 方式:Binder(最核心,一次拷贝、有权限校验、支持跨进程对象引用)、共享内存(Ashmem/MemoryFile,适合大数据量的单向传输,如图像、音视频帧)、Socket(跨设备或本机不同进程,适合流式数据)、管道与消息队列(轻量但单向、容量有限)、文件(简单但慢、无同步机制)、以及 ContentProvider(基于 Binder 的上层封装)。选型的关键是数据量、方向、以及是否需要同步语义。

MCP 底层的传输:MCP 的 stdio 传输就是父子进程的标准输入输出管道(进程间通信),远程传输走 HTTP 或 SSE/WebSocket。理解这一点后,「MCP 通过哪种进程间通信调用工具」这个问题的答案就落到「本地是 stdio 管道、远程是 HTTP 系」上,而不是玄学。

共享内存与 Socket 的选型:共享内存适合大数据量、低延迟、且双方在同一台机器上的场景(相册传大图、视频帧传递、跨进程共享缓冲区),代价是要自己做同步(信号量、锁)和生命周期管理;Socket 适合跨机器、或需要可靠有序传输、或需要标准化协议的场景,代价是多一次内核拷贝和协议开销。

Java 堆内存与 Native 内存的边界是这组题的核心。Kotlin/Java 对象分配在 ART 的 Java 堆上,由 GC 管理;ByteBuffer.allocateDirect 分配的是堆外(Native)内存,不受 GC 的直接管理,但对应有一个 DirectByteBuffer 对象在堆上,靠 Cleaner 在对象回收时释放。Bitmap 的像素数据在 Android 8.0 之后存放在 Native 堆(heap 上只有一个小对象),所以大图不会撑爆 Java 堆、也不会被 GC 频繁扫描。这正是为什么 Bitmap 像素放在 Native 层能实现快速共享——C++ 侧可以直接拿到这块内存的指针做编解码,不需要跨语言拷贝。Native Buffer(AHardwareBuffer/GraphicBuffer)更进一步,可以做到 GPU 与 CPU 共享,零拷贝。

JNI 调用与 .so 的接口:Java 侧通过 native 方法与 System.loadLibrary 加载的 .so 关联,底层靠 JNIEnv 提供的函数表(RegisterNatives 或按命名约定 Java_包名_类名_方法名 查找)来绑定;第三方 SDK 的 .so 通常只暴露一组 C 风格的函数(要么是 JNI 约定好的符号,要么是自定义的 C API 外层包一层 JNI 胶水)。C++ 里 new/malloc 分配的内存在 Native 堆(进程的 native heap,由 malloc 或自定义分配器管理),不在 ART 的 GC 管辖范围内。

缺页中断:访问一个当前不在物理内存中的虚拟页时,MMU 触发缺页异常,内核负责分配物理页、从磁盘(或交换区、或 mmap 的文件)读入内容、更新页表,然后重新执行那条指令。它分「主缺页」(需要读磁盘)和「次缺页」(页已在内存但页表未映射),后者快得多。大图加载、mmap 文件、以及内存不足时的换页都大量涉及缺页,这也是为什么「内存看起来够但依然卡」的原因之一。

TCP 的核心特性:面向连接、可靠(序号 + 确认 + 重传 + 校验和)、有序、字节流(没有消息边界,需要应用层自己分帧)、全双工、流量控制与拥塞控制。

三次握手的必要性:要让双方都确认「自己的发送能力和对方的接收能力都正常」,并且同步初始序号。两次不够,因为服务端无法确认客户端收到了自己的 SYN-ACK(客户端可能已经关闭),而且历史上迟到的 SYN 会导致服务端建立无效连接。四次挥手的必要性:TCP 是全双工的,一方关闭只是关闭了自己的发送方向(FIN),另一方可能还有数据要发,所以 ACK 和 FIN 要分开发,形成四次。

IP 地址与端口:IPv4 是 32 位,前三个字节是网络号(在传统 A/B/C 类划分下,实际由子网掩码决定网络位与主机位的边界),最后一个字节是主机号。端口是 16 位整数,用来在同一台主机的 IP 上区分不同的进程/服务,0 到 1023 是知名端口,1024 到 49151 是注册端口,49152 以上是动态端口。子网掩码的作用是标出 IP 地址中哪些位是网络号——用 IP 与掩码做按位与就得到网段,判断目标是否在同网段决定是直接 ARP 还是发给网关。

「点一下购买」的完整流转:App 发起 HTTP 请求 → DNS 解析(浏览器/系统缓存 → hosts → 递归查询)→ 与服务器建立 TCP 连接(三次握手)→ TLS 握手(如果是 HTTPS)→ 发送 HTTP 请求 → 经过手机 Wi-Fi 网卡 → 家庭路由器(NAT 转换,把内网源地址换成公网地址)→ 运营商网络 → 互联网路由 → 服务端负载均衡 → 应用服务器 → 响应原路返回 → 家庭路由器根据 NAT 表把包还给手机 → 手机内核根据目标端口找到对应的 socket → 唤醒等待的进程。

公网回来的包怎么找到你家的路由器:靠 NAT。家用路由器维护一张转换表,把「内网 IP:端口」映射到「公网 IP:端口」,出向时记录、回程时按表还原。所以是路由器主动建立的映射让回程包有路可走,而不是公网能直接寻址到你的手机。

包到了手机网卡,怎么知道给哪个 App:靠四元组(源 IP、源端口、目的 IP、目的端口)匹配内核里的 socket。每个 socket 在内核中有对应的结构,数据到达后按端口找到监听的 socket,唤醒阻塞在它上面的进程。

WebSocket 与 SSE 的区别:WebSocket 是全双工、基于 TCP 的自定义协议(握手用 HTTP Upgrade),双方都能主动发;SSE(Server-Sent Events)是单向的服务器推送,基于普通 HTTP,自带断线重连和事件 id,实现简单但不能客户端推。选型看是否需要双向:聊天、协同编辑用 WebSocket;通知、进度推送用 SSE 就够。

OkHttp 拦截器链:应用拦截器(addInterceptor)与网络拦截器(addNetworkInterceptor)的区别是前者只调用一次、能看到应用层的 Request/Response,后者每次网络往返都会经过、能看到重定向和重试的中间态。内置的拦截器依次处理重试与重定向、桥接(转换)、缓存、连接、网络读写。理解这个链的关键是「责任链模式 + 每一环只做一件事」。

LRU 用哈希表 + 双向链表:哈希表提供 O(1) 查找,双向链表维护访问顺序(头部最新、尾部最旧),命中时把节点移到头部,插入超容量时删尾部。用双向链表而不是单链表是因为删除任意节点需要前驱指针。Java 里 LinkedHashMap 的 accessOrder=true 加 removeEldestEntry 就是现成的实现。

RecyclerView 场景下的缓存上限设计,答题要点是「分层 + 动态」:RecyclerView 自己已有 View 池(按 viewType 分池),图片缓存应该独立于它,按「当前屏幕能显示的张数 × 2 到 3」定基础容量;上限不能写死,要按设备可用内存的比例算(比如 Runtime.maxMemory 的一个百分比,或用 ActivityManager.getMemoryClass()),并在 onTrimMemory 回调里按等级主动裁剪(UI 不可见时裁一半、内存紧张时清空)。还要区分内存缓存与磁盘缓存,磁盘按可用空间定。

Low Memory 下的动态回收:监听 ComponentCallbacks2.onTrimMemory,不同等级对应不同动作(TRIM_MEMORY_UI_HIDDEN 释放大部分、TRIM_MEMORY_RUNNING_LOW 进一步收缩);用 LruCache 的 entryRemoved 做资源回收;对正在显示的图片用弱引用或强引用单独持有,避免被回收后立刻重新解码造成抖动。核心思想是「缓存是可丢弃的,正在用的不是」。

git rebase 与 merge:rebase 把当前分支的提交逐个「搬到」目标分支的最新提交之上,结果是线性的历史、没有合并提交;merge 保留分叉并生成一个合并提交,历史是真实但杂乱的。用 rebase 的场景是整理本地未推送的提交、保持主干历史线性;用 merge 的场景是把已推送的公共分支合入(rebase 已推送的分支会改写历史,其他人拉取时会冲突)。团队约定里常见的是「本地 rebase、合入用 merge 或 squash」。