JVM-内存模型详解
JVM 把内存分为若干个不同的区域,这些区域有些是线程私有的,有些则是线程共享的,Java 内存区域也叫做运行时数据区,它的具体划分如下:
虚拟机栈
Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈中创建一个 栈帧(stack frame)。每个方法执行的过程就对应了一个入栈和出栈的过程。
在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。
一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表
局部变量表是变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在java编译成class文件的时候,就在方法的Code属性的max_locals数据项中确定该方法需要分配的最大局部变量表的容量。
局部变量表的容量以变量槽(Slot)为最小单位,32位虚拟机中一个Slot可以存放32位(4 字节)以内的数据类型( boolean、byte、char、short、int、float、reference和returnAddress八种)
对于64位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的Slot空间,也就是相当于把一次long和double数据类型读写分割成为两次32位读写。
reference类型虚拟机规范没有明确说明它的长度,但一般来说,虚拟机实现至少都应当能从此引用中直接或者间接地查找到对象在Java堆中的起始地址索引和方法区中的对象类型数据。
Slot是可以重用的,当Slot中的变量超出了作用域,那么下一次分配Slot的时候,将会覆盖原来的数据。Slot对对象的引用会影响GC(要是被引用,将不会被回收)。
系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)。也就是说不存在类变量那样的准备阶段。
操作数栈
操作数栈和局部变量表一样,在编译时期就已经确定了该方法所需要分配的局部变量表的最大容量。
操作数栈的每一个元素可用是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型占用的栈容量为2。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈 / 入栈操作。
在概念模型里,栈帧之间是应该是相互独立的,不过大多数虚拟机都会做一些优化处理,使局部变量表和操作数栈之间有部分重叠,这样在进行方法调用的时候可以直接共用参数,而不需要做额外的参数复制等工作。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中方法的符号引用为参数。
这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用(静态方法,私有方法等),这种转化称为静态解析,另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
静态解析
静态解析的四种情形:
1.静态方法
2.父类方法
3.构造方法
4.私有方法(无法被重写)
5.final修饰的方法
动态连接
有些符号引用则是每次运行期间转化为直接引用,这种转换叫做动态链接.这体现为Java的多态性.
相关字节码指令:
1.invokeinterface—调用接口中的方法,实际上是在运行期决定的,决定到底调用实现该接口的那个对象的特定方法
2.invokestatic—调用静态方法
3.invokespecial—调用自己的私有方法,构造方法(以及父类的方法)
4.invokevirtual—调用虚方法,存在运行期动态查找的过程
5.invokedynamic—动态调用方法
方法返回地址
一个方法开始执行后,只有两种方式可以退出这个方法:
1.执行引擎遇到任意一个方法返回的字节码指令。
传递给上层的方法调用者,是否有返回值和返回值类型将根据遇到何种方法来返回指令决定,这种退出的方法称为正常完成出口。
2.方法执行过程中遇到异常。
无论是java虚拟机内部产生的异常还是代码中throw出的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出的方式称为异常完成出口,一个方法若使用该方式退出,是不会给上层调用者任何返回值的。
无论使用那种方式退出方法,都要返回到方法被调用的位置,程序才能继续执行。方法返回时可能会在栈帧中保存一些信息,用来恢复上层方法的执行状态。
一般方法正常退出的时候,调用者的pc计数器的值可以作为返回地址,帧栈中很有可能会保存这个计数器的值作为返回地址。
方法退出的过程就是栈帧在虚拟机栈上的出栈过程,因此退出时的操作可能有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者的操作数栈每条整pc计数器的值指向调用该方法的后一条指令。
本地方法栈
本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用 native 关键字修饰的方法所存储的区域。
程序计数器
程序计数器也是线程私有的数据区,这部分区域用于存储线程的指令地址,用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能,这些都通过程序计数器来完成。
方法区
方法区是各个线程共享的内存区域。
《深入理解Java虚拟机》书中对方法区(Method Aera)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存、域信息、方法信息等。
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
1.类型的完整有效名称(全名=包名.类名);
2.类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类);
3.类型的修饰符(public,abstract,final的某个子集);
4.类型直接接口的一个有序列表;
域(Field)(属性)信息
1.JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
2.域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
方法(Method)信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
1.方法名称;
2.方法的返回类型(或void);
3.方法参数的数量和类型(按顺序);
4.方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的某个子集);
5.方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外);
6.异常表(abstract和native方法除外)。
每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。
静态变量(non-final的类变量)
1.静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。
2.类变量被类的所有实例共享,即使没有类实例时你也可以访问它。
静态变量(全局常量static+final)
被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。
常量池
实际上分为两种形态:常量池(静态常量池 Constant Pool Table)和运行时常量池(Runtime Constant Pool)。
常量池(静态常量池 Constant Pool Table),每个class一份,存在于字节码文件中。常量池中有字面量(数量值、字符串值)和符号引用(类符号引用、字段符号引用、方法符号引用),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
运行时常量池(Runtime Constant Pool),每个class一份,存在于方法区中(元空间)。当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是下面的StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。
字符串常量池(堆中字符串常量池 String Pool),每个JVM中只有一份,存在于方法区中(堆)。全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的)。
在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串(用双引号括起来的引用而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”驻留字符串”的身份。
方法区和永久代
说到方法区就联想到永久代,他们之间什么关系呢?
《Java虚拟机规范》只是规定了有方法区这个概念和它的作用,并没有规定如何去实现它。
不同的 JVM 上方法区的实现有所区别。在HotSpot上把GC分代收集扩展至方法区,或者说使用永久代来实现方法区。
永久代是HotSpot的概念,方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。其他的虚拟机实现并没有永久带这一说法。
各版本JVM方法区变化
版本 | 说明 |
---|---|
jdk1.6及以前 | 有永久代(permanent generation),静态变量存放在永久代上 |
jdk1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
jdk1.8 | 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆 |
配置参数
JVM 1.7即以前:
#方法区初始大小
#默认是物理内存的1/64
-XX:PermSize=32m
#方法区最大大小,超过这个值将会抛出OutOfMemoryError异常:java.lang.OutOfMemoryError: PermGen
#默认是物理内存的1/4
-XX:MaxPermSize=64m
1.8以后使用了Metaspace,使用PermSize参数配置出现提示:
Java HotSpot™ Client VM warning: ignoring option PermSize=32m; support was removed in 8.0
Java HotSpot™ Client VM warning: ignoring option MaxPermSize=64m; support was removed in 8.0
Metaspace元空间
JDK1.8中,永久代已经不存在,存储的类信息、编译后的代码数据等已经移动到了MetaSpace(元空间)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。
Java8为什么要将永久代替换成Metaspace?
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
参数配置:
# 初始Metaspace元空间的大小
-XX:MetaspaceSize=128m
# 最大Metaspace元空间大小,默认是没有限制
-XX:MaxMetaspaceSize=128m
堆
堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,几乎所有的对象实例都会分配在堆上。JDK 1.7后,字符串常量池从永久代中剥离出来,存放在堆中。
堆内存划分
Eden、S0、S1归为Young区(Young Gen),即新生代,执行new时大部分对象在此分配内存,经过一定GC次数(默认15次)后进入old区。
大部分对象在Eden区中生成,当Eden区满时,还存活的对象将被复制到Survivor区,当这个 Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制到老年代。
Eden和survivor(S0、S1)默认比例是8:1:1。
old区(Old Gen)即老年代。
堆内存GC
堆是垃圾回收机制的重点区域。垃圾回收机制有三种:minor gc,major gc 和full gc
年轻代中存在的对象是死亡非常快的。存在朝生夕死的情况。
为了提高年轻代的垃圾回收效率,又将年轻代划分为三个区域,一个eden和两个S0(sunrvivor) S1(from)。
堆内存配置参数
#初始总堆内存,推荐和最大堆内存一样大
-Xms512m
#最大总堆内存
-Xmx512m
#新生代(Eden + 2*S)与老年代的比值
# 2:新生代占总堆大小1/3,老年代总堆大小2/3
# 3:新生代占总堆大小1/4,老年代总堆大小3/4
-XX:NewRatio=2
#S0,S1和Eden比值,S0,S1的比例固定为1/X ,X=SurvivorRatio+1+1
#默认Eden:S0:S1比值8:1:1
#配置6,Eden:S0:S1比值6:1:1
-XX:SurvivorRatio=8
执行以下配置验证:
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=128m -Xms512m -Xmx512m -XX:NewRatio=4 -XX:SurvivorRatio=6
执行结果如下:
Heap Configuration:
MinHeapFreeRatio = 40
MaxHeapFreeRatio = 70
MaxHeapSize = 536870912 (512.0MB)
NewSize = 107347968 (102.375MB)
MaxNewSize = 107347968 (102.375MB)
OldSize = 429522944 (409.625MB)
NewRatio = 4
SurvivorRatio = 6
MetaspaceSize = 134217728 (128.0MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 134217728 (128.0MB)
G1HeapRegionSize = 0 (0.0MB)
Heap Usage:
New Generation (Eden + 1 Survivor Space):
capacity = 93978624 (89.625MB)
used = 14530664 (13.857521057128906MB)
free = 79447960 (75.7674789428711MB)
15.461669240869073% used
Eden Space:
capacity = 80609280 (76.875MB)
used = 14530664 (13.857521057128906MB)
free = 66078616 (63.017478942871094MB)
18.026043651549795% used
From Space:
capacity = 13369344 (12.75MB)
used = 0 (0.0MB)
free = 13369344 (12.75MB)
0.0% used
To Space:
capacity = 13369344 (12.75MB)
used = 0 (0.0MB)
free = 13369344 (12.75MB)
0.0% used
tenured generation:
capacity = 429522944 (409.625MB)
used = 0 (0.0MB)
free = 429522944 (409.625MB)
0.0% used
2070 interned Strings occupying 158440 bytes.
NewRatio=4
老年代大小为总堆大小4/5,即512*4/5=409.6(结果输出:409.625MB);
年轻代大小为总堆大小1/5,即512*1/5=102.4(结果输出:102.375MB)。
SurvivorRatio=6
Eden占young区6/8,即102.375*6/8=76.78125(结果输出:76.875MB);
S0占young区1/8,即102.375*1/8=12.796875(结果输出:12.75MB)。
内存分配方式
在类加载完成后,虚拟机需要为新生对象分配内存,为对象分配内存相当于是把一块确定的区域从堆中划分出来,这就涉及到一个问题,要划分的堆区是否规整。
假设 Java 堆中内存是规整的,所有使用过的内存放在一边,未使用的内存放在一边,中间放着一个指针,这个指针为分界指示器。那么为新对象分配内存空间就相当于是把指针向空闲的空间挪动对象大小相等的距离,这种内存分配方式叫做指针碰撞(Bump The Pointer)。
如果 Java 堆中的内存并不是规整的,已经被使用的内存和未被使用的内存相互交错在一起,这种情况下就没有办法使用指针碰撞,这里就要使用另外一种记录内存使用的方式:空闲列表(Free List),空闲列表维护了一个列表,这个列表记录了哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
所以,上述两种分配方式选择哪个,取决于 Java 堆是否规整来决定。在一些垃圾收集器的实现中,Serial、ParNew 等带压缩整理过程的收集器,使用的是指针碰撞;而使用 CMS 这种基于清除算法的收集器时,使用的是空闲列表。