《深入理解Java虚拟机》——Java内存区域与内存溢出异常学习总结

时间:2022-12-27 16:32:07

序言:在Android开发方面,可以说所有的Android开发人员都知道程序计数器,堆,栈,方法区,常量池,GC回收等这些东西,作为一名老菜鸟,有一天我突然问自己,自己真的懂这些吗?真的懂吗?于是就有了《深入理解Java虚拟机》的阅读,这个系列会记录总结这本书的知识。


先抛出几个问题,通过该篇文章你可以知道以下问题的答案:

  1. java虚拟机执行程序时内存是如何划分的?

  2. 哪些数据区是线程私有的?

  3. 哪个数据区是不会发生OOM的?

  4. 对象在虚拟机层面是怎么创建的?

  5. 对象分为几个部分?

  6. 对象是如何访问的?


Java虚拟机执行程序时内存是如何划分的?没图没真相,先上图(图2-1来源于书本):

《深入理解Java虚拟机》——Java内存区域与内存溢出异常学习总结

《深入理解Java虚拟机》——Java内存区域与内存溢出异常学习总结

从图中我们可以得到两点信息:

1.程序计数器、虚拟机栈、本地方法栈是线程私有的,换句话说就是随线程而生随线程而死。

2.Java 虚拟机在执行Java程序的过程中把它所管理的内存划分:方法区(Method Area)、堆(Heap)、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、程序计数器(Program Counter Register)。

下面我们一起来了解一下这些数据区


程序计数器


程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号(行号其实就是指正在执行的虚拟机字节码指令的地址)指示器。在虚拟机的概念模型里(仅仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。怎么来理解呢?大家在编辑代码时,大部分人会把行号给显示出来,可以认为程序计数器里的值就是行号,和我们写代码一行一行的写一样,字节码解释器也要一行行读取解释,遇到分支比如switch语句时,会发生跳行读取解释,相信大家都已经理解了程序计数器这个概念了。


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


Java虚拟机栈


与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。


我们口中经常提及的堆和栈中的栈就是指虚拟机栈或者说是虚拟机栈中局部变量表部分。

局部变量表存放了编译期可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和return Address类型(指向了一条字节码指令的地址)。


局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。


在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出*Error异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。


综上所述,我们只需要记住两点:

虚拟机栈中存放的是方法的栈帧

虚拟机栈会抛出两种异常,一种是*一种是oom


本地方法栈


本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的native方法服务。该内存区域也会抛出*Error和OutOfMemoryError异常。


Java堆


这块内存区域是我们最为关注的区域,也是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上连续的即可。该区域在内存不够时会抛出OOM异常。


方法区


方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。该区域在内存不够时会抛出OOM异常。运行时常量池是方法区的一部分。Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池中存放。



到目前为止我们可以很轻松的回答前3个问题

java虚拟机执行程序时内存是如何划分的?

Java 虚拟机在执行Java程序的过程中把它所管理的内存划分:方法区(Method Area)、堆(Heap)、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、程序计数器(Program Counter Register)。

哪些数据区是线程私有的?

程序计数器、虚拟机栈、本地方法栈是线程私有的

哪个数据区是不会发生OOM的?

程序计数器不会发生OOM。




接着看我们所熟悉的对象,有个梗大家都熟悉:

MySelf mySelf=new MySelf("Android程序员");

mySelf.grilFriend=new GrilFriend(".......");


言归正传,我们看一下对象的创建,在Java应用程序中,我们通常都是通过new关键字来创建对象,但是在虚拟机层面的对象创建你了解吗?


虚拟机遇到new指令——>检查new指令参数是否能映射到一个类的符合引用——>检查引用代表的类是否已加载、解析和初始化——>直接分配内存——>对对象进行必要的设置。

这些步骤是虚拟机创建一个对象的步骤。



虚拟机遇到一条new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。内存分配完成后,虚拟机需要将分配到的内存空间初始化为零值(不包括对象头)。接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息。在上面工作都完成之后,从虚拟机的角度来看,一个新的对象已经产生了,但是从Java程序的视角来看,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为零。所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。而事实上在字节码文件中,无论是空类还有有属性与方法的类都会有invokespecial指令。大家可以自己做个实验,写一个空类与一个正常的业务类,查看它们的字节码文件,这里就不贴代码了。



这里有两种内存分配方法:指针碰撞和空闲列表


指针碰撞法:假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为"指针碰撞".


空闲列表法:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。


《深入理解Java虚拟机》——Java内存区域与内存溢出异常学习总结

《深入理解Java虚拟机》——Java内存区域与内存溢出异常学习总结


对象的创建过程已经明白了,那么我们再来看看对象在内存中分为哪几个部分:

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3个区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。


HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为"Mark Word"。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。


实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。


第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。


建立对象肯定是为了使用,那么我们如何访问对象呢?

对象的访问方式取决于虚拟机的实现,目前主流的访问方式有使用句柄和直接指针两种。


句柄方式:如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,如图2-2所示:

《深入理解Java虚拟机》——Java内存区域与内存溢出异常学习总结

《深入理解Java虚拟机》——Java内存区域与内存溢出异常学习总结

直接指针方式:如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址,如图2-3所示:

《深入理解Java虚拟机》——Java内存区域与内存溢出异常学习总结

《深入理解Java虚拟机》——Java内存区域与内存溢出异常学习总结

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改.

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。


该篇总结也到了该结束的时候,如果你已经认真看完,我想开头的几个问题肯定可以回答上来的。


虽然文中很多段落来自书中,但还烦请转载注明出处:http://blog.csdn.net/android_jiangjun/article/details/78052858