Java面试相关(二)--堆和栈

引言:看了网上一些作品,没有特别清晰的一个结构,所以,这里本人整理一下Java的堆栈相关知识。Java 中的堆和栈 Java把内存划分成两种:一种是栈内存,一种是堆内存。至于“方法区”(静态存储区),可以理解为:主要存放静态数据、全局 static 数据和常量。这块内存在程序编译时就已经分配好,并且在程序整个运行期间都存在。总的来说:堆和栈针对非静态数据,而方法区针对静态数据。

一、堆内存和栈内存

栈(stack)与堆(heap)都是Java用来在Ram中存放数据的地方。与C++不同,Java自动管理栈和堆,程序员不能直接地设置栈或堆。

  • 栈:
    • 简单理解:堆栈(stack)是操作系统在建立某个进程或者线程(在支持多线程的操作系统中是线程)为这个线程建立的存储区域,该区域具有先进后出的特性。
    • 特点:存取速度比堆要快,仅次于直接位于CPU中的寄存器。栈中的数据可以共享(意思是:栈中的数据可以被多个变量共同引用)。
    • 缺点:存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。
    • 相关存放对象:①一些基本类型的变量(,int, short, long, byte, float, double, boolean, char)和对象句柄【例如:在函数中定义的一些基本类型的变量和对象的引用变量】。②方法的形参 直接在栈空间分配,当方法调用完成后从栈空间回收。
    • 特殊:①方法的引用参数,在栈空间分配一个地址空间,并指向堆空间的对象区,当方法调用完成后从栈空间回收。②局部变量new出来之后,在栈控件和堆空间中分配空间,当局部变量生命周期结束后,它的栈空间立刻被回收,它的堆空间等待GC回收。
  • 堆:
    • 简单理解:每个Java应用都唯一对应一个JVM实例,每一个JVM实例唯一对应一个堆。应用程序在运行中所创建的所有类实例或者数组都放在这个堆中,并由应用所有的线程共享。Java中分配堆内存是自动初始化的,Java中所有对象的存储控件都是在堆中分配的,但这些对象的引用则是在栈中分配,也就是一般在建立一个对象时,堆和栈都会分配内存。
    • 特点:可以动态地分配内存大小、比较灵活,生存期也不必事先告诉编译器,Java的垃圾收集器会自动收走这些不再使用的数据。在堆中分配的内存,由Java虚拟机的自动垃圾回收器来管理。
    • 缺点:由于要在运行时动态分配内存,存取速度较慢。
    • 主要存放:①由new创建的对象和数组 ;②this
    • 特殊:引用数据类型(需要用new来创建),既在栈控件分配一个地址空间,又在堆空间分配对象的类变量。

补充: 在堆中产生了一个数组或对象后,还可以在栈中定义一个特殊的变量,让栈中这个变量的取值等于数组或对象在堆内存中的首地址,栈中的这个变量就成了数组或对象的引用变量。
引用变量就相当于是为数组或对象起的一个名称,以后就可以在程序中使用栈中的引用变量来访问堆中的数组或对象。
引用变量是普通变量,定义时在栈中分配内存,引用变量在程序运行到作用域外释放。而数组&对象本身在堆中分配,即使程序运行到使用new产生数组和对象的语句所在地代码块之外,数组和对象本身占用的堆内存也不会被释放,<u>数组和对象在没有引用变量指向它的时候,才变成垃圾,不能再被使用,但是仍然占着内存,在随后的一个不确定的时间被垃圾回收器释放掉。这个也是java比较占内存的主要原因</u>。
这里可以理解为:String s1 = new String("abc");这里面: "abc"表示栈中的一个存储空间中的一个数据,new String("abc")表示存在于堆中的一个对象,这个对象的值为‘abc’,String s1则表示栈中定义的一个取了new String("abc")在堆中的首地址的一个特殊变量,也就是:s1成了引用变量,相当于一个别名。

二、Java数据存储和JVM内存分区

  • <u>在JAVA中,有六个不同的地方可以存储数据:</u>
    1. 寄存器(register)。这是最快的存储区,因为它位于不同于其他存储区的地方——处理器内部。但是寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配。你不能直接控制,也不能在程序中感觉到寄存器存在的任何迹象。
    2. 堆栈(stack)。位于通用RAM中,但通过它的“堆栈指针”可以从处理器哪里获得支持。堆栈指针若向下移动,则分配新的内存;若向上移动,则释放那些内存。这是一种快速有效的分配存储方法,仅次于寄存器。创建程序时候,JAVA编译器必须知道存储在堆栈内所有数据的确切大小和生命周期,因为它必须生成相应的代码,以便上下移动堆栈指针。这一约束限制了程序的灵活性,所以虽然某些Java数据存储在堆栈中——特别是对象引用,但是JAVA对象不存储其中。
    3. 堆(heap)。一种通用性的内存池(也存在于RAM中),用于存放所以的JAVA对象。堆不同于堆栈的好处是:编译器不需要知道要从堆里分配多少存储区域,也不必知道存储的数据在堆里存活多长时间。因此,在堆里分配存储有很大的灵活性。当你需要创建一个对象的时候
      ,只需要new写一行简单的代码,当执行这行代码时,会自动在堆里进行存储分配。当然,为这种灵活性必须要付出相应的代码。用堆进行存储分配比用堆栈进行存储存储需要更多的时间。
    4. 静态存储(static storage)。这里的“静态”是指“在固定的位置”。静态存储里存放程序运行时一直存在的数据。你可用关键字static来标识一个对象的特定元素是静态的,但JAVA对象本身从来不会存放在静态存储空间里。
    5. 常量存储(constant storage)。常量值通常直接存放在程序代码内部,这样做是安全的,因为它们永远不会被改变。有时,在嵌入式系统中,常量本身会和其他部分分割离开,所以在这种情况下,可以选择将其放在ROM中 。
    6. 非RAM存储。如果数据完全存活于程序之外,那么它可以不受程序的任何控制,在程序没有运行时也可以存在。

