运维开发网

浅谈Java内存区域与对象创建过程

运维开发网 https://www.qedev.com 2020-05-26 16:29 出处:网络 作者: jingxian
一、java内存区域 Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有的区域则依赖用户线程的

一、java内存区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有的区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(JavaSE7版)》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。

1.程序计数器(线程私有)

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时 就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

每条线程都有一个独立的程序计数器,各个线程之间互不影响,独立储存。

如果线程正在执行的是一个java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;

如果线程正在执行的是一个Native方法,计数器值为空(undefined)。

此内存区域是唯一一个在Java规范中没有规定任何OutOfMemoryError(OOM)情况的区域

2.Java虚拟机栈(线程私有)

虚拟机栈(Java Virtual Machine Stacks)描述的是Java方法执行的内存模型:每个方法在执行的同时会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈和出栈的过程。

通常大家所说的虚拟机堆内存和栈内存中,栈内存一般是指这里的局部变量表部分。

局部变量表存放了编译期可知的各种基本数据类型、对象引用(不是对象本身,可能是指向对象的指针或代表对象的句柄或其他的与此对象相关的位置,涉及到下文的“对象的访问定位”)和returnAddress类型。

3.本地方法栈(线程私有)

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,区别不过是虚拟机栈为Java方法(字节码)所服务,而本地方法栈则为虚拟机使用的Native方法服务。Java虚拟机规范没有对本地方法栈使用的语言、方式和数据结构有强制规定。因此虚拟机可以自由实现它,甚至有的虚拟机直接把本地方法栈和虚拟机栈合二为一。

4.Java堆(线程共享)

Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。唯一的目的就是存放对象实例。所有的对象实例和数组都要在堆上分配内存。

Java堆是垃圾收集器管理的主要区域。Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,类似于磁盘空间。在实现时既可以是固定大小,也可以是可扩展的,当前的主流虚拟机都是按照可扩展实现的。如果在堆中没有内存完成实例化分配,并且堆也无法扩展时,会抛出OOM异常。

5.方法区(线程共享)

方法区(Method Area)用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

虚拟机规范把方法区描述为了堆的一个逻辑部分,但有个别名Non-Heap。

6.运行时常量池(线程共享) 

运行时常量池(Runtime Constant Pool) 是方法区的一部分。

class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池中保存。 

Java虚拟机对Class文件中的每一部分的格式都有严格的规定。每一个字节用于存储哪种数据都必须符合规范才会被虚拟机认可、装载和执行,但是对于运行常量池,Java虚拟机规范没有做任何细节的要求,不同的虚拟机可以按照自己的需求进行实现。不过,一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定要只有编译的时候产生,运行期间也可能将新的常量放入池中,利用的多的便是String类的intern()方法。

在无法再申请到内存是会排除OOM异常。

二、HotSpot虚拟机对象探秘

HotSpot虚拟机在Java堆中对象分配、布局和访问过程的探讨。

1.对象的创建

Java是面向对象的语言,在程序的执行过程中随时都有可能会有对象被创建,从语言层面上讲,创建对象(例如克隆,反序列化)通常仅仅是new关键字。

过程:

加载类:当虚拟机遇到一个new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且通过检查这个符号引代表的类是否已经被加载、解析和初始化过。如果没有则执行相应的类加载过程。(此处不讨论这部分的具体内容,详情参考第七章)

分配内存:在类加载通过后,虚拟机要为新生对象分配内存,对象所需要的内存在类加载后就可以确定,为对象分配空间就是从Java堆中划分出来。此时常见有两种方法:(1)指针碰撞:若堆中内存是绝对规整的,已经使用的在一边,没有使用的在一边,中间有个分割指针,则只需要把分割指针向没有使用的内存移动所需要的固定大小(2)空闲列表:Java堆内存不是规整的,已使用的未使用的是相互交错的,虚拟机就要维护一个列表,列表保存哪些是已经使用的,哪些是未被使用的,分配的时候只需要从未被使用的内存中分配固定的大小给新对象。

