为什么Java没有真正的多维数组?

时间:2014-10-11 19:17:53

标签: java arrays performance multidimensional-array

TL; DR版本,对于那些不想要背景的人来说,是以下具体问题:

问题

  

为什么Java没有真正的多维数组的实现?有坚实的技术原因吗?我在这里缺少什么?

背景

Java在语法级别具有多维数组,可以声明

int[][] arr = new int[10][10];

但似乎这真的不是人们所期望的。它不是让JVM分配大到足以存储100 int s的连续RAM块,而是作为int s的数组数组出来:所以每个层都是一个连续的RAM块,但是事情本身并非如此。因此访问arr[i][j]相当慢:JVM必须

  1. 找到存储在int[];
  2. arr[i]
  3. 将其编入索引以查找int存储的arr[i][j]
  4. 这涉及查询对象从一层到另一层,这相当昂贵。

    为什么Java会这样做

    在一个层面上,不难看出为什么即使将它们全部分配到一个固定块中,也不能将其优化为简单的扩展和添加查找。问题是arr[3]是一个自己的引用,它可以被更改。因此,尽管数组具有固定大小,但我们可以轻松编写

    arr[3] = new int[11];
    

    现在,因为这个层已经成长,所以缩放和添加。您需要在运行时知道是否所有内容仍然与以前相同。此外,当然,这将被分配到RAM中的其他位置(它必须是,因为它比它更换的更大),所以它甚至都没有在适当的地方进行扩展和添加。

    它有什么问题

    在我看来,这并不理想,这有两个原因。

    首先,它。对于多维情况(int[1000000]int[100][100][100]分别填充随机int值,使用热缓存运行1000000次。

    public static long sumSingle(int[] arr) {
        long total = 0;
        for (int i=0; i<arr.length; i++)
            total+=arr[i];
        return total;
    }
    
    public static long sumMulti(int[][][] arr) {
        long total = 0;
        for (int i=0; i<arr.length; i++)
            for (int j=0; j<arr[0].length; j++)
                for (int k=0; k<arr[0][0].length; k++)
                    total+=arr[i][j][k];
        return total;
    }   
    

    其次,因为它很慢,所以鼓励模糊编码。如果你遇到一些对于多维数组自然会完成的性能关键的事情,你就有动力把它写成一个扁平数组,即使这会使它变得不自然且难以阅读。你离开了一个令人不快的选择:代码模糊或代码慢。

    可以做些什么

    在我看来,基本问题很容易解决。正如我们之前看到的那样,唯一的原因是它无法优化,结构可能会发生变化。但是Java已经有了一种使引用不可更改的机制:将它们声明为final

    现在,只需用

    声明它
    final int[][] arr = new int[10][10];
    

    不够好,因为它arr final只有arr[3]final仍然不是,并且可以更改,所以结构可能仍会改变。但是如果我们有一种方式来声明事物,那么它始终是int,除了存储final int[final][] arr = new int[10][10]; 值的底层,那么我们就有了一个完整的不可变结构,它可以全部分配为一个块,并使用scale-and-add进行索引。

    它在语法上看起来如何,我不确定(我不是语言设计师)。也许

    final
    虽然承认这看起来有点奇怪。这意味着:final位于顶层; final位于下一层;底层没有int(否则int值本身就是不可变的。)

    整个过程将使JIT编译器能够对其进行优化,以提高单维数组的性能,从而消除了为了绕过多维数组的缓慢而采用这种方式进行编码的诱惑。

    (我听说有传言说C#会做这样的事情,虽然我也听到另一个传言说CLR的实施非常糟糕,以至于它不值得......也许他们只是谣言...... 。)

    问题

      

    那么为什么Java没有真正的多维数组的实现呢?有坚实的技术原因吗?我在这里缺少什么?

    更新

    一个奇怪的旁注:如果您使用long作为运行总计而不是int,则时间差异会下降到几个百分点。为什么与long存在这么小的差异,与public class Multidimensional { public static long sumSingle(final int[] arr) { long total = 0; for (int i=0; i<arr.length; i++) total+=arr[i]; return total; } public static long sumMulti(final int[][][] arr) { long total = 0; for (int i=0; i<arr.length; i++) for (int j=0; j<arr[0].length; j++) for (int k=0; k<arr[0][0].length; k++) total+=arr[i][j][k]; return total; } public static void main(String[] args) { final int iterations = 1000000; Random r = new Random(); int[] arr = new int[1000000]; for (int i=0; i<arr.length; i++) arr[i]=r.nextInt(); long total = 0; System.out.println(sumSingle(arr)); long time = System.nanoTime(); for (int i=0; i<iterations; i++) total = sumSingle(arr); time = System.nanoTime()-time; System.out.printf("Took %d ms for single dimension\n", time/1000000, total); int[][][] arrMulti = new int[100][100][100]; for (int i=0; i<arrMulti.length; i++) for (int j=0; j<arrMulti[i].length; j++) for (int k=0; k<arrMulti[i][j].length; k++) arrMulti[i][j][k]=r.nextInt(); System.out.println(sumMulti(arrMulti)); time = System.nanoTime(); for (int i=0; i<iterations; i++) total = sumMulti(arrMulti); time = System.nanoTime()-time; System.out.printf("Took %d ms for multi dimension\n", time/1000000, total); } } 有如此大的差异?

    基准代码

    我用于基准测试的代码,以防有人想要尝试重现这些结果:

    {{1}}

6 个答案:

答案 0 :(得分:19)

  

但似乎这真的不是人们所期望的。

为什么?

考虑形式T[]表示&#34;类型为T&#34;的数组,然后正如我们所期望的那样int[]表示&#34;类型为int&#34;的数组,我们希望int[][]表示&#34;类型为int&#34;的数组数组,因为int[]作为T而不是{{}} {1}}。

因此,考虑到可以拥有任何类型的数组,只需使用int[来声明和初始化数组(就此而言] },{}),如果没有禁止数组数组的某种特殊规则,我们就可以免费获得这种使用&#34;。

现在还要考虑我们可以用锯齿状数组做些事情,否则我们无法做到:

  1. 我们可以&#34;锯齿状&#34;不同内部数组大小不同的数组。
  2. 我们可以在外部数组中使用适当的数据映射,或者允许延迟构建的空数组。
  3. 我们可以故意在数组中使用别名,例如,lookup[1]的数组相同。 (这可以通过一些数据集实现大量节省,例如,可以在少量内存中为完整的1,112,064个代码点映射许多Unicode属性,因为可以针对具有匹配模式的范围重复属性的叶阵列。) / LI>
  4. 某些堆实现可以比内存中的一个大对象更好地处理许多较小的对象。
  5. 有些情况下,这些多维数组很有用。

    现在,任何功能的默认状态都未指定且未实现。有人需要决定指定和实现一个功能,否则它就不存在。

    因为,如上所示,除非有人决定引入特殊的禁止数组数组功能,否则将存在数组数组排列的多维数组。由于上述原因,数组数组很有用,这将是一个奇怪的决定。

    相反,多维数组的类型,其中数组具有可以大于1的定义的等级,因此与一组索引而不是单个索引一起使用,并不是从已经定义的内容中自然地遵循。有人需要:

    1. 决定声明的规范,初始化和使用是否有效。
    2. 记录下来。
    3. 编写实际代码来执行此操作。
    4. 测试代码以执行此操作。
    5. 处理错误,边缘情况,不存在实际错误的错误报告,修复错误导致的向后兼容性问题。
    6. 此外,用户还必须学习这一新功能。

      所以,它必须值得。一些值得的东西是:

      1. 如果没办法做同样的事情。
      2. 如果做同样事情的方式很奇怪或不为人所知。
      3. 人们会从类似的背景中得到它。
      4. 用户无法自行提供类似的功能。
      5. 在这种情况下:

        1. 但是有。
        2. C和C ++程序员已经知道在数组中使用步幅,并且基于其语法构建了Java,因此可以直接应用相同的技术
        3. Java的语法基于C ++,而C ++同样只能直接支持多维数组作为数组数组。 (除非是静态分配,但在Java中不是类似于数组是对象的类比)。
        4. 可以轻松编写一个包含数组和stride-sizes细节的类,并允许通过一组索引进行访问。
        5. 真的,这个问题不是&#34;为什么Java没有真正的多维数组&#34;?但是&#34;为什么要这样?&#34;

          当然,你支持多维数组的观点是有效的,有些语言确实有这个点,但是负担仍然是争论一个特征,而不是争论它。

            

          (我听说有传言说C#会做这样的事情,虽然我也听到另一个传言说CLR的实施非常糟糕,以至于它不值得......也许他们只是谣言...... 。)

          像许多谣言一样,这里有一个真理要素,但这不是全部真相。

          .NET数组确实可以有多个排名。这不是它比Java更灵活的唯一方式。每个等级也可以具有除零之外的下限。因此,你可以有一个数组从-3到42或一个二维数组,其中一个等级从-2到5,另一个从57到100,或其他。

          C#没有通过其内置语法提供对所有这些内容的完全访问权限(您需要为除0以外的下限调用lookup[5]),但它允许您使用语法{{1对于三维数组的Array.CreateInstance()int[,]的二维数组,依此类推。

          现在,处理除零之外的下限所涉及的额外工作增加了性能负担,但这些情况相对不常见。因此,具有0的下限的单列数组被视为具有更高性能实现的特殊情况。实际上,它们在内部是一种不同的结构。

          在.NET中,下限为零的多维数组被视为多维数组,其下限恰好为零(即,作为较慢情况的示例)而不是更快的情况能够处理等级大于1。

          当然,.NET 可以对基于零的多维数组有一个快速路径的情况,但是Java的所有原因都没有应用事实上已经有一个特例,特殊情况很糟糕,然后会有两个特殊情况,他们会吮吸更多。 (实际上,尝试将一种类型的值分配给另一种类型的变量时可能存在一些问题。)

          上面没有一件事清楚地表明Java不可能有你所谈论的那种多维数组;这本来是一个明智的决定,但所做的决定也是明智的。

答案 1 :(得分:15)

我认为这对James Gosling来说应该是一个问题。 Java的初始设计是关于OOP和简单性,而不是关于速度。

如果你对多维数组应该如何工作有更好的了解,有几种方法可以实现它:

  1. 提交JDK Enhancement Proposal
  2. 通过Java Community Process开发新的JSR。
  3. 提出新的Project
  4. <强> UPD 即可。当然,您并不是第一个质疑Java数组设计问题的人 例如,项目SumatraPanama也将受益于 true 多维数组。

    "Arrays 2.0"是John Rose在2012年JVM语言峰会上就此主题发表的演讲。

答案 2 :(得分:10)

对我而言,你似乎有点自己回答了这个问题:

  

......将其作为平面阵列编写的动机,即使这会使其不自然且难以阅读。

所以把它写成一个易于阅读的平面数组。有一个像

这样的琐碎助手
double get(int row, int col) {
    return data[rowLength * row + col];
}

和类似的setter以及可能的+=相当,你可以假装你正在使用2D数组。这真的没什么大不了的。你不能使用数组符号,一切都变得冗长和丑陋。但这似乎是Java的方式。它与BigIntegerBigDecimal完全相同。您无法使用大括号访问Map,这是一个非常相似的案例。

现在的问题是所有这些功能的重要性如何?如果他们可以写x += BigDecimal.valueOf("123456.654321") + 10;spouse["Paul"] = "Mary";,或者使用没有样板的2D数组,或者什么?所有这些都很好,你可以更进一步,例如阵列切片。 但是没有真正的问题。你必须在许多其他情况下在冗长和低效率之间做出选择。恕我直言,花在这个功能上的努力可以更好地花在其他地方。你的2D阵列是最好的....

Java实际上没有2D原始数组,......

它主要是一个语法糖,底层的东西是对象数组。

double[][] a = new double[1][1];
Object[] b = a;

随着数组的实现,当前的实现几乎不需要任何支持。你的实现会打开一堆蠕虫:

  • 目前有8种原始类型,即9种数组类型,2D数组是第10种吗? 3D怎么样?
  • 数组有一个特殊的对象标头类型。 2D阵列可能需要另一个。
  • java.lang.reflect.Array怎么样?克隆2D阵列?
  • 许多其他功能将被改编,例如序列化。

什么会

??? x = {new int[1], new int[2]};

是?旧式2D int[][]?那么互操作性呢?

我想,这一切都是可行的,但Java中缺少更简单,更重要的东西。有些人一直需要2D数组,但很多人几乎不记得他们何时使用任何数组。

答案 3 :(得分:9)

我无法重现您声称的性能优势。具体来说,测试程序:

public abstract class Benchmark {

    final String name;

    public Benchmark(String name) {
        this.name = name;
    }

    abstract int run(int iterations) throws Throwable;

    private BigDecimal time() {
        try {
            int nextI = 1;
            int i;
            long duration;
            do {
                i = nextI;
                long start = System.nanoTime();
                run(i);
                duration = System.nanoTime() - start;
                nextI = (i << 1) | 1;
            } while (duration < 1000000000 && nextI > 0);
            return new BigDecimal((duration) * 1000 / i).movePointLeft(3);
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public String toString() {
        return name + "\t" + time() + " ns";
    }

    public static void main(String[] args) throws Exception {
        final int[] flat = new int[100*100*100];
        final int[][][] multi = new int[100][100][100];

        Random chaos = new Random();
        for (int i = 0; i < flat.length; i++) {
            flat[i] = chaos.nextInt();
        }
        for (int i=0; i<multi.length; i++)
            for (int j=0; j<multi[0].length; j++)
                for (int k=0; k<multi[0][0].length; k++)
                    multi[i][j][k] = chaos.nextInt();

        Benchmark[] marks = {
            new Benchmark("flat") {
                @Override
                int run(int iterations) throws Throwable {
                    long total = 0;
                    for (int j = 0; j < iterations; j++)
                        for (int i = 0; i < flat.length; i++)
                            total += flat[i];
                    return (int) total;
                }
            },
            new Benchmark("multi") {
                @Override
                int run(int iterations) throws Throwable {
                    long total = 0;
                    for (int iter = 0; iter < iterations; iter++)
                        for (int i=0; i<multi.length; i++)
                            for (int j=0; j<multi[0].length; j++)
                                for (int k=0; k<multi[0][0].length; k++)
                                    total+=multi[i][j][k];
                    return (int) total;
                }
            },
            new Benchmark("multi (idiomatic)") {
                @Override
                int run(int iterations) throws Throwable {
                    long total = 0;
                    for (int iter = 0; iter < iterations; iter++)
                        for (int[][] a : multi)
                            for (int[] b : a)
                                for (int c : b)
                                    total += c;
                    return (int) total;
                }
            }

        };

        for (Benchmark mark : marks) {
            System.out.println(mark);
        }
    }
}

使用

在我的工作站上运行
java version "1.8.0_05"
Java(TM) SE Runtime Environment (build 1.8.0_05-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.5-b02, mixed mode)

打印

flat              264360.217 ns
multi             270303.246 ns
multi (idiomatic) 266607.334 ns

也就是说,我们观察到您提供的一维和多维代码之间仅有3%的差异。如果我们使用惯用Java(特别是增强的for循环)进行遍历,这种差异会下降到1%(可能是因为对同一个数组对象执行了边界检查,循环解除引用,使得及时编译器能够更加完全地忽略边界检查)

因此,性能似乎不足以证明增加语言的复杂性。具体来说,为了支持真正的多维数组,Java编程语言必须区分数组数组和多维数组。 同样,程序员必须区分它们,并意识到它们之间的差异。 API设计者必须思考是使用数组数组还是多维数组。必须扩展编译器,类文件格式,类文件验证器,解释器和及时编译器。这将是特别困难的,因为不同维度计数的多维数组将具有不兼容的存储器布局(因为必须存储它们的维度的大小以启用边界检查),因此不能是彼此的子类型。因此,类java.util.Arrays的方法可能必须为每个维度计数重复,就像使用数组的所有其他多态算法一样。

总之,扩展Java以支持多维数组将为大多数程序提供可忽略的性能增益,但需要对其类型系统,编译器和运行时环境进行非平凡的扩展。因此,引入它们将与Java编程语言的设计目标不一致,特别是simple

答案 4 :(得分:3)

由于这个问题在很大程度上取决于性能,所以让我提供一个适当的基于JMH的基准。我也改变了一些东西,使你的例子更简单,性能优势更加突出。

在我的例子中,我将1D数组与2D数组进行比较,并使用非常短的内部维度。这是缓存的最坏情况。

我尝试过使用longint累加器,看不出它们之间的区别。我提交的版本为int

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(X*Y)
@Warmup(iterations = 30, time = 100, timeUnit=MILLISECONDS)
@Measurement(iterations = 5, time = 1000, timeUnit=MILLISECONDS)
@State(Scope.Thread)
@Threads(1)
@Fork(1)
public class Measure
{
  static final int X = 100_000, Y = 10;
  private final int[] single = new int[X*Y];
  private final int[][] multi = new int[X][Y];

  @Setup public void setup() {
    final ThreadLocalRandom rnd = ThreadLocalRandom.current();
    for (int i=0; i<single.length; i++) single[i] = rnd.nextInt();
    for (int i=0; i<multi.length; i++)
      for (int j=0; j<multi[0].length; j++)
          multi[i][j] = rnd.nextInt();
  }

  @Benchmark public long sumSingle() { return sumSingle(single); }

  @Benchmark public long sumMulti() { return sumMulti(multi); }

  public static long sumSingle(int[] arr) {
    int total = 0;
    for (int i=0; i<arr.length; i++)
      total+=arr[i];
    return total;
  }

  public static long sumMulti(int[][] arr) {
    int total = 0;
    for (int i=0; i<arr.length; i++)
      for (int j=0; j<arr[0].length; j++)
          total+=arr[i][j];
    return total;
  }
}

性能差异大于

Benchmark                Mode  Samples  Score  Score error  Units
o.s.Measure.sumMulti     avgt        5  1,356        0,121  ns/op
o.s.Measure.sumSingle    avgt        5  0,421        0,018  ns/op

这是三个以上的因素。 (注意,每个数组元素报告的时间是。)

我还注意到没有涉及预热:前100毫秒与其余时间一样快。显然,这是一项非常简单的任务,解释器已经尽其所能使其达到最佳状态。

更新

sumMulti的内部循环更改为

      for (int j=0; j<arr[i].length; j++)
          total+=arr[i][j];
正如maaartinus预测的那样,

(注意arr[i].length)导致了显着的加速。使用arr[0].length使得无法消除索引范围检查。现在的结果如下:

Benchmark                Mode  Samples  Score   Error  Units
o.s.Measure.sumMulti     avgt        5  0,992 ± 0,066  ns/op
o.s.Measure.sumSingle    avgt        5  0,424 ± 0,046  ns/op

答案 5 :(得分:1)

如果你想快速实现真正的多维数组,你可以编写一个这样的自定义实现。但你是对的......它不像数组符号那样清晰。虽然,整洁的实施可能非常友好。

public class MyArray{
    private int rows = 0;
    private int cols = 0;
    String[] backingArray = null;
    public MyArray(int rows, int cols){
        this.rows = rows;
        this.cols = cols;
        backingArray  = new String[rows*cols];
    }
    public String get(int row, int col){
        return backingArray[row*cols + col];
    }
    ... setters and other stuff
}

为什么不是默认实施?

Java的设计者可能不得不决定通常的C数组语法的默认表示法如何表现。它们有一个数组符号,可以实现数组数组或真正的多维数组。

我认为早期的Java设计师真正关心的是Java的安全性。似乎已经采取了许多决定,使普通程序员(或者糟糕的一天的优秀程序员)难以搞砸某些东西。使用真正的多维数组,用户可以通过分配无用的块来更容易地浪费大块内存。

此外,从Java的嵌入式系统的根源,他们可能发现它更有可能找到要分配的内存片段而不是真正的多维对象所需的大块内存。

当然,另一方面是多维阵列真正有意义的地方受到影响。你被迫使用一个库和杂乱的代码来完成你的工作。

为什么它还没有包含在语言中?

即使在今天,从可能的内存浪费/误用的角度来看,真正的多维数组也是一种风险。