上一篇文章中,我们提到了对象在内存中的分布情况,图解Java传递类型,那么在这篇文章中,我会用比较通俗易懂的方式,讲解一下Java的内存和对象的内存分布情况。
Java内存区域
Java是一座围城,Java开发不需要像C、C++开发人员那样,维护每个对象从开始到终结的职责。因为Java虚拟机会帮助我们完成这些职责,但是一旦发生内存泄漏和溢出,就需要我们排查。
Java虚拟机执行Java程序时,把它管理的整个内存区域称为运行时数据区。同时根据区域的用途,以及创建和销毁时间等因素,将运行时数据区分成不同的区域。
程序计数器
程序计数器表示当前线程所执行字节码指令的行号计数器。字节码解释器通过改变程序计数器的值,选取下一条需要执行的指令。为了保证线程切换之后恢复到正确的执行位置,每条线程都需要独立的程序计数器,所以程序计数器是线程私有的。同时程序计数器是唯一一个在虚拟机规范中没有规定OutOfMemoryError
的区域。
注:线程执行Java方法,程序计数器记录字节码指令地址;如果执行的是本地(Native)方法,程序计数器为空。
虚拟机栈
虚拟机栈是Java方法执行的线程内存模型。每个方法的执行,Java虚拟机都会创建一个栈帧存储方法相关变量。每个方法被调用到执行完毕的过程,对应栈帧在虚拟机栈中入栈到出栈的过程。
如下图所示,当虚拟机执行swap(a,b)
方法时,会创建一个单独的栈帧swap(a,b)
栈帧,在该栈帧中会存储与方法相关的变量,该栈帧的入栈和出栈操作对应着方法的执行和结束。
每个栈帧都包含了局部变量表、操作数、动态链接、方法返回值。
- 局部变量表:存放方法参数和内部定义的局部变量。局部变量表的容量以变量槽为最小单位每个变量槽可以存放一个
boolean
、byte
、char
、short
、int
、float
、reference
、returnAddress
数据类型。 - 操作数栈:底层也是栈结构,是进行数据运算的地方。
- 当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。
- 动态链接:将常量池中指向方法的部分符号引用,在方法运行期间转为直接引用。
- 字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
- 返回地址:方法执行退出后,返回到方法被调用的地方。
在swap
函数执行的过程中,a
、b
、temp
都会保存到局部变量表中,其中的赋值操作则通过操作数栈执行,
方法执行完毕返回到调用的地方的地址则存储在返回地址中。
本地方法栈
本地方法栈与 Java 虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。
Java堆
Java堆是虚拟机管理的内存中最大的一块,几乎所有对象都在Java堆分配内存。Java堆在虚拟机启动的时候创建,被所有的线程共享。Java堆也会涉及到内存回收的内容,本篇文章先不展开了。Java堆无法扩展时,会报出OutOfMemoryError
异常。
方法区
方法区存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码缓存等数据。方法区是各个线程共享的内存区域。
屏幕面前的你,会不会遇到这样的困惑。方法区和永久代有什么关系?和元空间呢?
方法区和永久代的关系
方法区是JVM规范概念,而永久代则是HotSpot虚拟机特有的概念。
《Java虚拟机规范》只是规定了有方法区的概念和作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。同时大多数用的JVM都是Sun公司的HotSpot。在HotSpot上把GC分代收集扩展至方法区,或者说使用永久代来实现方法区。因此永久代是HotSpot的概念,方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。其他的虚拟机实现并没有永久带这一说法。在1.7之前在(JDK1.2 ~ JDK6)的实现中,HotSpot 使用永久代实现方法区,HotSpot 使用 GC分代来实现方法区内存回收,可以使用如下参数来调节方法区的大小。
元空间
对于Java8, HotSpots取消了永久代,取代永久代的就是元空间。永久代存在内存上限(
-XX:MaxPermSize
,即使不设置也有默认大小),当进程申请不到足够的内存,会造成内存溢出。改成元空间后,改用本地内存,只要本地空间足够,就不会有内存溢出的问题。元空间和永久代有什么不同的?
存储位置不同,永久代是堆的一部分,和新生代,老年代地址是连续的,而元空间属于本地内存;
存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。
运行时常量池
运行时常量池是方法区的一部分,是一块内存区域。Class 文件常量池将在类加载后进入方法区的运行时常量池中存放。一个类加载到 JVM 中后对应一个运行时常量池。
易混淆的概念
屏幕面前的你,会不会遇到这样的困惑。运行时常量池和Class文件常量池有什么关系?和字符串常量池呢?和缓冲池呢?
Class文件常量池
Class 文件常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。常量池中主要存放两大类常量:字面量和符号引用。当Class文件常量池加载到方法区时,会把符号引用转换为直接引用,存放到运行时常量池。
字符串常量池
字符串常量池是全局的,JVM
中独此一份,因此也称为全局字符串常量池。
其中:
在 jdk1.6(含)
之前也是方法区的一部分,并且其中存放的是字符串的实例;在 jdk1.7(含)
之后是在堆内存之中,存储的是字符串对象的引用,字符串实例是在堆中;
底层原理
在HotSpot VM
里实现线程池功能的是一个StringTable
类,它是一个Hash表,默认值大小长度是1009;这个StringTable
在每个HotSpot VM
的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable
上。
String str1 = "图解Java";
String str2 = new String("图解Java");
System.out.println(str1 == str2);
在这段代码中,当执行String str1 = "图解Java"
时,先到常量池中查询有没有"图解Java"
字符串的引用,如果没有,则会在Java堆
上创建"图解Java"
字符串,在常量池中存储字符串的地址,str1
则指向字符串常量池的地址。
String str2 = new String("图解Java")
,则会直接在Java堆中创建对象。str2
指向堆中的地址。
看到这里,屏幕面前的你有没有想到最后的结果是false
呢。
如果此时还有String str3 = "图解Java"
那么str1==str3
的结果是什么?
此时str3
发现字符串常量池中已经有了"图解Java"
字符串的引用,则直接返回,不会创建新的对象。
看到这里,屏幕面前的你有没有想到最后的结果是true
呢。
JVM
中除了字符串常量池,8种基本数据类型中除了两种浮点类型剩余的6种基本数据类型的包装类,都使用了缓冲池技术,但是 Byte
、Short
、Integer
、Long
、Character
这5种整型的包装类也只是在对应值在 [-128,127] 时才会使用缓冲池,超出此范围仍然会去创建新的对象。
Class文件常量池、运行时常量池、字符串常量池的联系
我们平时写好的Java代码即Java格式的文件,经过编译,会变成Class类型的文件。而Class文件有一部分是Class文件常量池,用于存储字面量和符号引用。
Class文件经过类加载器加载后,之前Class文件常量池的内容会存放到方法区的运行时常量池,需要注意的是Class文件常量池的符号引用会转变直接引用存入运行时常量池。
字符串常量池是JVM
的一部分,整个JVM
只有一份,在将Class文件常量池的字面量也会在类加载的时候进入到字符串常量池中。
份数 | 内容 | |
---|---|---|
Class文件常量池 | 每个类对应一份 | 字面量、符号引用 |
运行时常量池 | 每个类对应一份 | 字面量、直接引用 |
字符串常量池 | 整个JVM 仅有一份 | 字符串 |