JVM(1):技术体系与内存区域

1. Java技术体系

        从广义上来讲,KotlinClojureJRubyGroovy等运行于Java虚拟机上的编程语言及其相关的程序都属于Java技术体系中的一员。从传统意义上来看,JCP官方所定义的Java技术体系包括以下几个部分:

        我们可以把Java程序设计语言、Java虚拟机、Java类库这三部分统称为JDK ( $Java\ \ Development\ \ Kit$ ) ,JDK是用于支持Java程序开发的最小环境。可以把Java类库API中的Java SE API子集和Java虚拟机这两部分统称为JRE ( $Java\ \ Runtime\ \ Environment$ ),JRE是支持Java程序运行的标准环境。
        以上是按照Java组成部分来进行划分,如果按照技术领域来划分,则可以分为以下四条:

2. 自动内存管理

        JVM在执行Java程序的过程中会将内存划分为若干个不同的数据区域。

2.1 程序计数器

        程序计数器 ( $Program\ \ Counter\ \ Register$ ) 是一块比较小的内存空间,可以看作当前线程所执行的字节码的行号指示器。程序的分支、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器。在JVM中,一个处理器只会处理一个线程,多线程通过轮流切换来实现,因此每个线程都会有一个程序计数器。对于每个线程之间独立存储的内存,我们称之为“线程私有”内存。
        Java中存在 $native$ 关键字,用于指示本地方法。通过 $native$ 关键字,Java程序可以调用本地应用(或库),也可以被其他程序调用。对于本地方法,在执行过程中,程序计数器的值为空 ( $Undefined$ )。而对于Java方法 ( 也就是字节码 ) ,程序计数器的值为正在执行的虚拟机字节码的指令地址。

2.2 Java虚拟机栈

        Java虚拟机栈 ( $Java\ \ Virtual\ \ Machine\ \ Stack$ ) 描述的是Java方法执行的线程内存模型。与程序计数器一样,它也是线程私有的。在每个方法被执行时,JVM都会同步创建一个栈帧 ( $Stack\ \ Frame$ ) 用于存储局部变量表、操作数栈、动态连接、方法出口等信息。在方法被调用时,这个栈帧会被压入Java虚拟机栈。当方法执行完毕时,其对应的栈帧也会被从Java虚拟机栈中弹出。如果将Java的内存区域像C/C++那样简单地划分为堆内存 ( $Heap$ ) 和栈内存 ( $Stack$ ),那么这里的虚拟机栈就可以视为栈内存。
        局部变量表中存储基本数据类型、对象引用以及 $returnAddress$ 类型 ( 指向一条字节码指令的地址 )。这些数据类型在局部变量表中以局部变量槽 ( $Slot$ ) 表示。对于 $64$ 位长度的 $long$ 和 $double$ 类型,它们将会占用两个槽。其余数据类型只占用一个。局部变量表的大小是在进入方法时就已确定的,不会随着运行而改变。
        当线程请求的栈深度大于虚拟机允许的深度时就会抛出 $StackOverflowError$ 异常。Java虚拟机栈的容量允许动态扩展。如果在扩展时无法申请到足够内存,那么就会抛出 $OutOfMemoryError$ 异常。

2.3 本地方法栈

        本地方法栈 ( $Native\ \ Method\ \ Stacks$ ) 类似于虚拟机栈,但不同之处在于其为本地方法服务。《Java虚拟机规范》中并未规定如何实现,因此在不同的虚拟机中,其实现可能都是不同的。甚至有的虚拟机直接将其与Java虚拟机栈合二为一。本地方法栈抛出的异常的条件和类型也与Java虚拟机栈一样。

2.4 Java

        Java ( $Java\ \ Heap$ ) 是虚拟机管理内存中最大的一块,在虚拟机启动时创建,被所有线程所共享,用于存放对象实例。Java堆也被叫做GC堆 ( $Garbage\ \ Collected\ \ Heap$ ),因为它是垃圾收集器所管理的内存区域。类似于磁盘空间,Java堆在物理上可以是不连续的内存空间,但在逻辑上是连续的。Java堆可以是固定的,也可以是可扩展的,分别通过 $+Xms$ 和 $+Xmx$ 设置。在实例分配的过程中,如果没有足够的内存去分配,也无法再扩展时,就会抛出 $OutOfMemoryError$ 异常。

2.5 方法区

        方法区 ( $Method\ \ Area$ ) 也是一块多线程共享区域,用于存储已被加载的类型信息、常量、静态常量、即时编译器编译后的代码缓存等数据。在《Java虚拟机规范》中,方法区是堆的一个逻辑部分,但是为了与堆区分,它有个别名叫非堆 ( $Non-Heap$ )。和Java堆一样,方法区可以是固定大小,也可以扩展。但与之不同的是,方法区可以不实现垃圾收集。如果方法区无法满足新内存的分配需求,就会抛出 $OutOfMemoryError$ 异常。

