更有逻辑的学习JVM

85 阅读15分钟

1. 分析思路

• JVM是Java virtual machine/java虚拟机的意思,他负责运行我们的编译出来的Java程序,也就是说他是整个Java应用的核心。因此学习好JVM可以帮助我们更好的了解Java的运行情况,针对它的运行情况去调优我们的应用服务,解决实际应用开发遇到的问题。

• JVM主要分为五大模块的内容需要学习:

  1. 运行时数据区:它规定了整个JVM在运行的过程中,如何管理控制硬件上的内存;
  2. 类加载机制:它定义了类对象如何从.class文件中加载到运行时数据区上;
  3. 对象的创建机制:当加载完成类对象之后,此时需要创建出具体的实例对象,这个模块将总结实例对象如何依赖类对象进行创建;
  4. 垃圾回收机制:当大量的对象被创建并使用完毕之后,大量的内存对象将堆积在运行时数据区,JVM屏蔽了我们直接对内存的操作,我们无法直接回收不需要的内存对象,因此它需要有一个机制帮我们回收不需要的垃圾对象,这就是垃圾回收机制;
  5. JVM调优和问题解决:上面四大模块让我们了解完JVM的整个执行流程,然后我们需要根据它的执行流程针对性的对各个模块进行优化,并处理如OOM之类的问题。

2. 运行时数据区

• 当一个JVM运行起来之后,它需要有效的管理它的内存使用来更高效的运行程序;

• 两种内存:在JVM中,主要将内存分配为两种内存,一种是共享的内存,一种是线程私有的内存;

• 五大区域:其中,共享的内存主要定义了两大区域,堆区和方法区。而线程私有的内存是每个线程都有申请的一块内存,而每个块内存都定义了三大区域,栈区、程序计数器、本地方法区;

• 一般讨论的时候,我们都是具体每块区域进行讨论的,接下来先从堆区开始研究;

2.1 堆区

2.1.1 数据存放

  1. 实例化对象:堆区主要是用于放置实例化对象,也就是通常我们说被new出来的对象;
  2. 类对象:同时,堆区还会存放类对象,需要注意的是,这是类对象,而不是类的结构/字节码信息;
  3. 运行时常量池:需要注意的是,在JVM中存在常量池的概念,指的是一些不会变化的对象或信息。常量池仅是一个统称,实际上它是由几种常量池构成的:

a. Class文件中的信息常量池:它被编写定义在了class文件上,在JVM加载.class文件的时候会被加载到方法区上面,主要存储java文件编译的时候生成的字面量或符号引用等非.class文件天然提供的信息;

b. 基本类型包装类的常量池:这通常是在启动JVM的时候进行生成的;

c. 字符串的常量:通常也是在加载.class文件上的时候,会生成字符串的常量池;

在定义上,运行时常量池归属于方法区进行管理,但是在实现上不同的JVM会有所差异。

• 在JDK1.6和1.6之前提供的虚拟机上,运行时常量池存放在方法区;

• 在JDK1.7和1.7之后提供的虚拟机上,运行时常量池被挪动到了堆区上;

2.1.2 管理和划分

• 在上面我们研究完了堆区存放了什么东西,那么我们再来具体看看堆区如何进行管理和划分;

• 堆区在管理上的需求主要有两点:

  1. 为新建的对象规划分配内存;
  2. 执行策略删除没用的对象释放内存;

• 而这部分的管理的具体实现,是由GC回收器进行实现的。

• 不同的GC回收器会根据自己的特点,从逻辑上对堆区进行划分,这里简单的说一下不同GC回收器对堆区的划分,具体内容探讨我们将留在GC回收机制进行讨论。

• 目前对于堆区的划分方式分为三种:

  1. 由单线程垃圾回收器、多线程垃圾回收器、parNew+CMS的垃圾回收器进行管理划分的年轻代+老年代划分方案;
  2. 由G1垃圾回收器进行管理划分的Region/块划分方案;
  3. 由ZGC垃圾回收器进行管理划分的方案,这里未了解不做详谈。

2.2 方法区

2.2.1 数据存放

• 类的元数据信息:方法区最重要存放的东西,就是类的元数据信息,需要注意的是这并不是指类对象,而是直接从.class文件中加载进来的,用于指导如何生成类对象的元数据信息;

• 运行时常量池:上一章已经进行讨论,从定义上运行时常量池都是由方法区进行管理,但是在不同的虚拟机实现上,该部分内存存放的位置不同;

2.2.2 方法区实现

• 需要注意的是,不管是堆区还是方法区这些划分,都是作为一种规范存在,可以理解为一种接口。而接口的具体情况则是由具体的java虚拟机进行实现。

• 其他的四大区域的实现都没什么特别需要讲的,主要是方法区的实现上,存在永久代和元空间两种实现方案;

2.2.2.1 永久代

• 在JDK1.7和JDK1.7之前,方法区的实现名称都是永久代;

• 永久代指的是在JVM启动进程的时候,申请了一块内存区域,然后将一块区域划分为共享内存,其中一部分为堆区,另一部分为非堆区,而永久代就属于非堆区的一部分。

