- 1、内存区域
- 2、JVM当中常用的内存区域
- 3、对象的内存格局
- 4、对象的访问定位
- 5、对象的创建过程
1、内存区域
对于一个程序来说,数据和指令就是该程序的全部。而这里所指的指令也就是开发人员所编写的代码,而数据便是一个程序的输入与输出。在一个大型的系统运行起来以后,便会有成千上万的数据来来回回的穿梭在整个系统当中,而这些数据来回穿梭的时候,会存储在什么地方呢? 又是以何种形式进行存储。这便是本章节的重点。
数据在程序运行起来以后所存储的地方便是内存当中,而不同的数据类型存储在不同的内存区域当中。在我们开始学习编程的第一天便知道,C++这种语言是开发人员自己去管理自己的内存区域,而Java语言便是把内存的管理完全托管给了GC(Java当中的垃圾回收机制)。这样我们就不用去管内存当中的一些事情了,但是随着我们开发经验的不断增加,发现其实完全托管对于Java开发也不完全是好事,比如OOM,当内存溢出的时候,我们排查溢出点的难度将大大增加,如何提高自己排查关于内存方法的Bug呢。那便是研究JVM当中内存到底是如何分配的,如何由GC进行托管的。
2、JVM内存区域
JVM当中把内存主要分为了7部分,想弄明白GC如何托管JVM的内存,那这7个内存区必须熟悉。下面了解一下这7个内存区域到底都是干什么的。
1、程序计数器
这是一块很小的内存区域,同时这块内存可以被JVM快速的进行访问,里面存储这一个计数器,这个计数器用来表示当前这个程序执行到什么地方(.class文件的基础之上),如果理解起来比较费劲的话,可以理解为我们IDE当中deBug模式下的执行光标。
这个区域是所有区域当中最好理解的,就是告诉JVM,我们这个程序现在执行到了第几行,下面应该执行第几行。需要注意的便是在并发编程的时候,每一条线程都存在单独属于自己的程序计数器,因为如果微观的理解JVM并发编程的话,那么在一个确定的时刻,一个处理器都只会执行一条线程当中指令,这个时候线程调度器如何切换到了其他的线程,当前的计数器就应该保存在线程单独的内存当中好方便下次复位的时候继续执行。所以各条线程的程序计数器是相互独立的,我们称这类内存区域为“线程私有”的内存。有点类似于线程当中ThreadLocl
这一块内存是由JVM去统一管理,不管是插入还是删除,都和开发人员没有一点关系,所以这个地方的内存不会存在溢出的可能性。
2、JVM栈
这一块内存区域当中存放的是方法,每个方法执行的时候,会创建一个栈帧,一个栈帧里面存放着执行方法所需要的数据,比如局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用直至执行完成对应着一个栈帧在JVM栈中入栈到出栈的全过程。其实好多Java开发人员对JVM栈的描述不完全,并不是存储这单一的对象引用,其实还有其他的信息,而我们理解的对象的引用是存放在局部变量表当中的。
局部变量表:主要存放着两个比较重要的内容分别是
- 存放编译期可知的基本数据类型和对象的引用,对象的引用可能是对象实体起始地址的引用指针,也可能是一个代表对象的句柄
- 存放着returnAddress(指向了一条字节码指令的地址)
注意:存放基本数据类型区域当中,long和double会占2个局部变量空间,其余的只占一个。局部变量表所需的空间在编译期间完成分配。在运行期间不会改变当前局部变量表的大小
JVM栈是线程私有的,生命周期和线程的生命周期一样。该区域如果线程请求的栈深度大于JVM所允许的深度,将抛出StackOverflowError的异常。也就是我们所说的无限递归调用,如果JVM栈是允许动态扩展的,如果申请不到足够的内存的时候,抛出OutOfMemoryError。
3、Native 栈
这一块区域也别称为本地方法栈,该区域和上述的JVM栈非常相似,具体的区别便是JVM栈是为.class文件服务,而Native栈便是为Native方法服务,除了这一点没有什么不同,甚至有的JVM虚拟机在实现的时候,将JVM栈和Native栈进行了合并。
4、JVM 堆
这个块内存是JVM所管理的内存最大的一个区域,这个区域主要是用来存放对象实例,几乎所有的对象实例于数组都在该区域上面,但是随着科技的进步,不同的虚拟机有着不同的实现,导致了所有对象都分配在堆上面渐渐不是那么绝对了,该内存区域是被所有的线程所共享的,在JVM创建的时候创建。该区域上面可以处于物理上面不连续,但是只要逻辑上面是连续的即可。
JVM堆是GC重点的照顾对象,也被国人称为垃圾堆,JVM堆当中也会有很细小的划分,通过不同的划分条件,可以划分为不同的区域。如下:
- 内存回收角度
- 分代收集算法:新生代、老年代
- 细致划分:Eden空间、From Survivor空间、To Survivor空间
- 内存分配角度
- 多个线程共享的区域当中划分出多个线程私有的分配缓存区(Thread Locl Allocation Buffer,TLAB )
当该区域的实在没有能力完成实例分配的时候,并且JVM堆再也没有办法进行扩展的时候,抛出OutOfMemoryError异常。
5、方法区
这个区域按照JVM的规范来说应该是在JVM堆当中的,JVM堆当中存储的是对象实例,而这一块区域当中存储的是类的信息、常量、静态变量、即编译器编译以后的代码等数据,根据功能来说比较特殊,所以它有着一个别名叫做Non-Heap(非堆),所以就从堆当中独立出来的,但是基本属性还是和堆保持一致,比如这个区域是被所有线程所共享的。
这个区域GC的痕迹是比较少见的,但这不代表这进入这个区域的数据就会一直存在。这个区域的回收主要就是针对常量池和堆类型的卸载两个方面。当方法区当中无法满足内存分配需求的时候,将抛出OOM异常。
6、常量池
这个区域是上述方法区当中的一个子区域,正如它的名字一样,这个区域当中存放着各种字面量和符号的引用,当一个类被加载后,这个类当中所描述的常量便会存入方法区的运行时常量池当中。
JVM规范当中对于一个class文件有着严格的规定,只有满足了JVM规范,JVM才会装载、执行。
一般常量池当中除了保存class文件中描述的符号引用,同时还会把翻译出来的直接引用也存储在这个区域当中。这个区域还有一个比较特殊的存在便是这个区域时在运行的时候进行管理的,也就是当动态的把一个class文件加载到了JVM上面,那么就会把class当中的常量动态的加载到方法区的常量池当中。这个区域是在方法区当中的,也就受限于方法区,当常量池无法再次申请到内存的时候就会抛出OOM异常。
7、直接内存
这个区域的内存其实不是JVM运行时数据区域的一部分,也不是Java虚拟机规范定义的内存区域。但是这部分内存也会被我们开发者频繁的进行使用,如果我们使用的方法有误,同样也会抛出OOM异常,这也就是我们需要了解它的原因之一吧。
我们知道在JDK1.4之后,Java为了提升IO的效率,引入了新的IO那便是NIO,NIO当中有一个概念便是基于Channel于Buffer的I/O方式,其实说白了就是它使用了Native函数库直接分配堆外内存,然后通过一个存储在Java堆上面的DirectByteBuffer对象所持有并且可以进行操作,这样便能显著的提升IO的效率,因为能避免Java堆和Native堆中来回复制数据
不属于JVM当中的内存肯定就不会收到JVM的限制,但是既然是内存,肯定还是会受到本机内存大小的限制,如果超出限制便会OOM
3、对象的内存格局
一个对象的实例在JVM堆当中时以何种形式进行保存的?其实一个对象的实例在JVM堆当中是以三个部分进行保存的,下面我们来了解一下这三个部分
1、对象头
对象头有点像一个进程在操作系统里面的PCB一样,里面存储该对象的控制属性(运行时数据),例如:哈希码、GC分代、锁状态标示、线程持有的锁、偏向线程ID、偏向时间戳等等,对象头的大小在JVM规范当中是有限制的,在32位JVM虚拟机上面时32位Bitmap结构所能记录的限度,64位虚拟机便是64位Bitmap所能记录的限度,官方称之为“Mark Work”。Mark Work被设计为一个非固定的数据结构以便在极小的空间内存存储尽量多的信息,会根据对象的状态复用自己的存储空间。
对象头的另外一个部分就是类型指针,即对象指向它的类元数据的指针,也就是在方法区的类元信息,JVM通过这个指针来确定这个对象是哪个类的实例。如果这个对象时数组,那么对象头当中还必须有一块用于记录数组长度的数据,因为JVM可以通过普通Java对象的元数据信息确认该Java对象的大小,但是从数组的元数据类型当中无法确认数组的大小。
2、实例数据(Instance Data)
这部分内容就是我们定义的各种字段内容,也就是真正存储着的有效信息,包括从父类继承下来的,存储的顺序主要受两个方面的影响
- 在Java源码当中的定义顺序
- JVM的参数分配策略,分配策略主要是:longs/double、int、shorts/chars、bytes/boolean、oops(Ordinary Object Pointers)相同宽度的字段被分配到一起。
在JVM分配策略满足的前提下,在父类中定义的变量会出现在子类之前。
3、对齐填充
这部分的数据其实是没有任何意义的,仅仅起到了占位符的作用,由于JVM自动内存管理系统要求对象起始地址必须要是8个字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍,对象头的数据正好就是8字节的整数倍,但是当对象实例数据不足以是8字节的整数倍的时候,通过这部分数据填充补全。
4、对象的访问定位
Java中,我们创建对象,就是为了使用这个对象,我们通过JVM栈中的局部变量表当中的对象的reference来操作在JVM堆上面的实例对象,JVM规范当中只规范了在局部变量表当中的reference应该指向JVM堆当中的对象实例,具体的如何指向没有说,这样的话,具体实现便由JVM的实现来规定,当前市面上面比较火的JVM虚拟机,主要通过以下两种方式进行实现
1、使用句柄来定位到对象
如果使用句柄来进行访问的话,Java堆当中将会划分出来一块内存作为句柄池,reference中存储这对象实例的地址说白了就是句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址,具体关系如下所示
2、使用直接指针访问,在reference当中存储的直接就是对象的地址。如下所示:
总结:这两种对象访问方式又有优缺点,
- 使用句柄的方式最大的好处便是reference中存储的信息是稳定的句柄地址,在对象实例数据被移动的时候(GC回收对象的时候必然会做到),只会改变句柄池当中实例数据的指针,而reference本身便不用修改
- 使用直接指针访问的好处在于速度是最快的,它节省了一次在句柄池当中定位的时间开销。
具体使用哪一种方式进行使用,这遍取决于市面上面的JVM虚拟机,因为在JVM规范当中并没有明确说明应该如和去做。
5、对象的创建过程
当虚拟机遇到我们源码当中的new关键字的时候,是如何进行工作,是如何进行内存的,这个过程到底是一个如何的过程,下面主要研究一下这一方面的知识
1、首先会去检查这个new这个指令的参数是否能够在常量池当中定位到一个类的符号引用,检查这个符号引用代表的类是否已被加载、解析和初始化过。
2、如果没有则先执行相应类加载过程。
3、在类加载检查通过了以后,JVM为新生的对象分为内存,(就是在JVM堆当中把一块确定的内存划分出来)对象所需内存的大小在类加载完成以后便可以完全确定的。
- 3.1、假设堆当中的内存是绝对规整的,所有使用过的内存在一边,没有使用过的内存在另外一边,中间放着一个指针来作为绝对分界点的指示器,那所分配的内存就是仅仅把那个指针向空闲区域移动一段和对象大小相等的距离,这种分配方式叫做 Bump the Pointer 指针碰撞
- 3.2、假设堆当中的内存不是绝对规整的,JVM就必须维护一个列表,用来记录那块内存可以使用,那块内存不可以使用,在分配的时候找到一块足够大的空间划分给对象实例,并且更新表,这种方式被称为Free List 空闲列表
具体使用哪一种方式进行内存分配,主要取决于当前堆当中的内存是否规整,而是否规整取决于GC是否带有压缩整理功能,如果有压缩整理功能的GC便使用 Bump the pointer 没有则使用 Free List
关于内存分配来说,会出现并发分配的情况,即把同一块内存分配给了A线程当中的某一个对象,和B线程当中的某一个对象。解决办法一般来说,有以下两种方式
- 给JVM分配内存的动作进行同步处理,也就是加锁操作
- 每个线程在Java堆当中预先分配一小块内存,称为本地线程分配缓存(Thread Loacl Allocation Buffer TLAB)。那个线程要分配内存,就在那个线程的TLAB上面进行内存的分配,只有TLAB用完并分配新的TLAB的时候,才需要同步锁定,虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数进行控制
4、内存分配完成以后,虚拟机需要将分配到的内存空间都初始化为0(不包括对象头),如果使用TLAB,这一个工作可以提前至TLAB进行分配的时候进行,这一操作便可以让Java代码在不赋初值的时候就可以直接使用
5、JVM对对象进行必要的初始化操作,就是初始化对象的对象头当中存储的信息
6、这个时候,当前对象便算是创建完成了。