JVM内存结构
JVM内存结构
学习思路参考
假设我们设计“Java内存结构”,如何做呢:
a)、Java程序是多线程的,CPU数量有限,CPU执行线程会有停顿和切换,因此需要有“虚拟机栈”管理线程,另外需要程序计数器保存线程跑到哪个位置了。
b)、然后就是创建Java对象了,Java对象的类结构和常量等信息只需要一份就可以了,所以就有方法区来存储这些;Java对象本身就用堆来存储好了。
c)、Java是跨平台的,有些指令要调用操作系统本地的方法,因此有本地方法栈,只是我们常用的Oracle的HotSpot虚拟器中“本息方法栈”和前面的“虚拟机栈”合二为一了
d)、上面是JAVA本身管理的内存,有些时候为了效率要求能直接操作外部内存,这就是直接内存,直接内存并不是虚拟机运行时数据的一部分,NIO引入的,可以直接操作堆外内存,提升效率
JVM内存结构架构图
内存结构整体架构图如下:
内存结构详细介绍
Java虚拟机(Java Virtual Machine=JVM)的内存空间分为如下部分:
- 程序计数器
- Java虚拟机栈
- 本地方法栈
- 堆
- 方法区
- 直接内存
以下详细进行介绍
程序计数器
线程私有,即每个线程都会有一个,线程之间互不影响,独立存储。
如果执行的是JAVA方法,计数器记录正在执行的java字节码地址,如果执行的是native方法(本地方法),则计数器为空。
备注:本地方法即一个java调用非java代码的接口,例如:
//java调用dll并执行dll的方法,dll一般是c或者c++编译完的库
// msvcrt为dll名称,msvcrt目录的位置为:C:\Windows\System32下面,
public interface CLibrary extends Library {
CLibrary INSTANCE = (CLibrary) Native.loadLibrary((Platform.isWindows() ? "msvcrt" : "c"), CLibrary.class);
void printf(String format, Object... args);
}
public static void main(String[] args) {
CLibrary.INSTANCE.printf("Hello World, ");
for (int i = 0; i < args.length; i++) {
CLibrary.INSTANCE.printf("Argument %d: %s, ", i, args[i]);
}
}
虚拟机栈
虚拟机栈:线程私有,它的生命周期和线程相同。
管理方法执行的内存模型 。每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链表、方法出口等信息。每一个方法从调用直至完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。
局部变量表存放了编译期可知的各种基本数据类型和对象引用,所需内存空间在编译期确定。
栈的大小可以是固定的,或者是动态扩展的。如果请求的栈深度大于最大可用深度,则抛出stackOverflowError;如果栈是可动态扩展的,但没有内存空间支持扩展,则抛出OutofMemoryError。
本地方法栈
同虚拟机栈,只不过本地方法栈则为虚拟机使用到的native方法服务
Sun HotSpot虚拟机把本地方法栈和虚拟机栈合二为一,所以一般说的栈就是“虚拟机栈+本地方法栈”
Java堆
Java堆:线程共享的,存放所有对象实例和数组。垃圾回收的主要区域。可以分为新生代和老年代(tenured)。
新生代用于存放刚创建的对象以及年轻的对象,如果对象一直没有被回收,生存得足够长,老年对象就会被移入老年代。
新生代又可进一步细分为eden、survivorSpace0(s0,from space)、survivorSpace1(s1,to space)。刚创建的对象都放入eden,s0和s1都至少经过一次GC并幸存。如果幸存对象经过一定时间仍存在,则进入老年代(tenured)。
方法区
线程共享的,用于存放被虚拟机加载的类的元数据信息:如常量、静态变量、即时编译器编译后的代码。也成为永久代。如果hotspot虚拟机确定一个类的定义信息不会被使用,也会将其回收。回收的基本条件至少有:所有该类的实例被回收,而且装载该类的ClassLoader被回收。
这里有一个小例子,来说明堆,栈和方法区之间的关系的:
public class Test2 {
public static void main(String[] args) {
public Test2 t2 = new Test2(); //JVM将Test2类信息加载到方法区,new Test2()实例保存在堆区,Test2引用保存在栈区
}
}
运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。运行期间也可能将新的常量放入池中,比如String类的intern()方法。当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
运行时常量池
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。
字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量,符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。
常量池表会在类加载后存放到方法区的运行时常量池中。
运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError
错误。
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp
,StringTable
本质上就是一个HashSet<String>
,容量为 StringTableSize
(可以通过 -XX:StringTableSize
参数来设置)。
StringTable
中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
JDK 1.7 为什么要将字符串常量池移动到堆中?
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
直接内存
直接内存并不是虚拟机运行时数据的一部分,也不是JAVA虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)*与*缓存区(Buffer)*的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为*避免了在 Java 堆和 Native 堆之间来回复制数据。
直接内存的大小不受Java虚拟机控制,但既然是内存,当内存不足时就会抛出OOM异常
总结
- Java虚拟机的内存模型中一共有两个“栈”,分别是:Java虚拟机栈和本地方法栈。
两个“栈”的功能类似,都是方法运行过程的内存模型。并且两个“栈”内部构造相同,都是线程私有。
只不过Java虚拟机栈描述的是Java方法运行过程的内存模型,而本地方法栈是描述Java本地方法运行过程的内存模型。 - Java虚拟机的内存模型中一共有两个“堆”,一个是原本的堆,一个是方法区。方法区本质上是属于堆的一个逻辑部分。堆中存放对象,方法区中存放类信息、常量、静态变量、即时编译器编译的代码。
- 堆是Java虚拟机中最大的一块内存区域,也是垃圾收集器主要的工作区域。
- 程序计数器、Java虚拟机栈、本地方法栈是线程私有的,即每个线程都拥有各自的程序计数器、Java虚拟机栈、本地方法区。并且他们的生命周期和所属的线程一样。
而堆、方法区是线程共享的,在Java虚拟机中只有一个堆、一个方法栈。并在JVM启动的时候就创建,JVM停止才销毁
创建对象过程
对象的创建
Java 对象的创建过程我建议最好是能默写出来,并且要掌握每一步在做什么。
类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式 (补充内容,需要掌握):
- 指针碰撞 :
- 适用场合 :堆内存规整(即没有内存碎片)的情况下。
- 原理 :用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
- 使用该分配方式的 GC 收集器:Serial, ParNew
- 空闲列表 :
- 适用场合 : 堆内存不规整的情况下。
- 原理 :虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
- 使用该分配方式的 GC 收集器:CMS
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。
内存分配并发问题(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
执行 init 方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。
Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
直接指针
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。