就速度来说,有如下关系:
寄存器 < 堆栈 < 堆 < 其他

  • <u>JVM的内存分区:</u>
    JVM的分区可分为三个:堆(heap)、栈(stack)和方法区(method)
    1. 堆区:
    • 存储的全是对象,每个对象都包含一个与之对应的class信息(我们常说的类类型,Clazz.getClass()等方式获取),class目的是得到操作指令。
    • JVM只有一个堆区(heap)被所有线程共享,堆中不存放基本类型和对象引用,只存放对象本身。【这里的‘对象’,就不包括基本数据类型】
    1. 栈区:
    • 每个线程包含自己的一个栈区,栈中只保存基本数据类型的对象和自定义对象的引用。
    • 每个栈中的数据(基本类型和对象引用)都是私有的,其他栈不可访问。
    • 栈 = 基本类型变量区 + 执行环境上下文 + 操作指令区(存放操作指令)
    1. 方法区【这个可能比较陌生】
    • 又称为‘静态区’,和堆一样,被所有的线程共享。
    • 方法区包含所有的class和static变量。

补充:大家也许听说过“数据区”或者“运行时数据区”这个名词,这里,我们说JVM是驱动Java程序运行的基础,而它有三个分区:堆、栈、方法区,实际上,JVM的三个方法区就是包含于 JVM的运行时数据区中的三大块。于是,“数据区”与上述的分区的关系就明朗了。

三、Java的两种数据类型:

  1. 基本类型(primitive types), 共有8种,即int, short, long, byte, float, double, boolean, char(注意,
    并没有string的基本类型)。这种类型的定义是通过诸如int a = 3; long b = 255L;的形式来定义的,称为自动变量。【自动变量存的是字面值,不是类的实例(即不是类的引用),这里并没有类的存在,如int a=3;这里a只是指向int类型(不是类)的引用,指向字面值3,此时,由于这些字面值的数据大小可知并且生存期可知(他们在程序内某个固定代码块中,代码块退出,他们就消失),为了追求速度,于是存在中】
  2. 包装类,如Integer, String, Double等将相应的基本数据类型包装起来的类。这些类数据全部存在于中,Java用new()语句来显式地告诉编译器,在运行时才根据需要动态创建,因此比较灵活,但缺点是要占用更多的时间。

四、代码示例说明

用一些例子来理解哪些数据属于栈内存,哪些数据属于堆内存:

示例一:对于字面值和字面值引用
//.....
int a = 1; //a属于字面值 1 的引用
//.....
int b = 1;//b属于字面值 1 的引用

执行上面的代码是这样的一个过程:

  1. 编译器先处理int a = 1;首先它会在栈中创建一个变量为a的引用,然后查找有没有字面值为 1 的地址,没找到,就开辟一个存放 1 这个字面值的地址,然后将a指向3的地址。
  2. 接着处理int b = 1;在创建完b的引用变量后,由于在栈中已经有3这个字面值,便将b直接指向3的地址。这样,就出现了a与b同时均指向3的情况。

注意:上面代码注释说了,a和b都是字面值 1 的引用,他们和我们理解的类对象的引用不同:假定两个类对象的引用同时指向一个对象,如果一个对象引用变量修改了这个
对象的内部状态,那么另一个对象引用变量<u>也即刻反映出这个变化</u>。而通过字面值的引用来修改其值,不会导致另一个指向此字面值的引用的值也跟着改变的情况。如上例,我们定义完a与b的值后,再令a=2;那么,b不会等于2,还是等于1。在编译器内部,遇到a=2时,它就会重新搜索栈中是否有2的字面值,如果没有,重新开辟地址存放2的值;如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响到b的值。

示例二:由new String()开始解释

代码一:

String str1 = "abc"; 
String str2 = "abc"; 
System.out.println(str1==str2); //true 

代码二:

String str1 =new String ("abc"); 
String str2 =new String ("abc"); 
System.out.println(str1==str2); // false 

