【深入浅出JVM】Java虚拟机内存模型

1,110 阅读13分钟

虚拟机内存模型

虚拟机内存逻辑分区如上图所示,主要分为方法区,堆,虚拟机栈和本地方法栈和程序计数器。其中,方法区和堆的数据是所有线程共享的,虚拟机栈,本地方法栈和程序计数器的数据是线程隔离的。

image.png

程序计数器

  • 当前线程所执行字节码的行号指示器(当前线程执行到字节码的第几行)
  • 唯一一个在java虚拟机规范中没有规定任何outOfMemoryError情况的区域
  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。

虚拟机栈

虚拟机栈.png

虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧[1](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表是虚拟机栈的一部分,这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在HotSpot虚拟机中,如果线程请求的栈深度大于虚拟机所允许的深度或者请求的栈的内存大于虚拟机的规定的最大栈内存,将抛出StackOverflowError异常。

本地方法栈

  • 与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
  • 有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一

《Java虚拟机规范》中规定, 所有的对象实例以及数组都应当在堆上分配。Java堆是垃圾收集器管理的内存区域,Java堆更加细致的划分与不同的垃圾收集器有关,我们以G1收集器为例看一下Java堆的划分。

G1堆内存分布.png

堆根据对象存活时间的不同,堆被分为年轻代、老年代两个区域:

年轻代又分为Eden区和两个Survivor区,其中所有新创建的对象都在Eden区,当 Eden 区满后会触发 minor GC 将 Eden 区仍然存活的对象复制到其中一个Survivor区中,另外一个Survivor区中的存活对象也复制到这个Survivor中,以保证始终有一个Survivor区是空的。默认参数配置下,Eden:from :to = 8:1:1。

老年代存放的是Young区的Survivor满后触发minor GC后仍然存活的对象,当Eden 区满后会将对象存放到 Survivor 区中,如果 Survivor 区仍然存不下这些对象,GC收集器会将这些对象直接存放到 Old区。如果在 Survivor区中的对象足够老,也直接存放到Old区。如果Old区也满了,将会触发Full GC,回收整个堆内存。

Perm区存放的主要是类的Class对象,Perm区的垃圾回收也是由Full GC触发的。

实际上,堆的内存不可能全都是连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址(下图的H表示大对象): G1堆链表.png

方法区

用于存储已被虚拟机加载的类信息(类的全限定名 访问控制 类的属性和方法)、常量、静态变量、即时编译器编译后的代码等数据。

方法区在不同版本的虚拟机有不同的表现形式:

  1. 在 1.8 之前的 HotSpot 虚拟机中,使用永久代来实现方法区
  2. JDK 7的HotSpot,把原本放在永久代的字符串常量池、静态变量等移出,放到Java堆中
  3. JDK 8完全废弃了永久代的概念,在本地内存中实现的元空间(Metaspace)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

使用永久代实现方法区的内存模型:

JDK1.8 之前的内存模型.png

使用Metaspace实现方法区的内存模型:

image.png

永久代的大小由JVM参数MaxPermSize(-XX:MaxPermSize)决定 ,不设置也有默认大小,放到元空间后,只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题。

放弃使用永久代实现方法区,是因为使用永久代来实现方法区导致了Java应用更容易遇到内存溢出的问题。

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。

直接内存

直接内存(Direct Memory),又叫堆外内存,并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域,直接内存的分配不会受到Java堆大小的限制。

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

public static void main(String[] args) throws IOException {
        File file = new File("");
        FileInputStream fileInputStream = new FileInputStream(file);

        // 申请 100 字节的堆外内存
        ByteBuffer byteBuffer = ByteBuffer.allocate(100);
        FileChannel fileChannel = fileInputStream.getChannel();

        int len = 0;
        while ((len = fileChannel.read(byteBuffer)) != 1){
            byte[] bytes = byteBuffer.array();
            System.out.write(bytes,0,len);

            byteBuffer.clear();
        }
    }

堆外内存是直接受操作系统管理( 而不是虚拟机 )。这样做的结果就是能保持一个较小的堆内内存,以减少垃圾收集对应用的影响。

对象与内存模型

对象内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:

对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象内存分布.png

对象头

HotSpot虚拟机对象的对象头部分包括两类信息:

  1. Mark Word(标记字段):存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特。
  2. Klass Point(类型指针):对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。

如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

实例数据

类的字段信息(包括从父类继承下来的数据)

这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

对齐填充数据

HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍。

对象创建过程

对象创建过程.png 对象创建过程主要如下:

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

  2. 在类加载检查通过后,接下来虚拟机将为新生对象分配内存,内存分配主要有指针碰撞和空闲列表两种方式。

  3. 内存分配完成之后,虚拟机会将分配到的内存空间(但不包括对象头)都初始化为零值。

Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。

  1. 使用构造函数初始化对象。

对象访问定位

对象的访问定位主要有两种方式:句柄访问和直接指针访问。

句柄访问

image.png

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

使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

直接指针访问

image.png 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。

使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本HotSpot虚拟机主要使用直接指针访问。

内存溢出

堆内存溢出

image.png OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况,出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。

常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置

如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

栈内存溢出

无论是由于线程请求的栈深度大于虚拟机所允许的最大深度,还是申请的栈容量超过限制,当新的栈帧内存无法分配的时候, HotSpot虚拟机抛出的都是StackOverflowError异常。

可以通过 -Xss 设置线程栈占用内存大小。

对于不同版本的Java虚拟机和不同的操作系统,栈容量最小值可能会有所限制,这主要取决于操作系统内存分页大小。譬如-Xss128k可以正常用于32位Windows系统下的JDK 6,但是如果用于64位Windows系统下的JDK 11,则会提示栈容量最小不能低于180K,而在Linux下这个值则可能是228K,如果低于这个最小限制,HotSpot虚拟器启动时会给出如下提示:

The Java thread stack size specified is too small. Specify at least 228k

方法区(永久代实现)溢出

方法区溢出主要是由于大量的常量和动态生成的class,使用永久代实现方法区,方法区溢出时,一般会出现如下报错

java.lang.OutOfMemoryError: PermGen space

直接内存溢出

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。

直接内存溢出时,一般报错如下:

image.png

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。