JVM-JVM内存结构

159 阅读17分钟

一、在Java中那些组件需要使用内存

1. Java堆

Java堆使用来存储Java对象的内存区域,堆的大小在JVM启动时就一次向操作系统申请完成,通过-Xmx和-Xms两个选项来控制大小,Xmx表示堆的最大大小,Xms表示堆的初始大小。一旦完成分配,堆的大小就将固定,不能在内存不够用时再向操作系统重新申请,同时当内存空闲时也不能将多余的控件交还给操作系统。 在Java堆中内存空间的管理由JVM来控制,对象的创建由java应用程序来控制,但是对象所占的内存空间的释放由管理堆内存的垃圾收集器来完成。根据垃圾收集的算法不同,内存回收的时机和方式也会不同。

2.线程

JVM运行实际程序的实体是线程,当然线程需要内存空间类存储一些必要的数据。每个线程创建时JVM都会为他创建一个堆栈,堆栈的大小根据不同的JVM实现而不同,通常在256kb~756kb之间。

线程所占用的空间相对堆空间来说比较小,但是如果线程过多,线程堆栈的总内存使用量可能也非常大。当前后很多应用程序根据CPU的核数来分配创建的线程数,如果运行的应用程序的线程数量比可用于处理它们的处理器数量多,效率通常很低,并且可能导致比较差的性能和更高的内存占用率。

3.类和类加载器

在Java中的类和类加载器本身同样需要存储空间,在Sun JDK中它们也被存储在方法区中,,这个区域叫“永久代”(PermGen区)

需要注意的是JVM是按需来加载类的,JVM加载一个jar包时并不是把jar包中的所有类都加载进内存中,JVM只会加载在你的应用程序中明确使用的类到内存中。可以在启动参数上加上-verbose:class查看JVM到底加载了哪些类。

理论上使用的Java类越多,需要占用的内存也会越多。注意的是,JVM可能会重复加载同一个类。通常情况下,JVM只会加载一个类到内存中一次,但是如果是自己实现的类加载器可能会出现重复加载的情况,如果PermGen区不能对已经失效的类做卸载,可能会导致PermGen区的内存泄漏。所以需要注意PermGen区的内存回收问题。通常一个类能够被卸载,有如下条件需要满足:

  • 在Java堆中没有表示对该类的加载器的java.lang.ClassLaoder对象的引用。
  • Java堆没有表示类加载器加载的类的任何java.lang.Class对象的引用。
  • 在Java堆上该类加载器加载的任何类的所有对象都不在存活(被引用)。

需要注意的是,JVM所创建的3个默认类加载器Bootstarp ClassCloader、ExtCLassLoader和AppClassLoader都不可能满足这些条件,因此,任何系统类(如java.lang.String)或通过应用程序加载器加载的任何应用程序类都不能在运行时释放。

4.NIO

Java在1.4之后引入了一种基于通道和缓冲区来执行I/O的新方式,就向在Java堆上的内存去支持I/O缓冲区一样,NIO使用java.nio.ByteBuffer.allocateDirect()方法分配内存,这种方式也就是通常所说的NIO direct memory。 ByteBuffer.allocateDirect()分配的内存使用的是本机内存而不是Java堆上的内存,这也进一步说明每一次分配内存时都会调用操作系统的os::malloc()函数。

直接Buffer会自动清理本机缓冲区,但这个过程只能作为java堆GC的一部分来执行,因此它们不会自动响应来自施加在本机堆上的压力。GC仅在Java堆被填满,以至于无法为堆分配请求提供服务时发生,或者在Java应用程序中系统请求时发生。当前很多NIO框架都在代码中显式的调用System.gc()来释放NIO持有的内存。但是这种方式会影响应用程序的性能,因为会增加GC的次数,一般情况下通过设置 -XX:+DiableExplicitGC来控制System.gc()的影响,但是会有导致NIO direct momery 内存泄漏问题。

5.JNI

JNI技术使得本机代码可以调用Java方法,也就是通常所说的native memory。实际上Java运行时本身也依赖JNI代码来实现类库功能,如文件操作、网络I/O操作或者其他系统调用。所以JNI也会增加Java运行时本机内存占用。

二、JVM内存结构

