JVM 内存结构
特别说明
JVM是一个规范,而不是一个实体。本篇文章描述的是日常开发中最常用的虚拟机:HotSpot JVM。
本篇文章主要围绕运行时数据区而展开。
由图可知,运行时数据区可以分为两大部分:线程私有区域和线程共享区域。
线程私有区域:程序计数器、虚拟机栈、本地方法栈
线程共享区域:堆、方法区
程序计数器
学过计算机组成原理
或者操作系统
的同学应该都知道,CPU 的主要工作方式就是取指执行
。有一个比较重要的寄存器,负责记录当前正在执行指令的地址,控制程序的流程,叫做程序计数器
。CPU 就从这里,一条又一条指令的获取和执行。
JVM 中,每个线程都有自己独立的程序计数器,不同于硬件,它是一个特定的内存空间,用来指示当前线程所执行的字节码行号,也可以行号指示器
。
JVM 的执行引擎不断的从程序计数器中获取字节码指令地址,在方法区中找到对应的字节码,解释或者编译运行。
特点
线程私有,生命周期与线程一致
分时操作系统依靠时钟中断 + 操作系统策略来进行任务的调度。不同的线程执行的流程是不一样的,必须保证互不干扰。线程上下文切换时,必须保存当前的上下文信息,以便于再次获得CPU执行权时,能够在上一次的保存点继续执行。
运行速度最快
这个没什么好说的,代码执行慢了,还有人用 Java 嘛?
执行 Java 代码时,指向的是字节码地址;执行本地方法时,则不指定值(Undefined)
本地方法指的是在 java 词法层面,被标记为
native
的方法,底层是 C/C++ 实现的。唯一一个 JVM 规范中没有规定任何
OOM(OutOfMemory)
的区域
虚拟机栈
1、概述
也许你之前听到过什么,方法执行,入栈,执行完成,出栈什么的。描述的栈就是每个线程独有的虚拟机栈,栈中放的不是方法,而是栈帧。每执行一个方法,都会压入一个栈帧;方法正常执行或者抛出异常退出,弹出栈帧。出栈和入栈的这个过程,方法确定且唯一。
2、特点
内存分配效率高,仅次于程序计数器
栈的内存分配和回收效率非常高效。因为栈的内存操作是连续的,通过简单的指针操作,即可完成内存的分配和释放。
栈中不存在垃圾回收的问题
也不是说不存在,是几乎不需要,栈上的内存分配和释放是确定的,方法执行时分配,方法退出释放。栈上面也的数据生命周期也很短,作用域也很明确,没有太多复杂的引用关系。通过简单的指针操作即可完成栈内存的分配和释放
3、栈中可能出现的异常
Java虚拟机规范指出:虚拟机栈的大小可以是动态的或者是固定不变的
固定不变,线程请求分类的容量唱过了最大阈值后,就是虚拟机就会抛出一个StackOverflowError
异常;
动态的,无法分配更多的空间时,虚拟机就会抛出OutOfMemoryError
异常。
栈的大小直接决定了函数调用的最大深度。可通过-Xss
设置最大阈值。
4、栈的运行原理
一个方法调用对应一个栈帧,执行方法入栈,方法退出或者内部抛出异常,出栈。
在栈顶的栈帧叫做当前栈帧,当前栈帧对应的方法称作当前方法,当前方法所在的类称为当前类。
执行引擎执行的字节码操作只对当前栈帧有效。
栈是独立的,栈里面包含的栈帧也是独立的,不可能相互引用。
当前方法返回的结果会传到前一个栈帧当中,然后弹出当前栈帧。
5、栈帧的内部结构
有图可知,栈帧中有:局部变量表,方法返回地址,操作数栈,动态链接,附加信息。
5.1、局部变量表
就是字节码文件中的LocalVariableTable
,是一个字节数组。
表中存放了方法的的参数,方法内部的局部变量,方法的返回地址等。
局部变量表的大小在编译期间就已经确定好了,运行期间不会改变。
方法调用时,调用者会将参数按顺序依次传递到被调用者的局部变量表中去。(知道形参和实参的区别了吗?)
局部变量表的存储单位是变量槽(Slot):
32位以内的类型占用一个槽,包括:对象引用、int、short、char、byte、boolean,returnAddress 类型
这里发散一下,为什么会出现基本类型的隐式转化这么一说,就是来自这里。short、char、byte 和 boolean 类型的变量在存储之前会被升级为 int 类型:
public String foo(){ byte a = 0; char c = '1'; boolean d = true; short f = 2; return ""; }
public java.lang.String foo(); descriptor: ()Ljava/lang/String; flags: (0x0001) ACC_PUBLIC Code: stack=1, locals=5, args_size=1 0: iconst_0 1: istore_1 2: bipush 49 4: istore_2 5: iconst_1 6: istore_3 7: iconst_2 8: istore 4 10: ldc #7 // String 12: areturn LineNumberTable: line 7: 0 line 8: 2 line 9: 5 line 10: 7 line 11: 10 LocalVariableTable: Start Length Slot Name Signature 0 13 0 this Lcom/kaiven/Test; 2 11 1 a B 5 8 2 c C 7 6 3 d Z 10 3 4 f S
解释一下这个
iconst_0
是什么意思哈,开头的i
代表操作的是一个整数类型的数据,整个指令就是将常数0
压入操作数栈(下文会说)中去。在 JVM 中,为了简化类型的表示,最小的操作数据类型就是32位的。
64位类型(long 和 double)占两个
Slot
每一个Slot都会有一个索引值(从0开始),可以通过索引访问指定的Slot
方法被调用的时候,参数值和局部变量值会按照顺序复制到局部变量表的每一个Slot上。
实例方法调用时,局部变量表的第一个Slot默认注入this
静态方法中为什么不能用this?因为局部变量表中没有啊。
栈帧中的槽位是可复用的,如果一个变量过了其作用域,其对应的槽位很有可能给新的变量使用
局部变量表中的变量是一个重要的
GC Roots
,被局部变量表中的变量之间或间接引用的对象都不会被回收
5.2、操作数栈
怎么又来一个栈???
方法体中的代码在执行的时候,总要产生一些中间(临时)数据吧,这些中间数据就保存在操作数栈中。
每个操作数栈都有一个明确的最大栈深度(不是虚拟机栈哦),编译期间就规定好了,字节码文件中code
属性的stack
属性值表示最大栈深度。
(32位类型数据占用一个栈深度,64位占用两个)
当前栈帧的对应的方法调用有返回值的话,会被压入当前栈帧的操作数栈中去。
扩展:
栈顶缓存:
寄存器是CPU的组成单元之一,意味着CPU可以非常高效的访问其中的数据。操作数栈中的操作数存储在内存当中,而执行一项操作的时候必然要涉及到多项入栈和出栈的指令以及栈顶数据的访问。每次都访问内存的话,会影响执行效率,于是乎,HotSpot JVM 的设计者们提出了栈顶缓存技术。
所谓的栈顶缓存就是将栈顶元素全部缓存在 CPU 的物理寄存器中,以此降低对内存的读/写频次,提高代码的执行效率。
5.3、动态链接
很重要哦!!!
这个其实涉及到编译原理
了,不过嘛,不会那么深入的。
之前的字节码文件详解
一文中提到过,字节码文件有一块区域叫做常量池,存放着类、接口、方法等的描述信息,在字节码文件中,以符号引用的形式出现。
这个符号引用呢,可以理解为就是一个占位符,等待填充。类加载器加载并连接后,就转换成了直接引用。
这个直接引用,不一定是目标对象的内存地址,也有可能是一个对象句柄,不过最终的目的都是起一个指向作用,指向一个目标。
一个方法调用了另一个方法,调用者怎么才能知道被调用方法的信息和字节码地址呢?
动态链接的作用就是将描述方法的符号引用转为调用方法的直接引用。
为什么叫做动态呢?
javac 编译的时候,不涉及传统编译器的连接步骤,只有在类加载阶段,甚至是运行期间才能,明确目标方法的直接引用。
将方法的符号引用转为直接引用还与方法的绑定机制有关:
- 静态链接:目标方法在编译期可确定,运行期间保持不变
- 动态链接:目标方法在编译期无法确定下来,只能在运行时进行符号引用到直接引用的转换
对应的方法绑定机制可以分为静态绑定和动态绑定,所谓的绑定就是一个类、方法或者字段在符号引用被替换为直接应用的过程,只会发生一次。
由此又引入了两个概念:虚方法和非虚方法
- 非虚方法:编译期间可确定其调用目标,运行时不变的方法,例如静态方法、私有方法、final 方法、构造器
- 虚方法:其他方法,一般是实例方法
(最明显的例子就是我们会在 Java 代码中进行大量的向上转型操作,即使用父类型的变量承接子类型的对象,只有在运行时才能知道调用的具体方法,当然,也包括方法的重载)
这里就会引出一个问题了,JVM 怎么知道调用的具体方法?
这里就要引入一个虚方法表的概念了。
如果在运行时,每一次的方法调用都要查找类的元信息的话,效率就态度了,维护一张表的效率就要好得多。
JVM 会在类的方法区中维护一个虚方法表,使用索引来代替查找,每个类都有一张虚方法表,表中存放的是各个方法实际的入口。虚方法表会在类加载的连接阶段开始创建并初始化,类变量初始化完成后,方法表也建立好了。
5.4、方法的返回地址(Return Address)
A 方法调用 B 方法,B 方法执行完毕后,肯定是要回到 A 方法继续执行的。
方法的返回地址就保存了调用方的程序计数器的值。
方法的执行,有两种情况:正常返回和异常退出。无论是哪一种情况,本质上都是当前栈帧的弹出与销毁。不过我们还是来讨论一下它们的区别:
- 正常返回:执行引擎遇到方法返回指令,将返回值传递给调用方后,退出方法
- 异常返回:方法执行期间抛出异常,查询对应的异常表,找到异常处理器并执行,找不到就退出当前方法,不会给调用方返回任何信息。当前方法退出后,继续查找调用方的异常表,以此类推,直至栈被清空。
本地方法栈
虚拟机栈呢,是用来保存 Java 代码调用时产生的上下文信息,与之相呼应的本地方法栈,是用来保存本地方法执行时产生的上下文信息。
什么是本地方法呢?
UnSafe 类中就有很多的本地方法(被标记为 native),它就叫做“不安全类”也是有原因的,执行本地方法时,具有和 JVM 同样的权限,不受 JVM 限制。
(本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,甚至可以直接使用本地处理器中的寄存器,直接从本地内存堆中分配任意数量的内存)
并不是所有的 JVM 都支持本地方法,JVM 规范没有明确的指出一定要实现本地方法栈。
HotSpot VM 中,本地方法栈和虚拟机栈合二为一。
堆内存
所有线程共享的区域,几乎所有的对象实例数据都在堆上分配,内存管理的重要区域。
(内存管理指的就是内存的分配与释放)
为了高效的进行内存管理,虚拟机把堆内存逻辑上划分为三个部分:
新生代、老年代、元空间。
-Xmx
设置最大堆内存容量(默认系统内存大小/64),-Xms
设置堆的最小初始化容量(默认系统内存大小/4)。
1、新生代
新对象的主要创建区域,也是垃圾收集的主战场,该区域的垃圾收集称为“young gc”。
每一次的gc
操作,都会将存活的对象放入到其中一个幸存者区中去(S0、S1),即每次都会有一个幸存者空间空闲。当经历多次gc
后,还存活的对象,就晋升到老年代中去。(默认阈值是15次)。
新生代中“Eden”区与两个幸存者区的比例是 8:1:1。(可以调整)
2、老年代
对象可以通过晋升来到此区域,如果是一个大对象(占用大量的连续内存空间)的话,直接进入老年代。(阈值也是可以调整的)
为什么不放入新生代?
从动机出发,它大概率不会消亡,在年轻代中经历一次又一次的gc,内存拷贝的开销太大了。
3、元空间
jdk 7 之前叫做永久代
,jdk 8 及之后叫做元空间
。元空间其实已经不属于 java 堆了,用的是本地内存
。
4、TLAB(Thread Local Allocation Buffer)
多线程共享堆内存这块区域,线程并发的情况下,内存空间的分配存在冲突,这往往需要加锁等操作来解决。而大部分的任务执行都不需要太多的内存分配,JVM 为每个线程配备了一个私有的线程缓冲区。新对象分配时,优先在缓冲区中进行。该缓冲区在Eden
园区中。
5、逃逸分析
对象一定是在堆上分配嘛?
答案是否定的,因为有个逃逸分析
。
它的基本行为就是分析一个对象的作用域,看它是否只是在方法内部使用。如果是,那么认为对象没有逃逸;如果是不是,那么对象逃逸了。
jdk 6 之后的 HotSpot Vm 默认开启了逃逸分析。
使用逃逸分析,可以对代码做如下的优化:
栈上分配
减轻了垃圾回收的压力
同步省略
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
分离对象或标量替换
有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器
(标量指的无法分解成更小数据的数据,比如 java 中的原始类型;可以分解的就叫做聚合量,比如对象)
当然,美好的事情总归是要付出代价的,JVM 不承诺逃逸分析的消耗一定小于执行原始代码。
方法区
所有线程共享,存放的基本上是一些不会改变的东西,有一个运行时常量池,开发人员可以将新的常量放入其中,如果无法申请到更多的内存区域,会抛出OutOfMemory
异常。
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
方法区其实只是个概念,相当于 JVM 规范要求你要实现一个怎样的东西。
永久代是 HotSpot VM 中的说法,jdk 8被元空间取代了。
之前的永久代是并入到堆里面的,但是其中的内容基本上又不会发生改变,GC
受益并不高,如果频繁的进行类加载行为的话,非常容易造成OutOfMemory
,jdk 8索性将其从堆中抽离开来,不受 JVM 的内存管理。当然,如果方法区中的常量是对象的话,还是在堆中分配的,引用它即可。
对于方法区的回收,主要的就两个东西:类的卸载和常量池的回收。
对于常量池来说,其中的常量没有被引用了,就可以被回收。(每个类加载后都有对应的常量池)
对于类的卸载,需要满足以下条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
就算满足这三个条件,JVM 也不承诺一定会对无用类进行回收。
总结
内容确实很多,多看几遍,理解记忆。
2025/01/21
writeBy kaiven