面试 -- Java内存布局图以及java各种存储区详解

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试 -- Java内存布局图以及java各种存储区详解相关的知识,希望对你有一定的参考价值。

一、Java内存布局浅谈

 

1. 总述

我们知道,线程是操作系统调度的基本单元。所有线程共享父进程的堆空间,而每个线程都有自己的栈空间和程序计数器。所以,Java虚拟机也看以看作是一个独立的进程,里面的内存空间分为线程共享空间和线程独有空间。Java虚拟机内存布局如下:

技术分享

2. 所有线程共享的内存空间

 

(1)堆空间:JVM规范中规定,所有对象实例以及数组都要在堆上进行分配。一般来说,堆空间都有一个默认大小,取决于JVM实现,而且可以根据需要动态扩展。当创建对象需要在堆上分配空间,而且堆本身的空间不够也无法申请额外的内存空间,则会抛出OutOfMemoryError异常。
(2)方法区:存储已被JVM加载的类信息常量静态变量等数据。方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。静态域和常量池(Runtime Constant Pool)就是方法区的一部分。

3. 每个线程独有的内存空间

(1)程序计数器:虽然很多程序都是多线程的,但是由于一般只有一个处理器,所以当前时刻只可能执行一个线程。而经过不停的线程切换,则达到一种多线程并发执行的假象。如果线程A执行到某一条指令时被挂起,切换到线程B。而线程B执行完后,需要执行线程A,这时处理器必须要知道线程A上次执行到了哪条指令,才能从中断处进行恢复。所以,每个线程都一个程序计数器,用来表示线程当前需要执行的Java指令地址(这里指的是JVM内存空间地址)。

(2)虚拟机栈空间:JVM在执行一个线程的方法时,会为这个线程方法创建一个栈帧(可以理解为JVM栈空间中的一段存储区域)。这个栈帧用于存储局部变量表、操作数栈、动态链接和方法入口信息。

 

    经常有人把Java内存区分为堆内存(Heap)和栈内存(Stack),这种分法比较粗糙,Java内存区域的划分实际上远比这复杂。这种划分

方式的流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的内存区域是这两块。其中所指的“堆”在后面会专门讲述,而所指

的“栈”就是现在讲的虚拟机栈,或者说是虚拟机栈中的局部变量表部分。

  局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)的变量、对象引用

(reference类型)。对象引用不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的

句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

 

(3)本地方法栈空间:跟虚拟机栈空间类似,只是用来存储本地方法调用的相关信息。

 

    本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字

节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没

有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

    总之,初学阶段可以把虚拟机栈空间和本地方法栈空间就统一理解为“栈”(和堆对应)。

 

二、详解Java中各种数据存放区域

 

1.寄存器:最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制。【由于无法控制,所以不作了解】
2. 栈:存放基本类型的变量数据和对象的引用,但对象本身不存放在栈中,而是存放在堆(new 出来的对象)或者常量池中(字符串常量对象存放在常量池中。) 
3. 堆:存放所有new出来的对象。 
4. 静态域:存放静态成员(static定义的) 【属于共享空间的方法区】
5. 常量池:存放字符串常量和基本类型常量(public static final)。 【属于共享空间的方法区】
6. 非RAM存储:硬盘等永久存储空间【不作了解】

总之,就区分栈、堆、静态域和常量池四种区域就足够了。常常也把静态域和常量池看成一个区,不加区分。

这里我们主要关心栈,堆和常量池,对于栈和常量池中的对象可以共享,对于堆中的对象不可以共享。栈中的数据大小和生命周期是可以确定的,当没有引用指向数据时,这个数据就会消失。堆中的对象的由垃圾回收器负责回收,因此大小和生命周期不需要确定,具有很大的灵活性。 
对于字符串:其对象的引用都是存储在栈中的,如果是编译期已经创建好(直接用双引号定义的)的就存储在常量池中,如果是运行期(new出来的)才能确定的就存储在堆中。对于equals相等的字符串,在常量池中永远只有一份,在堆中有多份。 
如以下代码: 

Java代码 
1.String s1 = "china";   //String默认就是常量类型,可以认为默认省略了final,因为String内容是不可改变的
2.String s2 = "china"; 
3.String s3 = "china"; 
4.String ss1 = new String("china"); 
5.String ss2 = new String("china"); 
6.String ss3 = new String("china"); 
对于通过new产生一个字符串(假设为”china”)时,会先去常量池中查找是否已经有了”china”对象,如果没有则在常量池中创建一个此字符串对象,然后堆中再创建一个常量池中此”china”对象的拷贝对象。这也就是有道面试题:String s = new String(“xyz”);产生几个对象?一个或两个,如果常量池中原来没有”xyz”,就是两个。 
对于基础类型的变量和常量:变量和引用存储在栈中,常量存储在常量池中。 
如以下代码: 

Java代码 
1.int i1 = 9; 
2.int i2 = 9; 
3.int i3 = 9; 
4.public static final int INT1 = 9; 
5.public static final int INT2 = 9; 
6.public static final int INT3 = 9;

