JVM学习二:Java内存区域以及对象揭秘

一、Java 内存区域

Java虚拟机会在Java程序执行的过程中将它所管理的内存划分为若干个不同的数据区域。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区,如下图所示:

Java虚拟机运行时数据区

1. 程序计数器(Programming Counter Register)

程序计数器是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型中,字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,程序计数器是程序控制流的指示器,分治、循环、跳转、异常处理、线程恢复等都需要依赖程序计数器

由于Java虚拟机的所线程是通过线程轮流切换、分配执行器执行时间的方式实现,为了线程切换后能够恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互相不影响。因此程序计数器是"线程私有的内存"

程序计数器是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

2. Java 虚拟机栈(Java Virtual Machine Stack)

Java 虚拟机栈也是线程私有的。每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧用于存放局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的时候,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。Java中与对象内存分配关系最密切的区域是"堆"和"栈"两块,而"栈"通常指的就是Java 虚拟机栈。

局部变量表存放着编译期可知的各种Java虚拟机基本数据类型、对象引用和returnAddress。这些数据类型在局部变量表中的存储空间以局部变量槽来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存在编译期间完成分配,在方法运行期间不会改变局部变量表的大小。这里的大小指的是变量槽的数量,而虚拟机选择使用多大内存空间(32比特 or 64比特,还是更多)来实现一个变量槽,则有虚拟机自行决定。

根据《Java虚拟机规范》,如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

3. 本地方法栈(Native Method Stack)

本地方法栈与Java 虚拟机栈作用相似,区别在于虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法(Native)服务

《Java虚拟机规范》并没有强制规定本地方法栈中方法使用的语言、使用方式与数据结构,虚拟机可以根据需要自由实现本地方法栈,甚至有些Java虚拟机(如HotSpot虚拟机)直接将本地方法栈和虚拟机栈合二为一。

和虚拟机栈一样,本地方法栈也会因为栈深度溢出或者栈扩展失败分别抛出StackOverFlowError异常和OutOfMemoryError异常。

4. Java堆(Java Heap)

Java堆是虚拟机所管理的内存中内存最大的一块,是被所有线程共享的一块内存区域

《Java虚拟机规范》中描述"所有对象实例以及数组都应当在堆上分配"。但是,随着Java的发展,已经有迹象表明日后会出现值类型的支持。即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些变化发生。因此,所有对象实例都在堆上分配逐渐不再那么绝对。

Java堆是垃圾回收器进行垃圾回收的重点区域,因此也被称为"GC堆"。而由于很多垃圾回收器是基于分代收集理论设计的,因此常常将Java堆分为"新生代"、“老年代”、“Eden区”"、“From Survivor空间”、"To Survivor空间"等。但是这些区域划分并非是某个虚拟机具体实现的固有内存分配,更不是《Java虚拟机规范》中对Java堆的进一步细致划分。

虽然Java堆是被所有线程共享的一块内存区域,但是还是可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB),以提升对象分配的效率。

Java堆可以处于物理上不连续的内存空间中,但是在逻辑上它应该被视为连续的。对于大对象(典型的如数组对象),多数虚拟机基于实现简单、存储高效的考虑,可能会要求连续的内存空间。

Java堆既可以被实现为固定大小的,也可以是可扩展的。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

5. 方法区(Method Area)

方法区是各个线程共享的内存区域,用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据

很多人常常将方法区称为"永久代",或者将两者混为一谈。事实上,这两者并非等价的,仅仅是因为当时的HotSpot虚拟机设计团队选择将收集器的分代设计扩展到方法区,或者说用永久代实现方法区而已,这样的好处是HotSpot的垃圾回收器可以像管理Java堆一样管理方法区,不需要专门为方法区编写内存管理的代码

对于其他虚拟机,如JRockit、J9等虚拟机,并不存在"永久代"的概念,原则上,如何实现方法区由虚拟机自己考虑,并不受《Java虚拟机规范》约束。而HotSpot当时采用永久代的方法造成了内存泄露的问题(永久代有-XX:MaxPermSize的上限,即使不设置也会有默认大小,而JRockit、J9中只要没有触碰到进程可用内存的上限,就不会出问题)。随着Oracle收购JRockit之后,为了将JRockit的优良特性集成到HotSpot中,最终在JDK 8中放弃了永久代的概念,采用了与JRockit、J9一样在本地内存中实现的元空间来代替

《Java虚拟机规范》对于方法区的约束十分宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至可以选择不实现垃圾回收。对方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,并且回收的效果不佳

《Java虚拟机规范》规定,如果方法区无法满足新的内存分配需求时,将会抛出OutOfMemoryError异常。

