本文主要讲述虚拟机内存区域,下图是本文结构。
一、概述
当初刚学习java的时候,只知道java内存包括堆和栈,基本数据类型和引用数据类型的引用储存在栈中,对象储存在堆中。其实这是一个十分粗略的划分方式,java的内存结构要复杂的多。下面我会具体介绍一下java内存区域的划分。
二、运行时数据区域
这张图我已经看过1001遍了,我也不知道原出处是哪里,我这张是在纯洁的微笑的虚拟机文章中摘过来的。根据《Java虚拟机规范(Java SE7)》规定,Java虚拟机所管理的内存将包括上述几个运行时区域。
线程私有内存区域
程序计数器(Program Counter Register)
计算机中的程序计数器(Program Counter)是一个处理器的寄存器,用于指示计算机在程序序列中的位置,为了使用程序连续执行下去,cpu必须通过某些手段获取下一条指令取址地址。程序计数器就是起到这个作用。
Java中的程序计数器(Program Counter Register)也很类似,是一块很小的内存区域,可以看成当前线程的行号指示器。字节码解释器在工作时就是通过改变计数器的值来选取下一条需要执行的字节码命令。
java多线程是轮流分配处理器时间的方式实现的,任何一个确定时刻,一个处理器都只会执行一个独立的程序计数器,各条线程之间互不影响,独立储存。
如果线程执行的是java方法,计数器记录的正是执行的虚拟机字节码指令地址;如果是执行的Native方法,这个计数器值为undifned。
1.线程私有
2.此区域是Java虚拟机规范中唯一没有规定OutOfMemoryError情况的区域
Java虚拟机栈(Java Virtual Machine Stack)
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
这里一段代码看以看出方法的入栈出栈过程(这里是个人理解,有问题大家一定要指出!!!)
1 | public class StackTest { |
入栈
main->test01->test02->test03
出栈
test03->test02->test01->main
局部变量表就是我开篇说的堆栈中的栈,存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
1.StackOverflowError:当线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflow异常。
2.OutofMemoryError: 如果虚拟机栈看以动态扩展(大部分Java虚拟机都可以动态扩展,但虚拟机规范允许有固定长度的虚拟机栈),扩展时无法申请到足够内存,就会抛出OutofMemoryError异常。
本地方法栈(Native Method Stack)
与虚拟机栈类似,java虚拟机栈执行的是java方法服务,本地方法栈执行的是本地方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,虚拟机可以自由实现它。HotSpot就直接就把本地方法栈和虚拟机栈合二为一。
线程共享内存区域
Java堆(Java Heap)
Java堆(Java Heap)一般来讲是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。(由于JIT编译器的发展与逃逸分析技术的成熟,所有的对象实例都在堆上分配就不是那么绝对了)
Java堆是垃圾收集器管理的主要区域,又被称做“GC堆”。
从内存回收的角度看:由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代,再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。
从内存分配角度看:线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
- OutOfMemoryError: 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,会抛出OutofMemoryError。
方法区(Method Area)
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区。
Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。
运行时常量池(Runtime Constant Pool)
Class文件中除了有类的版本信息、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中,另外翻译出来的直接引用也会存储在这个区域中。这个区域另外一个特点就是动态性,Java并不要求常量就一定要在编译期间才能产生,运行期间也可以在这个区域放入新的内容,String.intern()方法就是这个特性的应用。
- OutOfMemoryError: 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
- OutOfMemoryError: 当常量池无法再申请到内存时会抛出OutofMemoryError异常。
存在问题
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
根据官方文档,在java7中,将原来在方法区的常量池移动到 Java堆中,在java8中已经去除永久代,使用元空间来实现方法区。其实这也是HotSpot 与 JRockit结合的结果。因为JRockit从来没有一个叫永久代的东西, 但是运行良好, 也不需要开发运维人员设置这么一个永久代的大小。
上面这张图是java8中的虚拟机内存模式,方法区使用本地内存大元空间来实现了。
三、两张图
看完上面的内容应该就比较容易理解这张图了,这是一张java内存模型图。
java内存包括堆、方法区、线程私有内存区。
堆中包括年轻代和老年代。
年轻带包括EdenSpace、FromSpace、ToSpace。
下面这张图是虚拟机参数控制的内存区域,虚拟机性能调优也是使用这些参数来调优。
- -Xms设置堆的最小空间大小。
- -Xmx设置堆的最大空间大小。
- -XX:NewSize设置新生代最小空间大小。
- -XX:MaxNewSize设置新生代最大空间大小。
- -XX:PermSize设置永久代最小空间大小。
- -XX:MaxPermSize设置永久代最大空间大小。
- -Xss设置每个线程的堆栈大小。
四、对象的创建
当java遇到new指令,先检查这个指令参数能否在常量池中定位到一个类的符号引用,并检查这个符号代表的类是否被加载,解析和初始化,如果没有那就要先执行类的加载过程。
对象所需内存大小,类加载完成后就能确定,等同于把一块大小确定的内存从java堆中划分出来
分配方式
1) 指针碰撞(Bump the Pointer):正在使用的内存和空闲的内存各在一边,中间指针作为分界,,分配只需移动指针
2) 空闲列表(Free LIst):内存并不规整,虚拟机需要维护一个表,记录那些内存块可用,分配时找到一块足够的空间划分给对象
选择因素:分配方式根据使用的垃圾回收机制确定
1) Serial、ParNew等带Compact过程的收集器,采用指针碰撞
2) CMS这种Mark-Sweep算法的收集器,采用空闲列表频繁移动指针,线程不安全解决方案
频繁移动指针,线程不安全解决方案
1) 对分配内存空间的动作进行同步处理(CAS搭配失败重式方式保证更新操作的原子性)
2) 将内存分配按线程划分在不同空间中,即本地线程分配缓冲(TLAB),哪个线程需要分配内存就在哪个TLAB上完成,只有TLAB用完分配新TLAB才需要同步锁定
内存分配完成后,虚拟机需要把分配到的内存空间都初始化为0,(不包括Object Header)
1) 使用TLAB,可以在TLAB分配时完成
2) 之后虚拟机会对对象进行设置,将信息存放在Object Header中
以上虚拟机完成对象创建,但从java程序来看,还没有进行
方法,所以执行new后会接下来执行init对象内存布局(以HotSpot为例),对象在内存中的储存布局可以分为3块区域。 对象头(Header)(两部分)
1) Mark Word:储存自身的运行时数据:HashCode,GC分代年龄,锁状态,线程持有的锁,偏向线程ID,偏向时间戳,在32/64位虚拟机中分别是32/64
2) 类型指针:对象指向他的类元数据的指针(有些虚拟机没有类型指针)
3) 如果是数组对象头需要储存数组长度
实例数据(Instance Date)
1) 对象储存的有效信息,各种字段的内容
2) 储存顺序受虚拟机分配策略参数(FieldsAllocatoinStyle)和字段的定义顺序影响
对齐填充(Padding)
1) 非必须存在,起占位符的作用,当实例数据部分没有对齐需要对齐填充来补全
以上完成了一个对象的基本初始化过程。
五、对象的访问定位
需要根据栈上的reference类型来操作堆中的具体对象,具体取决虚拟机实现,目前主流访问方式:
- 句柄
java堆中分出句柄池,reference储存句柄地址,句柄中储存对象示例数据好对象类型数据的指针
优点:refrence储存的是稳定的句柄地址,垃圾回收中对象移动只会改变句柄地址,不需要修改reference中地址
- 直接指针(HotSpot)
reference直接指向对象实例数据地址,堆中的对象考虑放置对象类型数据的指针
优点:减少一次寻址操作,访问速度快,由于对象访问在java中非常频繁,这种方式可以一定程度上减少执行成本。
六、总结
本文就java虚拟机的内存区域进行总结,主要是程序计数器、虚拟机栈、本地方法栈、java堆、方法区几个方面的内容,并简单提及对象的创建与对象的定位,大多内容来自深入了解java虚拟机。另外,加入了java8使用元空间来实现方法区的部分内容。
参考资料: