[Java基础]从Java的各种基本数据类型看Java内存区域划分

一、各种基本数据类型的存储

我们先来看一段小代码:

public class{
	int a = 20;
	public static void main(String[] args){
		int b = 10;
		String str1 = ”abc“;
		String str2 = new String("abc");
	}
}

我们先初步分析一下着三个变量的存储过程:
int 声明的都是8中基本数据类型中的一种。

  • int a 是一个类的成员变量,成员变量的生命周期是和类在一起的,类下的每一个方法对于成员变量的值都是共享的,也就是成员变量需要多个方法都可以进行访问。
  • int b 是main里面的一个局部变量,他的生命周期随着方法的结束而结束。
  • String str1和String str2的两种声明方式相同吗? 答案是否定的,我们先阐述结果,然后带着疑问继续看后面的分析。
    首先str1和str2的内容是存储在stack(栈)中的,他们是分别指向自己应当指向的内存区域(可能是栈中,也可能是堆中)
    str1是一个String类型的内容,"abc"在编译时被放入静态常量池(也就是class常量池)中,运行时被拿到运行时常量池中的字符串常量池,然后由str1指向"abc"的区域。
    str2的 new String(“abc”)是存储在内存区域中的堆中的,这个new的过程是在运行期初始化阶段才确定的,然后Stack上的str2指向heap(堆)上的new String(“abc”)。
    因为他们是指向的不同的内存区域,System.out.println(str1 == str2); 的结果也自然就是false了。

二、Java内存区域的划分(运行时数据区)

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
[Java基础]从Java的各种基本数据类型看Java内存区域划分
我们可以把它们分为两个类型的区域,一种是线程私有的,另一种是线程共享的。

  • 线程共享的区域:
    • 堆(Heap)
    • 方法区(Method Area)
  • 线程私有:
    • 程序计数器(PC)
    • 虚拟机栈(VM Stack)
    • 本地方法栈(Native Method Stack)

2.1 程序计数器(Program Counter):

可以看作是当前线程所执行的字节码的行号指示器,他标记着我们当前执行到了哪一条指令。类比我们计算机组成中的PC计数器,他实现着我们代码的控制流程。
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

2.2 Java虚拟机栈(Stack)

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。
Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)

局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError :每当java程序启动一个新的线程时,java虚拟机会为他分配一个栈,java栈以帧为单位保持线程运行状态;当线程调用一个方法是,jvm压入一个新的栈帧到这个线程的栈中,只要这个方法还没返回,这个栈帧就存在。
    如果方法的嵌套调用层次太多(如递归调用),随着java栈中的帧的增多,最终导致这个线程的栈中的所有栈帧的大小的总和大于-Xss设置的值,而产生StackOverflowError溢出异常。
public class StackOverFlow {
    public int stackSize = 0;

    public void stackIncre() {
        stackSize++;
        stackIncre();
    }

    public static void main(String[] args) throws Throwable{
        StackOverFlow sof = new StackOverFlow();
        try {
            sof.stackIncre();
        } catch (Throwable e) {
            System.out.println(sof.stackSize);
            throw e;
        }
    }
}

  • OutOfMemery:当我们不断申请内存空间,到达大小的极限时,将抛出OutOfMemery异常
public class OutOfMemory {
    public static void main(String[] args){
        List list=new ArrayList();
        for(;;){
            int[] tmp=new int[1000000];
            list.add(tmp);
        }
    }
}

2.3 Java本地方法栈(Native Method Stack)

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

2.4 堆(Heap)

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:在细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
[Java基础]从Java的各种基本数据类型看Java内存区域划分
在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。

2.5 方法区(Method Area/Non-Heap)

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。
相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

2.6 运行时常量池

JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)
既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

除此之外,常量池还有class constant pool
推荐阅读:字符串常量池、class常量池和运行时常量池

2.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError异常出现。
JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

三、补充内容

3.1 String 对象的两种创建方式:

就是我们上面(一)中的str1和str2两种创建string类型的方式。第一种方式是在常量池中拿对象,第二种方式是直接在堆内存空间创建一个新的对象
[Java基础]从Java的各种基本数据类型看Java内存区域划分

