当前位置:   article > 正文

JVM底层实现原理_jvm 底层实现

jvm 底层实现

1.JVM内存模型
先上图,先上图,先上图!!!
dk体系结构图
想必大家都似曾相识的见过,见过,见过这张图,这是JDK体系结构图。
都是英文,翻译过来也看不懂,我还是介绍一下吧!!!
在这里插入图片描述
这里的核心就是JVM,它也是实现JAVA语言跨平台的核心。
在这里插入图片描述
这里注意,在我们搭建JAVA开发环境的时候,下载JDK的时候,会提供系统版本,下图在这里插入图片描述
这是因为不同的JDK搭载的环境系统,会将.class文件由JVM挂载到当前机器能识别的机器码,从而达到跨平台的特效。

往JVM内部近一步,看看JVM内部的结构(蓝色空间是线程独有,栈,本地方法栈,程序计数器,红色所有线程共享,堆,方法去)
在这里插入图片描述
我们手写一个类,看看这个类由代码到JVM的过程

/**
  * @description: TODO
  * @author TAO
  * @date 2020/4/22 17:53
  */
public class Math {
    public static final int initData=666;
    public static User user=new User();

    public int compute(){//一个方法对应一块栈内存区域
        int a=1;
        int b=2;
        int c=(a+b)*10;
        return c;
    }

    public static void main(String[] args) {
        Math math=new Math();
        math.compute();
    }
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  1. 由Math.java编译成Math.class

  2. Math.class交给类装载子系统

  3. 由类装载子系统将类装载到JRE(JAVA运行时数据区/也就是JAVA程序的内存)

  4. 最终JAVA程序的执行是由字节码执行引擎执行内存里的程序

    完整的JVM是由类装载子系统、运行时数据区、字节码执行引擎组成
    像堆、栈,本地方法栈,方法区,程序记数器都只是运行时数据区(JRE的组成部分),JVM内存调优主要就是JRE这部分,也就是存数据的

堆:(JVM调优主要结合堆的内部结构优化)
JAVA代码中new出来的对象,是存储在堆
栈:(这玩意是最复杂的,这块空间是独立的,线程独立不共享,这里的栈和程序代码里面的栈数据结构是一样的,先进后出,结合我们的代码执行的顺序就不难理解)
栈是用来存放局部变量的,例如每个方法里的局部变量,在方法结束后,这里的变量自动销毁
结合上面Math中的代码,int a int b int c,这些都是放在栈里的,线程独享,当我们的代码执行到math.compute();时,JVM
虚拟机会为这个线程分配一个栈内存空间,也就是栈内存,来一个线程就会开辟一个栈空间
来来来,上图,上图,上图,上栈空间的图!!!

在这里插入图片描述

想要更加清楚的了解程序的执行原理可以将编译后的字节码.class文件反编译得到指令码文件。
(步骤:右键)
在这里插入图片描述

在这里插入图片描述
javap -c Math.class > Math.txt
对应上面的Math代码的指令码文件,这个文件包含很多底层JVM运行逻辑

Compiled from "Math.java"
public class Math {
  public static final int initData;

  public static User user;

  public Math();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int compute();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class Math
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method compute:()I
      12: pop
      13: return

  static {};
    Code:
       0: new           #5                  // class User
       3: dup
       4: invokespecial #6                  // Method User."<init>":()V
       7: putstatic     #7                  // Field user:LUser;
      10: return
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47

这里看到这行指令码public int compute();这个就代表JAVA中的compute()这个方法,或发现指令码比JAVA代码多了去了,因为越底层的代码对应的代码量越大,代码量越大,对应的细节逻辑就越多!JVM的底层运行原理就对应的这些字节码指令,我们来分析一下这段指令码。

public int compute();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

	#iconst_1-->将int型常量1压入操作数栈---这个值放在操作数栈
	#istore_1-->将int型数值存入局部变量1---这个变量放在局部变量表
	#这里的常量1对应JAVA代码中的是a这个变量(也就是第一个变量),0代表当前对象的本身
	#iconst_1、istore_1这两个指令码完成int a=1;
	#执行顺序先在操作数栈中开辟一块内存空间,放入数值1,然后执行第二个指令码,在将操作数栈中的数值1放入局部变量表中
	#这个iconst_2、istore_2的逻辑是一样的

