Java int内存使用量

33

在我思考各种类型的内存使用情况时,我开始对Java在将整数传递给方法时如何利用内存感到有些困惑。

比如说,我有以下代码:

public static void main (String[] args){
     int i = 4;
     addUp(i);
}

public static int addUp(int i){
     if(i == 0) return 0;
     else return addUp(i - 1);         
}

在以下示例中,我想知道我的逻辑是否正确:

  • 我最初为整数i = 4创建了一个内存。然后我将它传递给一个方法。但是,由于Java中的基元类型不是指针,在addUp(i == 4)中,我创建另一个整数i = 4。然后,在addUp(i == 3),addUp(i == 2),addUp(i == 1),addUp(i == 0)中有另一个i值,每次都会分配一个新的i值。
  • 因此,对于单个“int i”值,我使用了6个整数值内存。

如果我总是通过数组传递参数:

public static void main (String[] args){
     int[] i = {4};
     // int tempI = i[0];
     addUp(i);
}

public static int addUp(int[] i){
     if(i[0] == 0) return 0;
     else return addUp(i[0] = i[0] - 1);         
}
-由于我创建了一个大小为1的整数数组,然后将其传递给addUp,然后再传递给addUp(i[0] == 3)、addUp(i[0] == 2)、addUp(i[0] == 1)、addUp(i[0] == 0),因此我只使用了1个整数数组内存空间,因此更加成本高效。此外,如果我事先制作一个int值来存储i[0]的初始值,我仍然拥有我的“原始”值。

这就引出了一个问题,为什么人们要在Java方法中传递诸如int之类的基元类型?直接传递那些基元类型的数组值难道不更节省内存吗?或者第一个例子总体上仍然是O(1)内存吗?

除此之外,我还想知道使用int[]和int的内存差异,尤其是对于大小为1的情况。预先谢谢您。我只是想知道如何更有效地使用Java,这让我想到了这个问题。

感谢所有答案!我现在很快想知道如果我“分析”每段代码的大O内存,它们是否都被认为是O(1),还是假设这样会错?


16
为什么在Java方法中人们会传递像int这样的原始类型?因为Java是一种注重可读性和可维护性而非高效无意义代码的语言。 - Michael
5
顺便提一下,在分析递归函数时,应该在渐近内存使用量中计算隐式堆栈的大小。无论是否假定尾调用都是一个有趣的考虑因素。 - harold
14
@Nathan 这仅适用于 Integer 包装类,而不适用于原始的 int 类型。 - user439793
7
两种方法都使用O(n)的堆栈内存,因为它们都使用一个参数(对于大O符号表示法来说,参数是int类型需要4个字节或者是指向int数组的引用,这不影响其复杂度为O(n))。 - Thomas Kläger
3
如果你必须考虑几个字节的内存效率,那么Java并不是一个很好的语言来做这件事(相比于C语言)。虽然可能有相关的文档提到了堆栈分配等内容,但在像你的程序这样的程序中,你无法真正知道运行时发生了什么。这完全取决于编译器和JIT对你的代码进行的操作。你所知道的只是编译器可能会优化掉你的整个“程序”,因为它没有副作用并且具有零退出状态。 - MarioDS
显示剩余8条评论
7个回答

56
你在这里缺少的是:你示例中的int值存储在stack上,而不是堆上。
与堆上的对象相比,处理存在于栈上的固定大小的原始值的开销要小得多!
换句话说:使用“指针”意味着您必须在堆上创建一个新对象。所有对象都存储在堆上;数组没有栈!并且在停止使用它们后,对象立即成为垃圾回收的对象。另一方面,随着您调用方法,栈会出现和消失!
除此之外:请记住,编程语言为我们提供的抽象是为了帮助我们编写易于阅读、理解和维护的代码。你的方法基本上是进行某种微调,导致更复杂的代码。而这不是Java解决此类问题的方式。
意思是:使用Java时,真正的“性能魔法”发生在运行时,即时编译器启动时!你看,当方法被“频繁调用”时,JIT可以将对小方法的调用内联。然后,保持数据“紧密”在一起变得更加重要。例如:当数据存储在堆上时,你可能需要访问内存来获取一个值。而存储在栈上的项目-可能仍然“接近”(例如:在处理器缓存中)。因此,你为了优化内存使用而提出的小想法实际上可能会大大减慢程序执行速度。因为即使在今天,访问处理器缓存和读取主内存之间仍然存在数量级的差距。
长话短说:无论是为了性能还是内存使用,避免过于关注这种“微调”:JVM已经针对“正常、典型”的用例进行了优化。因此,你试图引入聪明的解决方案很容易导致“不太好”的结果。