6. 运行时常量池(Runtime Constant Pool)

运行时常量池是方法区的一部分,Class文件的常量池用于存放编译期生成的各种字面量和符号引用,这些内容将会在类加载后放到运行时常量池

对于运行时常量池,《Java虚拟机规范》并未做任何细节的要求,由虚拟机自行实现这块内存区域。一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池。

运行时常量池相对于Class文件的常量池的另一个特征是具备动态性除了预置于Class文件中常量池的内容,运行时常量池还会将运行期间的新变量放入池中

当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

7. 直接内存(Direct Memory)

直接内存并不是Java虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域

JDK 1.4中加入了NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里的DirectByteBuffer对象作为这块内存的引用进行操作。这样避免了在Java堆和Native堆中来回复制数据。

直接内存的分配不会受到Java堆大小的限制,但是还是会受到本机总内存大小(包括物理内存、SWAP分区或者分页文件)以及处理器寻址空间的限制。在分配内存时,如果忽略掉直接内存的存在,使得各个内存区域的总和大于物理内存的限制,会导致动态扩展时出现OutOfMemoryError异常。

二、HotSpot虚拟机对象探秘

1. 对象的创建

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载、解析和初始化过,如果没有,则执行相应类的类加载过程。

接下来,虚拟机将会为新生对象分配内存。对象所需的内存大小在类加载后便完全确定。内存分配的方式有两种:如果Java堆中的内存时绝对规整的,被使用的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点,此时分配内存只需要将指针向空闲方向移动一段与对象大小相等的距离即可,称为"指针碰撞"。如果Java堆内存并不规整,使用的内存和空闲内存相互交错,虚拟机需要维护一个列表,记录可用的内存块,此时分配内存需要借助该列表,并更新列表记录,称为"空闲列表"具体采用哪种方式分配内存由堆内存是否规整决定,而堆内存是否规整由于所采用的垃圾回收器是否带有空间压缩整理能力决定

对象创建在虚拟机中是非常频繁的行为,即使是指针碰撞的方法,在并发情况下也不是线程安全的。解决这个问题有两种方案:
一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方法保证更新操作的原子性;另一种是将内存分配的动作按照线程划分在不同的空间之中进行,每个线程在Java堆中预先分配一块小的内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),哪个线程要分配内存,只需要在该线程的分配缓冲中分配即可。只有在本地缓存区用完了,才采用同步操作。

内存分配完成后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零。如果使用了TLAB后,这一步可以提前到TLAB分配时顺便完成。

接下来,Java 虚拟机还需要对对象进行必要的设置,比如这个对象是哪个类的实例、对象的哈希码、对象的GC分代年龄等,这些信息会被存放在对象头之中。

以上动作完成后,一个对象就已经产生了。但是从Java程序的角度,对象创建才刚刚开始。在new指令之后会接着执行<init>()方法,按照程序员的意愿进行初始化。

2. 对象的内存布局

对象在堆内存中的存储布局可以分为三个部分:对象头、实例数据和对齐填充。

a. 对象头(Header)
包括两类信息:

第一类是用于存储对象自身的运行时数据,如:哈希码、GC分代年龄、锁标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据在32位和64位的虚拟机中分别为32比特和64比特,官方称为"Mark Word"。考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。

另一类是类型指针,即对象指向它的类型原数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。但并不是所有的虚拟机实现都必须要在对象数据上保留类型指针(如:基于句柄访问对象)。同时,如果对象是一个数组,对象头中还需要有一块记录数组长度的数据。

b. 实例数据(Instance Data)
对象真正存储的有效信息,即在程序代码中所定义的各种类型的字段内容。

这部分的存储顺序会受到虚拟机分配策略(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为:longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),相同宽度的字段总是被分配到一起存放,在这个前提条件下,在父类中定义的变量会出现在子类之前。

c. 对齐填充(Padding)
这部分仅仅起着占位符的作用。HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是任何对象的大小都必须是8字节的整数倍。当对象实例数据部分没有对齐时,就有对齐填充进行补全。

3. 对象的访问定位

在对象创建后,为了后续使用该对象,Java程序会通过栈上的reference数据来操作堆上的具体对象。《Java虚拟机规范》只规定了reference是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,因此对象访问方式也是由虚拟机定义实现的。

当前主流的访问方式有两种:
通过句柄访问对象:Java堆中会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
通过直接指针访问对象:Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址。如果只是访问对象本身的话,就不需要多一次间接访问的开销。

通过句柄访问对象
通过直接指针访问对象

HotSpot虚拟机中,使用的就是通过直接指针访问对象这种方式。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值