Java内存区域和对象布局

来源:互联网 发布:公司注册淘宝店 编辑:程序博客网 时间:2024/06/05 09:08

Java内存区域和对象布局

Java和c系列语言最大的区别就是Java将内存纳入虚拟机管理之下,内存的分配和释放程序员不再“关心”。但是这个不“关心”是个伪命题,它表现的只是代码中不再去直接分配和释放内存,只有对虚拟机如何管理内存这个Java核心概念有个清晰的理解,才能在遇到问题的时候不至于手忙脚乱。

运行时数据区域

根据Java虚拟机规范(Java SE 7),Java在程序运行的过程中会将内存分为几个区域,这些区域都有各自的用途,创建和销毁的时间也不一样。其中一些会随着虚拟机的启动而创建,随着虚拟机的退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的区域会随着线程开始和结束而创建和销毁。

图1所示Java虚拟机运行时的数据区。

这里写图片描述

图1

程序计数器

程序计数器是一块较小的内存空间。Java虚拟机可以支持多条线程同时执行,每一条Java线程都有自己的程序计数器。在任意时刻,一条Java虚拟机线程只会执行一个方法的代码。字节码解释器就是根据这个程序计数器来选择下一条需要执行的字节码指令。

Java的多线程是通过时间片轮转实现的,线程切换后能够根据线程私有的程序计数器恢复到正确的位置。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。

Java虚拟机栈

每一条Java线程都有自己私有的Java虚拟机栈,这个栈与线程共存亡,用于存储栈桢。每个方法在执行的时候都会创建一个栈桢。栈桢用于存储局部变量表、操作数栈、动态链接、方法出入参等。方法的调用和返回的过程就是栈桢在虚拟机栈的出入栈过程。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、long、double、float)和对象的引用类型。局部变量表所需要的空间在编译期确定,运行期间不会影响局部变量表的大小。

Java虚拟机规范中规定了两种可能在该区域发生的异常:

  1. StackOverflowError:如果线程请求的栈深度大于虚拟机所允许的深度,抛出该异常。例如方法递归层次太多可能会抛出该异常。
  2. OutOfMemoryError:如果虚拟机栈可以动态扩展(当前大部分的虚拟机栈都支持动态扩展),当申请不到足够的内存时,抛出该异常。

本地方法栈

Java虚拟机可能会使用到”传统”的栈来使用native方法(native方法指的是非Java语言的方法),这个栈就是本地方法栈。和Java栈一样,本地方法栈也有可能抛出StackOverflowError和OutofMemoryError异常。

Java堆

在Java虚拟机中,堆(Heap)是可供线程共享的内存区域,提供所有类实例和数组对象分配内存的区域。堆也是虚拟机所管理的最大内存区域。Java堆在虚拟机启动时创建。

Java堆是垃圾收集器(GC)管理的主要区域。GC是Java一个重要的主题,关于这块,以后还会作详细介绍。这里简单作下介绍。

从内存回收的角度来看,Java堆还可以细分为:新生代和老年代。其中新生代还可以分为Eden空间、From Survivor空间、To Survivor空间。

从内存分配的角度来看,线程共享的Java堆还可以划分为多个线程私有的”线程本地分配缓冲区”(Thread Local Allocation Buffer,TLAB)。

根据Java虚拟机规范,Java堆内存物理上可以不连续,只要逻辑上连续即可。Java堆大小可以实现成固定大小的,也可以实现可扩展的。目前的主流虚拟机都支持扩展,例如通过参数-Xms和-Xmx控制。

当堆上无法分配足够多的内存时,抛出OutofMemoryError异常。

方法区

方法区也是各个线程共享的内存区域,其逻辑上是Java堆的一部分,方法区也称为None Heap(非堆),目的是与堆区分开来。

方法区与传统语言中的编译代码存储区或者操作系统正文段的作用非常类似,它存储了每一个类的结构信息,例如运行时时常量池、字段和方法数据、构造函数和普通方法的字节码内容。

方法区在虚拟机启动时创建。Java虚拟机规范规定了方法区的内存不需要物理上连续,逻辑上连续即可。可以实现为固定或者可扩展的。甚至在方法区可以不实现GC。方法区的内存回收主要是类的卸载和常量池的回收,但是这些进行GC的条件也是相当严格的。当方法区实现为可扩展时,若分配内存无法提供足够的内存,将抛出OutofMemoryError异常。

运行时常量池

运行时常量池是每一个类或接口的常量池的运行时表现。包括了不同的常量:

  1. 编译期可知的数值字面量
  2. 运行期解析后才可获得的方法或字段引用

运行时常量池是方法区的一部分,在类和接口被加载到虚拟机后,相应的运行时常量池将会被创建。

Java虚拟机规范在该区域定义了OutofMemoryError异常,当常量池无法申请到内存时,将出抛出该异常。

直接内存

顾名思义,直接内存是操作系统直接管理的内存,这块内存不是由Java虚拟机管理的,它不属于Java运行时数据区域。Java 1.4新加入了NIO类,它可以使用Native直接分配堆外内存,显然这块内存不会有java的GC动作,在某些情况下可能效率较高。

虽然不在Java虚拟机的管理下,但是内存终归有限,一旦无法分配内存还是会抛出OutofMemoryError异常。

HotSpot虚拟机对象

