Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
JDK 1.8 和之前的版本略有不同:
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器(逻辑上)。主要有以下两个作用:
此外,程序计数器还有如下特性:
每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口信息等。
从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。Java 方法有两种返回方式:
不管使用哪种返回方式都会导致栈帧被弹出。
可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小:
java -Xss512M HackTheJava
该区域可能抛出以下异常:
注意:HotSpot 虚拟机的栈容量不可以进行动态扩展的,所以在 HotSpot 虚拟机是不会由于虚拟机栈无法扩展而导致 OOM 的,但是如果申请时就失败,仍然会出现 OOM 异常。
局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)和对象引用。
通过 javap 命令分析 Java 汇编指令,感受操作数栈和局部变量表的关系。
定义测试类:该类中定义了一个静态方法 add()
public class JVMTest { public static int add(int a ,int b) { int c = 0; c = a + b; return c; } }
使用 javap 命令(javap 分析的是字节码文件)
javap -verbose JVMTest
得到如下汇编指令:
public static int add(int, int); descriptor: (II)I flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=2 0: iconst_0 1: istore_2 2: iload_0 3: iload_1 4: iadd 5: istore_2 6: iload_2 7: ireturn LineNumberTable: line 5: 0 line 6: 2 line 7: 6
解读上述指令:
执行 add(1,2),说明局部变量表和操作数栈的关系:
本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。
本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 。
几乎所有的对象实例以及数组都在这里分配内存,是垃圾收集的主要区域,所以也被称作 GC 堆(Garbage Collected Heap)。
现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:
堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常。
可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。
java -Xms1M -Xmx2M HackTheJava
JDK 7 版本及 JDK 7 版本之前,Hotspot 虚拟机的堆结构如下:
JDK 8 版本之后 HotSpot 虚拟机的永久代被彻底移除了,取而代之是元空间,元空间使用的是直接内存。
Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。引用对象、数组时,栈里定义变量保存堆中目标的首地址。
栈和堆的区别:
物理地址
堆的物理内存分配是不连续的;
栈的物理内存分配是连续的
分配内存
堆是不连续的,分配的内存是在运行期确定的,大小不固定;
栈是连续的,分配的内存在编译器就已经确定,大小固定
存放内容
堆中存放的是对象和数组,关注的是数据的存储;
栈中存放局部变量,关注的是程序方法的执行
是否线程私有
堆内存中的对象对所有线程可见,可被所有线程访问;
栈内存属于某个线程私有的
异常
栈扩展失败,会抛出 StackOverflowError;
堆内存不足,会抛出 OutOfMemoryError
用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。
对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
方法区只是一个 JVM 规范,在不同的 JVM 上方法区的实现可能是不同的。
方法区和永久代的关系类似 Java 中接口和类的关系,类实现了接口,永久代就是 HotSpot 虚拟机对 JVM 规范中方法区的一种实现方式。
方法区是 JVM 规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。
方法区只是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中:元空间存储类的元信息,静态变量和常量池等则放入堆中。
元空间与永久代的最大区别在于:元空间使用本地内存,而永久代使用 JVM 的内存,元空间相比永久代具有如下优势:
java.lang.OutOfMemoryError: MetaSpace
可以使用 -XX:MaxMetaspaceSize
标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。MaxPermSize
控制了, 而由系统的实际可用空间来控制,可以加载更多的类。运行时常量池是方法区的一部分。
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中)。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量
一定只有编译期才能产生,运行期间也可以将新的常量放入池中,例如 String 类的 intern()。
JDK 1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区。
JDK 1.7 字符串常量池被单独拿到堆,运行时常量池剩下的内容还在方法区。
JDK1.8 HotSpot 虚拟机移除了永久代,采用元空间(Metaspace) 代替方法区,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间。
创建方式 1:
String str1 = "abcd";
创建方式 2:
String str2 = new String("abcd");
这两种不同的创建方法是有差别的:
方式 1 是在常量池中获取对象(“abcd” 属于字符串字面量,因此编译时期会在常量池中创建一个字符串对象)。
方式 2 会创建两个字符串对象(前提是常量池中还没有 “abcd” 字符串对象):
(字符串常量"abcd"在编译期就已经确定放入常量池,而 Java 堆上的"abcd"是在运行期初始化阶段才确定)。
str1 指向常量池中的 “abcd” 对象,而 str2 指向堆中的字符串对象。
String 的 intern() 是一个 Native 方法,当调用 intern() 方法时:
String s3 = new String("1") + new String("1"); s3.intern(); String s4 = "11"; System.out.println(s3 == s4);
JDK 6 输出结果:
false
JDK 8 输出结果:
true
补充:String 的 intern() 方法详解
String str1 = "hello"; String str2 = "world"; String str3 = "hello" + "world";//常量池中的对象 String str4 = str1 + str2; //在堆上创建的新的对象 String str5 = "helloworld";//常量池中的对象 System.out.println(str3 == str4); System.out.println(str3 == str5); System.out.println(str4 == str5);
输出结果如下:
false true false
str1、str2 是从字符串常量池中获取的对象。
对于 str3,字符串 “hello” 和字符串 “world” 相加有后得到 “helloworld”,在字符串常量池中创建 “helloworld” 对象。
对于 str4,str1+str2 则会在堆中创建新的 “helloworld” 对象。
对于 str5,字符串常量池已有 “helloworld” 对象,str5 直接引用该对象。
所以,尽量避免多个字符串拼接,因为这样会重新创建新的对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。
在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:
Hotspot 虚拟机的对象头包括两部分信息:
一部分用于存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志等等);
另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。
因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
Java 对象的创建过程分为以下5步:
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,
并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。
如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。
对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定。
Java 堆内存是否规整,则取决于 GC 收集器的算法是“标记-清除”,还是“标记-整理”(也称作“标记-压缩”),值得注意的是,“复制算法”内存也是规整的。
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,
作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。
另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init > 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。
对象的访问方式视虚拟机的实现而定,目前主流的访问方式有两种:使用句柄、直接指针。
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息 。
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势: