简述
在开始学习java的时候,我们知道java是一个跨平台的语言,为什么java能够跨平台,主要是因为jvm屏蔽了操作系统底层的差异。下面重点来研究下jvm。本次研究的jvm都是jdk1.8的jvm
jvm整体结构主要包括三大部分。
一部分是类装载子系统。这一部分在jvm调优一的时候说过了,
一部分是jvm的运行时数据区,也就是常说的jvm内存模型,是接下来需要重点研究的。
另一部分是字节码的执行引擎,这部分是C++实现的,暂时不去研究。
JVM整体结构及内存模型
下面这张图是jdk1.8 的jvm内存模型示意图。
jvm的内存模型主要包括:堆、栈、本地方法栈、方法区、程序计数器。这几部分都是一块块的内存区域。
栈
栈也可以叫做线程栈,用来存放局部变量等信息,这样说会比较笼统,下面会详细说明。
比如说有个线程执行Main方法,那么在总的栈空间会分配一小块空间给这个线程。那么这就叫线程栈。如果又有一个线程开始执行,那么总的栈空间又会分配一小块空间给线程2。如上图的Main线程和线程2.
栈帧
每个线程栈里面又会栈帧的概念。什么是栈帧。下面通过这个案例说明
比如运行上面的main方法。栈空间分配一个线程栈。执行了main方法。就会在线程栈中分配一个main方法的栈帧(一块内存空间)。在main方法中调用了compute()方法。又分配一个compute的栈帧。简单的来说就是一个方法对应一块栈帧内存区域。也可以这么去理解,因为局部变量只在当前方法中生效,那么每个方法必须要有一个特定的区域将方法的变量保存起来,那么这个区域就是栈帧。
如果compute方法中还调用了方法1、方法2、方法3。那么会在computer栈帧上方继续创建栈帧,而不是在conpute方法的栈帧里面创建栈帧。如下图。整个线程栈和数据结构–栈(FILO)相同,遵循先进后出的概念。
这也好理解。main方法中调computer方法,compute方法调方法1、2、3。方法1、2、3出栈后compute才能出栈。compute出栈main才能出栈。出栈就表示方法执行结束。
栈帧保存了那些内容
每一个栈帧都保存了:局部变量表、操作数栈、动态链接、方法出口。下面一一解释这些概念
局部变量表:存放了编译期可知的各种基本数据类型(boolean byte char short int float long double) 、对象引用(不同于对象本身,可能指向对象起始地址的引用指针,也可能指向一个代表对象的句柄等) 、returnAddress类型(指向了一条字节码指令的地址)。
局部变量表所需的内存空间是在编译期完成分配的,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全正确的,在方法运行期间不会改变局部变量表的大小。
操作数栈:每个操作数进行操作的时候都需要有一个临时的内存空间,比如说compute方法里面的a=1、b=2、c = (a + b) * 10等这些进行运算的时候就需要放到操作数栈进行运算,或者赋值的时候也是先放入到操作数栈,最后在保存到局部变量表。也就是说操作数栈是一个中转站,临时操作的一块空间。
动态链接:动态链接在jvm调优一有说过,这里再说一遍。比如说上图里面的pute();
compute()就是一个符号引用,保存在常量池里面(这个常量池后续再说,先这么去理解)。调用到compute()方法时,就要去解析compute(),把符号引用解析成直接引用,获取compute()里面的变量、程序执行打的代码等等在jvm的内存地址。简单说就是解析compute(),获取compute()方法里面需要执行代码的内存地址。动态链接保存的就是方法的内存地址。
方法出口:比如说pute();
执行完成后会回到main方法里面。那么compute();执行完后怎么知道要回到main方法呢,这就是方法出口保存的内容。方法出口记录了方法执行结束后会返回到哪个程序,记录了那个程序的内存地址。
注意:如果栈帧当中局部变量表保存的是一个对象,比如上面的main这个栈帧,它的局部变量表就有math
这个对象,但是它保存的并不是真正的math对象,而是math对象的内存地址(这个内存地址就是堆的内存地址,后面会说)。
栈会抛出两种异常:
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常如果虚拟机可以动态扩展(当前大部分的java虚拟机都允许动态扩展),如果扩展是无法申请到足够的内存,就会抛出OutOfMemoryError异常
总结:栈是线程私有的,它的生命周期和线程相同。栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直到执行完成的过程,就对应着一个栈帧在线程栈中入栈到出栈的过程。
程序计数器
程序计数器是一块较小的内存空间,它线程私有的内存。可以看做是当前线程所执行的字节码的行号执行器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。由于Java虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器。
方法区(元空间)
方法区是所有线程共享的内存区域,用于存储已被jvm加载的类信息、常量、静态变量、即时编译期编译后的代码等数据。
运行时常量池:是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用。
如果静态变量是一个对象比如public static User user = new User();
那么方法区保存的是user对象在堆的内存地址。
先简单理解后续会专门讲解。像类信息这些它的部分概念是C++的概念,放到最后在理解。
本地方法栈
本地方法就是那些 native方法,这些本地方法是C++实现的。这些本地方法执行也需要有内存空间去存放相关信息,那么本地方法栈就是保存本地方法执行的相关信息。目前来说本地方法栈很少用到。
堆
堆,被所有线程共享的一块内存区域,在jvm启动时创建。此内存区域唯一的目的就是存放对象实例。在java虚拟机规范中描述是:所有的对象实例以及数组都要在堆上分配。
堆细分为新生代和老年代 ,新生代进一步分为Eden空间、From Survivor空间、 To Survivor空间。
根据java虚拟机规范规定:java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续即可如果在堆中没有内存完成实例分配,并且堆也无法在扩展时,将会抛出OutOfMemoryError异常。
在下一篇博客中会专门研究,对象在堆中的内存分配。
JVM内存参数设置
Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):
java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M ‐jar xxx.jar
关于元空间的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设置成一样的值,并设置得比初始值要大, 对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。
-Xss:单个线程栈的大小。设置越小说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多
这只是简单的配置。后续在专门写关于jvm调优实战的博客