从代码一和代码二分析:
String是一个特殊的包装类数据。可以用:
①String str = new String("abc");
②String str = "abc";
两种的形式来创建,第一种是用new()来新建对象的,它会在存放于堆中。每调用一次就会创建一个新的对象。
而第二种是先在栈中创建一个对String类的对象引用变量str,然后查找栈中有没有存放"abc",如果没有,则将"abc"存放进栈,并令str指向”abc”,如果已经有”abc” 则直接令str指向“abc”。

代码三:

String s1 = "ja"; 
String s2 = "va"; 
String s3 = "java"; 
String s4 = s1 + s2; 
System.out.println(s3 == s4);//false 
System.out.println(s3.equals(s4));//true 

从代码三分析:
比较类里面的数值是否相等时,用equals()方法;当测试两个包装类的引用是否指向同一个对象时,用==。

示例三:程序代码运行过程分析
public class Test {///运行时,JVM把TestB的类信息全部放入方法区
    public static void main(String[] args){//main方法本身是静态方法,放入方法区
        ///obj1 和 obj2都是对象引用,所以放到栈区,这个‘new Sample("xxx")’是自定义对象应该放到堆区
        Obj obj1 = new Obj("A");
        Obj obj2 = new Obj("A");
        obj1.printName();
        obj2.printName();
        //  这里,两个实例中的size成员都是int(基本类型),所以,这个“3”最终存在于栈区(而不是堆区),并供obj1和obj2共用。
        obj1.size = 3;
        obj2.size = 3;
        int A = 4;
        int B = 4;
        System.out.println(obj1.getName()==obj2.getName());
        System.out.println(obj1 == obj2);
        System.out.println(A == B);
    }
}
/**
 * 自定义类:Obj
 * 运行时,JVM把Obj的类信息全部放入方法区
 */
class Obj{
    private String name;//new出一个Obj实例后,‘name’这个引用放入了栈区,而给‘name’的赋值的是字面值"A"而不是一个newString("A"),则这个"A"会存在栈中,所以,obj1.name和obj2.name共用这个栈中的"A"
    public int size;//虽然size是基本数据类型的对象,但是它是跟随这Obj类初始化加载的,所以上面obj1和obj2两个对象的size指向的地址不同,由于此时赋予给他们的“3”在两个不同存储位置。
    public Obj(String name) {
        this.name = name;
    }

    public String getName(){
        return this.name;
    }

    public void printName(){///printName方法本身放入方法区中
        System.out.println(this.name);
    }
}

整体图解:



输出截图:


看过本人上一篇文章Java面试相关(一)-- Java类加载全过程的朋友应该比较清楚JVM在启动程序执行代码时对类的加载过程,这里可以简单看看上述代码的注释说明类的加载时机。这里重点配合上述代码注释说说过程中的数据存储分区情况:

  • Obj obj1 = new Obj("A");:JVM首先就在它的堆区中为一个新的Obj实例分配内存,这个Obj实例持有着指向方法区的Sample类的类型信息的引用(这个‘引用’,实际上指的是Obj类的类型信息在方法区中的内存地址)。obj1一看就知道是main()方法中定义的局部变量,所以,它会被添加到执行main()方法的主线程中Java方法调用栈中。而=将把这个obj1对象指向堆区的Obj实例,换句话说,obj1对象持有指向Obj实例的引用。
  • new出一个Obj实例后,‘name’这个引用放入了栈区,而给‘name’的赋值的是字面值"A"而不是一个newString("A"),则这个"A"会存在栈中,所以,obj1.name和obj2.name共用这个栈中的"A"。
  • Obj类中,虽然size是基本数据类型的对象,但是它是跟随这Obj类初始化加载的,所以上面代码中,obj1和obj2两个对象的size指向的地址不同,由于此时赋予给他们的“3”在两个不同存储位置。

五、扩展:Java内存分配策略

按照编译原理的观点,程序运行时的内存分配有三种策略,分别是静态的,栈式的,和堆式的。
静态存储分配是指在编译时就能确定每个数据目标在运行时刻的存储空间需求,因而在编译时就可以给他们分配固定的内存空间.这种分配策略要求程序代码中不允许有可变数据结构(比如可变数组)的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算准确的存储空间需求。
栈式存储分配也可称为动态存储分配,是由一个类似于堆栈的运行栈来实现的.和静态存储分配相反,在栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到运行的时候才能够知道,但是规定在运行中进入一个程序模块时,必须知道该程序模块所需的数据区大小才能够为其分配内存.和我们在数据结构所熟知的栈一样,栈式存储分配按照先进后出的原则进行分配。
 静态存储分配要求在编译时能知道所有变量的存储要求,栈式存储分配要求在过程的入口处必须知道所有的存储要求,而堆式存储分配则专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例.堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。

参考文章


https://github.com/GeniusVJR/LearningNotes/blob/master/Part1/Android/Android%E5%86%85%E5%AD%98%E6%B3%84%E6%BC%8F%E6%80%BB%E7%BB%93.md

推荐阅读更多精彩内容