最近学习了JVM的一些知识。作为后端开发人员,这一块还是需要了解的。不过,作为后端开发人员来说,对JVM的了解还是很有限的。这篇文章记录一下我学习JVM的一些思路。不说了,先上目录:

       1.JVM内存结构都有什么?具体分为哪几部分?

       2.一个java类在运行过程中,在JVM中的具体流转过程。

      // 3.JVM的垃圾回收原理和时机。

       //4.JVM的调优案例。

1.内存结构:

话不多说,先上图:

jvm res 内存包括那些部分_java

        主要有三部分组成:类装载子系统,运行时数据区,字节码执行引擎。我们考虑最多的是运行时数据区这块。

        运行时数据区包括:堆,栈,方法区,程序计数器。其中,栈又分为虚拟机栈和本地方法栈。我们经常说的栈就是虚拟机栈。那些用native修饰的方法,将来会入本地方法栈。接下来讨论一下,各个部分存储的内容。

       :Java堆分为年轻代(Young Generation)和老年代(Old Generation);年轻代又分为伊甸园(Eden)和幸存区(Survivor区);
幸存区又分为From Survivor空间和 To Survivor空间。上图:

jvm res 内存包括那些部分_后端_02

老年代和年轻代的比例一般是2:1.   年轻代中伊甸园区和survivor区比例是4:1.    from区和To区比例是1:1

对象创建所在区域:

一般情况下,新创建的对象都会被分配到Eden区(朝生夕死),一些特殊的大的对象会直接分配到Old区。

比如有对象A,B,C等创建在Eden区,但是Eden区的内存空间肯定有限,比如有100M,假如已经使用了100M或者达到一个设定的临界值,这时候就需要对Eden内存空间进行清理,即垃圾收集(Garbage Collect),这样的GC我们称之为Minor GCMinor 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区被填满,然后会将所有对象复制到老年代中。

jvm res 内存包括那些部分_JVM_03

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 设置栈大小

      方法区:同堆一样,线程共享。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。
    常量池是方法区的一部分。上图:

jvm res 内存包括那些部分_java_04

程序计数器

        当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令

        程序计数器是每个线程私有的内存。

        程序计数器不会发生内存溢出(OutOfMemoryError即OOM)问题。

好了,第一部分JVM内存结构学习完毕。已知晓了各个组成部分存储的内容,接下来我解释下一个类信息在加载过程中的流程。先上一个类:

jvm res 内存包括那些部分_开发语言_05

1.这个java类会被编译成Math.class文件。 

补充:怎么编译成的呢?编译过程如下:

jvm res 内存包括那些部分_后端_06

注释:字节码生成器

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?