从 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)是更彻底的解决方案。
相关推荐
IT枫斗者枫哥12 分钟前
Java 文件导出:写完了,为什么还不能标记成功?
java
陈皮波比茶18 分钟前
java笔记-typora快捷键
java
蚂蚁雅嘿27 分钟前
maven标签日常总结
java·maven
洋不写bug38 分钟前
二叉树(二) 常见基础操作解析|结点数、树高、查找结点、判断完全二叉树
java·开发语言·数据结构·完全二叉树·二叉树结点数·树高·查找结点
菠萝加点糖1 小时前
Maven 坐标系统说明
java·maven
hm宋1 小时前
Canal 升级 jar 包操作文档(以升级 FastJSON 为例)
java·canal·jar
后台模板学习1 小时前
学习的心态高频面试题
java·数据库·学习
用户3126874877201 小时前
线程池到底怎么管理线程的?从 ThreadPoolExecutor 到拒绝策略全链路拆解
java
cpolar技术支持2 小时前
Kafka Streams 窗口统计怎么验收:本地跑订单流聚合,用 cpolar 给同事看只读结果页
java·docker·kafka·cpolar·kafka streams
爱读源码的大都督2 小时前
DeepSeek面试官问:生产RAG系统回答不准确,该如何定位和优化?这样回答,能让面试官当场给你Offer!
java·后端·python