JVM笔记(一):Java内存区域

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

1.1 运行时数据区

根据《Java虚拟机规范SE7》的规定,Java虚拟机包括:程序计数器、虚拟机栈、本地方法栈、方法区、堆共5个运行时数据区。如下图:
JVM笔记(一):Java内存区域_第1张图片

1.1.1 程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,可以看做是当前程序所执行字节码的行号指示器。

特点:

  • 线程私有
  • 不会存在内存溢出问题

1.1.2 虚拟机栈(VM Stack)

虚拟机栈描述的的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、返回地址等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

特点:

  • 线程私有
  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常
  • 如果虚拟机栈可以动态扩展,扩展石时无法申请到足够的内存,就会抛出OutOfMemoryError异常

虚拟机栈结构:
JVM笔记(一):Java内存区域_第2张图片

1.1.2.1 局部变量表(Local Variable Table)

局部变量表是一组变量值存储空间,用于存放编译期可知的各种基本数据类型、对象引用、返回地址。局部变量表内部的最小单元为变量槽(Slot),每个slot都应该能存放一个 boolean、 byte、char、short、Reference 等类型的数据,允许 Slot 的长度可以随着处理器、操作系统或虚拟机的实现不同而发生变化。对于64位的数据类型(long,double),虚拟机会为其分配2个连续的Slot空间线程私有数据,无论读写2个连续的slot是否为原子操作,都不存在线程安全问题)。

如果执行的是实例方法,则 slot 0 存储的是方法所属对象的引用,在方法中可以通过关键字 this 来访问。局部变量表中的 Slot 是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

1.1.2.2 操作数栈(Operand Stack)

操作数栈是一个先入后出栈,用来保存在方法运行过程中,根据字节码指令,向栈中压入或提取数据,及入栈(push)、出栈(pop)。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的code属性中,为max_stack的值。

在概念模型中,两个栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但在大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。

1.1.2.3 动态链接(Dynamic Linking)

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如:invokedynamic指令。

在Java源文件被编译成字节码文件时,所有方法的引用作为符号引用(Symbolic Refernce)保存在class文件常量池中,动态链接的作用就是为了将这些方法的符号引用转化为方法的直接引用

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关:

  • 静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
  • 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。

1.1.2.4 返回地址(Return Address)

存放该方法调用者的PC寄存器的值。 一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器确定的,栈帧中一般不会保存这部分信息。

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

1.1.3 本地方法栈(Native Method Stack)

本地方法栈与虚拟机发挥的所有非常相似,区别为虚拟机栈为执行Java方法服务,而本地方法栈为为虚拟机使用到的Native方法服务。HotSpot虚拟机在实现的时候就直接将虚拟栈和和本地方法栈合二为一

特点:

  • 线程私有
  • 同样定义了StackOverFlowError异常和OutOfMemoryError异常

1.1.4 堆(Heap)

Java堆是Java虚拟机用来存放对象实例的空间。堆在逻辑上分为新生代(Young Generation)老年代(Old Generation)永久代(Perm Generation)。其中新生代细分为Eden空间From Survivor空间To Survivor空间。JDK 1.8 后方法区(HotSpot的永久代)被彻底移除,取而代之是元空间(Meta Space),元空间使用的是直接内存。如下图:
JVM笔记(一):Java内存区域_第3张图片

大部分情况,对象都会首先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden区->Survivor区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

特点:

  • 线程共享
  • 如果在堆中没有内存完成实例分配,并且堆也无法在扩展时,会抛出OutOfMemoryError异常

1.1.5 方法区(Method Area)

方法区用来存储已经被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。需要说明的是,HotSpot虚拟机用永久代来实现方法区。

特点:

  • 线程共享
  • 当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError异常

1.1.6 直接内存(Direct Memery)

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4中加入了NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

特点:

  • 内存分配不会受到Java堆大小的限制
  • 也会出现OutOfMemoryError异常

1.2 HotSpot虚拟机对象

1.2.1 对象的创建

1.2.1.1 空间的划分:

