从 6500ms 到 49ms:一次 Java 内存布局优化的实录
在 Android 上做图像模板匹配时,我把一段平平无奇的 Java 代码优化了 130 倍。瓶颈不在算法,而在内存布局。
问题背景
我在写一个 Android 辅助工具,需要在游戏截图(1080×1920)中识别几个固定图标的位置。思路很直接:预定义几个图形的颜色特征点,然后遍历整张图的每个像素,看是否匹配。
核心逻辑就两块:
Shape:定义一个图形的颜色特征点,提供find()方法在指定坐标匹配GameWorld.parse():遍历 1080×1920 的像素,调用Shape.find()找目标
原始代码如下:
java
public class Shape {
public static class PointColor {
public Point point;
public Color color;
public PointColor(Point point, Color color) {
this.point = point;
this.color = color;
}
}
private final List<PointColor> pointColorList;
private final float threshold;
public Shape(int width, int height, Point topLeftOffset,
float threshold, List<PointColor> pointColorList) {
// ... 归一化偏移量 ...
this.pointColorList = pointColorList;
}
public boolean find(int[] pixels, int width, int x, int y) {
int similarCount = 0;
for (int i = 0; i < this.pointColorList.size(); i++) {
var pointColor = this.pointColorList.get(i);
var px = pointColor.point.x + x;
var py = pointColor.point.y + y;
var pos = py * width + px;
if (pos >= 0 && pos < pixels.length) {
var pixel = pixels[pos];
int r = (pixel >> 16) & 0xff;
int g = (pixel >> 8) & 0xff;
int b = pixel & 0xff;
if (pointColor.color.isSimilar(r, g, b)) {
similarCount++;
} else {
if (i == 0) return false; // 首点不匹配直接放弃
}
}
}
return similarCount >= this.pointColorList.size() * this.threshold;
}
}
java
public class GameWorld {
private static final int Width = 1080;
private static final int Height = 1920;
public static void parse(Bitmap bitmap) {
int[] pixels = new int[Width * Height];
bitmap.getPixels(pixels, 0, Width, 0, 0, Width, Height);
boolean[] visited = new boolean[Width * Height];
for (int y = 0; y < Height; y++) {
for (int x = 0; x < Width; x++) {
if (visited[y * Width + x]) continue;
// 跳过小地图区域 ...
if (PlayerShape.find(pixels, Width, x, y)) {
// 记录结果并标记 visited ...
}
// 另外两个 Shape 同理 ...
}
}
}
}
跑出来的耗时:6500ms。
对于一张静态截图,6.5 秒显然不可接受。
瓶颈分析:不是算法,是内存布局
很多人第一反应是"Java 太慢"。但这段逻辑只是简单的整数运算和数组遍历,没有复杂算法。真正的问题是Java 的对象模型在百万级循环中产生了严重的缓存失效。
原始内存拓扑
原始代码中,pointColorList 是一个 ArrayList<PointColor>,而每个 PointColor 内部又引用了 Point 和 Color 对象。在堆上的布局大致如下:
css
ArrayList(对象数组,连续)
[0] → PointColor 实例 A(堆上某处)
├── point ──→ Point 实例 B(堆上另一处)[x][y]
└── color ──→ Color 实例 C(堆上第三处)[r][g][b][tol]
[1] → PointColor 实例 D(堆上别处)
├── point ──→ Point 实例 E ...
└── color ──→ Color 实例 F ...
每次循环执行:
java
var pointColor = this.pointColorList.get(i); // ① 数组边界检查 + 读取引用
var px = pointColor.point.x + x; // ② 解引用跳到 Point 实例
var py = pointColor.point.y + y; // ③ 同上
// ...
if (pointColor.color.isSimilar(r, g, b)) // ④ 解引用跳到 Color 实例 + 虚方法调用
这被称为 Pointer Chasing(指针追踪):CPU 需要跟着引用地址在堆上跳来跳去才能拿到数据。
为什么缓存失效是致命的
现代 CPU 读取内存时,会以**缓存行(Cache Line,通常 64 字节)**为单位把数据批量载入 L1/L2 缓存。
在优化前的代码中:
- 读
PointColor对象,载入 64 字节,但只用到了其中的两个引用(16 字节) - 跟着
point引用跳到Point对象,这是另一个内存地址,之前的缓存基本没用 - 再跟着
color引用跳到Color对象,再次换地址
200 万像素 × 3 个 Shape × 平均 4 个特征点,意味着上亿次跨对象解引用。大部分时间 CPU 都在等内存,而不是在做计算。
JIT 的无奈
JVM 的 JIT 编译器(HotSpot C2)确实会做激进优化:方法内联、边界检查消除、循环展开、甚至向量化。但 JIT 只能优化执行方式 ,它无法重构你的内存布局 。对象在堆上分散分配是 JVM 的底层机制,JIT 跑再快也改变不了你要跨三个对象才能凑齐 x, y, r, g, b 的事实。
优化方案:扁平化数组
核心思路是把对象图拍平成基本类型数组,消除所有中间引用跳转。
java
public class Shape {
// 扁平化:用基本类型数组替代 List<PointColor>
private final int[] offX;
private final int[] offY;
private final int[] r;
private final int[] g;
private final int[] b;
private final int[] tol;
private final int count;
private final int need; // 预计算的最低匹配数
// 首点颜色缓存,供外部快速筛选
public final int fr, fg, fb, ftol;
public final int width;
public final int height;
public final Point topLeftOffset;
public Shape(int width, int height, Point topLeftOffset,
float threshold, List<PointColor> list) {
if (list == null || list.isEmpty()) throw new IllegalArgumentException("empty");
this.width = width;
this.height = height;
this.count = list.size();
this.need = (int) Math.ceil(count * threshold);
this.offX = new int[count];
this.offY = new int[count];
this.r = new int[count];
this.g = new int[count];
this.b = new int[count];
this.tol = new int[count];
// 归一化,同时把对象数据抽进数组
Point first = list.get(0).point.clone_new();
for (int i = 0; i < count; i++) {
PointColor pc = list.get(i);
pc.point.x -= first.x;
pc.point.y -= first.y;
offX[i] = pc.point.x;
offY[i] = pc.point.y;
this.r[i] = pc.color.r;
this.g[i] = pc.color.g;
this.b[i] = pc.color.b;
this.tol[i] = pc.color.tolerance;
}
this.fr = this.r[0];
this.fg = this.g[0];
this.fb = this.b[0];
this.ftol = this.tol[0];
topLeftOffset.x -= first.x;
topLeftOffset.y -= first.y;
this.topLeftOffset = topLeftOffset;
}
// 首点颜色快速检查
public boolean firstMatch(int pixel) {
int pr = (pixel >> 16) & 0xff;
int pg = (pixel >> 8) & 0xff;
int pb = pixel & 0xff;
return Math.abs(pr - fr) <= ftol &&
Math.abs(pg - fg) <= ftol &&
Math.abs(pb - fb) <= ftol;
}
// 首点已匹配,检查剩余点
public boolean restMatch(int[] pixels, int width, int x, int y) {
int similar = 1; // 首点已算匹配
for (int i = 1; i < count; i++) {
int px = x + offX[i];
int py = y + offY[i];
int pos = py * width + px;
if (pos < 0 || pos >= pixels.length) continue;
int p = pixels[pos];
int pr = (p >> 16) & 0xff;
int pg = (p >> 8) & 0xff;
int pb = p & 0xff;
if (Math.abs(pr - r[i]) <= tol[i] &&
Math.abs(pg - g[i]) <= tol[i] &&
Math.abs(pb - b[i]) <= tol[i]) {
similar++;
}
}
return similar >= need;
}
}
GameWorld.parse() 也做了配套调整:
java
public static void parse(Bitmap bitmap) {
GameWorldInfo.clear();
long start = System.currentTimeMillis();
int[] pixels = new int[Width * Height];
bitmap.getPixels(pixels, 0, Width, 0, 0, Width, Height);
boolean[] visited = new boolean[Width * Height];
final int miniX = MiniMap.MiniMap_X;
final int miniH = MiniMap.MiniMap_Height;
for (int y = 0; y < Height; y++) {
int row = y * Width;
boolean inMiniY = y < miniH;
for (int x = 0; x < Width; x++) {
int idx = row + x;
if (visited[idx]) continue;
if (inMiniY && x > miniX) {
visited[idx] = true;
continue;
}
int pixel = pixels[idx];
if (PlayerShape.firstMatch(pixel)) {
if (PlayerShape.restMatch(pixels, Width, x, y)) {
GameWorldInfo.PlayerPos = new Point(x, y);
Point lt = PlayerShape.topLeftOffset;
rangeVisited(visited, x + lt.x, y + lt.y,
PlayerShape.width, PlayerShape.height);
continue;
}
}
if (GetTaskShape.firstMatch(pixel)) {
if (GetTaskShape.restMatch(pixels, Width, x, y)) {
GameWorldInfo.GetTaskPosList.add(new Point(x, y));
Point lt = GetTaskShape.topLeftOffset;
rangeVisited(visited, x + lt.x, y + lt.y,
GetTaskShape.width, GetTaskShape.height);
continue;
}
}
if (SubmitTaskShape.firstMatch(pixel)) {
if (SubmitTaskShape.restMatch(pixels, Width, x, y)) {
GameWorldInfo.SubmitTaskPosList.add(new Point(x, y));
Point lt = SubmitTaskShape.topLeftOffset;
rangeVisited(visited, x + lt.x, y + lt.y,
SubmitTaskShape.width, SubmitTaskShape.height);
continue;
}
}
}
}
Log.d("TAG", "parse: find shape time " + (System.currentTimeMillis() - start) + "ms");
}
private static void rangeVisited(boolean[] visited, int x, int y, int w, int h) {
int x0 = Math.max(0, x);
int y0 = Math.max(0, y);
int x1 = Math.min(Width, x + w);
int y1 = Math.min(Height, y + h);
for (int yy = y0; yy < y1; yy++) {
int row = yy * Width;
for (int xx = x0; xx < x1; xx++) {
visited[row + xx] = true;
}
}
}
优化后的内存布局
现在 Shape 内部的数据是:
ini
offX: [0, 566, 597, 585] // 连续 int[]
offY: [0, 231, 231, 261] // 连续 int[]
r: [0, 255, 149, 154] // 连续 int[]
...
遍历时,CPU 一次缓存行(64 字节)能装下 16 个 int,循环变成线性扫描,缓存命中率极高。同时:
- 消除了
ArrayList.get()的边界检查 - 消除了
pointColor.point/pointColor.color的引用解引用 - 消除了
isSimilar()的虚方法调用
结果
| 版本 | 耗时 |
|---|---|
| 原始版本 | 6500 ms |
| 扁平化数组版本 | 49 ms |
130 倍的差距。
深入:为什么 Java 需要手动拍平,而 Rust 不需要?
这个优化过程让我深刻体会到不同语言的对象模型差异。
在 Java 中,List<PointColor> 默认就是"引用数组 + 分散堆对象"的结构。即使 JIT 再强,也只能在这个结构上修修补补。
而在 Rust 中,如果你严格对应 Java 的嵌套结构:
rust
struct Point {
x: i32, y: i32,
}
struct Color {
r: u8, g: u8, b: u8, tol: u8,
}
struct PointColor {
point: Point,
color: Color,
}
let list: Vec<PointColor> = ...;
Vec<PointColor> 在内存中天然就是连续扁平的:
css
[PointColor]
├── point: Point {x, y} ← 直接内联,不是指针
└── color: Color {r,g,b,tol} ← 直接内联,不是指针
关键区别:值语义 vs 引用语义
Rust 的结构体字段默认是值语义 ------PointColor 包含 Point 和 Color 时,编译器会把它们直接内联到父结构体的内存布局中。没有隐藏引用,没有指针追踪。
Java 的对象字段默认是引用语义 ------PointColor 包含 Point 和 Color 时,实际存储的是堆地址指针。对象本体分散在堆的不同位置,遍历时要跟着引用跳来跳去。
所以即使 Rust 写成嵌套结构体,Vec<PointColor> 仍然是 CPU 缓存友好的线性扫描 。而 Java 的 List<PointColor> 则是指针追踪。
Rust 的借用检查器还会阻止你犯"一边持有引用一边修改数据"的错误(我在 Java 里就因为这个 bug 导致坐标归一化失败)。
当然,Rust 也不是自动快------如果你坚持用 Vec<Box<PointColor>>(显式堆分配),性能一样差。但 Rust 的类型系统默认 就是缓存友好的结构,而 Java 的面向对象模型默认就是缓存不友好的。
总结
- Java 的"慢"往往不是语言慢,而是对象模型在数值密集型场景下的内存布局问题。
- 当循环规模达到百万级时,指针追踪和缓存失效会成为绝对瓶颈。
- 优化关键:扁平化、连续内存、减少引用跳转。
- 对于 Android 图像处理这类场景,如果性能要求极致,核心逻辑下沉到 Rust(通过 JNI)是更彻底的解决方案。