• 但是,JDK1.7和JDK1.7之前版本的数据管理是有所差异的:

  1. JDK1.7之前运行时常量池是存放在永久带的;
  2. JDK1.7的时候运行时常量池被挪到了堆区中,此时剩余的主要数据就是类的元数据信息了

2.2.2.2 元空间

• 在JDK1.8之后,方法区的实现名称为元空间;

• 相比较于永久代,元空间的主要不同主要是,元空间并不使用非堆内存,而是直接使用的本地内存,也就是JVM启动进程的时候没有申请或者说单独申请的一块内存空间。

2.3 栈区

2.3.1 特点和作用

• 栈区是线程私有的一块区域,也就是说每个线程都持有自己管理的一块内存区域;

• 栈区的作用是在我们执行具体的方法调用的时候,来作为暂存运行中需要使用到的基本类型、局部变量等数据所使用的一块栈型结构的区域;

• 栈区的特点:他是一个栈型结构,也就是一种先进后出的结构,这就很符合我们常规对方法的调用逻辑。

2.3.2 栈桢

• 栈桢:栈区中直接存储的数据为栈桢,当我们调用具体的方法之后,JVM会创建出一个叫做栈桢的内存结构,将其压入栈区之中,而在我们调用完成一个方法之后,栈区又回将栈桢进行弹出回收,因此他也是一块很高效的创建和回收的区域。

• 栈桢本身也是由多块小的内存区域进行构成

  1. 局部变量表:它的主要作用是缓存,用来方法执行中需要被暂存起来的基本数据和局部变量等;
  2. 操作数栈:它同样是一种栈型结构,主要存储着正在执行的数据操作,通过一些调用指令,CPU将会获取操作数栈中的数据进行计算;
  3. 返回地址:用来存储方法调用完成之后,数据需要传递到地址;
  4. 动态连接:指运行时常量的方法引用 2.4 程序计数器 • 程序计数器主要是用来记录当前线程执行的Java代码的位置,用于在线程来回切换的时候快速找回正在执行任务而使用;

2.5 本地方法区

• 这是一块用于提供给其他本地方法使用的内存区域,在常规Java开发中不会直接使用到。

3. 类的加载机制

3.1 类加载机制

• 通过上面的讨论,我们了解到JVM启动之后,它的内存是如何管理和划分的,那么接下来为了执行任务,我们首先需要把我们的任务加载到JVM上,而这些任务的信息就定义在我们写的一个个.class文件上。

  1. 接下来让我们来看一下怎么将.class文件加载成为一个class对象;
  2. 类的加载机制其实很简单,
  3. 就是先将.class文件从磁盘上加载到内存中,这通常会是流的方式读取到该文件;
  4. 然后校验一下这份数据有没有问题,如果没有问题的话就把这份数据先找个地方存储起来,这个地方就是方法区,而这份信息便是类的元数据信息;
  5. 之后就需要利用这部分元数据信息创建一个类对象出来,具体表现就是在堆区中申请一块内存区域,并且根据类元数据信息的指导下存储具体的数据; • 而实际上这整个流程已经被进行整理和规范,具体流程如下:
  6. 加载:将.class文件从磁盘加载到内存中;
  7. 校验:校验这份数据是否符合规范;
  8. 准备:在堆中申请一块内存区域,准备用来存放类对象;
  9. 解析:比较学术性的说法为将常量池中的符号引用替换成直接引用,具体的做法是将文本描述1、2、3或字符串替换或生成具体的基本类型包装对象或字符串对象;
  10. 初始化:给这个类对象上常量赋一下值,而在没有赋值之前都有一个初始值;
  11. 使用:这个类对象可以被用来指导生成实例对象了;
  12. 销毁:释放堆上的这个类对象占用的空间

3.2 类加载器

• 上面的这整个步骤,总需要一个载体进行实现吧,这个载体就是类加载器。

• 类加载器简单的说就是实现了上面一整套流程的工具,我们也可以认为它是一个实例对象;

• Java的类加载器并不只有一个,默认情况下,Java的类加载有三种,同时支持开发者自己定义自己的类加载;

• 自带的类加载器如下:

  1. 启动类加载器:它是由C++编写的,主要用于加载启动所需要类文件;
  2. 拓展类加载器:它是由Java编写的,主要用于加载一些JRE自带的一些额外的类库里面的类文件;
  3. 应用类加载器:它是由Java编写的,主要加载Classpath下面的类文件; • 自定义类加载:有时候自带的类加载器不满足我们的要求,例如加载非Classpath下面的类文件,那么我们就需要自定义自己的类加载了,具体的的自定义方式只需要我们继承 $ClassLoader 抽象类即可。

3.2 双亲委派机制

• 假如我们在classpath下面创建一个String.class文件,那么在JVM中将存在几个String对象?

• 答案是一个,这是因为Java在设计类的加载器的时候,设计了一套名为双亲委派机制的模型。

• 双亲委派机制的规则:当前的类加载器准备加载类对象的时候,首先会询问父类加载是否已经加载过该类对象了,如果加载过了那么直接使用,如果没有加载过则自己加载它。