当虚拟机遇到一条new指令时,首先会检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用所代表的类是否被加载、解析和初始化过。如果没有,必须先执行相应类的加载过程,然后为对象分配内存,分配算法主要有两种:

  • 指针碰撞(Bump the Pointer):要求堆中内存绝对规整的,所有的用过的内存放在一边,空闲的放在另一边,中间放着一个指针作为分界点的指示器,在分配时只需要将指针向空闲的一边挪动指定大小的空间即可
  • 空闲列表(Free List):使用的内存和空闲的内存相互交错,需要维护一个列表,记录哪些内存块是可用的,在分配的时候找到一块足够大的空间划分给对象实例,并更新表中的记录

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾回收器是否带有压缩整理功能(Compact)决定。一般在使用带有 Compact 过程的垃圾回收器时,系统采用指针碰撞算法;而使用CMS这种基于 Mark-Sweep 算法的垃圾回收器时,通常使用空闲列表。

划分空间时需要考虑线程安全问题,有可能正在给A对象分配内存,指针还没来得及修改,对象B同时使用了原来的指针来分配内存,解决这种问题有2种方案:

  • 同步内存分配动作:采用CAS配合失败重试的方式保证更新操作的原子性
  • 线程本地分配缓冲(Thread Local Allocation Buffer,TLAB):把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一块小内存,可以通过 -XX:+/-UseTLAB 来开启关闭TLAB

1.2.1.2 内存空间初始化

对象内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用了TLAB,这一工作过程也可以提前至TLAB分配时进行。这一操作保证了对象实例的属性在Java代码中可以不赋值就直接使用,程序能访问到这些属性的数据类型所对应的零值。

1.2.1.3 对象初始化

上面的工作完成后,从虚拟机角度来看,一个新的对象已经产生,但是从Java程序角度来看,对象的创建才刚刚开始,此时 方法还没执行,所有的字段都还为零。一般来书,执行 new 指令后会接着执行 方法,把对象进行初始化,这样一个真正可用的对象才算完全产生出来。

1.2.2 对象的内存布局

Java虚拟机规范定义了对象类型的数据在内存中的存储格式,一个对象由:对象头、实例数据、对齐填充3部分组成,如下图:
JVM笔记(一):Java内存区域_第4张图片

1.2.2.1 对象头

对象头的数据包含3个部分:

  • Mark Word:包含对象HashCode、锁标记、对象年龄等数据,在32位操作系统占4字节,64位操作系统中占8字节
  • Class Pointer:指向对象所属的Class在方法区的内存指针,通常在32位操作系统中占4字节,64位系统中占8字节,64位JVM在1.6版本后默认开启了指针压缩,占用4字节
  • Length:如果对象是数组,还需要一个保存数组长度的空间,占用4字节

其中 Mark world 是对象头中非常关键的一部分,其在JVM中结构如下:
JVM笔记(一):Java内存区域_第5张图片
JVM笔记(一):Java内存区域_第6张图片

1.2.2.2 实例数据

实例数据重要存储对象的字段数据信信息,包括父类的字段信息。JVM为了内存对齐会进行字段重排。JVM的选项 -XX:FieldsAllocationStyle 有3个可选值:

  • 0:先放入oops(普通对象引用指针),再放入基本类型变量(long/double->int->short/char->byte/boolean)
  • 1:默认值,先放入基本类型变量,然后放入oops
  • 2:oops和基本变量交叉存储

无论何种排序都需要遵循以下规则:

  • 子类继承的父类字段的偏移量必须和父类保持一致
  • 如果一个字段的大小为 N 字节,则从对象开始的内存位置到该字段的位置偏移量一定满足:字段的位置 - 对象开始位置 = mN (m >=1), 即是 N 的整数倍

