文章目录
JVM内存模型/内存布局
一、运行时内存区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,如图所示:
- 程序计数器、虚拟机栈、本地方法栈——线程私有(随用户线程的启动/结束而创建/销毁)
- Java堆、方法区——线程共享(随虚拟机的启动/关闭而创建/销毁)
1.程序计数器(Program Counter Register)
程序计数器是一块较小的内存空间,用于记录下一条要运行的指令,可以看作是当前线程所执行的字节码的行号指示器,分支,循环,跳转,异常处理,线程回复等都需要依赖这个计数器来完成。每个线程都需要一个独立的程序计数器,各个线程之中的计数器相互独立,互不影响、独立存储,是线程中私有的内存空间。
Java虚拟机的多线程(JVM的并发)是通过线程切换并分配时间片执行来实现的。任何时刻,一个处理器内核都只会执行一条线程中的指令,某个线程在执行的过程中因为时间片耗尽而挂起,当它再次获取时间片时,需要从挂起的地方继续执行,程序计数器就是用来记录程序的字节码执行位置。因此,为了线程切换后能恢复到正确的执行位置,每天线程都需要有一个独立的程序计数器,所以这部分是“线程私有”的内存。
如果一个线程执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个Native方法(本地方法),这个计数器的值则为空,此内存区域是唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError异常情况的区域。
2.虚拟机栈(Java Virtual Machine Stacks)
虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧用来存储局部变量表,操作数栈,动态链接,方法出口等信息,参与方法的调用和返回,每一个方法从调用到执行完成的过程就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了编译期可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference类型即所有引用类型的父类,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
异常状况:我们知道,一个线程拥有一个自己的栈,这个栈的大小决定了方法调用的可达深度(递归多少层次,或嵌套调用多少层其他方法,-Xss 参数可以设置虚拟机栈大小),当栈帧一直在执行入栈操作时,线程请求的栈的深度大于虚拟机栈所允许的深度时,就会发生栈溢出(StackOverflowError),一般造成这个问题的原因是程序中方法被循环调用没有退出。虚拟机栈也可以动态扩展,但当超过Java虚拟机规范中所规定的长度时,就会报OutOfMemoryError(内存溢出)异常。
- StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度,将会抛出此异常。
- OutOfMemoryError:当可动态扩展的虚拟机栈在扩展时无法申请到足够的内存,就会抛出该异常。
3.本地方法栈(Native Method Stack)
本地方法栈和java虚拟机栈的功能相似,java虚拟机栈用于管理Java函数(字节码)的调用,而本地方法栈用于管理本地Native方法的调用,但不是由Java实现的,而是由C实现的。本地方法栈中方法使用的语言,使用方式,数据结构没有强制要求。有的虚拟机比如(HotSpot)直接就将虚拟机栈和本地方法栈合二为一。
与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
参数:使用-Xss参数减少栈内存容量。
4.Java堆(Heap)
堆是JVM里最大的一块内存区域,被所有线程共享,在虚拟机启动时创建,此区域的目的就是存放对象实例和数组,为所有创建的对象和数据分配内存空间,且每次分配的空间是不定长的。在Heap 中分配一定的内存来保存对象实例,实际上只是保存对象实例的属性值,属性的类型和对象本身的类型标记等,并不保存对象的方法(方法是指令,保存在Stack中)。
Java堆是垃圾收集管理的主要区域,也被称为GC堆,由于现在收集器基本都采用分代收集算法,所以Java的堆中还可以分为新生代,老年代,永久代,JDK1.8之后取消了永久代;其中新生代又划分为Eden空间,From Survivor空间,To Survivor空间。无论怎么划分都是为了更好的回收,分配,利用内存。
Java堆可以处于物理上不连续,逻辑上连续的存空间,Java堆在实现时,既可以是固定大小的,也可以是可拓展的,并且主流虚拟机都是按可扩展来实现的(通过-Xmx(最大堆容量) 和 -Xms(最小堆容量)控制)。如果在堆中没有内存完成实例分配,并且堆也无法再拓展时,将会抛出 OutOfMemoryError 异常。
5.方法区(Method Area)
也被称为永久区,与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,堆的一个逻辑部分,Non-Heap(非堆),其中运行时常量池就在方法区,对永久区的GC回收,主要是针对常量池的回收和对类型的卸载。
Java堆是 Java代码可及的内存,是留给开发人员使用的;而非堆(Non-Heap)是JVM留给自己用的,所以方法区、JVM内部处理或优化所需的内存 (如JIT编译后的代码缓存)、每个类结构 (如运行时常量池、字段和方法数据)以及方法和构造方法的代码都在非堆内存中。
根据Java虚拟机规范的规定,当方法区无法满足内存分配的需求时,将抛出 OutOfMemoryError 异常。
5.1运行时常量池(Runtime Constant Pool)
运行时常量池时方法区的一部分,用来存放编译器生成的各种字面量(常量和字符串)和符号引用(类和接口的符号引用、字段名称和描述的符号引用、方法名称和描述的符号引用),当常量池无法再申请到内存时也会抛出 OutOfMemoryError 异常。运行时常量池相对于Class文件常量池的一个重要特征是具备动态性。
Java语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,比如字符串的手动入池方法intern()。
6、直接内存(Direct Memory)
直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但这部分内存也被频繁运用,而却可能导致OutOfMemoryError异常出现。
这个我们实际中主要接触到的就是NIO,在NIO引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能避免在Java堆和Native堆中来回复制数据,在一些场景里显著提高性能。这块内存不受Java堆大小限制,但受本机总内存限制,可以通过MaxdirectMemorySize来设置(默认与堆内存最大值一样),所以也会出现OOM异常;
二、栈和栈帧
虚拟机栈是线程私有的,每个方法在执行时都会创建一个栈帧,加入虚拟机栈,方法执行结束后,所对应的栈帧出栈。栈帧用来存储局部变量表,操作数栈,动态链接,返回地址,附加信息等,参与方法的调用和返回,每一个方法从调用到执行完成的过程就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
1、栈:
又名堆栈,它是一种运算受限的线性表。其限制是仅允许在表的一端进行插入和删除运算。这一端被称为栈顶,相对地,把另一端称为栈底。其特性是先进后出。栈是线程私有的,生命周期跟线程相同,当创建一个线程时,同时会创建一个栈,栈的大小和深度都是固定的。方法参数列表中的变量,方法体中的基本数据类型的变量和引用数据类型的引用都存放在栈中,成员变量和对象本身不存放在栈中。运行时,成员函数的局部变量引用也存放在栈中。栈的变量随着变量作用域的结束而释放,栈中的变量随着方法的调用而创建,当方法执行结束后,jvm会自动释放内存,不需要GC回收。栈不是全局共享的,每个线程创建一个栈,该线程只能访问其对应的栈数据。栈内存的大小是在编译期就确定了的。
2、栈帧:
一个栈中可以有多个栈帧,栈帧随着方法的调用而创建,随着方法的结束而消亡。该栈帧中存储该方法中的变量,原则上各个栈帧之间的数据是不能共享的,但是在方法间调用时,jvm会将一方法的返回值赋值给调用它的栈帧中。每一个方法调用,就是一个压栈的过程,每个方法的结束就是一个出栈的过程。压栈都会将该栈帧置于栈顶,每个栈不会同时操作多个栈帧,只会操作栈顶,当栈顶操作结束时,会将该栈帧弹出,同时会释放该栈帧内存,其下一个栈帧将变为栈顶。栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。
在一个线程执行的任何时刻,都只会有一个帧是处于激活的。这个帧被称为当前帧,与之对应的方法被称为当前方法,方法所在的类被称为当前类,此时用到的本地变量数组和操作数栈也都是当前帧的。
1、局部变量表:是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量,所需内存空间(最大容量)在编译期间就已经确定,方法运行时不会改变,局部变量表的容量以**变量槽(Slot)**为最小单位,虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0到局部变量表最大的Slot数量。在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的。
2、操作数栈:也叫操作栈,后入先出栈,线程执行时使用到的数据的存储空间,操作数栈的最大深度在编译时就已经确定。方法刚开始执行时,操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,出栈/入栈操作。方法执行中进行算术运算或者调用其他的方法进行参数传递的时候是通过操作数栈进行的。
在概念模型中,两个栈帧是相互独立的。但是大多数虚拟机的实现都会进行优化,令两个栈帧出现一部分重叠。令下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一块,这样在方法调用的时候可以共用一部分数据,无需进行额外的参数复制传递。
3、动态链接:一些方法区的引用,Class文件的运行时常量池会保存大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,如果符号引用一部分是在类加载阶段或者第一次使用的时候转化为直接引用,那么这种转换称为静态解析,如果是在运行期间转换为直接引用,那么这种转换就称为动态连接。
4、方法返回地址:存储这个方法被调用的位置,退出当前方法时都会跳转到当前方法被调用的位置。
方法的退出分为两种情况,一种是正常退出,退出后会根据方法的定义来决定是否要传返回值给上层的调用者,一种是异常导致的方法结束,这种情况是不会传返回值给上层的调用方法。如果方法是正常退出的,则调用者的PC计数器的值就可以作为返回地址,如果是因为异常退出的,则是需要通过异常处理表来确定。
方法的退出就对应着栈帧在虚拟机栈中的一次出栈操作,因此方法退出时可能执行的操作有:恢复上层方法的局部变量表以及操作数栈,如果有返回值的话,就把返回值压入到调用者栈帧的操作数栈中,还会把PC计数器的值调整为方法调用入口的下一条指令。
5、附加信息:JVM增加的一些规范里没有的信息。例如与调试有关的信息。一般把动态链接、方法返回地址与附加信息归为一类,称为栈帧信息。
3、栈的优点:
-
栈内存数据共享:栈帧之间数据不能共享,但是同一个栈内的数据是可以共享的,这样设计是为了减小内存消耗,例如:int a = 1, int b= 1时,前面定义了a=1,a和1都在栈内存内,如果再定义一个b=1,此时将b放入栈内存,然后查找栈内存中是否有1,如果有则b指向1。如果再给b赋值2,则在栈内存中查找是否有2,如果没有就在栈内存中放一个2,然后b指向2。也就是如果常量在栈内存中,就将变量指向该常量,如果没有就在该栈内存增加一个该常量,并将变量指向该常量。
-
存取速度比堆要快,仅次于寄存器。其一是栈在编译器就申请好了内存空间,所以在运行时不需要申请内存大小,节约了时间,其二是栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。其三是访问时间,访问堆的一个具体单元,需要两次访问内存,第一次得取得指针,第二次才是真正得数据,而栈只需访问一次。
4、栈的缺点:
- 存在栈中的数据大小和生存期必须是确定的,缺乏灵活性。当栈在运行执行程序时,发现栈内存不够,不会动态的去申请内存,以至于导致程序报错,所以灵活性较差。