深入了解Java虚拟机笔记 Java内存区域与内存溢出异常

Java内存区域与内存溢出异常

概述

C语言在内存区域有着最好的权利,拥有每一个对象的所有权,起着最基础的作用,负责每一个对象从生命开始到终结的维护责任。Java的内存管理由JVM负责,不需要程序猿负责,如果不了解虚拟机怎样使用内存的话,一旦有了内存泄漏和溢出方面的问题,排错纠正就会很困难。

运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。他们有自己的用途,创建和销毁时间也是不同的,有的一直存在,有的则依赖用户的线程和启动。各部分如图所示

各个线程之间互不影响,独立储存,这类内存区域叫做线程私有的内存。

深入了解Java虚拟机笔记 Java内存区域与内存溢出异常

程序计数器

与CPU中的PC相似,功能也基本相似,表示当前线程所执行的字节码的行号,也就是表示执行哪一条语句,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支跳转循环等等都需要它来完成。

Java实现多线程是通过线程的轮流切换来完成的,线程切换回来后,需要能够恢复到争取的执行位置,所以每一个线程才能都需要一个独立的程序计数器。

另外,线程在执行Java方法时,程序计数器的值为虚拟机字节码指令的地址,但在执行Native方法时,程序计数器的值应该为空。

Java虚拟机栈

虚拟机栈也是私有的,他的生命周期和进程相同。每个方法被执行的时候,虚拟机都会创建一个栈帧,用来储存局部变量表、操作数栈、动态链接、方法出口等信息。

虚拟机栈中最重要的是局部变量表部分,他存放了编译期可知的各种Java基本数据类型,对象引用类型和returnAddress类型。这些数据类型在局部变量表中以局部变量槽表示,64位的long类型和double类型数据占两个变量槽,其余数据只占一个,所需内存在编译期间完成分配,运行期间不会改变。一个变量槽的大小由虚拟机自行决定。

虚拟机栈中有两类异常,*Error(线程请求栈深度大于虚拟机所允许的深度)和OutOfMemoryError(栈扩展时无法申请到足够的内存,HotSpot虚拟机栈不能扩展,所以不会引发此类异常)

本地方法栈

与虚拟机方法栈基本相同,区别就是本地方法栈为Native方法服务。有的Java虚拟机如HotSpot直接将两类虚拟机合二为一了。

Java堆

Java堆是java虚拟机管理的最大的内存,在Java虚拟机启动时创建,是被所有线程共享的一段内存区域,用来存放对象实例。几乎所有的对象实例和数组都是在堆上分配,但随着“ 栈上分配、标量替换 ”技术的出现,不再是那么绝对了。

他也被称作GC堆,因为Java堆是垃圾收集器管理的内存区域。

现代垃圾收集器都是基于分代收集理论设计的

Java堆中又可以划分出多个线程私有的分配缓冲区,提升对象分配时的效率。堆的目的是为了更好地分配、回收内存。

Java堆的逻辑内存应该是连续的,但是对于大对象来说,可能要求连续的内存空间。他可以是大小固定的,也可以是可扩展的。但主流的Java虚拟机都是可扩展的。当没有内存来实例分配,也没有空间扩展时,就会抛出OutOfMemoryError异常。

方法区

方法区也是各个线程共享的内存区域,用于存放被虚拟机加载的类型信息、常量、静态变量、即时编译器变异后代码缓存等数据。有的说法将他并为堆的一个逻辑部分。

JDK 8之前,被称为“ 永久代 ”,是因为HotSpot团队将垃圾收集器的分代设计看扩展到方法区了,但其他虚拟机并没有,并且这也不是个好主意。从JDK 6 到JDK 8的时期,将永久代完全替换。

《Java虚拟机规范》对于Java堆约束很宽松,不需要连续的空间、可以选择固定大小或者可扩展、也可以选择不实现垃圾分类。

方法区的垃圾回收主要是针对常量池的回收和类型的卸载。

方法区无法满足内存分配需求时,就会抛出OutOfMemoryError异常。

运行时常量池