选择哪种方法由堆内存是否规整所决定,而是否规整取决于采用的垃圾收集器是否带有压缩整理功能。

可以很明显的看出,这种分配空间的方法不是线程安全的,可能在对A对象分配时指针还没来得及修改,对象B就采用了原来的指针,解决这个问题有两种方案:

 (1)对分配空间的动作进行同步化处理:实际上虚拟机采用CAS配上失败重试的方式保持更新操作的原子性(要么全部做完,要么一点也不做)。

 (2)把内存分配的动作按照线程划分在不同的空间上进行,即每个线程在Java堆中预先分配一块内存,成为本地线程分配缓冲TLAB。哪个线程要分配内存就在那个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时才需要同步锁定。

内存空间初始化:内存分配结束后,虚拟机将分配的内存空间都初始化为零值(不包括对象头,分配的内存空间的具体内容下一部分会解释),如果使用TLAB来完成,这一工作可以提前到TLAB分配时进行,这一操作保证了对象的实例字段在Java中不赋初始值就可以直接使用,即零值。

对象头设置:接下来,虚拟机要对对象进行必要的设置,例如:是哪个类的实例?如何找到类的元数据信息?哈希码?对象的GC分代年龄等。这些信息存放在对象的对象头(Object Header)中。

初始化:从虚拟机视角来看,一个新的对象已经产生,但是从Java程序来看,这才刚刚开始,因为内存的数据内容都是零值,执行new之后,会执行<init>方法,按照程序员的要求来进行初始化。

2.对象的内存布局

分为三部分:

(1)对象头

 ①存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。32位虚拟机中为32bit,64位虚拟机中为64bit,官方称之为"Mark Word"。但是需要的存储的运行时数据太多,其实已经超过了32bit或者64bit,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的效率Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态来复用自己的存储空间。例如在32位的HotSpot虚拟机中,如果对象处于未被锁的状态下,那么就不储存和锁有关的信息,25bit存储hashcode,4bit存储GC,2bit存储锁标志位、1bit固定为0。

 ②类型指针,即对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的示例。并不是所有的虚拟机实现都必须通过数据上保留类型指针(和对象的访问定定位有关,下面会进行解释)。如果对象是一个Java数组,那在对象头还必须有一块来记录数组长度的数据,因为从数组的元数据中无法确定数组的大小。 

(2)实例数据

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

(3)对齐填充

 不是必然存在的,也没有特殊的含义,仅仅是占位符的作用。由于HotSpot VM 的自动内存管理必须要求对象的起始地址必须是8字节的整数倍,换句话说就是对象的内存大小必须是8字节的整数倍,如果不足,则填充到8的整数倍。

3.对象的访问定位

即栈内存中的引用如何访问堆内存中的对象。目前主流的方式有两种:

(1)使用句柄:Java堆中会划分一块区域作为句柄池,栈中存储的就是对象的句柄地址,而句柄中包含了对象实例数据指针(指向堆内存中)和类型数据指针(指向方法区)。

(2)直接指针:栈中存储的直接就是对象地址,而堆中对象的布局要包括对象示例数据和类型数据指针(指向方法区)。

使用句柄的优点: 引用中存储的稳定的句柄地址,在对象移动(垃圾收集时移动对象是非常普遍的行为),只会改变句柄的实例数据指针,而引用本身不会改变。

直接指针的优点: 速度快,节省了一次指针定位的时间开销。由于对象访问十分频繁,极少成多之后也是可观的执行成本。HotSpot虚拟机是使用第二种进行对象访问的。

三、实战:OOM异常

原书阐述了产生几种异常的方式和简单代码,具体参考原书内容。

以上这篇浅谈Java内存区域与对象创建过程就是小编分享给大家的全部内容了,希望能给大家一个参考,也希望大家多多支持我们。

0

精彩评论

暂无评论...
验证码 换一张
取 消