2.6 运行时常量池

        运行时常量池 ( $Runtime\ \ Constant\ \ Pool$ ) 是方法区的一部分。Class文件中除了类的版本、字段、方法、接口等信息外,还包括常量池表 ( $Constant\ \ Pool\ \ Table$ ),用于存放编译期生成的字面量与符号引用。这部分内容会存放在运行时常量池中。除了存储符号引用外,还可以存储由符号引用直接翻译过来的直接引用。相比于Class文件常量池,运行时常量池是动态的,即允许运行期间产生的常量放入运行时常量池。最为常见的例子就是 $String.intern(\ )$ 方法。该方法将在常量池中查找字符串并返回,对于没有的字符串则会创建。当常量池无法再申请到内存时,会抛出 $OutOfMemoryError$ 异常。

2.7 直接内存

        在 $java.nio$ 中引入了一种基于通道 ( $Channel$ ) 与缓冲区 ( $Buffer$ ) 的I/O方式,可以使用 $Native$ 函数库直接分配堆外内存,然后通过一个存储在Java堆内的 $DirectByteBuffer$ 对象作为内存引用进行操作。直接内存 ( $Direct\ \ Memory$ ) 指的就是这块由本机直接分配,不受Java堆大小限制的内存,它并不是虚拟机运行时数据区的一部分。虽然不会受到Java堆大小限制,但仍然会受到本机内存大小和寻址空间的限制。在配置虚拟机参数时,如果直接根据实际内存来设置JVM内存,忽略了可能存在的直接内存,那么就会出现各内存区域总和大于物理内存的情况,就会抛出 $OutOfMemoryError$ 异常。

3. HotSpot虚拟机对象

3.1 对象创建

        当JVM遇到 $new$ 时,会检查其参数能否定位到一个类的符号引用,再去检查该引用是否已被加载、解析和初始化。如果没有,那么就会执行类加载过程。在类加载完毕后,虚拟机将为对象分配内存。内存分配完毕后,JVM会将内存空间除对象头之外的内容都设为零值。之后,JVM还要设置对象信息,比如实例信息、元数据信息、哈希码、GC分代年龄等信息。这一部分信息称为对象头 ( $Object\ \ Header$ ) 信息。一般情况下,接下来对象的构造函数,也即Class文件的 <$init$>$(\ \ )$ 方法会被调用,这时一个对象才算真正创建完毕。
        在类加载的过程中,对象所需的内存空间就已确定。如果Java堆是规整的,即已被分配的内存未被分配的内存分置于两边,中间使用一指针隔开,那么分配内存的时候只需要将指针向未被分配的内存方向移动需分配的内存大小即可,这种分配方式称为指针碰撞 ( $Bump\ \ The\ \ Pointer$ )。相反,如果Java堆是散乱的,即已被分配和未被分配的内存之间交错分布,那么就需要一个列表来记录哪些内存块已被分配,哪些内存块未被分配,这种分配方式称为空闲列表 ( $Free\ \ List$ )。要想保证Java堆的规整,那么垃圾收集器就要有空间压缩整理 ( $Compact$ ) 能力。与之对应的是清除 ( $Sweep$ ) 算法的收集器,并不能保证Java堆的规整。
        对象创建是一个非常频繁的行为,如果不能保证同步,那么可能就会出现在前一块内存还未分配给该指针时,后一块内存就准备分配给该指针的情况。解决方案有两种:同步分配内存的操作,或者通过线程将分配内存的操作划分在不同空间中进行。后一种方案称为本地线程分配缓冲 ( $Thread\ \ Local\ \ Allocation\ \ Buffer$, $TLAB$ ) ,即预先在Java堆中给每个线程分配一小块内存,在对象创建时会先在这块本地缓冲区中分配,这样就避免了同步问题。当本地缓冲区不够时,才进行同步操作。可以通过 $-XX:+/-UserTLAB$ 来设定是否使用 $TLAB$ 。如果使用了 $TLAB$ ,将已分配的内存空间清零的操作也可以提前到分配本地缓冲区时进行。

3.2 对象内存

        对象内存可以划分为三部分:对象头 ( $Header$ )、实例数据 ( $Instance\ \ Data$ ) 和对齐填充 ( $Padding$ )。
        对象头包括运行时数据和类型指针。运行时数据如哈希码、GC分代年龄、锁状态标志、线程锁、偏向线程ID、偏向时间戳等,在 $32$ 位和 $64$ 位虚拟机中分别为 $32$ 位和 $64$ 位的 $Bitmap$ 结构,称为 $Mark\ \ Word$ 。$Mark\ \ Word$ 被设计为动态的,含有两个标志位,用于标识其存储内容。在不同状态下,改变标志位即可表明存储内容。类型指针是一个指向对象元数据的指针,JVM通过该指针来确定对象是哪个类的实例。但并不是所有虚拟机都会保留类型指针。此外,如果对象是一个数组,那么对象头中还会有一块区域记录数组长度,因为通过元数据只能确定对象的大小,但在不知道数组长度的情况下是无法知道整个数组的大小的。
        实例数据部分,即对象真正储存信息的部分。对象中的字段,无论是继承的,还是子类中定义的,都要记录。存储顺序受到JVM的分配策略的影响,通过 $-XX:FieldsAllocationStyle$ 参数可以设置分配策略。默认的分配顺序为 $longs$ / $doubles$、$ints$ 、$shorts$ / $chars$、$bytes$ / $booleans$、$oops$ ( $Ordinary\ \ Object\ \ Pointers$ ),也即相同长度的字段会存放在一起。在这个前提下,父类字段会在子类字段之前。$+XX:CompactFields$ 参数默认为 $true$ ,代表允许子类中较短的字段插入父类中较长字段的缝隙中。
        对齐填充并非必要,仅仅作为占位符使用。因为HotSpot虚拟机中对象的起始地址必须是八字节整数,因此需要对齐填充来保证对象的长度足够。