	#程序计数器:记录着运行的指令码的行号,标识程序执行的顺序,每个线程启动后JVM虚拟机都会为这个线程分配一个独立的内存空间,叫做程序计数器,用来标识程序执行的行号位置,比如当前线程挂起的时候,那么这个线程的代码就停止执行,当这个线程恢复过来的时候,总不能又从第一行代码开始执行吧,所以程序计数器就是当一个挂起的线程恢复过来的时候知道从哪行代码开始继续运行
	#iadd-->执行int类型的加法,JVM在执行加法减乘除的指令码时会在操作数栈的栈定弹出两个元素,进行加减乘除运算,把最终的运算结果从新的结果押回操作数栈
	#bipush   10-->
       
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

本地方法栈:
比如new Thread().start();这个方法就是本地方法,往里面追一下,不难发现这里追下去有一个private native void start();这个native修饰的方法就是本地方法,这个方法底层不是JAVA语言写的,地层是C语言写的,这路有一个故事(大家都知道JAVA是95年创建的,比我还老!!!,在95年之前,95%的系统都是C或者汇编语言写的,C语言对新手是不太有好的,而且还要码农自己管理内存,这样新手很容易写出内存爆掉的程序,且代码量大,当我们的祖师爷高斯林搞出JAVA之后,很多公司都采用这种面相对象的高级语言来开发,但是也避免不了老的C系统和JAVA系统有交互,那么这里就诞生了native本地方法这个概念, 例如当JAVA代码调用native修饰的方法时,他会去到操作系统的函数库里面调用对应的方法实现,找到对应的xxx.dll文件执行里面的方法,可以理解为JAVA调用xxx.jar,当时本地方法就是做这事,虽然本地方法时C语言实现的,但在代码运行的时候也是需要一些内存空间去存放运行的代码和数据的,那么这样理解起来就不难了)说回来本地方法栈就是JAVA代码调用本地方法的时候用来存放本地方法需要的内存空间,

字节码执行引擎:找到对应的本地方法实现呀,JAVA代码的执行呀,都是我们这个字节码执行引擎做的事情

2.JVM垃圾回收
写到这里,介绍了栈,本地方法栈,程序计数器三块线程私有的空间,下面就开始看红色这块区域!!!,上图!上图!上图!!!
在这里插入图片描述
上面也提到过,new出来的对象都是放在堆里面的 ,大家都知道,堆是有大小限制的,例如,总堆大小600M那么堆中这些空间的分配比例就是老年代3/2=400M 年轻代200M ,在年轻代中Fden又是8/10=160M,S01/10=20M S21/10=20M ,我们的服务器不间断运行,160M的Fden区就会满,那么这个时候就会GC,但是这里不是FullGC,而是、YangGC/minorGC,这个就是一个垃圾收集的线程堆我们的年轻代进行垃圾回收,当我们的Fden内存不够的我们的JVM就会开启垃圾回收线程 堆年轻代进行垃圾回收,这个线程是由我们的字节码执行引擎开启的。

下面我们就来看看字节码执行引擎开启的对伊甸园区的YangGC/minorGC的垃圾回收线程是怎样进行垃圾回收的
在想知道GC线程是怎么执行的之前,先要了解GCRoots的概念!!!
GCRoots:就要牵扯到可达性分析算法,可达性分析算法是将GCRoots对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象,
在这里插入图片描述
(蓝色代表挂在GCRoots下的根节点,也就是仍存活的对象,白色就是没有挂到GCRoots的节点,代表垃圾对象)
蓝色的非垃圾对象也就是存活的对象直接复制到Survicor区,白色的垃圾对象直接GC垃圾清理掉

现在回到YangGC/minorGC的执行原理
在Survicor个内存空间里有牵扯到对象分带年龄的概念这里看一张图
在这里插入图片描述
这个图就代表一个对象的对象信息,简单介绍一下,我们创建对象的时候,赋的值就在实例数据这里,对对象加锁,这个就在对象头里描述,分带年龄就是一个对象每次GC没有被清理掉就会把年龄+1

看完这张图后大概了解了分带年龄的概念,这里肯定还有疑惑,在Survicor区中有S0、S1两个空间
这是为毛,下面开始解释一下!!!
当我们的程序不间断运行时,Fden区第一次爆满时,由字节码执行引擎调用yangGC/minorGC开启对伊甸园区的垃圾回收,第一次回收是将Fden区的对象根据GCRoots策略进行垃圾回收,在GCRoots中也说过,会将标记存活的对象复制,这个复制就是将复制的数据放到S0区,在将垃圾数据全部干掉,那么存活对象的分带年龄就会+1,刚才说了,这个程序不间断运行,那么这个Fden区内存还会爆满,那么这个时候爆满后又会触发GC,这次GC就会根据GCRoots策略对Fden和S0区的对象进行垃圾回收,会检测出Fden区和S0区存活的对象,将这两个区的对象复制到S1区存放然后对象的分带年龄+1,然后直接把Fden区和s0区的垃圾对象干掉,这个效率是非常高的,这里简单理解一下(至于清理的是S0还是S1区域,存放在S0还是S1区域,是根据S0和S1区域有没有数据来决定的,当第一次GC的时候S0和S1区域地没有数据那么久根据下标来决定),最后不难发现,这些非垃圾对象就会在S0和S1区复制来复制去!每次复制对象的分带年龄就会+1,说了这么多,貌似还没说到老年代,那么这个程序继续运行,当S0和S1去复制来复制去分带年龄+++的时候,对象的分爱年龄达到15岁的时候,那么这个对象就会JAVA的JVM虚拟机挪到老年代,说白了这些分带年龄达到15的就是一些老不死对象,
哪些常见对象会挪到老年代呢,结合业务不难发现,静态成员变量,Session缓存对象,数据库连接池对象,SpringIOC容器里的Bean,什么service,controller呀,会一直在容器里面存活着,会一直被GCRoots引用着,这些对象都会被挪到老年代里去,这里使用一下这个命令(cmd打开jvisualvm回车)
使用JDK只带的这个工具可以查看堆内存的GC处理过程!!!(这里缺少VisualGC插件)详情-JVM堆内存监控-VisualGC

//使用这个代码可以很好的展现GC的搜集情况
public class HeapTest {
    byte[] a=new byte[1024*100];//100KB