• 目的:设计该机制的最大目的是为了安全,防止有人篡改一些底层类的实现,而植入一些危险代码;

• 打破双亲委派机制:那么是不是我们必须完全遵从双亲委派机制呢?答案是否定的,我们常使用的 Tomcat ,他作为 servlet 容器 可以部署多个应用项目,需要让每个项目都有自己的类对象,防止不同版本的类对象发生冲突,所以它必须打破这个双亲委派机制。

• 如何打破双亲委派机制:我们没有办法打破java自带的三个类加载器的双亲委派机制,但是我们可以自定义类加载器来打破双亲委派机制,具体过程为:

  1. 继承 $ClassLoader 方法;
  2. 重写 #loadClass 方法,该方法也就是双亲委派机制的实现代码,需要研究的可以查看该方法;
  3. 读取.class文件成byte[],也就是将它从磁盘中读取到内存里,对应类加载机制的加载;
  4. 将byte[] 传入 #defineClass 方法,该方法将会执行类加载机制的剩余过程,然后返回类对象;

3.3 全盘委派机制

• 我们知道当我们执行new的时候,如果类对象还没有被加载,那么首先会加载该类对象,那么此时是使用什么类加载器进行加载这个类对象的?

• 答案是我们使用实例对象对应的类对象,使用什么类加载器,那么该类对象就是用哪种类加载器加载,这种机制便是全盘委托价值;

• 全盘委托机制避免我们直接面向类加载器,解耦了我们的代码和类加载器的使用。

4. 实例对象的创建

• 实例对象的创建过程其实无非就是根据类对象所提供的实例对象的元数据信息的指导下,在堆中申请一块内存空间的过程,而这里申请多大的空间需要根据对象的数据结构来决定。

4.1 对象结构

• 在HotSpot虚拟机中,对象的数据结构主要由三部分构成:对象头(Header)、实例数据(Instance Data)和对齐补充(Padding)。

• 对象头:对象头主要存储了对当前这个对象的一些运行信息、控制信息和类对象的引用等,对开发者隐藏,通常直接被JVM使用。在对象头里同样划分为几种类型数据,对象实例的对象头被划分为markwork和klass两种数据,而数据对象它的对象头在markwork和klass的基础上,还多出一个length数据;

  1. markwork:存储当前这个运行信息和控制信息等,如分代年龄等,具体在GC回收器中讨论。在32位系统下,为32位的长度也就是4个字节,在64位系统下,为64位的长度也就是8个字节(不计算指针压缩);
  2. klass:存储了指向类对象的引用,用来快速找到类对象,在32位系统下,为32位的长度也就是4个字节,在64位系统下,为64位的长度也就是8个字节(不计算指针压缩);
  3. length:顾名思义,即数组的长度,是int类型,也就是4个字节、32/4*8位;
  4. 实例数据:这里便是我们具体运行过程中使用到的数据了,主要组成是成员变量,在分析这块的大小的时候需要分析具体每个属性占用了多少内存;

• 对齐补充:仅作为占位符使用,原因HotSpot虚拟机在分配内存的时候,以8的倍数进行内存分配。

4.3 内存空间申请

• 在计算出实例对象实际需要占用的内存空间多大之后,接下来就需要申请这块内存空间了。

• 那么我们申请内存空间是不是直接在堆区中随便划分一块区域出来分配即可?当然可以这么做,但是为了更高效的维护内存空间,hotspot设计了一套高效分配内存的机制。

  1. 栈上分配:很多人以为对象仅仅会被分配到堆上,实际上为了更高效的利用内存,假如一个对象仅会在一个方法的同一个线程中使用,也就是它仅仅会被当前栈桢使用,并且随着栈桢弹出便可以销毁的话,那么hotspot认为它可以被分配到这个栈桢里,这便是栈上分配,而分析出这个对象能不能在栈上分配的方法叫做逃逸分析;
  2. 私有堆分配:可能会有人疑惑堆不都是共有的么,这里的私有堆实际上是逻辑上的私有,在一个线程被fork出来之后,他会向堆中申请一小块区域在逻辑上被自己私有,然后在分配内存的时候会优先查看自己的私有区域是否有足够的空间的分配,如果有则在这块空间上申请。这样设计的目的是为了提高搜索空间内存区域的速度,借此来提高分配申请的速度。
  3. 年轻代分配:在ZGC之前的垃圾回收器,都会将堆上的空间划分为年轻代和老年代两个区域,而在分配内存的时候,对象首先会在年轻代上申请内存空间。如果在年轻代上没有足够的空间,那么就,执行GC/垃圾回收,具体在后面章节讨论。
  4. 老年代分配:当一个对象太大,并超过一个阈值的话,hotspot支持直接在老年代上申请一块空间来存放,避免在垃圾回收中多次腾挪,影响机器的性能。

5. 垃圾回收机制

• 大量的对象被申请创建后,终于内存满了,那么就应该回收那些没有价值的对象,释放出空闲的空间支持程序运行,因此接下来让我们了解一下JVM的垃圾回收机制。

• 待续。。。。