浅谈JVM虚拟机内存区域

相信Java编程人员都或多或少了解变量在JVM内存中的分配情况,如基本数据类型、对象以及常量池。多数情况下可能我们比较关心的是堆内存和栈内存,某些开发人员比较常见的一种说法是基本数据类型一般存储在栈区,对象存储在堆区,常量分配在常量池。其实上面这种说法是很片面的,又如局部变量在多线程并发编程中是否需要使用同步锁等之类的问题。这些问题就是在接下来两篇博文中将要讨论介绍的,如果说你已经很明确知道上述描述的问题的答案,那么接下来的两篇博文可以直接忽略了。

本文更多的是理论方面的介绍,在下一篇中我们会结合具体代码示例分析一下Java内存分配情况。

在进行讨论之前先上一张Java内存区域划分的图片(Java SE 7版),对JVM内存区域有一个比较直观的认识。

这里所说的运行时数据区指的是Java编译后的class文件加载后在JVM所占的内存区域。

  • Java栈,另外一种说法是虚拟机栈;
  • 本地方法栈;
  • 程序计数器;
  • 方法区;
  • 堆区。

Java虚拟机栈

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

在平常开发中我们所说的“栈”就是指虚拟机栈,或者说仅是虚拟机栈的局部变量表部分。局部变量表包括了Java的8种基本数据类型(boolean、byte、char、short、int、float、long、double)、 对象引用reference类型和returnAddress类型

其中long和double类型64位长度的的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在虚拟机栈中会抛出两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

通过这里的介绍,上面的问题局部变量在多线程并发编程中是否需要使用同步锁其实也就迎刃而解了,由于局部变量对应的就是上面所介绍的局部变量表部分,位于方法执行过程中的栈帧中,对应了方法执行过程中栈帧从入栈到出栈的过程,所以局部变量不需要使用锁。其实如果对Java并发编程有所了解的话,之所以加锁是为了维护共享资源的一致性,局部变量在方法执行完成后就会被回收,不属于共享资源,多数情况下比Java虚拟机栈的生命周期还要短,生存周期仅限于方法的执行过程中,从这个方面来说也不需要使用锁。

本地方法栈

本地方法栈与Java虚拟机栈非常相似,只是Java虚拟机栈用于JVM执行Java语言,而本地方法栈是JVM执行Native方法的服务。在开发中如果涉及到JNI可能会接触本地方法栈多一些。在有些虚拟机的实现中已经将本地方法栈与虚拟机栈合二为一了。本地方法也会抛出StackOverflowError或者OutOfMemoryError异常。

文章开头所说的栈内存其实指的就是这里的Java虚拟机栈和本地方法栈,在开发时我们比较关心的局部变量就存于栈中。在栈中虽然会抛出StackOverflowError或者OutOfMemoryError异常,但是真正容易引发上面两种内存异常的还是对象以及成员变量,而对象以及成员变量都是存在堆中,堆才是垃圾收集器比较关心的地方。

程序计数器

程序计数器也有称作PC寄存器的,它是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

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

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,一个Java虚拟机实例只有一个堆空间。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。Java虚拟机规范中这样描述:所有的对象实例以及数组都要在堆上分配,因此很多时候也被称做“GC堆”。但是随着JVM优化技术的提升,所有对象内存都分配在堆上面也变得不是那么绝对了。

虽然对象实例本身存在于堆空间,但是对象的引用可能在很多地方都存在,如Java虚拟机栈、本地方法栈、堆空间本身以及方法区。

由于Java虚拟机只有在堆中分配新对象的指令,但是却没有释放内存的指令。虚拟机自己决定如何以及何时释放不再运行的对象的内存,程序本身一般不需要去考虑如何回收内存,通常情况下都是有垃圾收集器负责自动回收内存。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、 From Survivor空间、To Survivor空间等。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储被虚拟机加载的类的类型信息。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来,一个JVM实例中只存在一个方法区。

当虚拟机加载某个类的class文件时,虚拟机会提取这个类的类型信息存储到方法区中。一个类的类型信息一般包括如下部分:

  • 类的全限定名;
  • 当前类的直接父类或者接口的全限定名;
  • 这个类是接口类型,类类型,还是枚举类型;
  • 类的访问修饰符信息,如public、private以及protected;
  • 当前类型的常量池;
  • 除了常量外的静态变量;
  • 字段信息;
  • 方法信息。

类型数据中,除了这些基本信息外,还包括一个到类ClassLoader引用和一个到类的Class的引用

对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

运行时常量池

运行时常量池时方法区的一部分。在方法区介绍中说了class文件的类型信息中有一个当前类型的常量池,其实这个常量池是class文件中的常量池,当class被虚拟机加载成功后,class文件中的常量池便会存入运行时常量池。常量池主要存放的是各种字面量和符号引用。

符号引用(Symbolic References)以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方 法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

有关常量池的更多介绍在下一篇博文中再介绍。

通过下图来了解如何通过参数来控制各区域的内存大小。

  • -Xms设置堆的最小空间大小。
  • -Xmx设置堆的最大空间大小。
  • -XX:NewSize设置新生代最小空间大小。
  • -XX:MaxNewSize设置新生代最大空间大小。
  • -XX:PermSize设置永久代最小空间大小。
  • -XX:MaxPermSize设置永久代最大空间大小。
  • -Xss设置每个线程的堆栈大小。

没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制。

老年代空间大小=堆空间大小-年轻代大空间大小

其它

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

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

但是,既然是内存,则肯定还是会受到本机总内存(包括RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制。服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但经常会忽略掉直接内存,使得各个内存区域的总和大于物理内存限制(包括物理上的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

Java8内存模型

JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是Native Heap,永久代是HotSpot虚拟机对虚拟机规范中方法区的一种实现方式。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了Native Heap;字面量和静态变量转移到了Java Heap。 JDK 1.7和1.8将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代。

在JDK1.8中HotSpot虚拟机已经废弃了永久代(PermGen)转而使用元空间(Metaspace)替代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

参考资料

深入理解Java虚拟机-周志明著

深入Java虚拟机-Bill Venners著

Java8内存模型—永久代(PermGen)和元空间(Metaspace)

JVM内存结构和Java内存模型

JAVA8元空间是什么?

评论

您确定要删除吗?删除之后不可恢复