转载

Java虚拟机#1——Java内存模型

前言

在昨天我回答了一个关于Java虚拟机的问题,顺带复习了一边Java虚拟机,就打算写一篇关于内存模型的文章巩固记忆。在Java中,内存溢出异常不想C/C++那样频繁,但是一旦出现却难解决的多,需要丰厚的Java虚拟机方面的知识。身为一个Java程序员,是有必要在这方面多做积累的。本文以介绍概念与基本术语为主

运行时数据区域

Java虚拟机在执行Java程序的时候会将他所管理的内存分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间。有的区域随着JVM进程的启动就一直存在,而有的区域依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》,本文将简单介绍几个运行时区域:

  • 程序计数器:当前线程所执行的字节码的行号指示器
  • Java虚拟机栈和本地方法栈:方法执行的线程内存模型,存放了编译期可知的各种Java虚拟机基本数据类型、对象引用和returnAddress类型。
  • Java堆:存放对象实例
  • 方法区:用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据

程序计数器

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

“线程私有”的内存

重点说说这个多线程的情况。通过Java基础的学习我们都知道多线程是通过时间片轮转调度算法分配每个线程在CPU上的执行时间实现的。而每一个处理器(多核处理器来说是一个内核)同一时刻只会执行一条指令,为了让线程重新拿到CPU执行权时能够继续完成为执行完的程序。 每条线程都会有一个计数器 ,这个计数器用于记录线程让出CPU时执行到的地址,方便线程的恢复。而各条线程之间的计数器互不影响,独立存储。我们称这类内存区域为“线程私有”的内存。

如果线程在执行Java方法,这个计数器记录的时正在执行的虚拟机字节码指令的地址;如果正在执行一个本地方法(Native)这个计数器值为空(Undefined)。这个内存区域也是《Java虚拟机规范》唯一一个没有规定任何 OutOfMemoryError 情况的区域。

Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stack) 也是线程私有的, 它的生命周期与线程相同 。Java虚拟机栈描述的是 Java方法执行的线程内存模型 。每个Java方法被执行的时候,Java虚拟机都会同步创建一个栈帧( Stack Frame )用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机从入栈到出栈的操作。我们通常说的“栈”就指的这里的虚拟机栈。

局部变量表

虚拟机栈中,最广为人知的、也是我们接触的最多的就是局部变量表部分。局部变量表 存放了编译期可知的各种Java虚拟机基本数据类型(int,double,float,short)、对象引用(reference,指向对象地址的指针或者是一个代表对象的句柄,这个后面再讲)和returnAddress类型(指向了一条字节码指令的地址) 。这些数据类型以 局部变量槽(Slot) 来表示,其中64位的long和double类型的数据会占用两个变量槽。

局部变量表所需的内存空间实在编译期间完成分配的,在方法运行期间不会改变局部变量表的大小。这里说的大小指的是变量槽的数量,而具体的内存空间(变量槽的大小),由虚拟机自己决定。

异常情况

在《Java虚拟机规范》中,规定了两类异常情况。如果线程请求深度大于虚拟机允许的最大深度,将抛出 StackOverflowError 异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存将会抛出 OutOfMemoryError (之后简称OOM)异常

在HotSpot虚拟机中是不允许栈容量的自动扩展的,所以不会出现由于虚拟机栈无法扩展而抛出OOM异常的问题

本地方法栈

本地方法栈(Native Method Stacks) 与虚拟机栈所发挥的作用是类似的。但是 Java虚拟机栈是为执行Java方法服务的,而本地方法栈是为底层的本地(Native)方法服务的

在HotSpot虚拟机中将本地方法栈与虚拟机栈合二为一。

Java堆

Java堆(Java Heap) 是虚拟机所管理的在内存中最大的一块,Java堆是被所有线程共享的一块内存区域。这块内存区域唯一的目的就是存放对象实例,Java中几乎所有的对象实例都在这里分配内存。

虽然《Java虚拟机规范》中描述是“所有对象实例以及数组都应当在堆上分配”,但随着即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段导致Java对象实例在堆上分配内存不再那么绝对。

Java堆分配内存的特点

Java堆是垃圾收集器管理的内存区域,因此也被称为“GC堆”(Garbage Collected Heap),关于垃圾收集器以后会专门写文章记录,这里只简单提一下。Java堆分配内存有以下几个特点:

  1. 所有线程共享的Java堆可以 划分出多个线程私有的分配缓冲区 (Thread Local Allocation Buffer, TLAB ),用来提升对象分配的效率。不过无论怎么划分都无法改变Java堆的共性:所有区域存储的都只能是对象的实例。
  2. Java堆 可以处于物理上不连续的内存空间中 ,但在逻辑上他应该被视为连续的,这点和我们使用磁盘存储文件是一样的。不过对于大对象(例如数组对象),大多数虚拟机出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。
  3. Java堆既可以被实现成固定大小,也可以是可扩展的 。当前的主流虚拟机都是按照可扩展来实现的(通过参数 -Xmx-Xms 设定)。如果在Java堆中没有内存来完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出 OutOfMemoryError 异常。

方法区

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

