前言:我们都知道Java语言具有跨平台特性,正是由于JVM的存在,JVM相当于软件与硬件之间的中介,屏蔽了不同操作系统(Windows或Linux等等)底层指令集的区别,所以所深入了解JVM内存模型就显得很重要啦( 本文只针对JDK8进行探讨 )
那我就用下面这段代码作为抛砖引玉吧
public class Demo {
public Car car= new Car();
// 上一篇文章提到过,一个方法对应一块栈帧内存
public int compute(){
int x = 1;
int y = 2;
int z = (x + y) * 3;
return z;
}
public static void main(String[] args) {
Demo demo = new Demo();
demo.compute();
}
}
元空间(方法区的实现):这个概念在JDK8的时候被提出,在JDK8之前只有Perm区(永久代),在JDK7及之前版本由于永久代在JVM启动时大小被固定,难以对其进行调优,一旦发生Full GC就会移动类元信息,这个就会导致在某些极端场景中动态加载类过多,那么老年代会出现OOM。因此JDK8对这个问题进行了优化,JDK8的元空间在本地内存中分配,只要本地内存足够,那么就不容易让元空间出现OOM。在默认不指定元空间大小的情况下,其大小根据使用情况动态调整,当然也可以使用MaxMetaspaceSize来限制本地内存分配给类元数据的大小。元空间存放了常量、静态变量、类元信息、方法元信息。
注意点这里有个混淆点:方法区是JVM规范的一个概念定义,并不是一个具体的实现,每一个JVM的实现都可以有各自的实现;然后,在Java官方的HotSpot 虚拟机中,Java8版本以后,是用元空间来实现的方法区;在Java8之前的版本,则是用永久代实现的方法区;也就是说,“元空间” 和 “方法区”,前者是HotSpot 的具体实现技术,后者是JVM规范的抽象定义。
虚拟机栈: 描述Java方法执行的内存区域,线程私有,虚拟机栈由许多栈帧组成,栈帧是方法运行的基本结构,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程,在这个过程中位于栈顶的栈帧被称为当前栈帧,正在执行的方法被称为当前方法,在字节码执行引擎运行时,所有指令都只能针对当前栈帧进行操作。虚拟机栈主要由局部变量表、操作数栈、动态链接、方法出口组成。
(1)局部变量表:存放方法参数和局部变量,相对于类属性变量的准备阶段和初始化阶段来说,局部变量没有准备阶段,必须显式初始化。如果是非静态方法,则在index[0]位置上存储的是方法所属对象的实例引用(下图中的 this),随后存储的是参数和局部变量。
(2)操作数栈: Java虚拟机的解释执行引擎被称为"基于栈的执行引擎",其中所指的栈就是指-操作数栈。和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作—压栈和出栈—来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
(3)动态链接:每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态连接。
(4)方法出口(方法返回地址):执行时有两种结果,正常退出(正常执行到任何方法的返回字节码指令,如RETURN、IRETURN、ARETURN等)和异常退出,这两种退出情况,都将返回至方法当前被调用的位置,方法退出的过程相当于弹出当前栈帧。
注意点:StackOverflowError(栈内存溢出),通常出现在递归方法中。例如我在文末展示的代码就会出现:"java.lang.StackOverflowError"。
堆(Heap): 堆是一个共享区域,堆用于存放实例对象,存放几乎所有的实例对象,例如存放最开始代码中的Car对象,堆中的“垃圾”有垃圾收集器自动回收,堆区域主要分为两个区域:新生代和老年代,大小比例为1 : 2;而新生代中又被细分为1个Eden区(伊甸园,亚当夏娃出身的地方) + 2个Survivor区,Eden区:Survivor区 = 8 : 1;minor GC发生在新生代,full GC通常发生在老年代。
注意点:在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力,所以在线上生产环境中,JVM的Xms和Xmx设置成一样大小,避免在GC后调整堆大小时带来的额外压力。
程序计数器:每个线程私有区域,由于多线程并发执行可能导致CPU给每个线程分配固定的时间片去执行指令,所以当每次时间片轮换时(中断)就需要记录当前线程执行到哪一步指令(保存现场),以便下一次恢复现场继续执行还未执行的指令,程序计数器用于存放执行指令的偏移量和行号指示器,故为保存现场和恢复现场提供保障,程序计数器是每个线程的私有的,多个线程之间不受影响。特点 -- (1)是一块较小的内存空间。(2)线程私有。(3)不会出现OOM的内存区域。
本地方法栈:本地方法栈和虚拟机栈的功能差不多,只不过虚拟机栈是为了执行Java方法服务,而本地方法栈是为了虚拟机使用Native方法(基本上都是C或者C++代码方法)服务。
Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):
java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar mywebproject.jar
-Xss:每个线程的栈大小
-Xms:初始堆大小,默认物理内存的1/64
-Xmx:最大堆大小,默认物理内存的1/4
-Xmn:新生代大小
-XX:NewSize:设置新生代初始大小
-XX:NewRatio:默认2表示新生代占老年代的1/2,占整个堆内存的1/3。
-XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。
关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N
-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。
-XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的-XX:PermSize参数意思不一样,-XX:PermSize代表永久代的初始容量。
注意点:由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大。
public class StackOverflowTest {
static int a = 0;
static void add() {
a++;
add();
}
public static void main(String[] args) {
try {
add();
} catch (Throwable t) {
t.printStackTrace();
System.out.println(a);
}
}
}
运行结果:
更多关于JVM深度知识请看文章:
JVM类加载机制深入浅出分析--JVM系列(1)
JVM中对象创建与内存分配机制--JVM系列(3)