他是方法区的一部分,用于存放编译器生成的各种字面量与符号引用,这些内容在类加载后存放到方法区的运行时常量池中。

虚拟机可以按照自己的需求来实现这个内存区域,除了保存class文件中描述的常量以外,还会把符号引用与存储到运行时常量池中。

它相对于class常量池有个重要特征就是具备动态性,即,除了预置入class文件中的常量可以放入其中,运行时=也可以将新的常量方人员池中,例如String中的intern方法。常量池无法申请到内存是就会抛出OutOfMemoryError异常。

直接内存

他并不是虚拟机运行时数据区的一部分,但被平凡的使用,也可能导致OutOfMemoryError的出现。

JDK 1.4新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

他的内存不受Java堆的限制,但会受到物理内存等的限制,服务器配置员如果配置参数时忽略了直接内存,那么就会导致整个内存区域大于物理内存限制,最终导致出现OutOfMemoryError异常

HotSpot虚拟机对象

对象的创建

Java虚拟机遇到第一条指令时,首先将去检查常量池中能否定位到一个类的符号引用,并检查相对应的类是否被加载了。如果没有,就需要执行相应的类加载过程。接下来,就要给从堆中对象分配内存。分配方式有两种,一种是指针碰撞、一种是空闲列表。使用哪种方式由垃圾收集器是否具有空间压缩整理能力决定。

另外还需要考虑并发情况下线程的安全性。一种是对分配内存空间的动作进行同步处理,通过CAS配上失败重试的方式来保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

接下来就要将分配给的内存全部初始化为零值,如果使用TLAB的话,就可以提前至TLAB分配时顺便进行。

然后就要对对象进行必要的设置,例如对象是哪个类的实例、对象哈希吗(哈希吗实际上会延后到真正调用时才会计算)等。

接下来就要进行构造函数的执行,一般来说,new指令后就会接着执行class中的< init >方法,对对象进行初始化。

对象的内存布局

对象的存储布局可分为三个部分:对象头、实例数据、对其填充。

对象头

对象头包括两类数据。

一类是用于存储对象自身的运行时数据,例如哈希码、GC分代年龄等,32位系统长度为32Bit,64位的为64Bit。32位的中,25个bit为哈希码的位置,4个用于分代年级,2 bit用于存储锁标志位,1 bit固定位0,其他状态存储内容如下表

存储内容 标志位 状态
对象的哈希码,对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录的信息 11 CG标记
偏向ID、偏向时间戳、对象分代年龄 01 可偏向

另一类是类型指针,对象指向他的类型元数据的指针。*并不是所有的虚拟机实现都必须在对象数据上保留类型指针。*如果对象视野个Java数组,那么还需要一个地方来记录数据组长度的数据。

实例数据部分

他是对象真正存储的有效信息,即我们在程序代码中所定义的各个类型的字段内容。存储顺序受虚拟机分配策略参数和字段在Java源码中的定义顺序影响(宽度)。HotSpot虚拟机的顺序:longs/doublels、ints、shorts、chars、bytes/booleans、opps。相同宽度分配到一起存储,且父类变量在子类变量之前。子类变量中较窄的变量可以插入父类的空隙中。

对齐填充

这个只是起占位符的作用,就像内存访问是4字节的倍数的对齐访问一样,这里要求是8字节的倍数,需要将不对齐变为对齐内存时就需要用到对齐填充。

对象的访问定位

Java程序查找对象通过栈上的reference数据来操作堆上面的具体对象。对象的访问方式也是由虚拟机实现而定的,主流方访问方式有句柄和直接指针两种。

  • 句柄访问是在堆中划分出类一块内存来作为句柄池,reference中保存的就是稳定句柄的地址,句柄中保存了对象的实例数据和类型数据各自的地址。
  • 直接指针访问的话,reference中直接存储对象地址,如果访问对象本身的话,就不需要简介访问。

句柄访问时没对象在移动时,只改变句柄中的指针就好,reference本身不用修改。直接指针访问的优点就是访问速度快,由于对象访问频率很频繁,所以这是一种不小的成本节省。HotSpot主要是使用直接指针访问。