    public static void main(String[] args) throws InterruptedException {
        ArrayList<HeapTest> heapTest=new ArrayList<>();
        while (true){
            heapTest.add(new HeapTest());
            Thread.sleep(10);
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

当然,这个程序还在不间断的运行,这个老年代也是有大小限制的,当OLD区(老念带)爆满后JAVA的JVM虚拟机也会进行GC,只不过不是YangGC/minaorGC,而是非常可怕的FullGC,这个GC也是执行引擎开启的GC线程,这个线程也是搜集垃圾的,但是他搜集的垃圾范围是整个堆内存空间,堆+方法区,这个FullGC会对真个堆中的垃圾进行回收,但是这些老年代里的对象都是存活的被GCRoots引用的,执行FullGC的时候实际上是对这些存活被引用的对象是回收不了的,只有那些没被引用的对象,游离的对象才能被GC回收,就像上面的代码,new HeapTest();这些对象实际上是被引用的,当FullGC的时候实际上是回收不了这些引用的对象的,前面说到OLD老年代也是有大小限制的,老年代中的引用对象把老年代内存沾满后又回收不了,这时整个程序就会OOM(Exception ia thread “main” java.lang OutOfMemoryError:Java heap space) 程序也就会挂掉,也就是宕机!!!
讲到这里想必大家对JVM会有一个更深层次的了解!!!
这里在扩充一个概念STW Stop The World(叫做停止整个世界),解释一下,比如我们一个服务端程序,在运行的时候,一个用户在前端点击发送请求的时候,服务端程序会开启一个线程处理这个程序,这个线程叫做用户线程,垃圾GC线程属于程序内部线程,当我们的伊甸园区的内存满了的时候会开启YangGC/minorGC,当这个线程启动的时候会挂起所有的用户线程,那么对于用户来讲,这个用户发起的请求就会停止,用户就会感觉卡顿了一下,当YangGC/minorGC执行完毕后会激活用户线程继续执行,说白了当GC开启执行的时候,会挂起所有的用户线程,让CPU专心做垃圾收集,这个STW堆用户体验肯定是有影响的。

那么JVM为什么会设计一个STW这个机制呢?
为什么不能用户线程执行的时候边GC?
这里解释一下,当触发GC的时候会根据GCRoots策略找到挂载的引用对象,如果没有STW这个机制,JVM开启GC的时候用户线程也在执行,那么我这个用户线程引用的对象就还会存活着,那么我的GCRoots就会寻找引用的对象,把这些对象标记为非垃圾对象,好!!!在这是标记完了,这个用户线程突然执行完毕那么这个线程的引用对象是不是就没在引用了,是不是理论上变成垃圾对象,但是在结束前这些对象被标记为非垃圾对象,那么GC的时候就回收不了,在面对高并发请求网络请求场景,这个情况GCRoots就会非常复杂,线程之间的切换,这么多的GC会拖垮整个系统的性能,所以JVM就使用STW这样的机制,在GC的时候挂起所以的用户线程,专心执行GC,而且GC执行效率也很快,耗时也短,GC线程执行的速度快,耗时短,对用户线程,用户体验的影响也会降低,这也是JVM虚拟机设计STW这样的一个机制的初衷!!!

3.JVM性能调优
JVM性能调优,最终目的就是让用户用的爽,流畅,不卡顿,那就要JVM减少STW的时间,也就是减少GC的次数,我们GC会导致STW,那我们就尽量减少GC的次数,或者让一次GC执行的时间短一点,这也就达到我们调优的目的;

言归真正:例如普通4核8G服务器,系统运行差不多3个G,剩余5G用来跑其他程序,这时我们可以将5G的内存充分用起来,我们把5个G的内存划出3个G给JVM的堆空间,这里空间分配结合真实的服务器内存和运行时的数据所占的内存大小情况进行分配;(这里的数据所占的内存空间,是根据系统中的对象大小评估出来的,如int4个字节 ,boolean1个字节…根据一个类中的属性值类型【评估出来的】一个类几十个字段,一个字段几个字节,一个对象撑死也就几百个字节),这里结合实际业务,比如用户下订单这个业务,一个订单对象假设1kb大小,每秒300个订单数据(300kb),在用户下单这个操作的同时不排除有其他的业务逻辑执行。我们放大20倍,(300kb20),可能在这各业务之外还有其他的业务也在执行,那么我们在放大10倍就是(300kb20*10)预估大小60MB的大小,订单业务执行1s后这些60MB的对象就会成为垃圾对象在这里插入图片描述
在这里插入图片描述
在下单的业务内这60MB的对象还在被GCRoots引用,这些对象还不是垃圾对象,下单业务执行完后就会将GCRoots引用清理掉 ,清理掉后这些60MB的对象就成了垃圾对象,变成游离态了,结合上面上面的图每秒60M的垃圾对象存放在Eden区,也就是13S后Eden区的内存会爆满触发YangGC/minorGC回收没有引用的垃圾对象,这里可能会出想12S的时候触发GC,在这空闲的1S时间内的用户线程会被挂起,那么这1S产生的60MB的对象就不是垃圾对象,因为线程挂起这里面的对象GCRoots还是保持引用的,也就是这1S产生的60M的对象数据会被复制挪到S0区存放,这里插入一个小知识点,当我们YangGC/minorGC的时候,碰到打的对象超过S0/S1区域50%的内存会直接放到OLD老年代区,这里还有许多情况会导致对象GC的时候直接放入OLD老年代区,这里直接放到OLD区域的对象又会在1S后会变成垃圾对象,也就可以理解为没13S会有一批漏网60MB的对象放入OLD区
那么也就是OLD内存区的随着时间的流动,老年代的空间也会填满,大概5分钟上下老年代就会填满,填满后就会有字节码执行引擎启动FullGC,FullGC的耗时是比较长的,相对于minorGC是比较慢的,我们JVM调优是要尽量避免FullGC的,那么我们针对上面的情况进行优化,然我们的系统几乎不发生FullGC;

我们不难发现,OLD老年代区的大小就算划分的在大也是存放垃圾对象的,只要GC这个空间的对象都会被全部干掉,那么我们干脆把他的空间调小使他更加合理,我们把老年代的空间控制在1G大小,把年轻代的大小调成2G大小,那么结合年轻代的内存空间分配比例,Eden区大小就是1.6G,S0200MB,S1也是200MB,那么我们再来看看业务场景不变的情况下GC的回收情况,这时我们触发minorGC/YangGC就要25S,这时存活的数据对象大小还是60M,没有超过S0/S1的50%这时也就不会像之前一样直接放到OLD老年代中去,数据也不会在老年代中沉淀下来,只会在S0和S1区间复制来复制去,在这期间这些数据有些也会变成垃圾数据在S0/S1minorGC时直接干掉,永远也不会去到OLD老年代,说白了几乎也就避免了OLD老年代内存爆满的情况,同时也就避免了FullGC的触发。
JVM调优的一个重要的方法就是评估整个核心系统主流程的业务类对象的内存大小模型,让那些朝生夕死的对象在minorGC,S0/S1复制来复制去的时候就直接干掉,minorGC的性能是非常高的,耗时非常短,对系统的影响非常低,不要让垃圾对象到了OLD老年代沉淀下来触发FullGC,这样对系统影响非常大。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/2023面试高手/article/detail/352954
推荐阅读
相关标签
  

闽ICP备14008679号