近期面试记录 (1)

3月25的一场面试,有点翻车的感觉。记录一下,查漏补缺。

好多年没有正儿八经的技术面试,过去几年主要是我面别人,角色突然反转一下,还得适应适应。这个面试官风格四平八稳。虽然我有不少问题没答上来,但面试体验还是挺好的:因为主要是围绕着简历提问,然后深挖,想考查哪些东西我会,没有故意挑刺、专找不会的问。

面试中的问题回答不上来,其实分两种情况看:一是面试官的问题本身就有问题,可能超出了合理认知范围;二是问题是合理的,但面试者的知识积累有欠缺,无法作答。一个简单的原则就是:写在简历上的东西,应当答得上来。这也是我面别人的原则。(当然,有时面试官并非要求所有问题都有完美答案,不过只是考查就变能力或测试技术深度)

平心而论,我这次的表现很一般,过去的一些知识遗忘挺严重的,所以简历上引申出来的一些问题答得并不好。不过,好在我博客上有记录,其中一些内容是我六七年前晋级答辩的主题,重新捡起来也快,也算是知耻后勇亡羊补牢吧。

Bitmap 像素数据存储

演变历史

管理位图内存  |  App quality  |  Android Developers

在 Android 2.3.3(API 级别 10)及更低版本上,位图的后备像素数据存储在本地内存中。它与存储在 Dalvik 堆中的位图本身是分开的。本地内存中的像素数据并不以可预测的方式释放,可能会导致应用短暂超出其内存限制并崩溃。从 Android 3.0(API 级别 11)到 Android 7.1(API 级别 25),像素数据会与关联的位图一起存储在 Dalvik 堆上。在 Android 8.0(API 级别 26)及更高版本中,位图像素数据存储在原生堆中。

API 级别 像素数据 背景 好处 潜在问题 最佳实践
10及之前 native 层 早期设备 Java 堆内存上限小;GC 性能低 减少 Java OOM 及 GC 延迟 不能自动 GC,内存超限 及时调用 recycle()
11-25 Java 层 硬件提升,GC 性能提升 自动 GC 受限于 JVM ,不能充分使用系统内存 -
26之后 native 层 NativeAllocationRegistry 自动回收 Native 内存 减少 Java OOM,能自动 GC,充分使用系统内存 -

演变依据

抛开性能不谈,个人认为这种 Bitmap 像素数据到该放在 native 内存还是 Java 内存的演变更多还是基于稳定性、灵活性、易用性来考虑的,达到一个”既要稳定、又要灵活、还要易用”的平衡。

  • 稳定性 - 指的是尽可能少一些 Java OOM
  • 灵活性 - 指的是尽可能允许进程最大限度地使用内存
  • 易用性 - 指的是要尽可能自动 GC,减少手动干预

像素数据放在 native 层,稳定性和灵活性都比较好,但是易用性差(用完 Bitmap 之后要调用 recycle() 违背了 Java 自动管理内存的原则);像素数据放在 Java 层,易用性上来了,但稳定性和灵活性都降低了。Android 8.0 引入NativeAllocationRegistry 后,可以自动管理 native 层的像素数据,稳定性、灵活性和易用性三者达到了一个很好的平衡。

API 级别 稳定性 灵活性 易用性
10及之前 不好
11-25 不好 不好
26及之后

NativeAllocationRegistry 的更多细节,可以参考我之前写的 Bitmap 之从出生到死亡 (Bitmap 销毁过程那一节)。

以下是 DeepSeek 关于这个问题的回答。点击查看详情 Android 将 Bitmap 像素数据从 Java 层迁移到 Native 层的设计决策,主要基于以下核心原因:

1. 内存管理优化

  • Java 堆限制
    Java 堆内存存在固定上限(可通过 Runtime.getRuntime().maxMemory() 查询),早期设备通常为 16MB-256MB。若将 Bitmap 像素数据(例如 1920x1080 的 ARGB_8888 格式图片占用 8MB)存储在 Java 堆,极易触发 OOM(OutOfMemoryError)
  • Native 堆灵活性
    Native 堆仅受设备物理内存限制,且可动态扩展。迁移后,Bitmap 像素数据不再挤占 Java 堆空间,显著降低 OOM 风险。

2. 垃圾回收(GC)效率提升

  • Java GC 卡顿问题
    Java 垃圾回收会引发 Stop-The-World 暂停(尤其是 Full GC),影响应用流畅度。大尺寸 Bitmap 频繁分配/释放会加剧 GC 频率。
  • Native 内存自主管理
    Native 内存不受 Java GC 管辖,由开发者通过 Bitmap.recycle() 或系统自动释放(API 26+)。减少 GC 次数,提升 UI 响应速度。

3. 性能与跨进程优化

  • 减少数据拷贝开销
    图像处理算法(如缩放、滤镜)若需在 Native 层执行,直接访问 Native 内存可避免 Java-Native 层间像素数据的冗余拷贝。
  • 共享内存机制
    Android 使用 Ashmem(Anonymous Shared Memory) 管理 Native Bitmap 内存,支持跨进程高效传递 Bitmap(如 Binder 传输仅复制元数据,不复制像素数据)。

4. 系统版本演进

  • Android 3.0+(API 11)
    默认将 Bitmap 像素数据迁移至 Java 层。
  • Android 8.0(API 26)
    引入 NativeAllocationRegistry,自动回收 Native 内存,减少手动调用 recycle() 的需求。

开发者注意事项

