从 6500ms 到 49ms:一次 Java 内存布局优化的实录

从 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 内部又引用了 PointColor 对象。在堆上的布局大致如下:

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 包含 PointColor 时,编译器会把它们直接内联到父结构体的内存布局中。没有隐藏引用,没有指针追踪。

Java 的对象字段默认是引用语义 ------PointColor 包含 PointColor 时,实际存储的是堆地址指针。对象本体分散在堆的不同位置,遍历时要跟着引用跳来跳去。

所以即使 Rust 写成嵌套结构体,Vec<PointColor> 仍然是 CPU 缓存友好的线性扫描 。而 Java 的 List<PointColor> 则是指针追踪

Rust 的借用检查器还会阻止你犯"一边持有引用一边修改数据"的错误(我在 Java 里就因为这个 bug 导致坐标归一化失败)。

当然,Rust 也不是自动快------如果你坚持用 Vec<Box<PointColor>>(显式堆分配),性能一样差。但 Rust 的类型系统默认 就是缓存友好的结构,而 Java 的面向对象模型默认就是缓存不友好的。

总结

  • Java 的"慢"往往不是语言慢,而是对象模型在数值密集型场景下的内存布局问题
  • 当循环规模达到百万级时,指针追踪和缓存失效会成为绝对瓶颈。
  • 优化关键:扁平化、连续内存、减少引用跳转
  • 对于 Android 图像处理这类场景,如果性能要求极致,核心逻辑下沉到 Rust(通过 JNI)是更彻底的解决方案。
相关推荐
摇滚侠9 小时前
IDEA 新建 Java 项目 学习 Java SE
java·学习·intellij-idea
未秃头的程序猿9 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·后端·ai编程
程序员老乔9 小时前
03-Spring-Security-JWT认证
java·后端·spring
程序员buddha9 小时前
传统 Spring 框架,XML 配置 Bean 的方式
xml·java·spring
希望永不加班9 小时前
SpringBoot 消费者并发控制:线程池配置
java·spring boot·后端·spring
MateCloud微服务9 小时前
从 Karpathy 加入 Anthropic 到 Claude Agent 化:MateClaw 为什么要做企业级 Agent Runtime
java·java agent·mateclaw·mateclaw agent·mc runtime·mc harness·mateclaw open
Yolanda9410 小时前
【编程学习】复盘经典 VB OOP 示例:推翻旧认知,重学面向对象
java·面向对象
Y敲键盘的地方10 小时前
第9章 工具调用循环——Agent的行动闭环
java·服务器·前端
专注写bug10 小时前
Java线程池——ThreadLocal上下文污染问题
java