Java基础必备 -- 堆栈、引用传值、垃圾回收等

时间:2021-03-28 11:59:21

   在Java中,对象作为函数参数的传递方式是值传递还是引用传递?String str = "abc" 与 String str = new String("abc")在存储上有何区别?想成为一名合格的搬砖工,掌握吧,颤抖吧!

堆(heap)和栈(stack)

   在数据结构中存在堆和栈,在内存分配中也有堆和栈的概念,指的是堆空间和栈空间,注意不要将两者混为一谈,这里要讨论的是后者。

   在数据结构中,堆和栈是两种不同的数据结构。栈是一种后进先出的线性表,堆是一种特殊的完全二叉树,树中所有非终端结点的值均不大于(或不小于)其左右孩子结点的值。

   在内存分配中,栈(stack)与堆(heap)都是Java用来在Ram中存放数据的地方。栈主要存储基本数据类型和对象的引用(对象句柄),堆主要用来存储对象。在C++中,程序员可以通过malloc函数申请堆空间,可以通过free释放堆内存,但在Java中,程序员不能直接地操作堆与栈,只能由Java自动管理。

   栈的优势:① 存取速度比堆要快,仅次于直接位于CPU中的寄存器;② 栈数据可以共享。存在栈中的数据大小与生存期必须是确定的,缺乏一定灵活性。堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,Java的垃圾收集器会自动收走这些不再使用的数据。但缺点是,由于要在运行时动态分配内存,存取速度较慢。

   一个经典的关于堆和栈的比喻:使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是*度小。使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且*度大。

Java两种数据类型的存储结构

   Java有两种数据类型:① 一种是基本数据类型,共8种,即int、short、long、byte、float、double、boolean、char(注意不包含string数据类型);② 另一种是对象数据类型,是类类型或应用数据类型,如Integer、String、Double等。

1、基本数据类型

   这种类型定义的变量称为自动变量,存储的是字面值的地址,不是类的实例,即不是类的引用。如在语句:int a = 8; 中,a是一个指向int类型的引用,指向8这个字面值。这些字面值的数据,由于大小可知,生存期可知(这些字面值固定定义在某个程序块里面,程序块退出后,字段值就消失了),出于追求速度的原因,就存在于栈中。

   为了避免存储空间的浪费,栈中的数据是可以共享的。假设我们如下定义两个自动变量:int a = 8;  int b = 8;

   编译器执行第一条语句时,会在栈中开辟一个地址存放变量为a的引用,然后查找栈中是否存在字面值为8的地址,没找到,就在栈中新开辟了一个地址,并存放字面值8。编译器执行第二条语句时,同样会在栈中开辟一个地址存放变量为b的引用,然后同样查找栈中是否存在字面值为8的地址,由于此时栈中已经存在字面值为8的地址,于是就将变量b直接指向字面值为8的地址。此时,引用变量a和b都指向同一个地址,存储结构如图1左侧所示。

   接着,如果将a的值改成6,那么由于栈中不存在字面值为6的地址,编译器会在栈中开辟一个地址并存放字面值6,然后将引用变量a指向该地址,新存储结果如图1右侧所示。

Java基础必备 -- 堆栈、引用传值、垃圾回收等

图1 a=b=8与a=6,b=8栈中存储结构分配示意图

2、对象数据类型

   这种类型定义的数据全部存储于堆中,然后在栈中存储一个引用变量,指向堆中的数据存储地址。Java用new()语句来显示地告诉编译器,在运行时才根据需要动态创建,因此比较灵活,但缺点是要占用更多的时间。

   通过new()语句显示创建对象的堆存储空间是不共享的,不管对象存储内容是否相等,编译器都会在堆中分别创建存储空间。通过等号(=)给对象赋值时,编译器只在栈中创建了一个对象的引用,指向同一个堆地址。

   尽管引用和对象是分离的,但我们所有通往对象的访问必须经过引用这个“大门”,比如以“引用.方法()”的方式访问对象的方法。在Java中,我们不能跳过引用去直接接触对象。

   假设A是一个类,那么语句:A a1 = new A(); A a2 = a1; 中,new A()会在堆中开辟一个地址用于存储一个A的对象。语句执行后,堆和栈存储分配示意图如图2所示。

