JVM 基础 - JVM 内存结构
前言
先放一张图:
线程私有:程序计数器、虚拟机栈、本地方法栈
线程共享:堆、方法区, 堆外内存(Java7的永久代或JDK8的元空间、代码缓存)
一、程序计数器
1、什么是程序计数器
程序计数寄存器(Program Counter Register),也叫做PC寄存器,是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
2、作用
用来存储指向下一条指令的地址,和即将要执行的指令代码,供执行引擎读取下一条指令。
3、问题
3.1、使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?
因为程序在运行过程中,CPU需要不停的切换各个线程,当线程被切换以后,需要知道从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
3.2、PC寄存器为什么会被设定为线程私有的?
多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响。
4、总结
1、程序计数器是一块很小的内存空间,几乎可以忽略不计,也是JVM中运行速度最快的存储区域。
2、在 JVM 中,每个线程都有自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致。
3、任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined) 。
4、程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
5、字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令。
6、程序计数器是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域。
二、虚拟机栈
1、概述
Java 虚拟机栈(Java Virtual Machine Stacks),早期也被叫作 Java 栈。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用,是线程私有,生命周期和线程一致。虚拟机栈主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
特点:
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器;
JVM 直接对虚拟机栈的操作只有两个:方法执行时入栈(进栈/压栈),方法执行结束时出栈;
栈不存在垃圾回收问题。
2、栈的存储单位
每个线程都有自己的栈,栈中的数据都是以**栈帧(Stack Frame)**的格式存在;
每个线程上正在执行的每个方法都各自有对应的一个栈帧;
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息;
3、栈运行原理
JVM 直接对 Java 栈的操作只有两个,对栈帧的压栈和出栈,遵循“先进后出/后进先出”原则。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class) 。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果在方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧。
不同线程中所包含的栈帧是不允许相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。
如果当前方法调用了其他方法,方法返回时,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出。
4、栈帧的内部结构
每个栈帧(Stack Frame)中存储着:
局部变量表(Local Variables) ;
操作数栈(Operand Stack)(或称为表达式栈) ;
动态链接(Dynamic Linking):指向运行时常量池的方法引用 ;
方法返回地址(Return Address):方法正常退出或异常退出的地址 ;
一些附加信息。
三、本地方法栈
1、本地方法
一个**本地方法(Native Method)**就是一个 Java 调用非 Java 代码(比如java环境外、操作系统等)的接口。Unsafe 类就有很多本地方法(java并发的锁机制底层就是使用的Unsafe类进行控制的)。
2、本地方法栈(Native Method Stack)
Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用;
本地方法栈也是线程私有的;
本地方法是使用 C 语言实现的;
四、堆内存
1、内存划分
Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
新生带(年轻代):新对象和没达到一定年龄的对象都在新生代
老年代(养老区):被长时间使用的对象,老年代的内存空间要比年轻代更大
元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存
Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -Xmx 和 -Xms 控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError 异常。
1.1 年轻代 (Young Generation)
年轻代是几乎所有新对象创建的地方(大的对象会直接在老年代中创建),年轻代被分为三个部分——伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为from/to或s0/s1),默认比例是8:1:1。
大多数新创建的对象都位于 Eden 内存空间中。
当 Eden 空间被对象填充时,执行Minor GC,并将所有幸存者对象移动到一个幸存者空间中。
Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。所以总是有一个幸存者空间是空的。
经过多次 GC 循环后存活下来的对象会被移动到老年代。通常,这是通过设置年轻一代对象的年龄阈值来实现的,然后他们才有资格提升到老年代。
1.2 老年代(Old Generation)
在年轻代中经过多轮Minor GC后仍然存活的对象将会被转移到老年代中,老年代内存满时会执行Major GC,这通常会需要更长的时间。
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝。
2、设置堆内存大小
Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,可以通过 -Xmx 和 -Xms 来设定
-Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize
-Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize
如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。
我们通常会将 -Xmx 和 -Xms 两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。
默认情况下,初始堆内存大小为:电脑内存大小/64
默认情况下,最大堆内存大小为:电脑内存大小/4
在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小。
默认情况下新生代和老年代的比例是 1:2,可以通过 –XX:NewRatio 来配置。
新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过 -XX:SurvivorRatio 来配置。
若在 JDK 7 中开启了 -XX:+UseAdaptiveSizePolicy,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄 此时 –XX:NewRatio 和 -XX:SurvivorRatio 将会失效,而 JDK 8 是默认开启-XX:+UseAdaptiveSizePolicy。
在 JDK 8中,不要随意关闭-XX:+UseAdaptiveSizePolicy,除非对堆内存的划分有明确的规划。
3、对象在堆中的生命周期
在 JVM 内存模型的堆中,堆被划分为新生代和老年代,新生代又被进一步划分为 Eden区 和 Survivor区,Survivor 区由 From Survivor 和 To Survivor 组成。
当创建一个对象时,对象会被优先分配到新生代的 Eden 区(大对象会被直接分配到老年代中),并且会给该对象定义一个年龄计数器。
当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC),将Eden中存活的对象转移到Survivor from中,并且对象的年龄加一。
当再次触发垃圾回收(Minor GC)时,JVM会将Eden和Survivor from的存活对象一起转移到Survivor to中,并且对象的年龄加一,再次触发垃圾回收时,又会转移到from中,循环反复,Survivor区的from和to永远只有一个在存放对象。
当年轻代中的存活对象的年龄达到设定的年龄阈值时,默认为15,就会被转移到老年代中。
当老年代中内存不足时,触发 Major GC,进行老年代的内存清理。
若老年代执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常。
五、方法区
**方法区(Method Area)**与 Java 堆一样,是所有线程共享的内存区域。方法区(method area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。
1、方法区的内部结构
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
1.1 类信息
包括了JVM加载类型(类class、接口interface、枚举enum、注解annotation)的完整有效名称(包名+类名)、其直接父类的完整有效名称、类型的修饰符、其直接继承的接口列表。
1.2 域(Field)信息
方法区中保存类型的所有域的相关信息以及域的声明顺序 。
域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)。
1.3 方法(Method)信息
方法区中保存有:
1.方法名称;
2.方法的返回类型;
3.方法参数的数量和类型;
4.方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集);
5.方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外);
6.异常表(abstract 和 native 方法除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。
1.4 静态变量
non-final的静态类变量和全局常量。区别在于全局常量在编译器给指定值,静态类变量在加载时准备阶段赋初值,初始化阶段再给指定值。
1.5 JIT代码缓存
即时编译产生的代码缓存,将热点代码编译成与本地平台相关的机器码,并保存到内存。
1.6 运行时常量池
各种字面量和对类型、域和方法的符号引用。
2、栈、堆、方法区的交互关系
3、方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
只要常量池中的常量没有被任何地方引用,就可以被回收。
Java 虚拟机被允许对满足以下三个条件的无用类进行回收(这里说的仅仅是“被允许”,而并不是和对象一样,不使用了就必然会回收):
1、该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例;
2、加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成;
3、该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。