您的位置:首页 > 编程语言 > Java开发

JVM 学习笔记(一) JVM内存模型

2017-04-02 16:36 471 查看
本系列博客内容主要来自深入理解Java虚拟机(第二版) 的学习总结,图片来自于互联网。

1. JVM 内存区域划分

JVM内存区域可分为以下五类, 如图所示:



1.1 程序计数器

程序计数器可以看做是当前线程所执行的字节码的行号指示器。每个线程都有一个独立的程序计数器,并且互不影响,独立存储,是线程私有的内存。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空( Undefined )。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

1.2 Java 虚拟机栈

此区域也是线程私有的,生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行时都会创建一个栈帧( Stack Frame ) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

通常人们所说的栈内存(Stack)所指的就是虚拟机栈,或者说是虚拟机栈中的局部变量表部分。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double),对象引用( reference 类型 ) 和 returnAddress类型( 指向了一条字节码指令的地址 )。

其中 64 位长度的 long 和 double 类型的数据会占用 2 个局部变量空间( Slot ),其余的数据只占用 1 个。这些都在编译期间完成分配,当进入一个方法时,需要分配多大的局部变量空间是完全确定的, 方法运行期间不会改变。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常,如果虚拟机内存扩展时无法申请到足够的内存,抛出 OutOfMemoryError 异常。

1.3 本地方法栈

作用与虚拟机栈非常相似,区别是虚拟机栈为虚拟机执行 Java 方法服务,本地方法栈为虚拟机使用的Native方法服务。该区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

1.4 Java堆

Java堆是被所有线程共享的一块内存区域,所有的对象实例以及数组都要在对上分配内存。Java堆是垃圾收集管理的主要区域。堆中还可以细分为:新生代和老年代。在细致一点的有 Eden空间、From Survivor空间、To Survivior空间等。当堆无法扩展时抛出 OutOfMemoryError 异常。

1.5 方法区

和Java堆一样,是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编辑器编译后的代码等数据。

1.6 运行时常量池

方法区的一部分。 Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项常量池( Constant Pool Table )用于存放编译期生成的各种字面量和符号引用, 这部分内容会存放到方法区的运行时常量池中。但 Java 语言并不要求常量一定只能在编译期产生, 即并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池, 运行期间也可能将新的常量放入池中, 如 String 的 intern() 方法.

1.7 直接内存

直接内存并不是 JVM 运行时数据区的一部分, 但也会被频繁的使用: 在 JDK 1.4 引入的 NIO 提供了基于 Channel 与 Buffer 的 IO 方式, 它可以使用Native函数库直接分配堆外内存, 然后使用 DirectByteBuffer 对象作为这块内存的引用进行操作,这样就避免了在Java堆和Native堆中来回复制数据, 因此在一些场景中可以显著提高性能。

显然, 本机直接内存的分配不会受到Java堆大小的限制(即不会遵守-Xms、-Xmx等设置),但既然是内存, 则肯定还是会受到本机总内存大小及处理器寻址空间的限制, 因此动态扩展时也会出现 OutOfMemoryError 异常。

2. HotSpot虚拟机对象

2.1 对象的创建

这里讨论的对象限于普通 Java 对象,不包括数组和 Class 对象。当虚拟机遇到一条 new 指令时,会有如下操作:

首先去检查该指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。 如果没有,必须先执行相应的类加载过程。

类加载检查通过后: VM将为新生对象分配内存(对象所需内存的大小在类加载完成后便可完全确定), VM采用指针碰撞(内存规整: Serial、ParNew等有内存压缩整理功能的收集器)或空闲链表(内存不规整: CMS这种基于Mark-Sweep算法的收集器)方式将一块确定大小的内存从Java堆中划分出来.

除了考虑如何划分可用空间外, 由于在VM上创建对象的行为非常频繁, 因此需要考虑内存分配的并发问题. 解决方案有两个:

对分配内存空间的动作进行同步 -采用 CAS配上失败重试 方式保证更新操作的原子性

把内存分配的动作按照线程划分在不同的空间之中进行 -每个线程在Java堆中预先分配一小块内存, 称为本地线程分配缓冲TLAB, 各线程首先在TLAB上分配, 只有TLAB用完, 分配新的TLAB时才需要同步锁定(使用-XX:+/-UseTLAB参数设定)。

接下来将分配到的内存空间初始化为零值(不包括对象头, 且如果使用TLAB这一个工作也可以提前至TLAB分配时进行). 这一步保证了对象的实例字段可以不赋初始值就直接使用(访问到这些字段的数据类型所对应的零值)。

然后要对对象进行必要的设置: 如该对象所属的类实例、如何能访问到类的元数据信息、对象的哈希码、对象的GC分代年龄等, 这部分息放在对象头中(详见下)。

上面工作都完成之后, 在虚拟机角度一个新对象已经产生, 但在Java视角对象的创建才刚刚开始(init方法尚未执行, 所有字段还都为零). 所以new指令之后一般会(由字节码中是否跟随有invokespecial指令所决定-Interface一般不会有, 而Class一般会有)接着执行init方法, 把对象按照程序员的意愿进行初始化, 这样一个真正可用的对象才算完全产生出来.

2.2 对象的内存布局

HotSpot VM 内, 对象在内存中的存储布局可以分为三块区域:对象头、实例数据和对齐填充。

对象头包括两部分信息:

第一部分用来存储对象自身运行时的数据,如哈希吗( HashCode )、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据长度在32位和64位的虚拟机中分别为32bit和64bit,官方称为”Mark Word”。

另外一部分是类型指针,即对象指向它的元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。 另外, 如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。

实例数据部分是对象真正存储的有效信息, 也就是我们在代码里所定义的各种类型的字段内容(无论是从父类继承下来的, 还是在子类中定义的都需要记录下来). 这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响. HotSpot默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers), 相同宽度的字段总是被分配到一起, 在满足这个前提条件下, 在父类中定义的变量会出现在子类之前. 如果CompactFields参数值为true(默认), 那子类中较窄的变量也可能会插入到父类变量的空隙中.

对齐填充部分并不是必然存在的, 仅起到占位符的作用, 原因是HotSpot自动内存管理系统要求对象起始地址必须是8字节的整数倍, 即对象的大小必须是8字节的整数倍.

2.3 对象的访问定位

建立对象是为了使用对象, Java程序需要通过栈上的reference来操作堆上的具体对象. 主流的有句柄直接指针两种方式去定位和访问堆上的对象:

句柄: Java堆中将会划分出一块内存来作为句柄池, reference中存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体各自的地址信息



直接指针(HotSpot使用): 该方式Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址



这两种对象访问方式各有优势: 使用句柄来访问的最大好处是reference中存储的是稳定句柄地址, 在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不变. 而使用直接指针最大的好处就是速度更快, 它节省了一次指针定位的时间开销,由于对象访问非常频繁, 因此这类开销积小成多也是一项非常可观的执行成本。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  java jvm