最近学习了JVM的一些知识。作为后端开发人员,这一块还是需要了解的。不过,作为后端开发人员来说,对JVM的了解还是很有限的。这篇文章记录一下我学习JVM的一些思路。不说了,先上目录:
1.JVM内存结构都有什么?具体分为哪几部分?
2.一个java类在运行过程中,在JVM中的具体流转过程。
// 3.JVM的垃圾回收原理和时机。
//4.JVM的调优案例。
1.内存结构:
话不多说,先上图:
主要有三部分组成:类装载子系统,运行时数据区,字节码执行引擎。我们考虑最多的是运行时数据区这块。
运行时数据区包括:堆,栈,方法区,程序计数器。其中,栈又分为虚拟机栈和本地方法栈。我们经常说的栈就是虚拟机栈。那些用native修饰的方法,将来会入本地方法栈。接下来讨论一下,各个部分存储的内容。
堆:Java堆分为年轻代(Young Generation)和老年代(Old Generation);年轻代又分为伊甸园(Eden)和幸存区(Survivor区);
幸存区又分为From Survivor空间和 To Survivor空间。上图:
老年代和年轻代的比例一般是2:1. 年轻代中伊甸园区和survivor区比例是4:1. from区和To区比例是1:1
对象创建所在区域:
一般情况下,新创建的对象都会被分配到Eden区(朝生夕死),一些特殊的大的对象会直接分配到Old区。
比如有对象A,B,C等创建在Eden区,但是Eden区的内存空间肯定有限,比如有100M
,假如已经使用了100M
或者达到一个设定的临界值,这时候就需要对Eden内存空间进行清理,即垃圾收集(Garbage Collect),这样的GC
我们称之为Minor GC
,Minor GC
指得是Young区的GC
。
经过GC
之后,有些对象就会被清理掉,有些对象可能还存活着,对于存活着的对象需要将其复制到Survivor区,然后再清空Eden区中的这些对象。
TLAB的全称是 Thread Local Allocation Buffer,JVM
默认给每个线程开辟一个 buffer 区域,用来加速对象分配。这个 buffer 就放在 Eden 区中。
这个道理和 Java 语言中的ThreadLocal
类似,避免了对公共区的操作,以及一些锁竞争。
堆中主要存放:使用new关键字创建的对象,所有对象实例以及数组都要在堆上分配。是线程共享的区域。
Survivor区详解:
由图解可以看出,Survivor区分为两块S0和S1,也可以叫做From和To。在同一个时间点上,S0和S1只能有一个区有数据,另外一个是空的。
接着上面的GC
来说,比如一开始只有Eden区和From中有对象,To中是空的。
此时进行一次GC
操作,From区中对象的年龄就会+1,我们知道Eden区中所有存活的对象会被复制到To区,From区中还能存活的对象会有两个去处。
若对象年龄达到之前设置好的年龄阈值(默认年龄为15岁,可以自行设置参数‐XX:+MaxTenuringThreshold
),此时对象会被移动到Old区, 如果Eden区和From区 没有达到阈值的对象会被复制到To区。
此时Eden区和From区已经被清空(被GC
的对象肯定没了,没有被GC
的对象都有了各自的去处)。
这时候From和To交换角色,之前的From变成了To,之前的To变成了From。也就是说无论如何都要保证名为To的Survivor区域是空的。
Minor GC
会一直重复这样的过程,知道To区被填满,然后会将所有对象复制到老年代中。
Old区概览 :
从上面的分析可以看出,一般Old区都是年龄比较大的对象,或者相对超过了某个阈值(-XX:PretenureSizeThreshold
,默认为0,表示全部进Eden区)的对象。在Old区也会有GC
的操作,Old区的GC
我们称作为Major GC
。
对象的整个生命周期:
对象1
我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。
有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。
直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。
于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC
就加一岁),然后被回收。
对象2
我天生就是个特例,与众不同,出生就和大人一样大,于是Eden区说你太大了,我们这里不你适合,然后就直接把我送到了老年区。在老年混着混着就老死了(被回收了)。
栈:线程私有,每个线程拥有独立的栈空间,生命周期与线程生命周期相同。先进后出。里面存存放有栈帧元素。每个方法在执行时都会创建一个栈帧。栈帧分为:局部变量表,操作数栈,动态链接和方法出口。这里了解一下局部变量表和操作数栈。
局部变量表:
存储基本数据类型的局部变量(包括参数)、和对象的引用(String、数组、对象等),但是不存储对象的内容。
局部变量的容量以变量槽(Variable Slot)为最小单位,每个变量槽最大存储32位的数据类型。对于64位的数据类型(long、double),
JVM 会为其分配两个连续的变量槽来存储。以下简称 Slot 。
SLot复用:
为了尽可能的节省栈帧空间,局部变量表中的 Slot 是可以复用的。方法中定义的局部变量,其作用域不一定会覆盖整个方法。当方法运行时,如果已经超出了某个变量的作用域,即变量失效了,那这个变量对应的 Slot 就可以交给其他变量使用,也就是所谓的 Slot 复用。通过一个例子来理解变量“失效”。
public void test(boolean flag)
{
if(flag)
{
int a = 66;
}
int b = 55;
}
当虚拟机运行 test 方法,就会创建一个栈帧,并压入到当前线程的栈中。当运行到 int a = 66时,在当前栈帧的局部变量中创建一个 Slot 存储变量 a,当运行到 int b = 55时,此时已经超出变量 a 的作用域了(变量 a 的作用域在{}所包含的代码块中),此时 a 就失效了,变量a 占用的 Slot 就可以交给b来使用,这就是 Slot 复用。
凡事有利弊。Slot 复用虽然节省了栈帧空间,但是会伴随一些额外的副作用。比如,Slot 的复用会直接影响到系统的垃圾收集行为。
操作数栈:可以理解为栈帧中用于计算的临时数据存储区。操作数栈的元素可以是任意的Java数据类型。
可以运用java反编译工具JAD,结合JVM指令手册,看到操作数栈在虚拟机中的具体操作流程。这里就不不说具体做法了。
栈中可能出现哪些异常?StackOverflowError:栈溢出错误 ; OutOfMemoryError:内存不足
如果一个线程在计算时所需要用到栈大小 > 配置允许最大的栈大小,那么Java虚拟机将抛出 StackOverflowError
栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。
如何设置栈参数?使用 -Xss 设置栈大小
方法区:同堆一样,线程共享。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。
常量池是方法区的一部分。上图:
程序计数器:
当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令
程序计数器是每个线程私有的内存。
程序计数器不会发生内存溢出(OutOfMemoryError即OOM)问题。
好了,第一部分JVM内存结构学习完毕。已知晓了各个组成部分存储的内容,接下来我解释下一个类信息在加载过程中的流程。先上一个类:
1.这个java类会被编译成Math.class文件。
补充:怎么编译成的呢?编译过程如下:
注释:字节码生成器
1、词法分析
读取源代码,一个字节一个字节的读取,找出其中我们定义好的关键字(如Java
中的if、else、for、while等关键词,识别哪些if是合法的关键词,哪些不是),这就是词法分析器进行词法分析的过程,其结果是从源代码中找出规范化的Token流。
2、语法分析
通过语法分析器对词法分析后Token流进行语法分析,这一步检查这些关键字组合再一次是否符合Java
语言规范(如在if后面是不是紧跟着一个布尔判断表达式),词法分析的结果是形成一个符合Java
语言规范的抽象语法树。
3、语义分析
通过语义分析器进行语义分析。语音分析主要是将一些难懂的、复杂的语法转化成更加简单的语法,结果形成最简单的语法(如将foreach
转换成for循环 ,好有注解等),最后形成一个注解过后的抽象语法树,这个语法树更为接近目标语言的语法规则。
4、生成字节码
通过字节码生产器生成字节码,根据经过注解的语法抽象树生成字节码,也就是将一个数据结构转化为另一个数据结构。最后生成我们想要的.class文件。
补充:JVM是如何把class文件里的东西存放的呢?
2.类转载子系统尝试记载这个.class文件。首先会进入方法区。加载class文件的所有类具体信息。
3.会为每个方法创建栈帧。对象放入堆,局部变量和对象的引用放入栈帧中的局部变量表。
4.程序执行用程序计数器,字节码执行引擎负责读取指令。程序计数器用来计算和临时存储。
5.return为方法出口,先进后出。
最后,附一张图片:每个区域是否为线程共享,是否会发生OOM?
//第二部分类信息在JVM中的流转,介绍完毕。接下来讲一下垃圾回收时机和机制。讲这一部分,肯定离不开堆。所以,先去看一下上面堆的具体组成结构。接下来就更好理解了。
首选,new出来的对象会放入对象的伊甸园区,当伊甸园区满了以后,会触发一次MonitorGC,将没有用的对象回收掉,有用的对象进入FORM区。下一次伊甸园区满了后,会再次触发MonitorGC,这次不仅回收伊甸园无用的对象还会回收FORM区无用的对象,再将有用对象存入FROM区。合适的时机,FROM区会移动到To区,每移动一次,To区的对象标记加1,当等于15时,将会把老不死的对象放入老年代。老年代满了以后就会触发FULLGC。
JVM调优就是减少STW(Stop the work)也就是尽量减少FULLGC的触发次数。接下来,讲一下第四部分,真实的调优案例加详细过程分析:
8个G的电脑 3-5个G给操作系统 4-5个G给java虚拟机 堆2-3个G差不多 剩下给元空间,栈。
300个订单/秒 每秒300个订单对象,一个订单对象大小就是成员变量大小的总和,一般不超过1KB。
肯定还有其他业务对象,最终放大20倍
所以300*20个订单/秒,可能还会有其他操作,如订单查询等等 ,再放大10倍。
所以300KB*20*10个订单/秒,一秒后全部变成垃圾对象。
伊甸园区大概800M,那么13.6秒后会放满。那么前12秒的都变成了垃圾。但第13秒的60M会放入From区(因为minotorGC会暂停程序)。
5-6分钟老年代就会放满。(2G X 1024KB/60M)=5-6分钟。
调优:让其几乎不发生FULLGC
原来:老年代2G 伊甸园区800M FROM区100M To区100M
现在:老年代1G 伊甸园区1.6G FROM区200M To区200M。
现在是每过25秒伊甸园区会放满。
-XMs:初始堆大小
-Xmx: 最大堆大小
-XMn: 新生代大小
-Xss: 设置每个线程可使用的内存大小,即栈的大小
-XX:MetaspaceSize、-XX:MaxMetaspaceSize:分别设置元空间最小大小与最大大小(Java8以后)
如果servor区大于50%,也会存入老年代,所以在调优时尽量将垃圾回收放在伊甸园区回收完,即使有一部分没有回收的垃圾对象到达了from区,也不要让其大于from区的50%。那么当下一次伊甸园区满了以后,做GC时,因为时间已经很久了,所以这个from区的未被回收的对象也早已经变成垃圾对象了。
总结:
调优注意两点:1.尽量将伊甸园区放大 2.年轻代遵守8:1:1原则 3.放入from区的不要让其大于50%。
补充:每个区域是否为线程共享,是否会发生OOM?