JVM内存结构
JVM内存的运行时数据区:
线程私有(在线程启动时创建)
程序计数器Program Counter Register
一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
虚拟机栈VM Stack
描述的是Java方法执行的内存模型:每个方法在执行的同时会创建一个栈帧[Stack Frame]用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表
局部变量表存放了编译期可知的各种基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
局部变量表用于存放方法参数和方法内部定义的局部变量。
局部变量表的容量以变量槽(Slot)为最小单位。
虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐含的参数。
为了节省栈帧空间,Slot是可以重用的,但是会有一些副作用,比如会影响系统的垃圾收集行为。所以,如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用大量内存、实际上已经不会再使用的变量,手动将其设置为null值便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形下的奇技来使用。(经过JIT编译后,设置null没有意义)
操作数栈
后入先出栈
动态链接
在运行期间将符号引用转化为直接引用。
方法返回地址
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息。
两种异常:
- StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度
- OutOfMemoryError:虚拟机规范中允许固定长度的虚拟机栈。虚拟机栈可动态扩展的前提下,扩展时无法申请到足够的内存
本地方法栈Native Method Stack
为虚拟机使用到的Native方法服务。
与虚拟机栈一样,抛出的异常有StackOverflowError和OutOfMemoryError。
线程共享(在虚拟机启动时创建)
堆Heap
对于大多数应用来说,堆是最大的一块内存空间,用来存放对象实例。
堆是垃圾收集器管理的主要区域。
从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为新生代和老年代。
新生代:一个eden,两个survivor[from和to](提高GC的效率)
在一次GC中,Eden区的对象要么被回收,要么进入survivor区,在survivor区大小够用的情况下,已在survivor区的对象并不会马上进入老年代,而是等达到一定的年龄(GC次数,可人工设置)
堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。同样既可以是固定大小,也可以可扩展。
异常:OutOfMemoryError[Java heap space]:堆中没有内存完成实例分配,并且堆也无法再扩展
方法区Method Area
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
和堆一样不需要连续的内存和可以选择固定大小或者可扩展,还可以选择不实现垃圾收集。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
异常:OutOfMemoryError:方法区无法满足内存分配需求
运行时常量池Runtime Constant Pool
是方法区的一部分。
- 编译期:Class文件中常量池信息,用于存放编译期生成的各种字面量和符号引用。
- 运行期:例如
String.intern()
,如果池中已经包含一个等于此String对象的字符串,则返回池中的字符串,否则将此String对象添加到池中,并且返回此String对象的引用。
元空间Meta Space
JDK1.8+,使用直接内存。
运行图解
方法调用
解析和分派之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。
解析resolution
方法调用指令:
invokestatic 调用类(静态)方法
invokespecial 调用实例构造器<init>
方法、私有方法和父方法
invokevirtual 调用所有的虚方法
invokeinterface 调用接口方法,会在运行时再确定一个实现此接口的对象
invokedynamic现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本。非虚方法包括静态方法、私有方法、实例构造器、父类方法、final方法。它们在类加载的时候就会把符号引用解析为该方法的直接引用。[虽然final方法是用invokevirtual来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,是一种非虚方法]
分派dispatch
静态分派
Human man = new Man();
,其中Human是静态类型,Man是实际类型。
静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。
静态分派的典型应用是方法重载。编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的。
动态分派
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
动态分派的典型应用是方法重写。
invokevirtual指令的运行时解析过程:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验。如果通过则返回这个方法的直接引用,查找结束;否则产生异常。
- 否则,按照继承关系从下往上一次对C的哥哥父类进行第2步的搜索和验证过程。
- 如果是中没有找到合适的方法,则抛出异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上。这就是重写的本质。
单分派
方法的接收者与方法的参数称为方法的宗量。
单分派是根据一个宗量对目标方法进行选择。
Java的动态分派属于单分派。[根据实际类型选择]
多分派
多分派是根据多于一个宗量对目标方法进行选择。
Java的静态分派属于多分派。[根据静态类型和方法参数选择]
总结
Java是一门静态多分派,动态单分派的语言。
对象
对象的创建
- 虚拟机遇到一条new指令时,首先检查该指令参数是否能在常量池中定位到一个类的符号引用,并检查该符号引用代表的类是否已被加载、解析和初始化过。
- 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。[类加载后就可以确定内存大小!]
- 除对象头外,其余被分配的内存空间初始化为零。
- 虚拟机对对象进行设置(设置对象头)。
- 执行
<init>
方法,将对象初始化。[invokespecial
]
对象的内存布局
对象头Object Header
第一部分:存储对象自身的运行时数据(Mark Word)
第二部分:类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据Instance Data
对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
对齐填充Padding
非必然存在,仅仅是占位符的作用。
对象的访问定位
通过栈上的reference数据来操作堆上的具体对象。
句柄
堆中划分一块内存作为句柄池,reference中存储的是对象的句柄地址,句柄中包括了对象实例数据的具体地址(堆)和对象类型数据的具体地址(方法池)。
好处:reference中存储的是稳定的句柄地址,对象被移动(GC)时只会改变句柄中的指针,而不需要改变reference本身。
直接指针
reference中存储的是对象地址,而对象到访问类型数据的指针存放在堆中。
好处:访问速度快,节省了一次指针定位的开销。
对象的存活判定
引用计数算法
给对象添加一个引用,有一个地方引用就+1;引用失效就-1。
问题:循环引用。
可达性分析算法
通过一系列GC Roots
的对象作为起始点,从这些节点开始向下搜索。当一个对象到RC Roots
没有任何引用链相连时,则证明此对象是不可用的。
可作为RC Roots
的对象包括:
- 虚拟机栈栈帧中的本地变量表中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(Native方法)引用的对象
反汇编命令javap
javap -c
对代码进行反汇编
javap -v
输出附加信息(包括行号、本地变量表,反汇编等详细信息)
命令获得的指令可以通过jvm指令手册查看。