荐 JVM虚拟机底层原理分析与性能优化思想
欢迎关注我的公众号:松鼠技术站
一、Java虚拟机内存模型
首先,我们可以看JDK的体系结构图:
可以大致看到jdk的组成,有java language(java、javac等)、还有工具Tools(Java VisualVM、Deployment、interface、Libraries、Java Virtual Machine。JDK包含了JRE,关于这两者可以看: JDK、JRE和JVM到底是什么
JAVA虚拟机占据最下面的一块位置,虽然看起来占据位置并不大,但是java虚拟机在整个JDK中是非常重要的。
从JVM的整体结构图,可以看出JVM是在java语言跨平台上做什么工作:
跨平台简单来说就是我们程序员只需要写一份java代码,就可以在不同的操作系统下运行。 那么这个过程就是JVM来实现的。
我的理解是,JVM就像是翻译官,如果是业界大佬但是只会中文,那么去法国,会带一个精通法语的翻译官,去俄罗斯就会带一个精通俄语的翻译官,那么同样的window平台和linux平台有各自的JVM,我们只需要将自己的.java
代码转换成字节码.class
文件,那么拿到不同的平台都可以根据不同系统配套的JVM,将它翻译成系统可执行的机器码。这就是跨平台的原理。
那么JVM是如何实现运行java代码的呢?
我们可以看它的内部结构图:
首先它会将我们的.class
文件装载到类装载子系统里去,然后由类装载子系统加载到运行时数据区,然后再由字节码执行引擎来执行一行一行的代码。
我们先简单看一下各内存区是放什么的:
程序计数器:放当前线程正要执行的代码位置(行号)。为什么在一个线程中需要一个程序计数器呢?
因为在多线程中,如果当前线程执行在中途,cpu为这个线程分配的时间用完了,那么当前这个线程就要被挂起,去执行其他的线程,当又到了该线程执行的时间片,那么这时候程序计数器就会起作用,这个线程就会从上次被挂起的代码位置继续执行,而不必要从头开始,如果每次没执行完就被挂起,还要从头开始的话,就会陷入一个死循环,这个线程永远也结束不了,也不会释放它占用的空间。
堆:放我们new 的对象
栈:主要是用来装线程中生成的局部变量,每一个线程都会生成一个栈。栈的结构比较复杂,主要是栈帧的概念,栈帧它的作用是比如在一个线程中,调用了某个方法,这个方法里面也有局部变量,那么就会单独在这个栈中为它分配内存空间存放局部变量。这里的栈与我们数据结构中学到的栈是有很大的相似性的,都遵循先进后出(FILO)。
看下图是一个简单的执行过程,首先main线程启动,栈就会为main开辟一个栈帧空间存放局部变量(这时在栈底),主线程再接着执行第二行,会调用一个compute()函数,然后栈又会为它开辟一个存放局部变量的空间,当它执行结束,就会释放这部分栈帧,直到栈中为空,线程执行完毕。
这个就是虚拟机栈的基本原理,但实际上栈帧中存放一个方法的局部变量只是其中一部分,那么栈帧中还有什么呢?
栈帧中有四部分:
局部变量表、操作数栈、动态链接、方法出口。
这四个部分,我们可以从jvm可执行文件.class
来看,但是呢它的每一个字符都是用数字编码实现,看起来很枯燥也看不懂,我们可以找到刚才说的Math.java
的Math.class
文件,可以直接拖到idea中,输入javap -c Math.class
,就会将可执行的机器码反编译一下,也可以直接用cmd进到.class
的文件下执行。可以通过分析它的内部执行过程来理解。那上面compute()方法来看:
this就是相当于istore_0,指向当前局部变量表中元素。
通过这个运算的操作流程,我们就可以清楚的认识到局部变量表和操作数栈的作用。操作数栈,实际上就是操作数在进行运算过程中,临时存放值的一个内存空间。
动态链接:会将符号引用转变为直接引用。拿我们前面的Math.java的例子,就是在main执行过程中,会调用compute方法,那么动态链接的作用就是通过math.compute();这个符号的引用,知道它内部代码在内存区中的具体的位置。
方法出口:例如上面的Math.java的例子,我们在执行完compute方法以后,那么程序应该要回到compute方法下面一行。它是如何知道要准确的回到System那一行呢?就是方法出口起作用,在compute方法执行前,它对应的方法出口就会记录main中,下一行代码的执行位置。当compute方法执行结束,方法出口就会让其回到main中下一行执行代码位置。
每一个栈帧中,都有这四个部分。
但是局部变量表中存对象时,实际上存的是对象在堆内存中的地址。因为我们知道对象是存在堆内存中的,当一个线程启动时,栈就会为当前方法开辟一个栈帧,这个方法中涉及到对对象的操作,那么这时候它的局部变量表中为对象开辟的内存空间,实际上就是该对象在堆中的地址(也可以叫做指针),通过这个地址就可以找到对象。例如我们前面提到的Math.java中的main方法内的操作。
那么从中,也可以看出栈内存和堆内存的关系:栈中存放的都是对象在堆内存中的地址(指针)。
再看剩下几个区域存放内容:
方法区(元空间):存放常量+静态变量+类信息。如果静态变量是对象,那么实际上方法区存放的是对象在堆中的地址,也就是指针,与我们栈中的局部变量表存放对象是一样的。
本地方法栈:本地方法就是用native来修饰的方法,本地方法是由C或者C++实现的,java在执行到native修饰的方法的时候,会通过字节码搜索引擎,去到本地操作系统底层的库函数,例如C++的库函数、C语言的库函数(xxx.dll
就跟我们java语言的jar包一样)如果线程用到本地方法,那么就会为它开辟一个本地方法栈。
总结:
栈、本地方法栈、程序计数器这三个是每个线程独有的,而堆和方法区是所有线程共享的。
二、Java虚拟机垃圾收集机制
首先看一下堆的内部结构:
我们知道new的对象会放在堆中,那么具体会放在堆的哪个部分?
其实就是Eden区(伊甸园区,亚当和夏娃的出生地!有木有~)如果让一个线程不停的new对象,那么最终Eden区会放满,这时候就会调用一个垃圾回收机制(minor gc)
minor gc又是个啥?
它其实是字节码执行引擎在后台开启的一个垃圾回收线程,专门收集堆中的垃圾。minor gc主要针对的是年轻代。
那么它是如何实现回收的呢?准确的问就是它是如何判断哪些对象是可回收的呢?
我们用的是可达性分析算法:
将“GC Roots”对象作为起点,从这些结点开始向下搜索引用对象,找到的对象都标记为非垃圾对象,其余的标记的都是垃圾对象
GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等。
也就是我们从线程中的变量(引用对象)出发,找到堆中的对象,这个对象如果又引用了其他的对象则接着向下找,一直到找到引用的这个对象不再有其他引用,那么就将它们都标记为非垃圾对象。
一旦这些对象被标记为非垃圾对象,那么就会被复制算法复制到堆其中的一块Survivor区,然后将Eden区中的所有对象都清理掉。
那么接着不停的new对象,Eden区又装满了,又会触发minor gc,这时候不仅仅会回收Eden区,同时还会回收非空的Survivor区,它会对这两者都进行判断,然后标记,将非垃圾对象复制到另外一个空的Survivor区后,将Eden区和之前的Survivor区的对象全部清理掉。
这里我们还需要了解一个概念,叫做对象的分段年龄,这一部分是存储在对象头中的。就是对象在堆中,每被复制一次,它的分段年龄就会加1。当一个对象的分段年龄被加到15还没有被释放掉,就会被放到老年代。
这个过程实际上是可视的,需要 为JDK自带的jvisualvm安装Visual GC插件.(这个是我的另外一篇文章,想下的可以直接看详细教程)
我们看下面一个代码,去模拟Eden不停new对象的过程,并且还是符合非垃圾对象,无法释放掉的情形。
然后就可以打开cmd输入命令:jvisualvm
打开后
点击当前正在运行的这个java程序,就可以看到如下情形
这就是完整的JVM的垃圾回收机制了。
三、JVM性能优化思想
我们从上面的垃圾回收过程可以思考,如果程序一直这样执行下去,老年区迟早会放满,这时候就会触发一个叫full gc
的机制,但其实它并不能帮助释放很多空间,运行到最后程序就会报错:简称OOM
而堆每执行一次垃圾收集,就会发生STW(Stop The World):它会停掉所有的用户线程(操作),比如电商网站用户正在点击加入购物车的时候,后台发生了垃圾回收机制,用户很可能就是感觉卡顿了一下然后又好了。
那么就有疑问了,为什么非要设置这个STW呢?垃圾回收的时候,不停掉用户的操作,那不就不会发生卡顿了吗?
我们可以反证,如果执行gc回收机制,不让它发生STW,那么有些线程还在执行中,gc会通过一个根节点对象一直向下找,并标记为非垃圾对象,但由于线程还在继续,有些线程结束以后,局部变量就会被释放掉,那么前面gc已经标记为非垃圾的对象,其实变成了垃圾对象了,那么gc又要重新进行标记,如果有很多线程在不断new对象,执行,释放,那么这个gc就要一直在不停的做标记。总的来说就是如果不设置STW这个操作,那么在堆中的对象状态是在不停的变化的。这样效率反而没有提高,还不如停下来专心做这一件事情。一次性标记好然后清理,效率反而会更高。而且STW的时间并不是很长,很多时候用户感知不到。
那么我们性能调优针对的就是较少甚至避免full gc
举一个例子,假如一个电商平台,每秒钟点击量生成的对象的总大小为60MB,并且订单生成后一两秒钟,这些对象变量就会被释放掉。如果分配给jvm堆内存大学为3个G,那么该如何尽量避免发生full gc呢?
我们先看第一种分配内存情况:
每运行差不多14秒左右就占满Eden区就会触发minor gc,这时候会发生STW,那么最后一秒的线程执行在中途,那么它们就会被放到s0区。实际上当s中对象占的空间大于它的50%:(60MB>100MB*50%)就会直接被放到老年区,下一秒钟实际上这一部分已经是垃圾对象了,但由于放在老年区minor gc并不会释放,这样随着时间推进,老年区大概每几分钟就会满,就会触发full gc。那么我们可以想,既然这些对象再下一秒钟就是垃圾对象了
那么怎样才能避免它们进入老年区?
需要将年轻代的内存空间调大一点,由1G增长到2g
Eden:1.6G
s0=s1:200MB
old:1G
这样当地14秒触发minor gc,后,最后一秒的60MB放进s0中,将Eden清空,下一个第14秒钟的时候,minor gc会判断Eden和s0区中的垃圾对象,这时候将它们全部都释放了。
总的来说,也就是让分段年龄短的对象,尽可能的在年轻代被释放掉,而不要将它们放进老年代。
这就是JVM性能优化的思想。
JVM的调优涉及到很多知识,这里只是介绍了简单的调优场景。
从上面的例子说明堆中如何内存的分配是非常重要的,那么这就需要了解各个板块的性能,比如survivo区的对象何时会放到老年代是有很多种情形的,分段年龄达到15,s中对象的内存占s总内存的50%以上等等。
思想建立,之后就需要不断的丰富知识体系。这篇文章终于肝完了,加油吧!
本文地址:https://blog.csdn.net/Dan1374219106/article/details/107193466