美文网首页
JVM内存模型详解

JVM内存模型详解

作者: 为爱放弃一切 | 来源:发表于2020-07-11 13:56 被阅读0次

    jvm优化实战,程序猿门心中永远的痛!很多程序猿工作了多年,仍然停留在理论层面,对jvm生产环境的实战优化几乎一无所知,经本人总结,主要有两大原因:

    • jvm实战相关资料较为欠缺,虽然有不少牛人写的书籍,但偏向理论
    • 网上大量凌乱繁杂的jvm实践博客

    有些书籍确实写的不错,但主要是偏向理论层面,并不提供实战经验给你。而且虽然很多书的作者技术功力深厚,但是书里的内容深奥晦涩,难以理解。导致很多人即使看了这种jvm的理论知识,也仅仅是搞懂了小部分的内容,大部分的内容无法消化理解。

    至于网上博客,很多是作者在记录自己遇到的一些jvm的生产故障解决过程,关于项目的背景并没有做过多的介绍(比如用户量、数据量、并发量、核心业务流程),一切站在自己的角度出发,而且对于解决过程中涉及到的一些jvm底层原理也没有做介绍,这就导致我们很难看懂,如果按照他的模板设置,搞不好会弄崩自己的生产环境。

    有鉴于此,本文会对jvm做浅入深出的讲解。本文首先详细讲解jvm内存模型,在彻底了解jvm内存模型的基础上再讲解垃圾回收器,最后结合实际情况讲解jvm实战优化。

    类加载过程

    我们的代码是如何运行的?
    我们都知道平时编写好的java代码都存在一个后缀为“.java”的文件里,比如user.java、role.java 。
    但这些文件并不能被直接运行,它们需要被编译成“.class”后缀的字节码文件才能运行起来,比如:user.class、role.class 。
    这个时候采用诸如 “java -jar” 之类的命令就能运行我们写好的代码。此时一旦使用 “java” 命令,实际上就会启动一个jvm进程。这个jvm就会来负责运行这些“.class”字节码文件。所以平时我们在服务器上部署一个系统的时候,其实就是启动了一个jvm,由它来负责运行我们的系统。

    那jvm要如何运行这些 “.class”字节码文件呢?首先,jvm需要把这些 “.class”字节码文件加载进来。此时就需要一个“类加载器”把编译好的那些 “.class”字节码文件给加载到jvm中,供后续代码运行来使用。最后,jvm就会找到代码中的 “main()”方法,从这里开始执行写好的代码。

    那么jvm什么情况下会加载一个类?
    类的加载过程比较复杂,这里我们只介绍一下核心的工作原理即可。一个类从加载到使用,一般会经历下面几个过程:
    加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载
    那什么时候会从 “.class”字节码文件中加载这个类到jvm内存呢?其实答案非常简单,就是在你的代码用到这个类的时候。

    从本文需要出发,来聊聊验证、准备、解析和初始化的过程

    (1) 验证阶段
    简单来说,这一步就是根据java虚拟机规范,来校验加载进来的“.class”文件内容是否符合指定的规范。

    (2) 准备阶段
    我们先来看一段代码:

    public class User {
    
            public static int count;
    }
    

    这个准备工作,其实就是给这个 User 类分配一定的内存空间,然后给它的类变量(也就是static修饰的变量)分配内存空间,来一个默认的初始值。比如上面的示例,就会给 count 这个类变量分配内容空间,给一个0这个初始值。
    (3) 解析阶段
    所谓的解析就是寻找常量池中类、接口、字段和方法的符合引用,并将这些符合引用转成直接引用的过程。这个部分比较复杂,不是本文的讲解重点,我们知道有这回事就行了。
    (4) 初始化
    什么是类的初始化呢?我们来看下面一段代码:

    public class User {
            public static int count = Configuration.getInt("user.count");
    }
    

    上面的示例代码,我们打算通过 Configuration.getInt("user.count") 给count类变量赋值。那这行代码会在准备阶段执行吗?显然不会!准备阶段仅仅是给count类变量开辟一个内存空间,然后给个0的默认值。那么这段代码什么时候执行呢?答案是在“初始化”阶段来执行。
    另外比如下图的static静态代码块,也会在这个阶段来执行。当该类初始化的时候,会调用loadRole()方法。

    public class User {
            public static int count = Configuration.getInt("user.count");
       
            public static Map<String, Role> roleMap;
            static {
                  loadRole();
            }
            public static void loadRole() {
                  thia.roleMap = new HashMap<String, Role>(); 
           }
    }
    

    搞明白了类的初始化是什么后,就得来看看类的初始化规则了。

    什么时候会初始化一个类?
    一般来说有以下一些情况:比如用 “new User()” 来实例化类的对象,此时就会触发类的加载到初始化的全过程,把这个类准备好,然后再实例化一个对象出来。或者是包含“main()”方法的主类,那必须是立马初始化的。
    另外,当初始化一个类的时候,如果发现它的父类还没有初始化,那么必须先初始化它的父类。

    类加载器和双亲委派机制

    实现上述过程,必须依靠类加载器来完成。那么java里有哪些类加载器呢?简单来说有以下几种:
    (a)启动类加载器
    Bootstrap ClassLoader 称为根加载器,没有任何父加载器,由C++编写,它主要是负责虚拟机核心类库(机器上安装的java目录下的核心类)的加载。
    (b)扩展类加载器
    Extension ClassLoader,这个类加载器其实也是类似的,就是java安装目录下有一个“jre\lib\ext”目录,这里面有一些类就是使用这个类加载器来加载的。
    (c)应用程序类加载器
    Application ClassLoader,这个类加载器就负责加载ClassPath环境变量路径中的类。大致可以理解为去加载你写好的java代码。
    (d)自定义类加载器
    除了上面几种之外,还可以自定义类加载器,根据自己的需要加载你的类。

    双亲委派机制
    jvm的类加载器是有亲子层级结构的,顶层是根加载器,第二层是扩展类加载器,第三层是应用程序类加载器,最后一层是自定义类加载器。如下图:

    ClassLoader.jpg

    基于这个亲子层级结构,有一个双亲委派的机制。假设应用程序类加载器需要加载一个类,它首先会委派给自己的父类加载器去加载,最终传导到顶层的类加载器去加载,但是如果父类加载器在自己负责加载的范围内没有找到这个类,那么就会下推加载权利给自己的子类加载器去加载。这就是所谓的双亲委派机制。双亲委派机制避免了类的重复加载。

    JVM内存模型

    前面简单介绍了一下jvm类加载机制,搞明白了在什么情况下会触发类的加载,加载之后的验证、准备和解析分别是干什么的,尤为重要的是准备阶段和初始化阶段,是如何为类分配内存空间的,然后介绍了类加载器的规则。下面我们介绍一下jvm内存模型。

    jvm的内存区域划分

    到底什么是jvm内存区域划分
    其实这个问题比较简单,jvm在运行我们写好的代码时必须使用多块内存空间,不同的内存空间用来放不同的数据,然后配合我们写的代码流程,才能让我们的系统运行起来。我们先来看下图:

    jvmm1.jpg
    从图中我们可以知道加载的类需要内存,创建的对象需要内存,运行方法里的变量需要内存。这就是为什么jvm中必须划分出来不同的内存区域,它是为了代码在运行过程中根据需要来使用的。

    接下来我们来看看jvm中有哪些内存区域
    1、方法区
    这个方法区在jdk1.8以前的版本里代表jvm中的一块区域,主要是放从“.class”文件里加载进来的类,还会有一些类似常量池的东西放在这个区域里。但是在jdk1.8以后,这块区域的名字改为了“Metaspace”,一般称为“元数据空间”。

    2、程序计数器
    我们写好的代码会被翻译成字节码,对应各种字节码指令,然后字节码指令被一条一条执行,这样才能实现我们写好的代码执行的效果。那么在执行字节码指令的时候jvm里就需要一个特殊的内存区域了,那就是“程序计数器”。这个程序计数器就是用来记录当前执行的字节码指令的位置的,也就是记录目前执行到了哪一条字节码指令。我们都知道jvm是支持多线程的,所以就会有多个线程来并发的执行不同的代码指令,因此每个线程都会有一个自己的一个程序计数器,专门记录当前这个线程目前执行到了哪一条字节码指令。下图更加清晰的展示了我们之前的描述:

    zijiem.jpg

    3、Java虚拟机栈
    Java代码在执行的时候一定是线程来执行某个方法中的代码,我们在方法里经常会定义一些方法内的局部变量,因此,jvm必须有一块区域是来保存每个方法内的局部变量等数据的,这个区域就是java虚拟机栈。每个线程都有自己的java虚拟机栈。
    如果线程执行了一个方法,就会对这个方法调用创建对应的一个栈帧,栈帧里就有这个方法的局部变量表、操作数栈、动态链表、方法出口等,这里我们先关注局部变量。
    我们来看一段示例代码:

    public class User {
            public static void main() {
                 Role role = new Role();
                 role.getAllRole();
           }
    }
    

    在上述代码中,main线程执行了main方法,那么就会给这个main方法创建一个栈帧,压入main线程的java虚拟机栈,同时在main方法的栈帧里会存放对应的“role”局部变量。然后假设main线程继续执行Role对象里的方法,比如像下面这样:

    public class Role {
            public void getAllRole() {
                 boolean flag = false;
                 if (checkPower()){
                     ....
                 }
           }
    
            public boolean checkPower() {
                 boolean result = true;
                 return result;
           }
    }
    

    getAllRole方法里定义了一个局部变量:“flag”,那么main线程在执行上面的getAllRole方法时就会为它创建一个栈帧,压入线程自己的java虚拟机栈里面去。然后在栈帧的局部变量表里就会有“flag”这个局部变量。接着getAllRole方法又调用了checkPower方法,这个方法里也有自己的局部变量,那么这个时候会给checkPower方法又创建一个栈帧,压入线程的java虚拟机栈里,这个时候checkPower方法的栈帧的局部变量表里就会有一个“result ”变量。
    接着如果checkPower方法执行完毕,checkPower方法对应的栈帧就会从java虚拟机栈里出栈,然后如果getAllRole方法也执行完毕,getAllRole方法对应的栈帧也会从java虚拟机栈里出栈。
    上述就是jvm中的“java虚拟机栈”的作用,调用执行任何方法时都会给方法创建栈帧然后入栈,方法执行完毕后出栈,下面我们再来看一个图:


    xunijizhan.jpg

    4、Java堆内存
    java堆内存是jvm中另一个非常关键的区域,这里存放我们在代码中创建的各种对象。我们再来看一下之前的示例代码:

    public class User {
            public static void main() {
                 Role role = new Role();
                 role.getAllRole();
           }
    }
    

    上面的“new Role()”这个代码就是创建了一个Role 类的对象实例,这个对象实例会包含一些数据,如下面的代码所示:

    public class Role {
            private long count;
    
            public void getAllRole() {
                 boolean flag = false;
                 if (checkPower()){
                     ....
                 }
           }
    
            public boolean checkPower() {
                 boolean result = true;
                 return result;
           }
    }
    

    这个Role 类里的“count”就是属于这个对象实例的一个数据。类似Role 这样的对象实例就会存放在java堆内存里。当线程执行main方法的时候先创建了Role对象,那么会在main方法对应的栈帧的局部变量表里,让一个引用类型的“role”局部变量来存放Role对象的地址,相当于局部变量表里的“role”指向了java堆内存里的Role对象。
    5、核心内存区域总结
    下面我们通过一张图来总结上面讲的内容,这样会感觉更加清晰。

    jvm-all.jpg
    首先,启动jvm进程,就好先加载你的类到内存里,然后有一个main线程开始执行main方法。main线程关联了一个程序计数器,它执行到哪一行命令,就会记录在这里。
    其次,就是main线程在执行main方法的时候,会在main线程关联的java虚拟机栈里压入一个main方法的栈帧。接着会发现需要创建一个Role类的实例对象,此时会加重Role类到内存里来。
    然后会创建一个Role类的对象实例分配在java堆内存里,并且在main方法的栈帧里的局部变量表引入一个“role”变量,让它引入Role对象在java堆内存中的地址。
    接着,main线程开始执行Role对象中的方法,会依次把自己执行到的方法对应的栈帧压入自己的java虚拟机栈,执行完方法之后再把方法对应的栈帧从java虚拟机栈里出栈。
    其实大家理解了这个过程,那么jvm中的各个核心内存区域的功能和对应的我们的java代码之间的关系就很清楚了。
    6、其它内存区域
    在jdk很多底层API里,比如IO相关的,NIO相关的,网络socket相关的,如果去看它的内部源码会发现很多地方都不是java代码,而是走的native方法去调用本地操作系统里面的一些方法,可能调用的都是C语言写的方法,或者一些底层类库。比如像下面这样的:
    public native int hashCode();
    

    在调用这种native方法的时候,就会有线程对应的本地方法栈,这个也是跟java虚拟机栈类似,也是存放各种native方法的局部变量表之类的信息。
    还有一个区域是不属于jvm的,就是通过NIO中的allocateDirect这种API在java堆外分配内存空间,然后通过java虚拟机里的DirectByteBuffer来引用和操作堆外空间。

    有了对jvm内存模型的掌握,下一篇会和大家分享jvm的垃圾回收机制。

    相关文章

      网友评论

          本文标题:JVM内存模型详解

          本文链接:https://www.haomeiwen.com/subject/vxvrtktx.html