Java内存分配详解(堆内存、栈内存、常量池)
Java程序是运行在JVM(Java虚拟机)上的,因此Java的内存分配是在JVM中进行的,JVM是内存分配的基础和前提。Java程序的运行会涉及以下的内存区域:
1. 寄存器:JVM内部虚拟寄存器,存取速度非常快,程序不可控制。
2. 栈:存放基本类型的数据和对象的引用,但对象本身不存放在栈中,而是存放在堆中。
3. 堆:存放new出来的对象,注意创建出来的对象只包含各自的成员变量,不包括成员方法。
4. 常量池:存放常量,如基本类型的包装类(Integer、Short)和String,注意常量池位于堆中。
5. 代码段:用来存放从硬盘上读取的源程序代码。
6. 数据段:用来存放static修饰的静态成员。
下图表示了程序大致的内存分配情况:
下面通过具体的代码说明程序在内存中是如何运行的(图片来自尚学堂马士兵老师课件)。
1. 首先JVM找到main方法作为入口,执行第一句代码后,在堆中创建一个Test实例,并在栈中分配一块内存,存放指向堆中实例的引用变量(110925),该引用变量表示的是堆内存中对象的地址。
2. 创建一个int型的变量date,由于是基本类型,直接在栈中存放date对应的值9。
3. 在堆内存中创建两个BirthDate类的实例d1、d2,在栈中存放了指向各自对象的引用变量,表示实例对象的地址。在堆内存中创建的实例对象还包含各自的成员变量。
调用test对象的change1方法,并以date作为参数传入,此时JVM会为change1方法在栈中分配相应的内存,并将该方法的局部变量i存放在栈内存中,将date的值赋给i,因此i的值为9。
执行change1方法中的代码,将i的值修改为1234。
change1方法执行完毕,释放其占用的栈内存。
调用test对象的change2方法,JVM为该方法分配栈内存,将d1作为实参传入,并将局部变量b入栈。由于是引用类型,b中保存的同样是对象的地下。此时b和d1指向的是堆中的同一个对象。
change2方法中又实例化了一个BirthDate对象,并且赋给b。其执行过程为:在堆中new了一个对象,并将该对象的地址存放在栈中b对应内存,此时b不再指向d1所指向的对象。但是d1所指向的对象没有发生改变,对d1没有造成任何影响。
change2方法执行完毕,释放变量b所占的栈空间,注意只是释放了栈空间,堆空间对象要等待自动回收。
调用test实例的change3方法,传入参数d2,JVM会为变量b在栈中分配空间,并将d2的地址赋给b,此时d2和b指向同一个对象,再调用实例b的setDay方法,其实就是调用d2指向的对象的setDay方法。
调用实例b的setDay方法会影响d2,因为二者指向的是同一个对象。
change3方法执行完毕,立即释局部引用变量b占用的栈内存。
从以上的Java程序运行时内存的分配我们可以得出以下结论:
1. Java中有两种类型,分别是基本类型和引用类型。如果是基本类型则直接在栈中保存值,如果是引用类型,则真正new出来的对象会存放在堆内存中,栈内存中会保存指向该对象的引用,即对象在堆内存中的地址。
2. 栈中的数据和堆中的数据销毁并不是同步的。每个方法在执行时都会建立自己的栈区,方法一旦结束,栈中的局部变量立即销毁,但是堆中对象不一定销毁。因为可能有其他变量也指向了这个对象,直到栈中没有变量指向堆中的对象时,它才销毁,而且还不是马上销毁,要等垃圾回收才可以被销毁,这个是由JVM决定的。
3. 类中定义的实例成员变量在不同对象中各不相同,都有自己的存储空间(成员变量在堆中的对象中)。而类中定义的方法却是该类的所有对象共享的,只有一套,对象使用方法的时候方法才被压入栈,方法不使用则不占用内存。、
常量池
java中的常量池技术,是为了方便快捷地创建某些对象而出现的,当需要一个对象时,就可以从池中取一个出来(如果池中没有则创建一个),则在需要重复创建相等变量时节省了很多时间。常量池其实也就是一块内存空间,不同于使用new关键字创建的对象所在的堆空间。
java中的基本类型有:byte、short、char、int、long、boolean。其对应的包装类分别是:Byte、Short、Character、Integer、Long、Boolean。上边提到的这些包装类都实现了常量池技术,而两种浮点数类型的包装类则没有实现。另外,String类型也实现了常量池技术。
1. 基本类型和包装类
我们先来看一个例子:
public class Test {
public static void main(String[] args) {
int i = 40;
int i0 = 40;
Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);
Double d1 = 1.0;
Double d2 = 1.0;
// 在java中对于引用变量来说“==”就是判断这两个引用变量所引用的是不是同一个对象
System.out.println("i==i0\t" + (i == i0));
System.out.println("i1==i2\t" + (i1 == i2));
System.out.println("i1==i2+i3\t" + (i1 == i2 + i3));
System.out.println("i4==i5\t" + (i4 == i5));
System.out.println("i4==i5+i6\t" + (i4 == i5 + i6));
System.out.println("d1==d2\t" + (d1 == d2));
System.out.println();
}
}
输出结果如下:
i==i0 true
i1==i2 true
i1==i2+i3 true
i4==i5 false
i4==i5+i6 true
d1==d2 false
分析:
1. i和i0均是普通类型(int)的变量,所以数据直接存储在栈中,而栈有一个很重要的特性:栈中的数据可以共享。当我们定义了int i = 40;,再定义int i0 = 40;这时候会自动检查栈中是否有40这个数据,如果有,i0会直接指向i的40,不会再添加一个新的40。
2. i1和i2均是引用类型,在栈中存储对象地址,因为Integer是包装类。由于Integer包装类实现了常量池技术,因此i1、i2的40均是从常量池中获取的,均指向同一个地址,因此i1==12。
3. 很明显这是一个加法运算,Java的数学运算都是在栈中进行的,Java会自动对i1、i2进行拆箱操作转化成整型,因此i1在数值上等于i2+i3。
4. .i4和i5均是引用类型,在栈中存储地址,因为Integer是包装类。但是由于他们各自都是new出来的,因此不再从常量池寻找数据,而是从堆中各自new一个对象,然后各自保存指向对象的地址,所以i4和i5不相等,因为他们所存地址不同,所引用到的对象不同。
5. 这也是一个加法运算,和3同理。
6. d1和d2均是引用类型,在栈中存储对象地址,因为Double是包装类。但Double包装类没有实现常量池技术,因此Doubled1=1.0;相当于Double d1=new Double(1.0);,是从堆new一个对象,d2同理。因此d1和d2存放的地址不同,指向的对象不同,所以不相等。
注意:以上提到的几种基本类型包装类均实现了常量池技术,但他们维护的常量仅仅是【-128至127】这个范围内的常量,如果常量值超过这个范围,就会从堆中创建对象,不再从常量池中取。比如,把上边例子改成Integer i1 = 400; Integer i2 = 400;,很明显超过了127,无法从常量池获取常量,就要从堆中new新的Integer对象,这时i1和i2就不相等了。
2. String类型
对于字符串,其对象的引用都是存储在栈中的,如果是编译期已经创建好(直接用双引号定义的)的就存储在常量池中,如果是运行期(new出来的)才能确定的就存储在堆中。对于equals相等的字符串,在常量池中永远只有一份,在堆中有多份。
如以下代码:
String s1 = "china";
String s2 = "china";
String s3 = "china";
String ss1 = new String("china");
String ss2 = new String("china");
String ss3 = new String("china");
从这里可以看出,使用双引号直接定义的String对象会指向常量池中的同一个对象,通过new产生一个字符串(假设为“china”)时,会先去常量池中查找是否已经有了“china”对象,如果没有则在常量池中创建一个此字符串对象,然后堆中再创建一个常量池中此”china”对象的拷贝对象。
下面看两个关于String常量池的例子:
String中有一个扩充常量池的intern()方法。当调用 intern 方法时,如果常量池中已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),则返回常量池中的字符串引用。否则,将此 String 对象添加到池中,并且返回此 String 对象的引用。
public class Test {
public static void main(String[] args) {
String s0= "java";
String s1=new String("java");
String s2=new String("java");
String s3=new String("java");
s1.intern(); //intern返回的引用没有引用变量接收~ s1.intern();等于废代码.
s3=s3.intern(); //把常量池中“kvill”的引用赋给s2
System.out.println( s0==s1);//false s0引用指向常量池中对象,s1引用指向堆中对象
System.out.println( s1==s2);//false s1、s2引用分别指向堆中两个不同对象
System.out.println( s0==s1.intern() );//true
System.out.println( s0==s3 );//true s0、s3引用均指向常量池中对象
}
}
public class Test {
public static void main(String[] args) {
//(1)
String a = "ab";
String bb = "b";
String b = "a" + bb;
System.out.println((a == b)); //result = false
//(2)
String a1 = "ab";
final String bb1 = "b";
String b1 = "a" + bb1;
System.out.println((a1 == b1)); //result = true
//(3)
String a2 = "ab";
final String bb2 = getBB();
String b2 = "a" + bb2;
System.out.println((a2 == b2)); //result = false
}
private static String getBB(){
return "b";
}
}
分析:
1. JVM对于字符串引用,由于在字符串的”+”连接中,有字符串引用存在,而引用的值在程序编译期是无法确定的,即”a” + bb无法被编译器优化,只有在程序运行期来动态分配并将连接后的新地址赋给b。所以上面程序的结果也就为false。
2. (2)和(1)中唯一不同的是bb1字符串加了final修饰,对于final修饰的变量,它在编译时被解析为常量值的一个本地拷贝存储到自己的常量池中或嵌入到它的字节码流中。所以此时的”a” + bb1和”a” + “b”效果是一样的。故上面程序的结果为true。
3. JVM对于字符串引用bb2,它的值在编译期无法确定,只有在程序运行期调用方法后,将方法的返回值和”a”来动态连接并分配地址为b2,故上面程序的结果为false。
上一篇: (数据结构与算法)堆相关
下一篇: 堆及topk问题