Java基础必备 -- 堆栈、引用传值、垃圾回收等

图2 A a1 = new A(); A a2 = a1; 堆栈内存分配示意图

Java函数参数的传递

   关于Java中是否存在引用传递,网上有很多的观点。一种观点是Java中既存在值传递(函数参数为基本数据类型),也存在引用传递(函数参数为对象类型),而另一种观点支持Java中不存在引用传递,所有的参数传递方式都是值传递。

   我们先来看下关于值传递和引用传递的定义:

   值传递,是对所传递参数进行一次副本拷贝,对参数的修改只是对副本的修改,函数调用结束,副本丢弃,原来的变量不变(即实参不变)。

   引用传递,参数被传递到函数时,不复制副本,而是直接将参数自身传入到函数,函数内对参数的任何改变都将反映到原来的变量上。

   我们知道,在C++程序中,函数参数传递有三种方式,分别是传值调用、传址调用和引用传值。传址调用,就是将实参对应对象的地址值拷贝传递给函数。

   在Java中,将对象作为参数传递给函数,那么实际传递给函数的是对象本身吗?由Java对象数据类型的存储特点可知,真正的对象是存储在堆中的,栈中只是存储了一个引用变量。那么在将对象传递给函数的时候,其实传递的就是这个引用变量的一个拷贝,这个变量内包含了对象在堆中存储的地址。在函数体内,通过拷贝的变量中的地址可以改变堆中对象的内容,这些改变也会影响到函数体外的引用变量表现的对象值,但是函数实参(对象的引用变量)的值(即存储真正对象的对地址值)是不会改变的。

 class A {
private String name;
public A (String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
} public class ParamsTest { public static void main(String[] args) {
A a1 = new A("jack");
A a2 = new A("rose");
swap1(a1, a2); //输出:rose|jack 通过引用变量的副本中的地址改变了真正对象的内容,改变的影响在函数体外体现出来
swap2(a1, a2); //输出:jack|rose 引用变量副本不能改变引用变量本身的地址值,也即是实参引用变量a1和a2内存储的地址值(分别指向A的对象1和对象2地址的)是不能改变的
System.out.println(a1.getName()+"|"+a2.getName());
} static void swap1(A a1, A a2) {
String temp = a1.getName();
a1.setName(a2.getName());
a2.setName(temp);
} static void swap2(A a1, A a2) {
A temp = a1;
a1 = a2;
a2 = temp;
} }

   举一个形象的例子,定义一个对象就好比造了一间房子(存在堆中的对象本身),同时造了一把钥匙(栈中的对象引用变量),不能忽略钥匙而直接进入房间。用函数传递这个对象,就好比根据这把钥匙重新造了一把钥匙,然后在函数体内可以通过新钥匙进入房间,也可以操作房间内的东西,这会体现在用原来的钥匙操作房间的结果,但是你不能在函数体内改变原来那把钥匙的属性。

   再看值传递的定义,是不是这个传址调用也符合值传递的规定?因此,可以将址传递理解为值传递的一种特殊情况。

   在C++中可以通过&符号支持引用传递,如:void swap(int& a, int& b);,也可以在C#中通过ref或out关键字来定义引用传递,如void swap(ref int a, ref int b);,但是Java中貌似并不支持引用传递的实现。

   所以,在Java中是不存在类似C++的引用传递的,只存在值传递和址传递,也可以理解为只存在值传递。

Java字符串与字符串池

   从String的源码中可以看出,String类的本质是字符数组char[],并且String类是final的。

   Java运行时会维护一个字符串池(String Pool),用来存放不重复的字符串。并且JVM每个创建的字符串都可以在字符串池中找对对应的字符串对象。

   Java创建字符串的方式归纳起来以下几类:

   ① 使用new关键字创建字符串,如String str = new String("abc");

   ② 直接指定字符串的值,如String str = "abc";

   ③ 使用串联生成新的字符串,如String str = "ab" + "c"; (根据是否存在会在字符串池中选择创建对象"ab"、"c"和"abc")

   ④ 通过包含变量的表达式来创建String对象,如String str = s1 + "c";(其中s1="ab")

   对象创建规则如下:

   ① 不管使用何种方式创建字符串,JVM都会检查字符串池,若不存在当前字符串,则在池中创建一个字符串,内容与当前字符串相同;

   ② 通过new关键字或通过包含不确定变量的表达式来创建对象,则会在堆区或栈区创建一个新的对象;

   ③ 直接指定字符串或通过字符串串联的方式创建字符串,只会在对象池中检查并创建字符串对象,不会在堆栈中创建String对象(会在栈中创建字符串引用变量);

   在Java中,通过==比较对象类型的时候,比较的是变量对应的地址是否相同,通过String.equals()比较的是对象的内容是否相同。

 //在池中和堆中分别创建String对象"abc",s1指向堆中对象
String s1 = new String("abc");
//s2直接指向池中对象"abc"
String s2 = "abc";
//在堆中新创建"abc"对象,s3指向该对象
String s3 = new String("abc");
//在池中创建对象"ab" 和 "c",并且s4指向池中对象"abc"
String s4 = "ab" + "c";
//c指向池中对象"c"
String c = "c";
//在堆中创建新的对象"abc",并且s5指向该对象
String s5 = "ab" + c;
//在堆中创建新的对象"abc",并且s6纸箱该对象
String s6 = "ab".concat("c");
//与s6效果一致
String s7 = "ab".concat(c);
String s8 = "abc ".trim(); System.out.println(s1 == s2); //false
System.out.println(s1 == s3); //false
System.out.println(s2 == s3); //false
System.out.println(s2 == s4); //true
System.out.println(s2 == s5); //false
System.out.println(s2 == s6); //false
System.out.println(s2 == s7); //false
System.out.println(s2 == s8); //false

   所以,在编译器执行语句String str = new String("abc")的时候,若对象池中不存在"abc"对象,则编译器会分别在堆中和字符串池中创建字符串"abc"对象,并且str指向堆中的对象;若字符串池中已存在"abc"对象,则编译器只在堆中创建对象。因此,语句String str = new String("abc");编译执行时可能创建的对象数为1个或2个,同理,语句String str = "abc";编译执行时可能创建的对象数为1个或0个。

   此外,由于Java中的String对象是final的,因此当改变它的值的时候,JVM实际上是新建了一个String对象,并将引用变量重新指向新对象。因此在频繁的字符串操作中,使用"+"或者String.concat()来操作字符串效率是很低的,那么该怎么操作呢?

   在字符串需要大量拼接工作的时候,强烈建议使用StringBuffer或StringBuilder来代替String操作。StringBuilder是单线程操作的,线程不安全,而StringBuffer支持多线程同步,是线程安全的字符串操作类。

   因此,在Java的字符串使用上,应该遵循以下原则:操作少量的数据,用String;单线程操作大量数据,用StringBuilder;多线程操作大量数据,用StringBuffer。

JVM垃圾回收

   随着方法调用的结束,栈中的引用和基本类型变量会被清空。但是堆中存放的对象不会随着方法调用的结束而清空,因此进程空间可能很快被不断创建的对象占满。Java的垃圾回收机制就是为了及时释放堆中不再使用的对象所占用的空间,避免堆空间内堆积过多的内存垃圾。

   垃圾回收的基本原则是:当存在引用指向某个对象时,那么该对象不会被回收; 当没有任何引用指向某个对象时,该对象被清空。它所占据的空间被回收。