在这里我们提一嘴 永久代 的概念,在早期HotSpot的实现中,方法区是和Java堆连在一起的。那时Java堆是基于分代收集理论设计的,方法区也被习惯称为永久代(因为《Java虚拟机规范》对方法区的约束非常宽松。因为垃圾收集在这个区域很少见,可以不选择实现垃圾收集,所以很多常量随着程序编译就一直存在)。但是到了JDK8的时候,HotSpot就完全放弃了永久代的概念。方法区不再与Java堆连续,而是放在了 本地内存(Native Memory) 中实现的 元空间(Meta-space) 中。

放弃永久代的原因有很多。最大的原因就是Oracle希望能将JRockit的优点整合到Hotspot中,但是由于方法区差异过大而出现很多问题。考虑到hotspot未来的发展,Oracle决定放弃永久代的概念。

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

运行时常量池

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

重要特征:

  • 对于运行时常量池,《Java虚拟机规范》并没有任何细节上的要求,不同的虚拟机可以 按照自己的需求实现这个内存区域 。一般来说,除了保存Class文件中描述的符号引用歪,还会把由符号引用翻译过来的直接引用也存储到运行时常量池。
  • 运行时常量池相较于Class文件常量池的另外一个重要的特征就是 具备动态性 。Java语言并不要求常量一定只有在编译器才能产生。也就是说,运行期间产生的常量也能够加入池中。这种特性被开发人员用的比较多的就是String类中的 intern() 方法。

:small_red_triangle::不管如何修改,上述所有内存都是基于本机内存的。不能使各个区域内存总和大于物理内存限制的情况。否则会出现因为动态扩展申请不到足够的空间而发生 OutOfMemoryError 异常的情况。

HotSpot虚拟机对象探秘

对象的创建

在Java语言中创建对象只用一个 new 关键字就够了,但实际上呢?接下来就让我们看看HotSpot虚拟机是如何创建一个对象的。对象的创建经过了下面几个步骤:

  1. 检查类加载 。当Java虚拟机遇到一条字节码new指令时,首先会检查这个指令的参数是否能在常量池定位到一个类的符号引用,并检查符号所代表的类是否已被加载、解析和初始化过。如果没有先进行类加载过程;
  2. 为新生对象分配内存 。对象所需内存在类加载过程中就可以确定下来,随后就从Java堆中划分出一块内存给这个对象。分配的方法有 指针碰撞 (连续的空间分配)和 空闲列表 (零散的分配)两种,取决于
  3. 内存分配完成之后, 虚拟机必须将分配到的内存空间进行初始化 (如对int类型赋值为0,对boolean类型赋值为false)
  4. Java虚拟机对对象进行必要的设置 (对象头)
  5. 执行构造方法

对象的内存布局

在HotSpot虚拟机里,对象在堆内存的存储布局可以分为三个部分: 对象头(Header)实例数据(Instance Data)对齐填充(Padding)

对象头

对象头部分包含两部分信息:

  1. 存储对象自身的运行数据 ,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分在32位和64位虚拟机(未开启压缩指针)中分别为32个比特和64个比特。官方称为 “Mark Word”
  2. 类型指针 ,即对象指向它的类型元数据的指针。

如果对象是个Java数组,对象头中还必须有一块用于记录数组长度的数据。

关于类型指针,并不是所有虚拟机的实现都必须在对象数据上保留内存指针,也就是说查找对象的元数据信息并不一定要经过对象本身。例如通过句柄访问对象的虚拟机就不需要类型指针。HotSpot虚拟机使用直接指针访问对象,因此有这一部分,详细往后看。

实例数据

存放对象真正存储的有效信息

对齐填充

为了方便虚拟机管理,HotSpot虚拟机要求对象的起始地址必须是8字节的整数倍,所以不足8字节整数倍的都用对齐填充来补全

对象的访问

为了后续的使用对象,Java程序会通过栈上的reference数据来找到Java堆中的具体对象。这个reference就是指向对象的一个引用。而访问堆上的具体对象的方式是没有明确要求的,常见的对象的访问方式有两种:

  1. 使用句柄访问:在Java堆中划分出一块内存作为句柄池。当有对象在实例池中创建时,句柄池就会创建一个句柄,包含了两个指针(一个指向Java堆的实例数据,另外一个指向了方法区的对象类型数据)。reference存储的被创建对象的句柄地址。
  2. 直接指针访问:这就是在对象实例数据里面加了一个类型数据的指针,即类型指针。reference直接指向对象的实例数据,再通过类型指针找到方法区里面的对象类型数据。

两种访问方式各有千秋,句柄最大的好处就是修改简单,不需要改变reference数据即可完成对象的移动(垃圾收集时,对象的移动非常普遍)。而直接访问最大的好处就是速度快,节省了一次指针定位的时间。由于对象的访问非常频繁,这是一笔非常可观的数据。

总结

Java内存模型这一块是非常重要的,也是所有知识的基础。这里概念比较多,我只选取记录了最重要的几部分,方便日后针对性复习,一些详细的信息请看周志明老师的《深入理解Java虚拟机》一书。


很遗憾的说,推酷将在这个月底关闭。人生海海,几度秋凉,感谢那些有你的时光。

原文  https://segmentfault.com/a/1190000023350268
正文到此结束
Loading...