3.2 String 类型的常量池比较特殊。它的主要使用方法有两种:

  • 用双引号声明出来的 String 对象会直接存储在常量池中。
  • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
 String s1 = new String("计算机");
	      String s2 = s1.intern();
	      String s3 = "计算机";
	      System.out.println(s2);//计算机
	      System.out.println(s1 == s2);//false,因为一个是堆内存中的String对象一个是常量池中的String对象,
	      System.out.println(s3 == s2);//true,因为两个都是常量池中的String对

3.3 String 字符串拼接

		  String str1 = "str";
		  String str2 = "ing";
		  
		  String str3 = "str" + "ing";//常量池中的对象
		  String str4 = str1 + str2; //在堆上创建的新的对象	  
		  String str5 = "string";//常量池中的对象
		  System.out.println(str3 == str4);//false
		  System.out.println(str3 == str5);//true
		  System.out.println(str4 == str5);//false

[Java基础]从Java的各种基本数据类型看Java内存区域划分
尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的花,可以使用 StringBuilder 或者 StringBuffer。

3.4 八种基本数据类型,基本类型的包装类和常量池

关于包装类的知识推荐阅读:[Java基础] Java包装类及自动装箱、拆箱


基本数据类型(String不是基本数据类型)的数据保存在stack中,如目录(一)中的b保存在栈中,10也保存在stack中,然后由b指向10
  • Java 基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。
  • 两种浮点数类型的包装类 Float,Double 并没有实现常量池技术
		Integer i1 = 33;
		Integer i2 = 33;
		System.out.println(i1 == i2);// 输出true
		Integer i11 = 333;
		Integer i22 = 333;
		System.out.println(i11 == i22);// 输出false
		Double i3 = 1.2;
		Double i4 = 1.2;
		System.out.println(i3 == i4);// 输出false

Integer缓存源代码

/**
*此方法将始终缓存-128到127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
*/
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }
  1. Integer i1=40;Java 在编译的时候会直接将代码封装成Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
  2. Integer i1 = new Integer(40);这种情况下会创建新的对象。

Integer比较更丰富的一个例子:

Integer i1 = 40;
  Integer i2 = 40;
  Integer i3 = 0;
  Integer i4 = new Integer(40);
  Integer i5 = new Integer(40);
  Integer i6 = new Integer(0);
  
  System.out.println("i1=i2   " + (i1 == i2));
  System.out.println("i1=i2+i3   " + (i1 == i2 + i3));
  System.out.println("i1=i4   " + (i1 == i4));
  System.out.println("i4=i5   " + (i4 == i5));
  System.out.println("i4=i5+i6   " + (i4 == i5 + i6));   
  System.out.println("40=i5+i6   " + (40 == i5 + i6));     

运行结果:

i1=i2   true
i1=i2+i3   true
i1=i4   false
i4=i5   false
i4=i5+i6   true
40=i5+i6   true

语句i4 == i5 + i6,因为+这个操作符不适用于Integer对象,首先i5和i6进行自动拆箱操作,进行数值相加,即i4 == 40。然后Integer对象无法与数值进行直接比较,所以i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。

四、总结

我们平时讨论最多的是stack栈内存和heap堆内存,再次对数据类型在内存中的存储问题来解释一下:

  1. 方法中声明的变量,即该变量是局部变量,每当程序调用方法时,系统都会为该方法建立一个方法栈,其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因

    在方法中声明的变量可以是基本类型的变量,也可以是引用类型的变量。

  • 当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在JAVA虚拟机栈中
  • 当声明的是引用变量时,所声明的变量(该变量实际上是在方法中存储的是内存地址值)是放在JAVA虚拟机的栈中,该变量所指向的对象是放在堆类存中的。
  1. 类中声明的变量是成员变量,也叫全局变量,放在堆中的(因为全局变量不会随着某个方法执行结束而销毁)。
    同样在类中声明的变量即可是基本类型的变量 也可是引用类型的变量
  • 当声明的是基本类型的变量其变量名及其值放在堆内存中的
  • 引用类型时,其声明的变量仍然会存储一个内存地址值,该内存地址值指向所引用的对象。引用变量名和对应的对象仍然存储在相应的堆中


    最后来一个QA:
    Q:栈内存有什么优点?
    A:栈内存中的数据可以共享,速度快。

参考:https://juejin.im/post/5b7d69e4e51d4538ca5730cb#heading-18
http://tangxman.github.io/2015/07/27/the-difference-of-java-string-pool/
https://blog.****.net/zm13007310400/article/details/77534349
https://blog.****.net/jingjbuer/article/details/46348667