在昨天我回答了一个关于Java虚拟机的问题,顺带复习了一边Java虚拟机,就打算写一篇关于内存模型的文章巩固记忆。在Java中,内存溢出异常不想C/C++那样频繁,但是一旦出现却难解决的多,需要丰厚的Java虚拟机方面的知识。身为一个Java程序员,是有必要在这方面多做积累的。本文以介绍概念与基本术语为主
Java虚拟机在执行Java程序的时候会将他所管理的内存分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间。有的区域随着JVM进程的启动就一直存在,而有的区域依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》,本文将简单介绍几个运行时区域:
程序计数器(Program Counter Register)
是一块较小的内存空间,它可以看做是 当前线程所执行的字节码的行号指示器
。在Java虚拟机概念模型里,字节码解释器工作时就是通过改变这个指示器来选取下一条需要执行的字节码指令。他是程序控制流的指示器,分支、循环、异常处理等操作都依赖这个指示器完成。多线程的轮流切换、恢复线程继续执行也依赖这个指示器。
重点说说这个多线程的情况。通过Java基础的学习我们都知道多线程是通过时间片轮转调度算法分配每个线程在CPU上的执行时间实现的。而每一个处理器(多核处理器来说是一个内核)同一时刻只会执行一条指令,为了让线程重新拿到CPU执行权时能够继续完成为执行完的程序。 每条线程都会有一个计数器 ,这个计数器用于记录线程让出CPU时执行到的地址,方便线程的恢复。而各条线程之间的计数器互不影响,独立存储。我们称这类内存区域为“线程私有”的内存。
如果线程在执行Java方法,这个计数器记录的时正在执行的虚拟机字节码指令的地址;如果正在执行一个本地方法(Native)这个计数器值为空(Undefined)。这个内存区域也是《Java虚拟机规范》唯一一个没有规定任何 OutOfMemoryError
情况的区域。
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 Heap)
是虚拟机所管理的在内存中最大的一块,Java堆是被所有线程共享的一块内存区域。这块内存区域唯一的目的就是存放对象实例,Java中几乎所有的对象实例都在这里分配内存。
虽然《Java虚拟机规范》中描述是“所有对象实例以及数组都应当在堆上分配”,但随着即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段导致Java对象实例在堆上分配内存不再那么绝对。
Java堆是垃圾收集器管理的内存区域,因此也被称为“GC堆”(Garbage Collected Heap),关于垃圾收集器以后会专门写文章记录,这里只简单提一下。Java堆分配内存有以下几个特点:
TLAB
),用来提升对象分配的效率。不过无论怎么划分都无法改变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文件除了类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译器产生的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
intern()
方法。
:small_red_triangle::不管如何修改,上述所有内存都是基于本机内存的。不能使各个区域内存总和大于物理内存限制的情况。否则会出现因为动态扩展申请不到足够的空间而发生 OutOfMemoryError
异常的情况。
在Java语言中创建对象只用一个 new
关键字就够了,但实际上呢?接下来就让我们看看HotSpot虚拟机是如何创建一个对象的。对象的创建经过了下面几个步骤:
指针碰撞
(连续的空间分配)和 空闲列表
(零散的分配)两种,取决于
在HotSpot虚拟机里,对象在堆内存的存储布局可以分为三个部分: 对象头(Header)
、 实例数据(Instance Data)
和 对齐填充(Padding)
。
对象头部分包含两部分信息:
“Mark Word”
。 如果对象是个Java数组,对象头中还必须有一块用于记录数组长度的数据。
关于类型指针,并不是所有虚拟机的实现都必须在对象数据上保留内存指针,也就是说查找对象的元数据信息并不一定要经过对象本身。例如通过句柄访问对象的虚拟机就不需要类型指针。HotSpot虚拟机使用直接指针访问对象,因此有这一部分,详细往后看。
存放对象真正存储的有效信息
为了方便虚拟机管理,HotSpot虚拟机要求对象的起始地址必须是8字节的整数倍,所以不足8字节整数倍的都用对齐填充来补全
为了后续的使用对象,Java程序会通过栈上的reference数据来找到Java堆中的具体对象。这个reference就是指向对象的一个引用。而访问堆上的具体对象的方式是没有明确要求的,常见的对象的访问方式有两种:
两种访问方式各有千秋,句柄最大的好处就是修改简单,不需要改变reference数据即可完成对象的移动(垃圾收集时,对象的移动非常普遍)。而直接访问最大的好处就是速度快,节省了一次指针定位的时间。由于对象的访问非常频繁,这是一笔非常可观的数据。
Java内存模型这一块是非常重要的,也是所有知识的基础。这里概念比较多,我只选取记录了最重要的几部分,方便日后针对性复习,一些详细的信息请看周志明老师的《深入理解Java虚拟机》一书。
很遗憾的说,推酷将在这个月底关闭。人生海海,几度秋凉,感谢那些有你的时光。
原文 https://segmentfault.com/a/1190000023350268