1
2
3
4
// 旧版本需手动回收(API < 26)
if (!bitmap.isRecycled()) {
bitmap.recycle(); // 释放 Native 内存
}
  • 避免野指针:确保 Java 层 Bitmap 对象未被引用时再调用 recycle()
  • 兼容性处理:部分低端设备 Native 堆仍有限,需通过 BitmapFactory.Options.inSampleSize 合理缩放图片。

总结

迁移至 Native 层的本质是 权衡内存模型与性能

  • 利用 Native 堆的弹性规避 Java 堆限制;
  • 绕过 Java GC 提升内存效率;
  • 契合图像处理的低延迟需求,构建更健壮的图形子系统。

如何 hook Bitmap

Bitmap 对象是 Android 应用中的内存大户。优化 Bitmap 内存占用,代价小、收益大。所以一个很自然就能想到的问题是应用内哪些 Bitmap 对象占用内存最多?这些占用是否合理?

  • 获取应用全局范围内所有 Bitmap 对象的引用,简单地排序比比看,就能解决第一个问题
  • 如果同时还回溯创建 Bitmap 对象的来源,我们稍加分析就能回答前面第二个问题

想获取应用全局范围内的 Bitmap 对象,一个可行的方案就是在 native 层进行 hook。简单来说,

第一,无论在 Java 层用何种方式创建 Bitmap 对象,JNI 层的 Bitmap.createBitmap() 方法都是必经之路。所以,它就是最佳的 hook 点

第二,通过 hook 上述方法我们可以就可以玩些花样了。Hook 前后对比如下。

如何 hook Bitmap 的更多细节可以参考 Bitmap: 监控与分析

常见的 hook 方案

PLT hook

PLT(Procedure Linkage Table)Hook 是一种基于动态链接机制的 Hook 技术,主要用于拦截动态库函数调用。其核心原理是通过修改 GOT(Global Offset Table)表中的函数地址,实现函数调用的重定向。

PLT Hook 实现步骤:

  1. 定位目标函数 GOT 地址
    • 解析 ELF 文件获取 .rel.plt.rel.dyn 节区
    • 通过重定位条目计算目标函数在 GOT 中的偏移
  2. 修改内存权限
    1
    mprotect(ALIGN_ADDRESS(got_addr), PAGE_SIZE, PROT_READ | PROT_WRITE);
  3. 替换 GOT 值
    1
    *((void **)got_entry) = hook_function;
  4. 处理缓存一致性(ARM 架构需要)

PLT Hook 原理示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <dlfcn.h>
#include <sys/mman.h>
#include <unistd.h>

typedef int (*open_ptr)(const char*, int, mode_t);

int my_open(const char* path, int flags, mode_t mode) {
// 打印日志后调用原函数
return ((open_ptr)orig_open)(path, flags, mode);
}

void hook_plt() {
// 1. 获取目标库基地址
void* libc = dlopen("libc.so", RTLD_NOW);

// 2. 计算GOT条目地址(假设open的GOT偏移为0x1234)
uintptr_t got_addr = (uintptr_t)libc + 0x1234;

// 3. 备份原始函数地址
static void* orig_open = (void*)*((uintptr_t*)got_addr);

// 4. 修改内存权限
uintptr_t page_start = got_addr & ~(getpagesize()-1);
mprotect((void*)page_start, getpagesize(), PROT_READ | PROT_WRITE);

// 5. 替换GOT值
*((uintptr_t*)got_addr) = (uintptr_t)my_open;

// 6. 恢复内存保护(可选)
mprotect((void*)page_start, getpagesize(), PROT_READ);
}

通常我们使用现成的 PLT Hook 开源库,例如 bytedance/bhook

Inline Hook

Inline Hook 通过修改目标函数在内存中的机器码,插入跳转指令劫持执行流,实现拦截/监控目标函数行为。

Inline Hook 实现步骤:

  1. 内存操作:使用 mprotect 修改内存页属性为可写
  2. 指令替换:覆盖目标函数入口指令(ARM架构需处理Thumb/ARM模式)
  3. 跳转构造:插入 LDR PCB/BLX 指令跳转至Hook函数
  4. 指令恢复:备份原始指令用于后续还原

Inline Hook 原理示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <sys/mman.h>
#include <stdint.h>

#define JMP_CODE_SIZE 8

typedef int (*orig_getpid_t)(void);
orig_getpid_t orig_getpid = NULL;

// Hook替换函数
int my_getpid() {
printf("getpid() hooked!\n");
return orig_getpid ? orig_getpid() : -1;
}

void inline_hook(void* target, void* hook) {
uint32_t pagesize = sysconf(_SC_PAGESIZE);
uintptr_t addr = (uintptr_t)target;

// 修改内存权限
mprotect((void*)(addr & ~(pagesize-1)), pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);

// ARM Thumb模式跳转指令构造
uint16_t jmp_code[JMP_CODE_SIZE/2] = {
0xF8DF, 0xF000, // LDR.W PC, [PC]
(uint16_t)((uintptr_t)hook & 0xFFFF),
(uint16_t)((uintptr_t)hook >> 16)
};

// 备份原始指令
static uint16_t orig_code[JMP_CODE_SIZE/2];
memcpy(orig_code, target, JMP_CODE_SIZE);

// 写入跳转指令
memcpy(target, jmp_code, JMP_CODE_SIZE);

// 构造原始函数指针
orig_getpid = (orig_getpid_t)((uintptr_t)target + JMP_CODE_SIZE);
}

// 使用示例
void init_hook() {
inline_hook((void*)getpid, (void*)my_getpid);
}

Android-Inline-Hook 是一个开源的 Inline Hook 库,不过有段时间没更新了。

参考