二维数组以及空间局部性

5 阅读3分钟

在Java中,对二维数组先行后列(行优先​)的求和效率通常显著高于先列后行(列优先​)的求和。这背后的核心原因与计算机的内存架构和CPU缓存的工作机制密切相关。

🔍 深入理解性能差异的原因

1. 内存布局与访问模式

  • ​行优先存储​:在Java中,二维数组 int[][]​在内存中实际上是 “数组的数组”​ 。每一行(如 a[0]​)是一个独立的一维数组,在内存中是连续存储的。因此,当你按 a[i][j]​顺序访问时,你是在连续的内存地址上顺序读取数据,就像在一条直路上前进。
  • ​列优先存储​:而按 a[j][i]​顺序访问时,为了获取下一列同一行的元素,你需要 “跳跃”​ 到内存中相隔很远的位置(距离约为“一行的大小”),就像在一条路上不断折返跑。这种非连续的访问模式效率很低。

2. CPU缓存与空间局部性

CPU缓存是解决CPU高速运算与内存低速读取之间矛盾的关键部件。其工作原理基于​程序局部性原理​,其中空间局部性是指:如果某个内存位置被访问,那么其附近的内存位置也很有可能在不久的将来被访问。

  • ​缓存行​:CPU从内存读取数据时,并非只读取请求的一个字节,而是会一次性读取一个连续的块,称为​缓存行​,通常大小为 ​64 字节​。对于 int​类型(4字节),一个缓存行可以容纳约16个连续的数组元素。
  • ​行优先遍历的优势​:当你访问 a[i][0]​时,CPU不仅会加载这个元素,还会将其后的 a[i][1]​, a[i][2]​等约15个相邻元素一同加载到高速缓存中。接下来访问 a[i][1]​到 a[i][15]​时,数据已经在高速缓存中,CPU可以直接读取,速度极快。这就是​高缓存命中率​。
  • ​列优先遍历的劣势​:当你访问 a[0][j]​后,下一个要访问的是 a[1][j]​,它位于内存中完全不同的另一行。这个新地址很大概率不在当前缓存行中,导致​缓存未命中​。CPU必须暂停当前工作,去速度慢得多的主内存中加载新的缓存行。这种频繁的缓存未命中严重拖慢了处理速度

下面这个例子:行优先遍历用了41ms,但是列优先遍历用了1028ms,相差几十倍。

    @Test
    public void test(){
        int rows = 10000;
        int cols = 10000;
        int sum = 0;
        int[][] array = new int[rows][cols];
        long start = System.currentTimeMillis();
        for (int i = 0; i < rows; i++){
            for (int j = 0; j < cols; j++){
                sum = sum + array[i][j];
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("Time taken: " + (end - start) + " ms");

        sum = 0;
        long start1 = System.currentTimeMillis();
        for (int j = 0; j < cols; j++){
            for (int i = 0; i < rows; i++){
                sum = sum + array[i][j];
            }
        }
        long end1 = System.currentTimeMillis();
        System.out.println("Time taken: " + (end1 - start1) + " ms");
    }