理解了Java的各个运行时数据区域,接下来学习对象在内存中是怎么创建、布局和定位的。接下来介绍的内容基于HotSpot虚拟机。

对象的创建

代码中创建一个对象仅仅是new关键字而已,其实new关键字后面做了一系列的动作。虚拟机遇到一个new关键字时,执行以下一系列动作:

  1. 首先去检查这个命令对应的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那么必须进行类加载动作。
  2. 类加载检查通过后,将为对象分配内存。对象所需要的内存将在类加载完成过后就可确定,为对象分配内存的任务等同于在Java堆中划分一块内存出来。Java中分配内存方式有两种,包括:
    • 指针碰撞:假设Java堆是规整的,堆的一边是已使用过的内存,另一边是空闲的内存,有一个指针指向这两个区域的相连位置,那么分配内存仅仅是将该指针往空闲内存一边移动对象的大小即可。这种分配内存的方式称为”指针碰撞”。
    • 空闲列表:如果Java堆不是规整的,已用内存和空闲内存相互交错,那就没有办法简单移动一个指针来分配内存,此时虚拟机必须维护一个列表,记录哪些内存可用。分配内存的时候在该列表查找一块足够大的空间划分给该对象,并更新列表上的记录。这种分配内存的方式称为”空闲列表”。
    • TLAB:本地线程分配缓冲(Thread Local Allocation Buffer),每个线程维护一个TLAB,哪个线程需要分配内存就在哪个线程的TLAB上分配,当线程的TLAB用完并分配新的TLAB时,需要同步锁定。(并发环境下,分配内存不是线程安全的,需要同步锁定。TLAB上分配内存是线程安全的,因为它是线程私有的)。
  3. 内存分配结束后,虚拟机需要将分配的内存空间都初始化为0值(不包括对象头)。如果是TLAB,这一步可以提前到分配TLAB时进行。该步操作保证了程序能够访问到对象属性对应类型的0值。
  4. 0值设置完成后,虚拟机需要为对象设置对象头信息,对象头维护了这个对象是哪个类的实例、如何才能找到类的元数据、对象的哈希码、对象的GC分代年龄等重要信息。
  5. 执行完上面4步后,从虚拟机的角度来看,对象已经创建完成,但是从程序员的角度来说,对象还未创建完成,所有的字段还只是0值,方法还未执行。一般new指令执行完成之后将会执行方法,按照程序员的意愿初始化对象。到此为止对象才算是创建完成。

补充:编译生成class文件时,自动产生两个方法:类的初始化方法和实例的初始化方法。

  • :类初始化,在Java虚拟机第一次加载类时调用,包括类变量的赋值操作和静态语句块的执行。
  • :对象初始化,在实例对象创建出来的时候调用,也就是new之后调用。

对象的布局

在HotSpot虚拟机中,对象在内存中的存储的布局分为3个区域:对象头、实例数据和对齐填充

  1. 对象头(Header)

    • 运行时数据:包括对象的哈希码、GC分代年龄、锁状态标志、线程持的锁、偏向线程ID、偏向时间戳等。这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称为“Mark Word”,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间存储尽量多的信息。Mark Word存储内容见下表。

    • 类型指针:指向类元数据的指针。并不是所有的虚拟机实现都必须在对象头上保存该数据。另外如果对象是一个数组,那么对象头中还必须有一块用于记录数组长度的数据。

    存储内容 标志位 状态 对象哈希码、对象分代年龄 01 未锁定 指向锁记录的指针 00 轻量级锁定 指向重量级锁的指针 10 膨胀(重量级锁定) 空,不需要记录信息 11 GC标记 偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向
  2. 实例数据(Instance Data)

    实例数据记录了程序代码中定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的,都需要记录下来。这部分的存储顺序受到虚拟机分配策略和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointer ),从分配策略可以看出,相同宽度的字段被分配在一起。满足这个条件的前提下,父类中的字段会出现在子类前面。

  3. 对齐填充(Padding)

    这部分数据仅仅是占位符的作用,可能存在,也可能不存在。由于HotSpot虚拟机要求对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍,已知对象头大小肯定是8字节的整数倍,当实例数据部分的大小不是8字节的整数倍时,就需要对齐填充来保证对象大小是8字节的整数倍。

对象的定位

HotSpot虚拟机引用对象是通过栈上的reference数据来操作的。目前reference访问堆中的对象有两种方式:句柄和直接指针。

  1. 句柄

    如果采用句柄方式访问,堆中将会划分出一块内存专门存放对象实例数据和对象类型数据的引用,这块内存称为句柄池。如图2所示。

    这里写图片描述

图2

  1. 直接指针

    直接指针方式Java栈上直接存储的是对象的地址。如图3所示。

    这里写图片描述

图3

这两种访问对象的方式各有利弊,句柄方式存储的是稳定的句柄地址,对象移动时(因为GC,所以对象移动很频繁)栈上的引用不需要改变,只会改变句柄中的实例数据指针。

直接指针的方式最大的好处是速度快,只需要一次指针访问,HotSpot虚拟机正是采用这种方式来引用对象的,可以节省不少二次引用指针的开销。

参考:

  1. Java虚拟机规范(Java SE 7)
  2. 深入理解Java虚拟机. 周志明著
原创粉丝点击