JVM-内存模型结构
JVM简介
概念
- JVM是一种用于计算设备的规范,它是一个虚构出来的机器,是通过在实际的计算机上仿真模拟各种功能实现的。
- JVM是JRE的一部分,屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。JVM在整个jdk中处于最底层,负责于操作系统的交互,用来屏蔽操作系统环境,提供一个完整的Java运行环境。
我们可以通过java.exe或者javaw.exe来启动一个虚拟机实例。只要启动了几个java应用,相对应地也启动了几个java虚拟机实例。
原理
- JVM是Java的核心和基础,在Java编译器和os平台之间的虚拟处理器,可在上面执行字节码程序。
- Java编译器只面向JVM,生成JVM能理解的字节码文件。Java源文件经编译成字节码程序,通过JVM将每条指令翻译成不同的机器码,通过特定平台运行。
生命周期
1、 JVM实例对应了一个独立运行的Java程序,它是进程级别的。
- 启动:启动一个Java程序时,一个JVM实例就产生了,任何一个拥有
public static void main(String[] args)
函数的class都可以作为JVM实例运行的起点。- 运行:main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程。main()属于非守护线程,守护线程通常由JVM自己使用,Java程序也可以表明自己创建的线程是守护线程。
- 消亡:当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出。
2、JVM执行引擎实例则对应了属于用户运行程序的线程,它是线程级别的。
架构
JVM架构图如下:
JVM分为三个主要子系统:
- 类加载器子系统(Class Loader Subsystem)
- 运行时数据区(Runtime Data Area)
- 执行引擎(Execution Engine)
类加载器子系统
Java的动态类加载功能由类加载器子系统处理,处理过程包括加载和链接,并在类文件运行时,首次引用类时就开始实例化类文件,而不是在编译时进行。
加载
Bootstrap类加载器、Extension类加载器和Application类加载器是实现类加载过程的三个类加载器。
- Bootstrap类加载器:负责从引导类路径加载类,除了rt.jar,它具有最高优先级
- Extension类加载器:负责加载ext文件夹(jre\lib)中的类
- Application类加载器:负责加载应用程序级类路径、环境变量中指定的路径等信息
上面的类加载器在加载类文件时遵循委托层次算法
链接
- 验证(Verify): 字节码验证器将验证生成的字节码是否正确,如果验证失败,将提示验证错误
- 准备(Prepare):对所有静态变量,内存将会以默认值进行分配
- 解释(Resolve):有符号存储器的引用都将被替换为来自方法区(Method Area)的原始引用
初始化
这是类加载的最后阶段,所有的静态变量都将被赋予原始值,并且静态区块将被执行。
运行时数据区
运行时数据区可分为5个主要组件:方法区、堆、虚拟机栈、本地方法栈、程序计数器。各个组件的详细内容会在后面讲到。
执行引擎
分配给运行时数据区的字节码将由执行引擎执行,执行引擎读取字节码并逐个执行。
- 解释器:解释器可以更快地解释字节码,但执行缓慢。解释器的缺点是当一个方法被调用多次时,每次都需要一个新的解释。
- JIT编译器:JIT编译器消除了解释器的缺点。执行引擎将在转换字节码时使用解释器的帮助,但是当它发现重复代码时,将使用JIT编译器编译整个字节码并将其更改为本地代码。这个本地代码将直接用于重复的方法调用,这提高了系统的性能。
JIT的构成组件有:
1、中间代码生成器(Intermediate Code Generator):生成中间代码
2、代码优化器(Code Optimizer):负责优化上面生成的中间代码
3、目标代码生成器(Target Code Generator):负责生成机器代码或本地代码
4、分析器(Profiler):一个特殊组件,负责查找热点,即该方法是否被多次调用
- 垃圾收集器:收集和删除未引用的对象。
本地库接口(JNI)
JNI将与本机方法库进行交互,并提供执行引擎所需的本机库。
本地方法库(Native Method Libraries)
它是执行引擎所需的本机库的集合。
执行程序过程
当一个程序启动之前,它的class文件会被类加载器装入方法区(包括类信息、静态变量),执行引擎读取方法区的字节码自适应解析运行。执行引擎可以通过解释器和JIT编译器把字节码转换成可以直接被JVM执行的语言。然后pc寄存器指向了main函数所在位置,虚拟机开始为main函数在java栈中预留一个栈帧(每个方法都对应一个栈帧),然后开始执行main函数,main函数里的代码被执行引擎映射成本地操作系统里相应的实现,然后调用本地方法接口,本地方法运行的时候,操纵系统会为本地方法分配本地方法栈,用来储存一些临时变量,然后运行本地方法,调用操作系统API等等。
内存结构划分
Java程序在运行时,需要在内存中的分配空间。为了提高运算效率,就需要对数据进行了不同空间的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。
JVM内存结构指的是JVM架构中的运行时数据区,整个JVM内存结构图如下表示:
各结构的作用
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie 方法,这个计数器值则为空(Undefined)。由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,此内存区域是唯一一个在Java 虚拟机规范中没有规定任何OutOfMemoryError 情况的区域。
虚拟机栈
虚拟机栈(VM Stack)也是线程私有的。每个线程在创建的时候都会创建一个虚拟机栈,其生命周期与线程一致,线程退出时,线程的虚拟机栈也会被回收。线程执行的基本行为是函数调用,每次函数调用的数据都是通过虚拟机栈传递的。 虚拟机栈内部保持一个个的栈帧,每个栈帧用于存储局部变量表、操作数栈、动态链接(当前方法所属的类的运行时常量池的引用)、方法返回地址。每次方法调用都会进行压栈,JVM对栈帧的操作只有出栈和压栈两种。当函数返回时,栈帧从Java栈中被弹出。
Java方法区有两种返回函数的方式,一种是正常的函数返回,使用return指令,另一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
局部变量表用于保存函数的参数以及局部变量,即方法运行期间所需要的数据。局部变量表中的变量只在当前函数调用中有效,当函数调用结束,随着函数栈帧的弹出销毁,局部变量表也会随之销毁。局部变量表可保存有编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。其中64 位长度的long 和double 类型的数据会占用2 个局部变量空间(Slot),其余的数据类型只占用1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
由于局部变量表在栈帧之中,因此,如果函数的参数和局部变量很多,会使得局部变量表膨胀,从而每一次函数调用就会占用更多的栈空间,最终导致函数的嵌套调用次数减少。在相同的栈容量下,局部变量少的函数可以支持更深的函数调用。通过jclasslib工具可以查看函数的局部变量表。局部变量表中的变量也是垃圾回收根节点,只要被局部变量表中直接或者间接引用的对象都是不会被回收的。
操作数栈用于计算时,临时数据的存储区域。操作数栈的长度由编译期间确定,操作数栈初始时为空,每一个操作数栈的成员(Entry)可以保存JVM定义的任意数据类型的值。long和double占用2个栈深单位,其它数据类型占用一个栈深单位。可以通过入栈和出栈来访问操作数栈。其访问过程如下图:
上述表示两个数100和98相加的过程。
1 | public class LocalvarGC { |
在localvarGc1()中,在申请空间后,立即进行垃圾回收,很明显由于byte数组被变量a引用,因此无法回收这块空间。
对于localvarGc2(),它首先调用了localvarGc1(),很明显,在localvarGc1()中并没有释放byte数组,但在localvarGc1()返回后,它的栈帧被销毁,自然也包含了栈帧中的所有局部变量,故byte数组失去了引用,在localvarGc2()的垃圾回收中被回收。
在Java 虚拟机规范中,对虚拟机栈规定了两种异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError 异常;
- 如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError 异常。
本地方法栈
本地方法栈(Native Method Stacks)是线程私有的区域,与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常。
方法区
方法区(Method Area)与堆一样,是所有的线程共享的,存储被虚拟机加载的元数据(Meta),包括类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择规定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域很少出现,这个区域垃圾回收的主要目标是针对常量池的回收和类型的卸载。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError:PermGen space异常。由于早期HotSpot JVM的实现,将GC分代收集拓展到方法区,因此很多人将方法区称为永久代。
运行时常量池
运行时常量池(Run-Time Constant Pool),这是方法区的一部分,受到方法区内存的限制,当常量池无法再申请到内存时,会抛出OutOfMemoryError:PermGen space异常。在class文件中,除了有类的版本、方法、字段、接口等描述信息外,还有一项描述信息是常量池。每个class文件的头四字节称为Magic Number,它的作用是确定这是否是一个可以被虚拟机接受的文件,接着的四个字节存储的是class文件的版本号。在版本号后面的就是常量池入口了。常量池主要存放两大类常量:
- 字面量(Literal),如文本字符串、final常量值
- 符号引用,存放了与编译相关的一些常量,因为Java不像C++那样有连接的过程,因此字段方法这些符号引用在运行期就需要进行转换,以便得到真正的内存入口地址
class文件中的常量池,也称为静态常量池,JVM虚拟机完成类装载操作后,会把静态常量池加载到内存中,存放在运行时常量池。运行时常量池相对于class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只能在编译期产生,也就是并非预置入class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern() 方法。
堆
Java 堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,并且是完全自动化管理的。Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。如果在堆中没有内存完成对象实例的分配,并且堆无法再扩展时,将抛出OutOfMemoryError异常,抛出的错误信息是“Java.lang.OutOfMemoryError:Java heap space”。可以通过-Xmx和-Xms来控制堆内存的大小,发生堆上OOM的可能是存在内存泄露,也可能是堆大小分配不合理。
从内存回收的角度看,由于现在垃圾收集器基本都是采用的分代收集算法,所以Java 堆中还可以细分为:新生代和老年代;再细致一点新生代可以分为Eden 空间、From Survivor 空间、To Survivor 空间等。如果从内存分配的角度看,线程共享的Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
新生代Eden空间
在新生代的Eden空间,对象会优先分配在该空间,同时JVM可以为每个线程分配一个私有的缓存区域,称为TLAB(Thread Local Allocation Buffer),避免多线程同时分内存时需要使用加锁等机制而影响分配速度。TLAB在堆的Eden空间上工作,内存中Eden的结构大体为:
TLAB的管理主要是依靠三个指针:start、end、top。start与end标记了Eden中被该TLAB管理的区域空间,该区域空间不会被其他线程分配内存所使用。top是指分配指针,开始时指向start的位置,随着内存分配的进行,慢慢向end靠近,当到达end位置时触发TLAB refill。TLAB缺省情况下仅占有整个Eden空间的1%,当然可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。
新生代Survivor空间
当Eden空间足够大时,大部分新建对象会被分配在Eden区,有些大对象会被直接分配在老年代空间。
当Eden空间不足时,会发生一次Minor GC,未被引用的对象会被回收,Eden中仍存活的对象会被移动到From Survivor(避免直接进入老年代导致过早触发Full GC)。Survivor中的对象每经过一次Minor GC,对象的age会加1,默认age超过15依然存活的对象会被移入老年代空间。
当发生Minor GC时,如果Survivor空间不足以保存Eden区中存活的对象,那么该对象会被直接移入老年代;如果Survivor中同age的对象占用空间的总和达到或超过其中一个Survivor的一半,那么所有同age对象都会被移入老年代。
通常情况下,只有其中一个Survivor空间是存在对象的,另一个Survivor是空的。当再次发生GC时,Eden中的对象被复制到标记为To的空的Surivivor中,原来From中依然存活的未到达年龄的对象也会复制到To,此时To被标记为From,原来的From置空并被标记为To,轮换是为了避免Surivivor中因没有连续空间(避免内存碎片的产生)而导致对象被直接移入老年代。
老年代
老年代用于存放生命周期长的对象,通常是从Survivor空间移入过来的对象。当对象过大时,无法在新生代用连续内存分配,那么这个大对象会直接分配在老年代上。一般来说,普通的对象都是分配在TLAB上,较大的对象,直接分配在Eden区上的其他内存区域,而过大的对象,直接分配在老年代上。当老年代被占用的空间达到一定比例就会触发Full GC。
永久代
永久代是HotSpot的概念,方法区是Java虚拟机规范中的定义,是一种规范。HotSpot虚拟机把GC分代收集扩展至方法区,使用永久代来实现方法区,进而实现对方法区内存的回收。永久代存放着类信息、常量、静态变量、JIT编译后的代码,其物理上是堆的一部分。可以通过-XX:PermSize、-XX:MaxPermSize来控制方法区初始大小和最大大小,
超过这个值将会抛出OutOfMemoryError: PermGen异常。
由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出。最典型的场景就是,在 jsp 页面比较多的情况,容易出现永久代内存溢出。
Vritual空间
当使用Xms与Xmx来指定堆的最小与最大空间时,如果Xms小于Xmx,堆的大小不会直接扩展到上限,而是留着一部分等待内存需求不断增长时,再分配给新生代。Vritual空间便是这部分保留的内存区域。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常。
Java中的NIO类可以使用Native函数库,直接分配堆外内存,然后通过一个存储在Java堆里面的DirectoryByteBuffer对象作为这块内存的引用进行操作,这样能避免在Java堆和Native堆中来回复制数据,在一些场景中显著提高性能。通常访问直接内存的速度会优于Java堆。因此出于性能的考虑,读写频繁的场合可能会考虑使用直接内存。
从上面可以知道,本机直接内存的分配不会受到Java堆大小的限制。但还是会受到本机总内存(RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制。在配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但会经常忽略直接内存,使得各个内存区域的总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。
对象创建过程
最普通的对象创建会涉及到Java堆、虚拟机栈、方法区。假设在某个方法中创建一个对象,Object obj = new Object()
。那Object obj
这部分予以将会反映到虚拟机栈的本地变量表中,作为一个reference类型数据出现。而new Object()
这部分语义将会反映到Java堆中,形成一块存储了Object类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度不是固定的。而对象的类型数据,如对象类型、父类、实现的接口、方法等,则存储在方法区中。这些类型的地址信息则存储能查到对象类型数据的地址信息。
由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问Java堆中的对象具体位置。现主流的访问方式有两种:使用句柄和直接指针。
句柄访问方式
Java堆中将会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
直接指针访问方式
Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址。
区别
- 使用句柄访问方式的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(如垃圾回收)时,只会改变句柄中的实例数据指针,而reference本身不需要修改。
- 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销。
内存溢出代码示例
Java堆溢出
控制参数:-verbose:gc -Xms20M -Xmx20M -XX:+PrintGCDetails
1 | public class HeapOutOfMemory { |
通过参数-XX:+HeapDumpOnOutOfMemoryError
可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆情况快照。通过快照分析确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。如果是内存泄漏,可以查看泄漏对象到GC Roots的引用链,找出导致垃圾收集器无法自动回收的原因。如果不存在泄漏,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与-Xms)。
虚拟机栈溢出(栈层级不足)
控制参数:-Xss128k
1 | public class StackOverFlow { |
常量池溢出
控制参数: -XX:PermSize=10M -XX:MaxPermSize=10M
1 | public class ConstantOutOfMemory { |
方法区溢出
控制参数: -XX:PermSize=10M -XX:MaxPermSize=10M
1 | public class MethodAreaOutOfMemory { |
直接内存溢出
控制参数:-Xmx20M -XX:MaxDirectMemorySize=10M
1 | public class DirectoryMemoryOutOfmemory { |