JVM内存结构与对象的创建
1. 数据运行时区域
虚拟机的结构都是在内存中的一块空间,称为虚拟机内存结构,也称为数据运行时区域。
虚拟机的内存结构由五部分组成:
- 程序计数器
- Java 虚拟机栈
- 本地方法栈
- Java 堆
- 方法区
1.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各线程之间的计数器互不影响,独立存储。
如果线程正在执行的是一个 Java 方法,计数器记录的是正在执行的虚拟机字节码指令的地址;
如果正在执行的是 本地 方法,这个计数器的值为空。
程序计数器是唯一一个没有规定任何 内存溢出(OutOfMemoryError) 的区域。
1.2 Java虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stacks)是线程私有的,生命周期与线程相同。
虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候都会创建一个栈帧(Stack Frame),存储
- 局部变量表
- 操作栈
- 动态链接
- 方法出口
每一个方法被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
这个区域有两种异常情况:
- StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度
- OutOfMemoryError:虚拟机栈扩展到无法申请足够的内存时
当程序在运行时抛出异常时,进行捕获时,经常会用到e.printStackTrace(),其输出的异常信息就是按对应线程的Java 虚拟机栈出栈输出的。
1.3 本地方法栈
本地方法栈(Native Method Stacks)为虚拟机使用到的 本地(Native) 方法服务,是线程私有的。其与Java虚拟机栈类似,只不过Java 虚拟机栈为的是Java方法(字节码)服务 。不过,在虚拟机规范中,本地方法栈使用的语言、方式、数据结构没有绝对的规定,任何具体的虚拟机都可以自由实现。
1.4 Java 堆
Java 堆(Java Heap)是 Java 虚拟机中内存最大的一块。Java 堆在虚拟机启动时创建,被所有线程共享。
- 作用:存放对象实例,也是唯一的目的。
- 垃圾收集器主要管理的就是 Java 堆。
- Java 堆在物理上可以不连续,只要逻辑上连续即可。
1.5 方法区
方法区(Method Area)被所有线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也被叫做永久代,在Java堆之外,JDK8中已把方法区(永久代)移除,取而代之的是原数据区(Meta Space)。
和 Java 堆一样,不需要连续的内存,可以选择固定的大小,更可以选择不实现垃圾收集。
1.6 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。保存 Class 文件中的符号引用、翻译出来的直接引用。运行时常量池可以在运行期间将新的常量放入池中。
1.7 直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现,所以我们放到这里一起讲解。
在JDK 1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在Java 堆里面的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java 堆和Native 堆中来回复制数据。显然,本机直接内存的分配不会受到Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括RAM 及SWAP 区或者分页文件)的大小及处理器寻址空间的限制。服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但经常会忽略掉直接内存,使得各个内存区域的总和大于物理内存限制(包括物理上的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
2. 对象的创建
new指令 => 类加载 => 内存分配 => 内存初始化 => 对象头设置 => 构造方法执行
2.1 类加载
虚拟机遇到一个new指令时,首先会去检查这个指令的参数能否在常量池定位到一个符号引用,并检查这个符号引用代表的类是否已被加载、解析、初始化过,如果没有则进行类加载过程(见模块3)。
2.2 内存分配
类加载通过后,为对象分配内存,对象所占的内存大小由类加载可完全确定。内存分配方式有两种:指针碰撞和空闲列表。
指针碰撞:若内存是绝对规整的,所有用过的内存在一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器,分配内存的动作即为指针向空闲空间那边挪动一小段与对象大小相等的距离。
空闲列表:若内存不是规整的,已使用的内存和空闲的内存相互交错,虚拟机通过一个列表记录内存的使用状况,分配内存时从列表中找出一块足够大的空间给对象实例并更新记录。
选择哪种分配方式由Java堆内存是否规整决定,而Java堆内存是否规整由所采用的垃圾收集器决定
。使用Serial、ParNew等Compact过程的收集器,采用指针碰撞;使用CMS收集器等基于标记清除算法的收集器则采用空闲列表。
2.3 内存初始化
内存分配完毕后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)。保证对象的实例字段不赋初值就能够被访问到数据类型对应的零值。
2.4 对象头设置
对对象进行必要的设置,比如:该对象是哪个类的实例、如何找到这个类的元信息、对象的哈希码、GC分代信息。这些信息都放置在对象头中。
2.5 构造函数执行
以上操作完成后,从虚拟机角度来看,对象的创建已经完成了,但从Java程序的角度来讲,对象的创建才开始(虚拟机对上屏蔽了对象内存分配的细节),程序员通过执行构造函数来进行对象的初始化。
3. 对象的内存布局
对象在内存中的存储布局可分为3块:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
对象头(Header):对象头包括两部分信息,一是对象自身运行时的数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。另一部分是类型指针,即对象指向它类元数据的指针,虚拟机通过这确定对象是哪个类的实例。
运行时自身数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“MarkWord”。
在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码,4Bits用于存储对象分代年龄,2Bits用于存储锁标志 位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。
实例数据(Instance Data):对象真正存储的有效信息,也是程序代码中定义的各种类型的字段内容。无论是父类的还是子类的数据都要记录。
对齐填充(Padding):仅起着占位符的作用。对象起始地址必须是8字节的整数倍,对象头肯定是8字节的整数倍,而实例数据则不一定,此时用对齐填充来补全。
4. 对象的访问
程序中通过操作reference(引用)来操作对象,在Java堆内存中,对象的访问分为两种方式:句柄访问和直接访问。
句柄访问:Java堆中划分出一块内存作为句柄池,reference(引用)中存储的是对象的句柄地址,句柄包括了对象实例数据的地址和对象类型数据的地址。
直接指针访问:
使用句柄访问的好处是reference中存储的是稳定的句柄地址,对象被移动(如GC)只会改变实例数据指针的值,而不用reference本身的值。使用直接指针访问最大的好处是速度快。Hot Spot虚拟机采用的是直接指针访问
Copyright © 2018, GDUT CSCW back-end Kanarien, All Rights Reserved