上面简述了Java有哪些组件需要使用 内存。下面简述在JVM中是如何使用内存的。了解JVM内存的各个区域,是翻越虚拟机内存管理这堵墙的第一步。

jvm_memory_frame.png JVM是按照运行时数据的存储结构来划分内存结构的,JVM在运行Java程序时,将它们划分为不同格式的数据,分别存储在不同的区域,这些数据统一称为运行时数据(Runtime Data)。运行时数据包括Java本身的数据信息和JVM运行Java程序需要的额外数据信息,如要记录当前程序指令执行的指针等。

1.程序计数器(PC寄存器)

PC寄存器是线程私有的,用于保存当前正常执行的程序的内存地址。同时,因为Java是多线程执行的,所以不能按照线性执行下去,当有多个线程交叉执行时,就需要将被中断的线程的程序执行到哪条的内存地址保存下来。

JVM规范只定义了Java方法需要记录的指针的信息,而对于Native方法,并没有要求记录执行的指针地址,即如果正在执行的是Java方法,则计数器记录的是正在执行的虚拟机字节码的地址,如果正在执行Native方法,则计数器值为空。

程序计数器是唯一一个在Java虚拟机规范中没有规定任何 OutOfMemoryError情况的区域。

2.虚拟机栈

Java栈是线程隔离区,java栈总是跟线程关联在一起。它的生命周期与线程相同。每当线程创建时,JVM就会为该线程创建一个Java栈,在这个java栈中优惠包含多个栈帧(栈帧方法运行时的基础数据结构),栈帧是和每个方法关联起来的,每运行一个方法就会创建一个栈帧,每个栈帧会包含一些内部变量(在方法内定义的变量)、操作栈、和方法返回值等信息,每个方法从调用到执行完毕的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。

每当一个方法执行完成是,栈帧就会弹出栈帧的元素作为这个方法的返回值,并清除这个栈帧,java栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法,PC寄存器也会指向这个地址。只有这个活动(java栈顶部的栈帧)的栈帧的本地变量可以被操作栈使用,当在这个栈帧中调用另一个方法时,与之对应的一个新的栈帧又被创建,这个创建的栈帧又会放到Java栈的顶部,变为当前的活动栈帧。当当前的活动栈帧中的所有指令完成时,这个栈帧移除java栈。

虚拟机栈中存放了编译期可知的各种基本数据类型(int、float、char等基础类型)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针。对象本身存放在堆内存中。)和returnAddress类型(指向了一条字节码指令的地址)。

由于java栈是与java线程对应起来的,这个数据不是线程共享的,所以我们不用关心它的数据一致性问题,也不会存在同步锁的问题。

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

3.本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

4.堆(Java heap)

对于绝大许多数引用来说,堆是JVM所管理的内存中的最大的一块,堆是被所有线程共享的内存区域,在JVM启动时被创建。堆内存区的唯一目的就是存放对象实例,几乎所有的对象实例都在此分配内存。但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

Java堆是垃圾收集器的主要管理区域,所以别名GC堆,通过内存回收的角度看,在采用分代回收算法下,堆还可以细分为新生代和老年代;在细致点有Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度看,线程共享的Java堆还可能划分出多个线程私有的分配缓冲区(Thread local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存放的都仍然是对象实例,进一步的区分是为了更好的回收内存,或者更快的分配内存。

每一个存储在Java堆中的Java对象都是这个对象的类的一个副本,他会复制包括继承自它的父类的所有非静态属性。

堆是被所有Java线程共享,所以对它的访问需要注意同步问题,方法和对应的属性都要保证一致性。如果在堆中没有足够的内存完成实例分配,并且堆也无法在扩展时(堆是可以扩展的),将会抛出OutOfMeoryError异常。

5.方法区(Method Area)

JVM方法区是存储类结构信息的地方,是各个线程共享的内存区域,它用于存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

对于习惯在HotSpot虚拟机上开发、部署程序的开发者来说,很多人都更愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他虚拟机(如BEA JRockit、IBM J9等)来说是不存在永久代的概念的。原则上,如何实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但使用永久代来实现方法区,现在看来并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB,就不会出现问题),而且有极少数方法(例如String.intern())会因这个原因导致不同虚拟机下有不同的表现。因此,对于HotSpot虚拟机,根据官方发布的路线图信息,现在也有放弃永久代并逐步改为采用Native Memory来实现方法区的规划了,在目前已经发布的JDK 1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出。

