前言
一、Java中内存分配策略
在比较堆、栈区别之前,我们先了解下Java内存分配策略,按照编译原理的观点,程序运行时的内存分配有三种策略。分别是:静态的、栈式的、堆式的。
- 静态存储分配:是指在编译时,就能确定每个数据在运行时的存储空间,因而在编译时就给它们分配内存空间。这种分配策略不允许程序中存在可变数据结构,也不允许有嵌套或递归的的结构出现,因为它们都会导致编译程序无法准确计算出存储空间需求。
- 栈式存储分配:也可称为动态存储分配,是一个类似于堆栈的运行栈来实现的。和静态存储分配相反,在栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到运行的时候才能够知道。也就是规定在运行中进入一个程序模块时,必须知道该程序模块所需的数据区大小才能为其分配内存。栈式存储分配按照先进后出的原则进行分配。
- 堆式存储分配:静态存储分配要求在编译时能知道所有变量的存储要求,栈式存储分配要求在过程的入口处必须知道所有的存储要求,而堆式存储分配专门负责编译时或者运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例。堆由大片可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。
二、JVM中堆(heap)和栈(stack)的区别
JVM是基于堆、栈的的虚拟机,堆和栈都是Java在内存中存放是数据的地方。
1.功能与作用:
(1) 栈可以看成是方法的运行模型,所有方法的调用都是通过栈帧来进行的,JVM会为每个线程分配一个栈区,JVM对栈只进行两种操作:以栈帧为单位的压栈和出栈操作。当线程进入一个方法函数的时候,就会在当前线程的栈里压入一个栈帧,用于保存当前线程的状态(参数、局部变量、中间计算过程和其他数据),当退出函数方法时,修改栈指针就可以把栈中的内容销毁。
(2) 堆唯一的目的就是用于存放对象实例,每个Java应用都唯一对应一个JVM实例,每个JVM实例都唯一对应一个堆,该堆对应的堆内存被应用的所有线程共享。
所以,从功能与作用通俗地比较,堆主要是用来存放对象的,栈主要是用来执行程序的。
2.性能与存储:
(1)栈的性能优于堆,仅次于位于cpu中寄存器。但是在分配内存的时候,存放在栈中的数据大小与生存周期必须在编译时是确定的,缺乏灵活性。
(2) 堆可以动态分配内存大小,编译器不必知道要从堆里分配多少存储空间,生命周期也不必事先告诉编译器,Java的垃圾收集器会自动收走这些不再使用的数据,因此可以得到更大的灵活性。但是,由于在运行时动态分配内存和销毁对象都需要占用时间,所以效率低。由于面向对象的多态性,堆内存分配是必不可少的,因为多态变量所需的内存空间,只有在运行时创建对象之后才能确定。当然,为了达到这样的灵活性,必然会付出一定代价!
3.内存分配与回收:
跟C/C++不同,Java分配堆栈内存是由JVM自动分配和管理的。
Java中的数据类型有两种:8大基本数据类型、引用类型。
(1) 函数中基本类型和对象的引用都是在栈内存中分配。当在一段代码块中定义一个变量时,由于这些变量大小可知,生存期可知,出于追求速度的原因,Java就在栈中为这个变量分配内存空间,当超过变量的作用域后,Java会自动释放掉为该变量所分配的内存空间。
(2) 对于引用类型:Java中所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配。也就是说在建立一个对象时,从两个地方都分配内存,在堆中分配的内存实际用于建立这个对象,而在栈中分配的内存只是一个指向这个堆对象的引用而已。在堆中分配的内存,由Java虚拟机的自动垃圾回收器来管理。
4.内存共享:
(1) 栈数据的内存共享:
假设我们同时定义:int a = 3; int b = 3;编译器先处理int a =3;首先它会在栈中创建一个变量为a的引用,然后查找有没有字面值为3的地址,没找到,就开辟一个存放3这个字面值的地址,然后将a指向3的地址。接着处理int b = 3;在创建完b的引用变量后,由于在栈中已经有3这个字面值,便将b直接指向3的地址。这样,就出现了a与b同时均指向3的情况。
这种字面值的引用与类对象的引用不同。假定两个类对象的引用同时指向一个对象,如果一个对象引用变量修改了这个对象的内部状态,那么另一个对象引用变量也即刻反映出这个变化。相反,通过字面值的引用来修改其值,不会导致另一个指向此字面值的引用的值也跟着改变的情况。
如上例,我们定义完a与b的值后,再令a=4;那么,b不会等于4,还是等于3。在编译器内部,遇到a=4时,它就会重新搜索栈中是否有4的字面值,如果没有,重新开辟地址存放4的值;如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响到b的值。
(2) 引用类型的内存共享:
String类型用String str = new String(“abc”)的形式来创建,也可以用String str = "abc"的形式来创建。前者是规范的类的创建过程,即在Java中,一切都是对象,而对象是类的实例,全部通过new()的形式来创建。那为什么在String str = “abc”;中,并没有通过new()来创建实例,是不是违反了上述原则?其实没有:
关于String str = "abc"的内部工作。Java内部将此语句转化为以下几个步骤:
(1)先定义一个名为str对象引用变量:String str;
(2)在栈中查找有没有存放值为"abc"的地址,如果没有,则开辟一个存放字面值为"abc"的地址,接着创建一个新的String类的对象o,并将o 的字符串值指向这个地址,而且在栈中这个地址旁边记下这个引用的对象o。如果已经有了值为"abc"的地址,则查找对象o,并返回o的地址。
(3)将str指向对象o的地址。
值得注意的是,一般String类中字符串值都是直接存值的。但像String str = “abc”;这种场合下,其字符串值却是保存了一个指向存在栈中数据的引用! 为了更好地说明这个问题,我们可以通过以下的几个代码进行验证。
String str1 = “abc”; String str2 = “abc”;
System.out.println(str1==str2); //true
注意,我们这里并不用str1.equals(str2);的方式,因为这将比较两个字符串的值是否相等。而我们在这里要看的是,str1与str2是否都指向了同一个对象,==号只有在两个引用都指向了同一个对象时才返回真值。 结果说明,JVM创建了两个引用str1和str2,但只创建了一个对象,而且两个引用都指向了这个对象。 我们再来更进一步,将以上代码改成:
String str1 = “abc”;
String str2 = “abc”;str1 = “bcd”;
System.out.println(str1 + “,” + str2); //bcd, abc
System.out.println(str1==str2); //false
这就是说,赋值的变化导致了类对象引用的变化,str1指向了另外一个新对象!而str2仍旧指向原来的对象。上例中,当我们将str1的值改为"bcd"时,JVM发现在栈中没有存放该值的地址,便开辟了这个地址,并创建了一个新的对象,其字符串的值指向这个地址。
事实上,String类被设计成为不可改变(immutable)的类。如果你要改变其值,可以,但JVM在运行时根据新值悄悄创建了一个新对象,然后将这个对象的地址返回给原来类的引用。这个创建过程虽说是完全自动进行的,但它毕竟占用了更多的时间。在对时间要求比较敏感的环境中,会带有一定的不良影响。 再修改原来代码:
String str1 = “abc”;
String str2 = “abc”;
str1 = “bcd”;
String str3 = str1;
System.out.println(str3); //bcd
String str4 = “bcd”;
System.out.println(str1 == str4); //true
str3 这个对象的引用直接指向str1所指向的对象(注意,str3并没有创建新对象)。当str1改完其值后,再创建一个String的引用str4,并指向因str1修改值而创建的新的对象。可以发现,这回str4也没有创建新的对象,从而再次实现栈中数据的共享。
我们再接着看以下的代码:
String str1 = new String(“abc”);
String str2 = “abc”;
System.out.println(str1==str2); //false
创建了两个引用。创建了两个对象。两个引用分别指向不同的两个对象。
String str1 = “abc”;
String str2 = new String(“abc”);
System.out.println(str1==str2); //false
创建了两个引用。创建了两个对象。两个引用分别指向不同的两个对象。 以上两段代码说明,只要是用new()来新建对象的,都会在堆中创建,而且其字符串是单独存值的,即使与栈中的数据相同,也不会与栈中的数据共享。
综上:
(1)我们在使用诸如 String str = “abc” 的格式定义类时,对象可能并没有被创建。唯一可以肯定的是,指向 String类的引用被创建了,至于这个引用到底是否指向了一个新的对象,必须根据上下文来考虑,除非通过new()方法来显式地创建一个新的对象。
(2)使用 String str = “abc” 的方式,可以在一定程度上提高程序的运行速度,因为JVM会自动根据栈中数据的实际情况来决定是否有必要创建新对象,这是享元模式的思想。而对于String str = new String(“abc”);的代码,则一概在堆中创建新对象,而不管其字符串值是否相等,是否有必要创建新对象,从而加重了程序的负担。
三、通过堆和栈的理解,思考为什么Java慢的原因?
在Java中,除了简单的基本数据类型,其它都是在堆中分配内存,这也是程序慢的原因之一。
如果没有GC,上面的说法就是成立的。堆不像栈是连续的空间,没法指望堆本身的内存分配能够象栈一样拥有传送带般的速度,因为谁会为你整理庞大的堆空间,让你几乎没有延迟的从堆中获取新的空间呢?
这个时候,GC站出来解决问题。GC清除内存垃圾,为堆腾出空间供程序使用,但GC同时也担负了另外一个重要的任务,就是要让Java中堆的内存分配和其他语言中堆栈的内存分配一样快。要达到这样的目的,就必须使堆的分配也能够做到像传送带一样,不用自己操心去找空闲空间。所以,GC除了负责清除Garbage外,还要负责整理堆中的对象,把它们转移到一个远离Garbage的纯净空间中无间隔的排列起来,就像堆栈中一样紧凑,这样Heap Pointer就可以方便的指向传送带的起始位置,或者说一个未使用的空间,为下一个需要分配内存的对象"指引方向"。因此可以这样说,垃圾收集影响了对象的创建速度。
那GC怎样在堆中找到所有存活的对象呢?前面说了,在建立一个对象时,在堆中分配实际建立这个对象的内存,而在栈中分配一个指向这个堆对象的引用,那么只要在栈(也有可能在静态存储区)找到这个引用,就可以跟踪到所有存活的对象。找到之后,GC将它们从堆的一个块中移到另一个块中,并将它们一个挨一个地排列起来,这样就可以在速度可以保证的情况下,可以任意分配的。
但是,GC的运行要占用一个线程,这本身就是一个降低程序运行性能的缺陷,更何况这个线程还要在堆中把内存翻来覆去的折腾。不仅如此,如上面所说,堆中存活的对象被搬移了位置,那么所有对这些对象的引用都要重新赋值,这些开销都会导致性能的降低。