对于成员变量和局部变量:成员变量就是方法外部,类的内部定义的变量;局部变量就是方法或语句块内部定义的变量。局部变量必须初始化。 
形式参数是局部变量,局部变量的数据存在于栈内存中。栈内存中的局部变量随着方法的消失而消失。 
成员变量存储在堆中的对象里面,由垃圾回收器负责回收。 
如以下代码: 

Java代码 
1.class BirthDate { 
2. private int day; 
3. private int month; 
4. private int year; 
5. public BirthDate(int d, int m, int y) { 
6. day = d; 
7. month = m; 
8. year = y; 
9. } 
10. 省略get,set方法……… 
11.} 
12. 
13.public class Test{ 
14. public static void main(String args[]){ 
15.int date = 9; 
16. Test test = new Test(); 
17. test.change(date); 
18. BirthDate d1= new BirthDate(7,7,1970); 
19. } 
20. 
21. public void change1(int i){ 
22. i = 1234; 
23. } 
对于以上这段代码,date为局部变量,i,d,m,y都是形参为局部变量,day,month,year为成员变量。下面分析一下代码执行时候的变化: 
1. main方法开始执行:int date = 9; 
date局部变量,基础类型,引用和值都存在栈中。 
2. Test test = new Test(); 
test为对象引用,存在栈中,对象(new Test())存在堆中。 
3. test.change(date); 
i为局部变量,引用和值存在栈中。当方法change执行完成后,i就会从栈中消失。 
4. BirthDate d1= new BirthDate(7,7,1970); 
d1 为对象引用,存在栈中,对象(new BirthDate())存在堆中,其中d,m,y为局部变量存储在栈中,且它们的类型为基础类型,因此它们的数据也存储在栈中。 day,month,year为成员变量,它们存储在堆中(new BirthDate()里面)。当BirthDate构造方法执行完之后,d,m,y将从栈中消失。
5.main方法执行完之后,date变量,test,d1引用将从栈中消失,new Test(),new BirthDate()将等待垃圾回收。

 

三、补充一下栈和堆的区别:

 

Java的堆是一个运行时数据区,类的(对象从中分配空间。这些对象通过new、newarray、 anewarray和multianewarray等指令建立,它们不需要程序代码来显式的释放。堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存 大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java的垃圾收集器会自动收走这些不再使用的数据。但缺点是,由于要在运行时动态 分配内存,存取速度较慢。

  栈的优势是,存取速度比堆要快,仅次于寄存器,栈数据可以共享。但缺点是,存在栈中的数据大小与生存期必须是 确定的,缺乏灵活性。栈中主要存放一些基本类型的变量数据(int, short, long, byte, float, double, boolean, char)和对象句柄(引用)。

栈有一个很重要的特殊性,就是存在栈中的数据可以共享。假设我们同时定义:

  Java代码

  int a = 3;

  int b = 3;  //【这两句本质就是只在栈分配了一块内存空间,存放了3,只不过这块内存空间有两个别名,分别是a和b。

  编译器先处理int a = 3;首先它会在栈中创建一个变量为a的引用,然后查找栈中是否有3这个值,如果没找到,就将3存放进来,然后将a指向3。接着处理int b = 3;在创建完b的引用变量后,因为在栈中已经有3这个值,便将b直接指向3。这样,就出现了a与b同时均指向3的情况。

  这时,如果再令 a=4;那么编译器会重新搜索栈中是否有4值,如果没有,则将4存放进来,并令a指向4;如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响 到b的值。

  要注意这种数据的共享与两个对象的引用同时指向一个对象的这种共享是不同的,因为这种情况a的修改并不会影响到b, 它是由编译器完成的,它有利于节省空间。而一个对象引用变量修改了这个对象的内部状态,会影响到另一个对象引用变量。

 

 

四、String常量池问题的几个例子

 

示例1:

Java代码

  String s0="kvill";

  String s1="kvill";

  String s2="kv" + "ill";

  System.out.println( s0==s1 );

  System.out.println( s0==s2 );

  结果为:

  true

  true

分析:首先,我们要知结果为道Java 会确保一个字符串常量只有一个拷贝。

  因为例子中的 s0和s1中的”kvill”都是字符串常量,它们在编译期就被确定了,所以s0==s1为true;而”kv”和”ill”也都是字符串常量,当一个字 符串由多个字符串常量连接而成时,它自己肯定也是字符串常量,所以s2也同样在编译期就被解析为一个字符串常量,所以s2也是常量池中” kvill”的一个引用。所以我们得出s0==s1==s2;

示例2:

示例:

  Java代码

  String s0="kvill";

  String s1=new String("kvill");

  String s2="kv" + new String("ill");

  System.out.println( s0==s1 );

  System.out.println( s0==s2 );

  System.out.println( s1==s2 );

  结果为:

  false

  false

  false

分析:用new String() 创建的字符串不是常量,不能在编译期就确定,所以new String() 创建的字符串不放入常量池中,它们有自己的地址空间。

s0还是常量池 中"kvill”的应用,s1因为无法在编译期确定,所以是运行时创建的新对象”kvill”的引用,s2因为有后半部分 new String(”ill”)所以也无法在编译期确定,所以也是一个新创建对象”kvill”的应用;明白了这些也就知道为何得出此结果了。

示例3:

Java代码

  String a = "a1";

  String b = "a" + 1;

  System.out.println((a == b)); //result = true

String a = "atrue";

  String b = "a" + "true";

  System.out.println((a == b)); //result = true

String a = "a3.4";

  String b = "a" + 3.4;

  System.out.println((a == b)); //result = true

分析:JVM对于字符串常量的"+"号连接,将程序编译期,JVM就将常量字符串的"+"连接优化为连接后的值,拿"a" + 1来说,经编译器优化后在class中就已经是a1。在编译期其字符串常量的值就确定下来,故上面程序最终的结果都为true。

示例4:

Java代码

  String a = "ab";

  String bb = "b";

  String b = "a" + bb;

  System.out.println((a == b)); //result = false

分析:JVM对于字符串引用,由于在字符串的"+"连接中,有字符串引用存在,而引用的值在程序编译期是无法确定的,即"a" + bb无法被编译器优化,只有在程序运行期来动态分配并将连接后的新地址赋给b。所以上面程序的结果也就为false。

示例5:

Java代码

  String a = "ab";

  final String bb = "b";

  String b = "a" + bb;

  System.out.println((a == b)); //result = true

分析:和[4]中唯一不同的是bb字符串加了final修饰,对于final修饰的变量,它在编译时被解析为常量值的一个本地拷贝存储到自己的常量 池中或嵌入到它的字节码流中。所以此时的"a" + bb和"a" + "b"效果是一样的。故上面程序的结果为true。

示例6:

Java代码

  String a = "ab";

  final String bb = getBB();

  String b = "a" + bb;

  System.out.println((a == b)); //result = false

  private static String getBB() { return "b"; }

分析:JVM对于字符串引用bb,它的值在编译期无法确定,只有在程序运行期调用方法后,将方法的返回值和"a"来动态连接并分配地址为b,故上面 程序的结果为false。

 

关于String是不可变的

通过上面例子可以得出得知:

  String s = "a" + "b" + "c";

  就等价于String s = "abc";

  String a = "a";

  String b = "b";

  String c = "c";

  String s = a + b + c;

  这个就不一样了,最终结果等于:

  Java代码

  StringBuffer temp = new StringBuffer();

  temp.append(a).append(b).append(c);

  String s = temp.toString();

  由上面的分析结果,可就不难推断出String 采用连接运算符(+)效率低下原因分析,形如这样的代码:

  Java代码

  public class Test {

   public static void main(String args[]) {

   String s = null;

   for(int i = 0; i < 100; i++) {

   s += "a";

   }

   }

  }

  每做一次 + 就产生个StringBuilder对象,然后append后就扔掉。下次循环再到达时重新产生个StringBuilder对象,然后 append 字符串,如此循环直至结束。如果我们直接采用 StringBuilder 对象进行 append 的话,我们可以节省 N - 1 次创建和销毁对象的时间。所以对于在循环中要进行字符串连接的应用,一般都是用StringBuffer或StringBulider对象来进行 append操作。

  由于String类的immutable性质,这一说又要说很多,大家只 要知道String的实例一旦生成就不会再改变了,比如说:String str=”kv”+”ill”+” “+”ans”; 就是有4个字符串常量,首先”kv”和”ill”生成了”kvill”存在内存中,然后”kvill”又和” ” 生成 “kvill “存在内存中,最后又和生成了”kvill ans”;并把这个字符串的地址赋给了str,就是因为String的”不可变”产生了很多临时变量,这也就是为什么建议用StringBuffer的原 因了,因为StringBuffer是可改变的。

 

String中的final用法和理解

  Java代码

  final StringBuffer a = new StringBuffer("111");

  final StringBuffer b = new StringBuffer("222");

  a=b;//此句编译不通过

  final StringBuffer a = new StringBuffer("111");

  a.append("222");// 编译通过

  可见,final只对引用的"值"(即内存地址)有效,它迫使引用只能指向初始指向的那个对象,改变它的指向会导致编译期错误。至于它所指向的对象 的变化,final是不负责的。

 

总结

  栈中用来存放一些原始数据类型的局部变量数据和对象的引用(String,数组.对象等等)但不存放对象内容

  堆中存放使用new关键字创建的对象.

  字符串是一个特殊包装类,其引用是存放在栈里的,而对象内容必须根据创建方式不同定(常量池和堆).有的是编译期就已经创建好,存放在字符串常 量池中,而有的是运行时才被创建.使用new关键字,存放在堆中。

啦啦啦

 




































































以上是关于面试 -- Java内存布局图以及java各种存储区详解的主要内容,如果未能解决你的问题,请参考以下文章

Java对象的内存布局以及对象的访问定位

大厂Java研发岗面试复盘

jvm内存模型-回收算法-和内存分配以及jdkjrejvm是什么关系(阿里,美团,京东面试题)

Java高级面试题!java考试系统随机题目

检查 Java 中的内存布局

附 Java对象内存布局