需要说明的是:

  • 父类的私有成员变量会被子类继承。实际上类的属性的访问控制符只是编译层面的限制,在计算机内存中不论是私有还是公开的属性,都按照规则放在一起,对虚拟机来说没有区别
  • 子类继承了父类的属性,需要访问父类属性的时候可以通过 super 从自身内存中读取,并不需要创建并持有父类对象
  • 继承自父类的私有属性也需要初始化,子类对象创建的时候会调用父类构造方法来完成这部分工作,但不会创建出父类对象
    • 计算机处理器读取内存数据时不是逐个字节去访问,而是以内存访问粒度(2、4、8、16 甚至 32 字节的块) 为单位访问内存。对于未对齐地址的内存数据,处理器一次访问非常可能在这块内存中取到不需要的数据,必须额外做一些移除不需要的数据的工作,再将其放置在寄存器中,这会严重影响内存访问效率,需要字节对齐提高效率。而采用字段冲重排方式,将相同类型的字段组合在一起,减少一些补白操。

1.2.2.3 对齐填充

JVM堆中所有对象分配的内存字节数必须是8N,如果对象头和实例数据的总大小不满足要求,则需要通过对齐数据来填满。另外填充数据可能在实例数据末尾,也可能穿插在实例数据个属性之间

1.2.2.4 对象的栈上分配

在JVM 1.8版本后,虚拟机默认开启逃逸分析标量替换特性,如果对象不会被当前线程以外的线程访问到,则优化为在线程栈上分配,从而减轻 GC 压力。

  • 逃逸分析:是基于 JIT(即时编译) 的一种应用,只有在热点代码执行达到一定条件触发 JIT 后才能正确工作,存在延后性。也就是说,在逃逸分析得出结果并开始标量替换的优化之前,热点代码通常已经执行多次,已经有部分对象在堆中生成。除此之外,当栈上空间不足时,创建对象一定会往堆内分配
  • 标量替换:数据分为聚合量标量,聚合量是可以继续分解的,而标量是不可再分的。JVM 的标量替换是指,如果逃逸分析证明一个对象不会被其他线程访问到,并且这个对象可以再分,那么创建对象的时候,实际上会优化为使用若干个基本类型的标量数据来替换它。这样将对象拆开后在栈上分配,就可以大幅减少在堆中开辟空间创建对象的操作,降低堆内锁竞争和内存占用。

可以使用 -XX:+/-DoEscapeAnalysis 打开或关闭逃逸分析,使用 -XX:+/-EliminateAllocations来打开或关闭标量替换。

1.2.3 对象的访问定位

Java程序需要通过栈上的 reference 数据来操作堆上的具体对象。虚拟机规范只规定了 reference 类型是一个指向对象的引用,并没有定义引用如何去定位、访问堆中的对象的具体位置,各种虚拟机实现有所不同,目前主流的访问方式有2种,分别是句柄访问直接指针访问

1.2.3.1 句柄访问

句柄访问方式会在 Java堆中划分一块内存来作为句柄池,reference中存的就是对象的句柄地址,而句柄中则包含了对象的实例数据和类型数据各自的具体地址信息,如下图:
JVM笔记(一):Java内存区域_第7张图片

1.2.3.2 直接指针访问

直接指针访问,就需要在Java堆对象的布局中考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象的地址,如下图:
JVM笔记(一):Java内存区域_第8张图片

总结:

  • 句柄访问的优点就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时经常会移动对象)时只会改变句柄中的实例对象指针reference本身不需要修改
  • 直接指针访问的优点就是速度更快,节省了一次指针定位的开销,对象访问在Java中十分频繁,积少成多后也是一项非常可观的执行成本

2 扩展与思考

2.1 栈帧中的局部变量表和对象的实例数据都没有记录变量名称,虚拟机如何访问这些变量?

  • 局部变量表的访问是通过方法栈的基址 + 偏移量 这种方式实现的。每个变量的偏移量在编译阶段就确定了,即偏移量在具体生成的指令中是写死的,不需要变量名称。

  • 对象的成员变量是通过对象的基址 + 偏移量这种方式实现。虽然这个偏移量也是在编译阶段就确定了,但是Java类是动态加载的,也就是说其它类的方法中调用这个成员变量的时候是无法确定偏移量的,所以生成的指令里只能用成员变量名代替,真正执行时根据加载类的信息,将成员变量名称替换成偏移量在访问,即解析、链接的过程

你可能感兴趣的:(JVM,jvm)