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