所以 - 当你担心性能时:做每个人都在做的事情。如果你真的关心 - 那么学习JVM的工作原理。事实证明,即使我的知识略有过时 - 因为评论意味着JIT可以内联堆栈上的对象。从这个意义上讲:专注于编写干净、优雅的代码,以直接解决问题!

最后:这是可能会改变的。有想法引入真正的值对象到Java中。它们基本上生活在堆栈上,而不是堆上。但不要指望在Java10之前或之后发生这种情况。或者11。或...(我认为this在这里是相关的)。


1
顺便说一句,我知道这听起来很愚蠢和基础,但如果有人要求它们的大O内存空间,那么最终它们都被认为是O(1)吗? - AccCreate
1
没有傻瓜问题,只有傻瓜;-) ...说实话,我对那个问题感到非常困惑——因为我很久以前就不用计算堆栈和堆内存的 BigO 了。换句话说:我不记得确切的规则……因此,我恭敬地把这个方面留给其他人来回答。 - GhostCat
5
现代的JIT编译器可以进行逃逸分析并在栈上分配对象(而不是在堆上),如果它可以证明该对象的生命周期永远不会超出栈帧。因此,即使是问题中的示例代码,如果被识别为热点代码,则很可能会被JIT编译成将值保留在栈上的机器代码。 - Daniel Pryden
2
@CortAmmon:“相当聪明”的编译器在Java中似乎更难实现。据我所知,Sun的JVM(和OpenJDK)仍然不支持TCO(显然IBM的JVM支持https://softwareengineering.stackexchange.com/questions/157684/what-limitations-does-the-jvm-impose-on-tail-call-optimization#comment555950_157685)。Java安全模型涉及检查给定调用者是否有权限调用给定被调用方。此外,更改堆栈跟踪对TCO来说是一个潜在的问题。 - ninjalj
1
@AccCreate:从形式上讲,两者的堆栈空间复杂度都是O(n),因为所需空间呈线性增长。实际上,每种方法所需的堆栈空间可能相差六倍,这取决于JIT状态(请参见https://dev59.com/4F8d5IYBdhLWcg3wNQNg),更重要的是,HotSpot使用每个线程预分配的堆栈内存,无论您做什么都不会改变。当它耗尽时,您可能会收到“StackOverflowError”,但是就从操作系统分配的内存而言,它是恒定的,换句话说,递归本身不会在HotSpot JVM中导致内存分配。 - Holger
显示剩余5条评论

18

有几个问题:

第一个问题可能会纠缠于细节,但是在java中传递int类型时,你会将4字节分配到栈上,而当你传递数组(因为它是引用)时,你实际上会将8字节(假设x64架构)加上额外的4字节存储到堆中。

更重要的是,数组中存储的数据是分配到堆中的,而对数组本身的引用是分配到栈中的。当传递整数时,不需要堆分配,原始类型只分配到栈中。随着时间的推移,减少堆分配意味着垃圾回收器需要清理的东西更少。相比之下,堆栈帧的清理很简单,不需要额外的处理。

然而,在实际情况中,这些都没有什么用处(在我看来),因为当你拥有复杂的变量和对象集合时,你很可能会将它们组合成一个类。总的来说,你应该编写促进可读性和可维护性的代码,而不是试图从JVM中挤出每一点性能。JVM本身已经非常快了,并且还有摩尔定律作为后备。

因为要得到真正的图片,分析每个的Big-O是很困难的。你必须考虑垃圾回收器的行为,而这种行为高度依赖于JVM本身以及JVM针对你的代码所做的任何运行时(JIT)优化。

请记住唐纳德·克努斯的明智之言,“过早地进行优化是万恶之源”

编写避免微调的代码,具有促进可读性和可维护性的代码将在长期内表现更好。


1
关于您的一些细节问题:1. 使用压缩OOPs(现在是默认设置,据我所知)指向数组的指针只占用4个字节,即使在64位系统上也是如此;2. 对于没有终结器的对象的年轻代分配可以集中收集,并且实际上可能与堆栈分配一样便宜。 GC的开销只有在需要复制对象时才会真正发挥作用(特别是如果它被复制了足够多次以移出清道夫空间并进入老年代)。 - Daniel Pryden
同意,但所有这些都是为了强调我们不应该担心它,而应该让JVM/JIT自己解决(因为所有这些观点都是有效的,但严重依赖于JVM的实现)。相反,我们应该专注于编写清晰易懂、易于维护的代码,让JVM发挥其最大的作用。 - pfranza

10
如果你认为传递给函数的参数一定会占用内存(顺便说一句,这是错误的),那么在第二个示例中传递一个数组时,将会复制一个数组引用。该引用实际上可能比int类型更大,不太可能更小。

函数传递的参数必然会消耗内存。这是处理器的工作原理。我有什么遗漏吗? - Jack
1
@Jack 参数可以通过寄存器传递,这在x86代码中并不常见,但在x64和ARM中却很常见。当然,只有在一定限制范围内才能这样做。 - harold
1
@Jack - 此外,任何重要的Java代码都将被编译,可能会编译多次,并且最终结果将被大量内联,许多参数可能会有“零”内存使用,因为它们是通过寄存器传递的,或者仅仅因为函数调用本身已经完全消失,根本没有传递。因此,从一般意义上讲,谈论参数的内存使用并不真正有意义。通常可以谈论堆上对象的内存使用(但即使如此也受到各种优化的影响),但对于堆栈来说则不太行。 - BeeOnRope

7
无论这些方法的时间复杂度是 O(1) 还是 O(N),取决于编译器。(这里的 N 是 ii[0] 的值,具体取决于情况。)如果编译器使用尾递归优化,则参数、局部变量和返回地址的堆栈空间可以被重用,实现时空复杂度将为 O(1)。如果没有尾递归优化,则空间复杂度与时间复杂度相同,均为 O(N)。
基本上,尾递归优化(在这种情况下)相当于编译器将您的代码重写为:
public static int addUp(int i){
     while(i != 0) i = i-1 ;
     return 0;        
}

或者

public static int addUp(int[] i){
     while(i[0] != 0) i[0] = i[0] - 1 ;
     return 0 ;
}

一个好的优化器可以进一步优化掉循环。

据我所知,目前没有Java编译器实现尾递归优化,但在许多情况下,这是可以做到的。


2
严格来说,我应该使用大Theta而不是大Oh,因为我们讨论的是确切的复杂度,而不是复杂度的上界。 - Theodore Norvell

5
实际上,在将数组作为参数传递给方法时,底层传递的是对该数组的引用。数组本身存储在堆上。引用的大小可以是48字节(取决于CPU架构、JVM实现等;甚至更多,JLS不会说明引用在内存中有多大)。
另一方面,原始的int值总是只占用4字节并驻留在堆栈上。

4
当您传递一个数组时,接收该数组的方法可能会修改数组内容。当您传递int原始值时,接收该值的方法可能无法修改它们。这就是为什么有时候您可能会使用原始值,有时候您可能使用数组。
另外,在Java编程中,通常更偏向于可读性,并让这种内存优化由JIT编译器来完成。

但是,如果我通过 int tempI = i[0]; 保存原始 int[0] 值的值,那么仅使用数组是否更好,或者在一天结束时内存使用情况是否相似? - AccCreate
这是一个非常难回答的问题。您的示例代码实际上并没有做任何事情,因此我们无法理解您操作的上下文,以至于我们无法推荐一种方法而不是另一种方法。 - JakeRobb

4

整型数组引用在堆栈帧中所占空间比整型基本类型更大(8字节对4字节)。实际上,您使用的空间更多。

但我认为人们更喜欢第一种方式的主要原因是它更清晰易读。

当涉及更多整数时,人们实际上会更接近第二种方式。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接