第2章 Java内存区域与内存溢出异常
2.1
运行时数据区:Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为以下几个运行时数据区域。
- 程序计数器:一块较小的内存空间,可以看成是当前程序所执行的字节码的行号指示器(线程执行的是java方法,记录的就是正在执行的虚拟机字节码指令的地址,如果执行的是Native方法,指为空),由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
- Java虚拟机栈:线程私有,生命周期与线程相同,描述的是Java方法执行的内存模型。每个方法在执行时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。调用一个方法对应着栈帧入栈,方法结束对应着栈帧出栈。局部变量表存放的是编译期可知的基本数据类型(8种基本类型)和对象引用和returnAddress类型(指向一个字节码指令的地址)。局部变量表中的最小单位是Slot。其中64位长度的long和double类型的数据会占2Slot,其余类型只用1Slot。局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是确定的,运行期间不会改变局部变量表的大小。
- 本地方法栈:和虚拟机栈相似,区别就是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用到的Native方法服务。
- Java堆:被所有线程共享的一块区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。从内存回收角度看Java堆还可以细分为:新生代,老年代,Eden空间,From Survivor空间,To Survivor空间等。从内存分配角度看线程共享的Java堆可能划分出多个线程私有的分配缓冲区(TLAB)。进一步划分的目的是为了更好的回收内存或者更快的分配内存。
- 方法区(非堆):和Java堆一样是被所有线程共享的区域,它用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。
- 运行时常量池:方法区的一部分。Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息就是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行是常量池中。
2.2.1
对象的创建
在语言层面,创建对象通常仅仅是一个new关键字,当虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载,解析和初始化过。如果没有则需要先执行相应的类加载过程。如果类加载检查通过,虚拟机将为新生对象分配内存(内存大小在类加载完成后就可以完全确定)。内存分配完成后,虚拟机需要将分配到的内存空间都初始化为0值,接下来虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希吗,对象的GC分代年龄等信息,这些信息存放在对象的对象头中。以上工作都完成之后,从虚拟机的角度看,对象已经创建完成,但从java的角度来看,对象的创建才刚刚开始,还需要执行方法,给对象中的全局变量赋值,这样一个对象才算完全创建出来。
为对象分配内存的方式分为指针碰撞和空闲列表。
- 指针碰撞:假定Java堆中内存是绝对规整的,所有用过的内存都放一边,空闲的放另一边,中间放着一个指针作为分界点的指示器,分配内存就是仅仅把指针向空闲空间那边移动一段与对象大小相等的距离。
- 空闲列表:假定Java堆中内存不是规整的,使用过的和没用过的相互交错,虚拟机维护一个列表,记录那些内存是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象,并更新列表。
选择哪种方式由Java堆是否规整决定,Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
如何保证在并发情况下更新指针或列表以至于成功创建对象?
两种方案:
- 对分配内存空间的动作进行同步,实际上虚拟机采用CAS配上失败重试的方法保证原子性、
- 把分配内存的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存(即TLAB),哪个线程需要分配内存就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
2.2.2
对象的内存布局
对象在内存中存储的布局可以分为3块区域,对象头,实例数据和对齐填充。
对象头:对象头信息是与对象自身定义的数据无关的额外存储成本,包括两部分:
- 第一部分用于存储对象自身的运行时数据,如哈希吗,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。官方称为Mark Word。
- 另一部分是类型指针,即类元数据指针,通过这个指针来确定这个对象是哪个类的实例。
如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。
实例数据:对象真正存储的有效信息。
对其填充:对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,也就是说对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
2.2.3
对象的访问定位
- 句柄访问:在Java堆中将划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。优势就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
- 直接指针访问:对象头中存储访问类型数据的相关信息,reference中存储的直接就是对象的地址。优势就是速度更快,它节省了一次指针定位的时间开销。HotSpot使用的是这种。