文章目录
JVM内存结构
JVM内存结构,即Java运行时数据区
Java虚拟机定义了若干程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
线程私有:程序计数器、虚拟机栈、本地方法区
线程共享:堆、方法区、堆内外村(Java7的永久代或JDK8的元空间、代码缓存)
一、程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码和行号指示器。
作用
1.字节码解释器通过改变程序计数器依次来读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。程序计数器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
线程私有的原因
2.在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪里。因为CPU需要不停地切换各个线程,当切换回原来线程时,需要知道下一条需要执行的指令是什么。同时,程序计数器被设定为线程私有的原因也就显而易见了,为了能够准确地记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个程序计数器,每个线程独立计算,不会互相影响。
程序计数器是唯一一个JVM规范中没有规定任何OutOfMemoryErr
虚拟机栈
作用
除了一些Native方法调用是通过本地实现栈实现的,其他所有Java方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
栈的存储单位
- 每个线程都有自己的栈,栈中数据都是以栈帧的格式存在
- 在这个线程上正在执行的每个方法都有各自对应的一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
栈的运行原理
- JVM直接对Java栈的操作只有两个,对栈帧的压栈和出栈,遵循“先进后出/后进先出”原则
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,与当前栈帧对应的方法就是当前方法,定义这个方法的类就是当前类。
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧。
- 不同线程中所包含的栈帧是不允许存在相互引用的(线程私有),即不可能在一个栈帧中引用另外一个线程的栈帧
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
- Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出
StackOverFlowError:
若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候
OutOfMemoryError:
如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间
结构
栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
局部变量表
主要存放了编译器可这的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
操作数栈
主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中
动态链接
在 Java 虚拟机(JVM)中,动态链接是指在运行时将符号引用转换为直接引用的过程。符号引用是一种编译时的引用,它以一种符号形式来表示目标,而直接引用是直接指向目标的指针、相对地址或句柄。
动态链接的主要目的是实现 Java 程序的灵活性和可维护性。在运行时,Java 虚拟机可以根据需要连接或替换类中的一些部分,而无需重新编译整个程序。
以下是动态链接的几个方面:
符号引用和直接引用:
符号引用(Symbolic Reference): 在编译时,Java 类中的方法和字段不是通过直接的内存地址表示,而是通过符号引用表示,它是一种符号化的引用,类似于符号表中的条目。符号引用可以是类和成员的名字、类型、字段描述符、方法描述符等。
直接引用(Direct Reference): 在运行时,符号引用被解析为直接引用,直接引用是可以直接指向内存地址的实际数据,如指向方法区中的方法表的地址。
举例说明:
假设有一个类 Example:
public class Example {
public static void main(String[] args) {
int result = add(2, 3);
System.out.println(result);
}
public static int add(int a, int b) {
return a + b;
}
}
在编译时,add 方法的调用是符号引用,指向方法名 add。
在运行时,当 main 方法执行时,add 方法的符号引用会被解析为直接引用,指向实际的方法体。
动态链接的优势在于提供了更大的灵活性。例如,如果 add 方法的实现发生了变化,只要不改变其方法签名,可以通过更新实现而无需修改调用方。这种特性使得 Java 具有较高的可维护性和适应性。
本地方法栈
本地方法接口
一个Native Method就是一个Java调用非Java代码的接口。我们知道的Unsafe类就有很多本地方法。
为什么要使用本地方法?
有些层次的任务用Java实现起来也不容易,或者我们对程序的效率很在意时需要使用本地方法
- 与Java环境外交互:有时Java应用需要与Java外面的环境交互,这就是本地方法存在的原因
- 与操作系统交互:JVM支持Java语言本身和运行时库,但是有时仍需要依赖一些底层系统的支持。通过本地方法,我们可以实现用Java与实现了jre的底层系统交互,JVM的一些部分就是C语言写的
本地方法栈
- Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用
- 本地方法栈是线程私有的
- 异常情况和虚拟机栈相同
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
堆
Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老生代;再细致一点有:Eden、Survivor、Old等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。
再JDK7版本以及JDK7版本之前,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)
- 老生代内存 (Old Generation)
- 永久代 (Permanent Generation)
JDK8版本之后永久代(PermGen)已经被元空间(Metaspace)取代,元空间使用的是本地内存.
新生代
年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为 Minor GC。年轻一代被分为三个部分——伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为from/to或s0/s1),默认比例是8:1:1
- 大多数新创建的对象都位于 Eden 内存空间中
- 当 Eden 空间被对象填充时,执行Minor GC,并将所有幸存者对象移动到一个幸存者空间中
- Minor GC检查幸存者对象,并将它们移动到另一个幸存者空间。所以每次,一个幸存者空间总是空的
- 过多次 GC循环后存活下来的对象被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的,然后他们才有资格提升到老一代
老年代
旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主GC(Major GC),通常需要更长的时间。
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝
元空间
不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 Java 虚拟机规范中方法区的实现。
方法区
方法区属于是JVM运行时数据区域的一块逻辑区域,是各个线程共享的内存区域
当虚拟机要使用一个类时,它需要读取并解析Class文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常亮、静态变量、即时编译器编译后的代码缓存等数据
方法区和永久代已经元空间三者之间的关系
方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。
为什么要将永久代(PermGen)替换为元空间(MetaSpace)
- 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
- 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
- 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分
常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用。
一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。
运行时常量池
- 在加载类和结构到虚拟机后,就会创建对应的运行时常量池
- 常量池表(Constant Pool Table)是 Class文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
- JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的
- 运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
- 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的 intern()方法就是这样的
- 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出OutOfMemoryError 异常。
字符串常量池
字符串常量池是JVM为了提升性能和减少内存消耗针对字符串(String类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true
JDK1.7之前,字符串常量池存放在永久代。JDK1.7之后,字符串常量池和静态变量从永久代移动到了Java堆
为什么要将字符串常量池移动到堆中
因为永久代(方法区实现)的GC回收效率太低,只有在整堆收集(Full GC)的时候才会被执行GC。Java程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效即时地回收字符串内存。