3.3 对象访问

        《Java虚拟机规范》中并未定义 $reference$ 类型应该如何去定位和访问对象,因此对象访问方式是由虚拟机决定的。主要的访问方式有句柄和直接指针。如果使用句柄,Java堆中会划出来一块句柄池,$reference$ 指向句柄地址,句柄中包含了对象实例数据和类型数据的地址。如果使用直接指针,对象数据就会直接存放在Java堆中,$reference$ 就直接指向对象地址。HotSpot虚拟机中使用的是直接指针。

4. OutOfMemoryError

4.1 Java

        Java堆的 $OutOfMemoryError$ 异常是实际应用中最常见的内存溢出异常:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

        异常堆栈信息 $java.lang.OutOfMemoryError$ 之后会跟随 $Java\ \ heap\ \ space$ 。最常规的解决方案是通过内存映像分析工具 ( $Eclipse\ \ Memory\ \ Analyzer$ ) 对 $Dump$ 出的堆转储快照进行分析。要让虚拟机在内存溢出时 $Dump$ 出内存堆转储快照,可以通过参数 $-XX:+HeapDumpOnOutOfMemoryError$ 进行设置。第一步要先分清楚是内存泄漏 ( $Memory\ \ Leak$ ) 还是内存溢出 ( $Memory\ \ Overflow$ )。如果是内存泄漏,可以通过进一步工具找到泄露位置。而如果是内存溢出,那么就要检查虚拟机堆参数 ( $+Xmx$ 和 $+Xms$ ),再检查代码,尽量减少不合理的内存消耗。

4.2 虚拟机栈和本地方法栈

        HotSpot虚拟机并没有区分虚拟机栈和本地方法栈,因此设置本地方法栈大小的参数 $+Xoss$ 虽然可以使用,但是没有任何效果。虚拟机栈和本地方法栈只能通过 $+Xss$ 参数设定。如之前讲述的一样,虚拟机栈和本地方法栈可能抛出 $StackOverflowError$ 和 $OutOfMemoryError$ 。HotSpot虚拟机不支持动态扩展栈,因此出现 $OutOfMemoryError$ 的情况只有最初创建线程时就无法申请到足够的内存的情况。在一般情况下,使用HotSpot虚拟机的默认参数下,对于一般的方法调用是足够的。但是如果是因为多线程导致的溢出,就需要减小最大堆和减小栈容量换取更多线程。当出现这种问题,报错信息会注明 $possibly\ \ out\ \ of\ \ memory\ \ or\ \ process/resource\ \ limits\ \ reached$ 。

4.3 方法区和运行时常量池

        在JDK6之前的HotSpot虚拟机中,常量池都是分配在永久代中的,使用 $+XX:PermSize$ 和 $+XX:MaxPermSize$ 可以限制永久代大小。在这种情况下,异常信息会注明 $PermGEn\ \ space$ 。自JDK7起,字符串常量池被移至Java堆中。方法区溢出的条件比较苛刻,可能会出现在需要生成大量动态类的场景中。JDK8之后,永久代不再使用,元空间取而代之。HotSpot提供了一些参数:

参数 作用
$+XX:MaxMetaspaceSize$ 元空间最大值,默认为 $-1$ ,即不限制。
$+XX:MetaspaceSize$ 元空间初始大小,单位为字节,超过了该值就会触发垃圾收集。
$+XX:MinMetaspaceFreeRatio$ 垃圾收集后元空间的最小剩余容量百分比。
$+XX:MaxMetaspaceFreeRatio$ 垃圾收集后元空间的最大剩余容量百分比。

4.4 直接内存

        直接内存可以通过 $+X:MaxDirectMemorySize$ 参数指定,未指定则默认等同于Java堆 ( $+Xmx$ ) 大小。由直接内存溢出导致的异常,在 $Heap\ \ Dump$ 文件中不会有任何明显异常情况。如果发现 $Dump$ 文件很小,同时程序中又使用了例如 $NIO$ 等类,那么可能就是直接内存溢出导致的异常。

JVM(1):技术体系与内存区域