深入理解Java虚拟机 - 学习笔记 1

时间:2023-12-25 10:15:58

Java内存区域

程序计数器 (Program Counter Register)

是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一个线程中的指令。因此每个线程都需要有一个独立的程序计数器。此类内存为“线程私有”的内存。

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

字节码 (byte code)

是一种包含执行程序的二进制文件。对于Java来说class文件就是由字节码组成,对于机器来说不可直接运行,是由JVM负责解释执行(将字节码转换成机器码)。

Java字节码的执行需要经过以下3个步骤:

(1)由类装载器(class loader)负责把类文件(.class文件)加载到Java虚拟机中。在此过程需要检验该类文件是否符合类文件规范。

(2)字节码校验器(bytecode verifier)检查该类文件的代码中是否存在着某些非法操作,例如Applet程序中写本地计算机文件系统的操作。

(3)如果字节码校验器检验通过,由Java解释器负责把该类文件解释成为机器码进行执行。

Java虚拟机采用“沙箱”运行模式,即把Java程序的代码和数据都限制在一定内存空间里执行,不允许程序访问该内存空间以外的内存。如果是Applet程序,还不允许访问客户端机器的文件系统。

Java虚拟机栈 (Java Virtual Machine Stacks)

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

通常有人将java内存区域粗略分为堆内存和栈内存,栈指得就是虚拟机栈,或者说是虚拟机栈中局部变量表部分。局部变量表存放了编译期可知的各种基本数据类型(boolean, byte, char, short, int, float, long, double)、对象引用(reference类型,不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用一个。如果线程请求的栈深度大于虚拟机所允许的深度,将抛出*Error异常,如果虚拟机栈可以动态扩展(大部分java虚拟机都可以)而无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈(Native Method Stack)

与虚拟机栈所发挥的作用是非常相似的,区别不过是虚拟机栈为虚拟机执行java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务。本地方法栈也会抛出出*Error和OutOfMemoryError异常。

Java堆(Java Heap)

是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。但是随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有对象都分配在堆上也渐渐变得不是那么绝对了。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”(Garbage Collected Heap)。从内存回收角度来看,由于现在收集器都采用分代收集算法,所以Java堆还可以细分为新生代和老年代,再细致一点有Eden空间,From Survivor空间,To Survivor空间等。从内存分配角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLLAB)。无论那个区域,存放的都是对象实例。 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区(Method Area)

与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但它有一个别名叫Non-Heap,目的应该是与Java堆区分开来。这区域的内存回收主要是针对常量池的回收和对类型的卸载。

很多人习惯于将方法区成为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他虚拟机是不存在永久代概念的。使用永久代来实现方法区并不是一个好主意,因为更容易遇到内存溢出问题而且极少数方法(如String.intern())会因为这个原因导致不同虚拟机下有不同的表现。Java8已经取消永久代而采用元空间(Metaspace)的概念,元空间大小基于本地内存(Native Memory)及整个系统的可用内存,极大的缓解了OutOfMemoryError的问题。Metaspace背后的一个思想是,类和它的元数据的生命周期是和它的类加载器的生命周期一致的。也就是说,只要类的类加载器是存活的,在Metaspace中的类元数据也是存活的,不能被释放。

运行时常量池(Runtime Constant Pool)

是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号的引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存(Direct Memory)

并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,也可能导致OutOfMemoryError的出现。例如NIO中引入了一种基于Channel和Buffer的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著地提高性能,因为避免了在Java堆和Native堆中来回复制数据。

二、Hotspot虚拟机对象创建与分布

对象的创建

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

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

接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息,对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

在上面工作都完成之后,从虚拟机视角来看,一个新的对象已经产生了,但从Java程序视角来看,对象创建才刚刚开始,,<init>方法还没有执行,所有字段都还为零。所以,一般来说,执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

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

Hotspot虚拟机对象头

包括两部分信息,第一部分用于存储对象自身运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有锁、偏向线程ID,偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中为别为32bit和64bit,官方称它为“Mark Word”.

对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。

实例对象

是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是父类继承下来的,还是在子类中定义的都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。Hotspot虚拟机默认的分配策略为longs/doubles, ints, shorts/chars, bytes/booleans, oops (Ordinary Object Pointers)。从分配策略可以看出相同宽度的字段总是被分配到一起。

对齐填充

并不是必然存在的,也没有特别的含义。它仅仅起着占位符的作用。由于Hospot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说对象的大小必须是8字节的整数倍。对象头部分正好是8字节的整数倍,因此需要对齐填充来补全没有对齐的部分。

对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用作用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机的实现而定的。目前主流的访问方式有使用句柄和直接指针两种

句柄

如果使用句柄,那么Java堆中将会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

直接指针

Java堆对象的布局必须考虑如何放置访问类型数据的相关信息,而refernce中存储的直接就是对象地址。

使用句柄的最大好处就是refernce中存储的是稳定的句柄地址,在对象被移动时只会改变句柄的实例数据指针,而reference本身不需要修改。使用直接指针的最大好处就是速度快,节省了一次指针定位的时间开销。Hotsopt使用的是直接指针访问的方式。

三、内存分配与回收策略

对象优先在Eden分配

大多数情况下,对象在新生代Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的就是很长的字符串以及数组。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,避免Eden和两个Survivor区之间发生大量的内存复制。

长期存活的对象将进入老年代

虚拟机给每个对象定义了一个年龄(Age)计数器,如果对象在Eden出生并经过一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区每熬过一次Minor GC,年龄就增加一岁。当它的年龄增加到一定程度(莫认为15岁),就将会被晋升到老年代中。对象晋升老年代的阈值可以通过参数-XX:MaxTenuringThreshold

动态对象年龄判定

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立那么Minor GC可以确保是安全的,如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。如果大于,将尝试进行一次Minor FC(尽管这次有风险),如果小于,或者看HandlePromotionFailure不允许冒险,那这时要改为进行一次Full GC。

总结一下:

Oracle 的HotSpot JVM 又把新生代进一步划分为3个区域:一个相对大点的区域,称为”伊甸园区(Eden)”;两个相对小点的区域称为”From 幸存区(survivor)”和”To 幸存区(survivor)”。按照规定,新对象会首先分配在 Eden 中(如果新对象过大,会直接分配在老年代中)。在GC中,Eden 中的对象会被移动到survivor中,直至对象满足一定的年纪(定义为熬过GC的次数),会被移动到老年代。

基于大多数新生对象都会在GC中被收回的假设。新生代的GC 使用复制算法。在GC前To 幸存区(survivor)保持清空,对象保存在 Eden 和 From 幸存区(survivor)中,GC运行时,Eden中的幸存对象被复制到 To 幸存区(survivor)。针对 From 幸存区(survivor)中的幸存对象,会考虑对象年龄,如果年龄没达到阀值(tenuring threshold),对象会被复制到To 幸存区(survivor)。如果达到阀值对象被复制到老年代。复制阶段完成后,Eden 和From 幸存区中只保存死对象,可以视为清空。如果在复制过程中To 幸存区被填满了,剩余的对象会被复制到老年代中。最后 From 幸存区和 To幸存区会调换下名字,在下次GC时,To 幸存区会成为From 幸存区。