方法区也就是我们通常所说的java堆中的永久区,这个区域可以被所有线程共享,并且它的大小可以通过参数来设置。这个方法区的存储区域的大小一般在程序启动后的一段时间就固定了,因为在通常情况下,JVM运行一段时间后所有的类都会被加载进JVM中,但是有一种特殊情况:动态编译,当项目中存在动态编译时,需要关心方法区内存大小。

当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

6.运行时常量池

运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法(String对象会存储在常量池中,会通过intern()方法判断常量池中是否已经存在)。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

三、JVM内存分配策略

1.通常的内存分配策略

在通常的操作系统中将内存分配策略分为三种:静态内存分配,栈内存分配,堆内存分配。

静态内存是在程序在编译时就能确定每个数据在运行时的存储空间需求,因此在编译时就能给他们分配固定的内存空间。这种分配策略不允许在程序代码中有可变数据结构的存在(如可变数组),也不允许有嵌套可递归程序的存在,他们都会导致无法计算存储空间。

栈式内存分配是指动态内存分配,是由一个类似于堆栈的运行栈来实现的。和静态内存分配相反,栈式内存对数据区的需求在编译期时未知的。但是规定在运行中进入一个程序模块时必须知道该模块所需的内存大小才能为其分配内存。和我们熟悉的数据结构栈一样,栈式内存分配按照后进先出的原则进行分配。

在编写程序时,除了上述编译期确定空间需求和程序入口确定存储空间大小外,还有一种情况就是当程序员运行到相应代码时才知道空间大小,这种情况下我们就需要堆这种分配策略。但堆内存对内存的管理比较困难,且效率较差。

2.Java中的内存分配策略

在JVM运行时的各个区域中,其中程序计数器、虚拟机栈、本地方法栈都是随着线程诞生和毁灭。栈中的栈帧随着方法的进入和退出有条不紊的执行着出栈和入栈的操作,每一个栈帧分配多少 内存基本是在类结构确定时基本就已知的(但是随着JIT编译器的一些优化也是可变的),因此这几个区域的分配和回收具有确定性。而Java的堆和方法区不一样,只有在运行期间才会知道创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存,也就是说堆和方法区是垃圾回收的主要区域。

JVM的内存分配主要有两种 堆和栈

java栈分配是和线程分配绑定在一起的,每当创建一个线程JVM就会为之创建一个java栈,一个线程的调用和返回对应着这个Java栈的压栈和出栈(进出栈帧)。每个方法对应Java栈中的一个栈帧,在此方法执行期间,栈帧用来保存参数、局部变量、中间计算过程和其他数据。

栈中主要存放一些基本类型的变量数据(int、short、long等基本数据类型)和对象的引用(类似指针,指向对象地址的指针,对象存放于堆中)。存取速度比堆要快,仅次于寄存器,栈数据可以共享。缺点是存放在栈中的数据大小与生存期必须是确定的,这也导致缺乏了灵活性。

每个Java应用都唯一对应一个JVM实例,每个实例唯一对应一个堆。应用程序在运行时期所有类的实例或者数组都存放在这个堆中(当基本数据类型定义为类的成员时也存放在堆中),并由应用程序所有的线程共享。在Java中分配堆内存是自动初始化的,所有对象的存储空间都是在堆中分配的,但是这个对象的引用确是在堆栈中分配的(成员变量的引用在堆中)。也就是在建立一个对象时两个地方都分配内存

Java堆是一个运行时数据区,这些对象通过new、newarray、anewarray和multianewarray等指令建立,他们不需要程序代码显示的释放。堆是由垃圾回收来负责的,堆的优势是可以动态的分配内存大小,生存周期也不用事先告诉编译器,因为它是运行时分配内存的,Java的垃圾收集器会自动回收这些不再使用的数据。但缺点是,由于要在运行时动态分配内存,存取速度较慢。

从堆和栈的功能和作用来通俗的比较,堆主要用来存放对象,栈主要用来执行程序,这种不同主要由堆和栈的特点来决定的。