JVM超详细解析

x33g5p2x  于2022-05-07 转载在 Java  
字(9.5k)|赞(0)|评价(0)|浏览(519)

一、初始JVM

1. JVM 需要了解的三个方面

a)JVM中的内存布局是怎么样的,它们分别的作用是什么?
b)JVM中的类加载的过程是怎么样的,具体步骤?
c)JVM中的垃圾回收机制(GC)有什么优缺点,涉及到的回收机制和算法有什么?

下面我们就来一一探讨这三个问题。

2.常见的虚拟机

JVM,说是叫“虚拟机”,但是对比VMware和Virtual Box 虚拟机来说,并不是同一回事。JVM只是对硬件设备进行了简单的抽象封装,能够达到跨平台的效果。而VMware和Virtual Box 是100% 使用软件来模拟出真实的硬件。

还有HotSpot VM,是Oracle官方和开源OpenJDK 都是用这个虚拟机的。

二、JVM的内存布局

1.内存的划分

JVM划分的区域:
1.堆
2.栈
3.方法区
4.程序计数器

JVM实际上是一个Java 进程,进程就是用来管理硬件资源的,比如内存。JVM启动之后就会从操作系统这里申请到一大块内存

具体的内存布局:

对于堆区和方法区,在整个JVM中只存在一份,而程序计数器和栈区是跟进程绑定在一起的,每个不同的线程都有独立的一份程序计数器和栈区。

不同的区域放不同的东西
1.堆中放入的是 new 的对象。(不要忘了在JDK 1.8中,字符串常量池在堆中)

2.方法区放入的是 类对象。
.java->.class->JVM就会把.class文件进行加载,加载到内存中,最后变为类对象。
类的static 成员,作为类属性。同样也是在类对象当中的,就放到方法区里。

类对象里有什么?
a)包含这个类的各种属性的名字,类型,访问权限。
b)包含这个类的各种方法的名字,返回值,访问权限,参数类型,以及方法的实现的二进制代码。
c)包含这个类的static 成员。

方法区内部有个运行时常量池,存放字面量和符号引用:
字面量 : final常量、基本数据类型的值。
符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。

3.栈区放入的是 局部变量。
对于什么变量放在栈区,什么变量放在堆区,跟它是引用类型还是内置类型无关,只要取决于它是在内存中的哪个区域,是全局变量(成员变量),还是局部变量,还是静态变量?
Java虚拟机栈:给上层的Java代码来使用的。
本地方法栈:本地方法栈是给本地方法使用的。

4.程序计数器放入的是 内存地址。
这个内存地址的含义是,接下来要去执行的指令地址。
我们写的.java 文件 -> .class 文件 ->读到内存当中 -> 每个指令都有自己的地址 -> CPU要执行指令就需要从内存中去取地址,然后再在CPU上执行。

2.和内存区域相关的异常

1.堆溢出,代码中出现堆溢出的话就会抛出“java.lang.OutOfMemoryError”,典型的情况就是不断地去new 对象而不去释放内存。
2.栈溢出,代码中出现栈溢出的话就会抛出“java.lang.StackOverflowError”,典型的场景就是不断去递归不带有终止的if条件。栈里面除了要放局部变量外,还要放方法的调用关系。

堆和栈的空间大小,都可以通过JVM(Java进程的命令行参数)来进行配置。

3.根据代码判断该变量在JVM的哪个内存中

a)下面代码doGet方法中的test是在哪个内存区域?

class Test {
    public int val = 0;
}
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        Test test = new Test();
    }
}

答:Test test = new Test();中的test是一个局部变量,因此是存放在栈区中的,而new 出的对象就是放在堆区中。

b)下面代码Test test = new Test();是在哪个内存区域?

class Test {
    public int val = 0;
}
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
    Test test = new Test();
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }
}

答:因为HelloServlet这个类是需要new出来的,对应地对象里面就有test引用及其对象。t是一个全局变量,因此是在堆中的,后面new 出来的对象,也是在堆中的。

c)下面代码中static Test test = new Test();存放于内存的哪个区域?

class Test {
    public int val = 0;
}
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
    static Test test = new Test();
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }
}

答:因为Test test = new Test();是被static关键字修饰了,因此t变成了静态成员变量,此时t就是在类对象中,即方法区当中。而new 出的对象不变,仍然存放在堆中。

4.引用类型的理解

我们可以把引用类型当作一个“低配指针”,但从更严谨的角度去看,引用并不是一个指针。Java的引用相当于堆C语言的指针功能进行了裁剪,Java中的引用只能用来解引用(如:使用 . 就是默认地解引用)和比较(==或!=) 。

三、Java的类加载

1.有关.class文件的格式规范

类加载其实是JVM 中的一个非常核心的流程,做的事情,就是把.class 文件,转成JVM 中的类对象

要想完成 类加载,必须要明确的知道,.class文件中都有啥,按照.class文件中的规则进行解析。因此,编译器和类加载器(JVM)必须要商量好.class文件的格式。而.class 文件的格式,在JVM虚拟机规范文档 里面已经约定好了的,则编程语言的语法,也可以理解为一种“协议”。

发明/定义一个编程语言,就一定要让该编程语言跑起来,就需要把源码编译成可执行程序,再进行执行。过程:编译->.class->加载 。

在JVM虚拟机规范文档中有:
上面的是Java语法规范,是约束编译器和程序员之间的。下面的是Java虚拟机规范,是约束编译器和JVM 之间的。

我们可以选择HTML 的文档格式去查看,第二章的第一节就是有关.class文件格式的规范。而点进去又会提示你在第四章中才会有…

u4就是一个无符号四个字节的整数,u2就是两个字节的无符号整数。而带有info的都是结构体。可以看到,它把java代码中,定义的一个类的核心信息都体现进去了,只不过这个文件的格式是二进制的。

因此,根据上述的格式,我们可以自己开发一个编程语言,然后编译就根据.class文件的格式一样,就可以直接在JVM中去解析执行了。
这样就大大地降低了语言开发的成本,如Kotlin,Scala,Groovy等语言都是基于JVM体系的语言。因此,Kotlin就能够和Java无缝对接,非常方便地去使用Java现有的生态,对比Java,含有的语法糖更多一些。它是有更多现代一些编程语言的特点。

2.类加载的基本流程

类的生命周期:都离不开.class文件的格式

加载:目的是把.class 文件给找到。如果代码中需要加载某个类,就需要去特定的目录下去查找该.class文件,找到之后,就需要打开这个文件,并且读取这个文件。此时这些数据就已经读到内存里了。

验证:目的是验证后缀为.class 的文件是否是编译器编译生成的,如果是人为地去改后缀变为.class 的文件,那么就不是一个合法的.class 文件。除了验证.class 文件的格式外,还需要验证文件里面的字节码指令是否正确。(方法里面具体要执行的指令)

准备:目的是为类对象中的一些成员变量分配内存空间(静态变量…),并且进行一个初步的初始化(初始空间大小为0).

解析:主要是针对字符串常量进行的处理。.class文件涉及到一些字符串常量,在解析的过程中,就把这些字符串常量替换成当前JVM中的字符串常量。
注:不是程序一启动,就把所有的类都加载完毕的,而是用到哪个类就加载哪个类,而字符串常量是最初启动JVM的时候就有的。

初始化:主要针对在“准备”环节中,对初步初始化的静态变量进行真正地初始化。同时也会执行static 的代码块。

前面两个过程是重要理解的

针对上述JVM类加载过程,有个代码需要注意一下:

我们发现结果是:(由父及子,静态最先)

原因:当new B() 的时候,就会先尝试去加载 B 这个类,然后加载B 的时候,因为是B 继承于A ,于是又得先加载A 。等到两个类都加载完了,再进行实例化的操作

3.类加载中的双亲委派模型

双亲委派模型,是类加载中的加载环节里面的很小的一部分细节。更准确地说,应该叫“父亲委派模型”。

在进行类加载的过程中,其中一个非常重要的环节,就是根据这个类的名字(如:java.lang.String) 找到对应的.class 文件。
在JVM中,有三个类加载器(三个特殊的对象)来负责找文件的操作。这三个类加载器对象都有各自找的区域。

图示如下:

这三个类加载器之间存在父子关系(但并不是继承中的父子关系,而是类似于链表一样,每个类里面都有个 parent 字段,指向了父类加载器)。

双亲委派模型的流程
当代码中使用到某个类的时候,就会触发类加载。首先是从AppClassLoader 开始的,但是AppClassLoader 并不会直接开始去扫描自己负责的目录,而是先找它的爸爸。找到了ExtClassLoader 之后,它也一样,不会立刻去扫描自己负责的目录,而是又去找它的爸爸。
找到BootStarp 之后,它也不会立刻去扫描自己负责的目录,而去找它的爸爸。但是它并没有爸爸,因此就只能自己先去扫描自己负责的目录。如果在自己的目录中,找到了复合的类,就没有其它类加载器的事情了。但是如果没有找到匹配的类,就告诉儿子(ExtClassLoader)。
ExtClassLoader再来找自己负责的目录,如果找到,就加载,找不到就告诉儿子(AppClassLoader)去查找。
AppClassLoader就在自己负责的目录去查找,如果找到就加载,找不到就抛出ClassNotFound异常。

这里有这么一套规则,其实就是在约定上述被扫描的目录的优先级。这个优先级在正常情况下没有什么作用,假设如果是我们自己创建了一个java.lang.String 的类(只有一个类),同时有标准库中的String 类。那么有优先级后,就会先去加载标准库中的String类,因为我们创建的类是一个复合类,因此就没有其它加载器的事情了。

4.双亲委派模型的优点

1.避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。
2.安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了。

5.破坏双亲委派模型

我们学了Servlet后,知道里面根本就是没有main方法的,而且很少会涉及到标准库中的类,一般涉及的类都有Servlet相关。因此Tomcat 的webapps 中就有很多的类,是Tomcat 内部自己实现的类加载器来完成的(目的是告诉程序去额外的目录去找.class)。则Tomcat就没有去遵守 双亲委派模型。

四、垃圾回收机制(GC)

1.什么是垃圾回收?

垃圾回收,回收的是内存。JVM 其实是一个进程(java),一个进程会持有很多的硬件资源,如(CPU,内存,硬盘,带宽),而系统的内存总量,是一定的。因此对内存的合理使用是非常重要的。
内存要经过:申请->使用->释放 过程。内存是有限的,并且要给很多的进程去使用。从代码编写的角度看,内存申请的时机是很明确的,但是内存的释放时机很模糊。对于C语言来说还好,内存的释放是靠程序员自己去手动释放的,如malloc、free等。但是一旦忘了释放内存,就会造成内存泄漏,直到内存耗尽为止。

对于内存泄漏问题,不同的语言有了不同的解决方法:在C++中引用了智能指针,在合适的时机去自动释放内存,(一般是通过引用计数的方式来衡量这个内存被引用了多少次,当引用计数为0时就真正释放内存)。在Rust中,采取的方案是基于语法上的强校验,Rust引入了很多对内存操作相关的语法规则,在编译器编译期间就会对进行严格的检查和校验,一旦发现有代码存在内存泄漏的风险,就编译报错。但是也有不好的地方,它的语法非常丑陋,同时也限制了很多功能的实现。以至于在实现一些特殊功能的时候,要使用个’unsafe’操作,引入这个操作,之前的校验也就部分的失效了。

而Java中采用垃圾回收的方式,对于该机制来说,哪一个代码申请都可以,哪里申请都可以,都是由JVM统一去进行垃圾回收(内存释放),具体来说,就是由JVM 内部的一组专门负责垃圾回收的线程来进行这样的工作。

优点:能够非常好地保证不出现内存泄漏的情况(不是100%保证),并且是自动去进行内存释放。
缺点:
1.需要消耗额外的系统资源。
2.内存释放可能存在延时(不是内存不用了就马上回收,可能过段时间才会回收)
3.可能会出现STW 问题(stop the world),比如说有一大段内存需要去释放,那么可能系统的资源都用来去释放该内存了,而其它的代码就不能够继续执行,没法去做别的事情了。但是现在大佬们能够将STW 问题限制在了1ms 之内。

2.Java的垃圾回收要回收的内存是哪些?

JVM中有四个区域:堆区、方法区、栈区、程序计数器。堆区里面的内存就主要是JVM需要释放的内存对象。而方法区里面的是类对象,它是类加载过来的,而对方法区进行垃圾回收,就相当于“类卸载”,这里的规则比较特殊,我们不用考虑。而栈区和程序计数器是跟进程绑定在一起的,在进程结束的时候,相应地,栈区里面的变量和程序计数器就会随之自动释放内存空间了。

在上述几个区中,堆占据的内存空间就是最大的,本来就是占据了一个程序的大部分内存

3.回收堆上的内存,具体回收的是什么?

堆内存中,可以划分为:

垃圾回收机制主要回收的就是 完全不再使用的内存。对于一半在使用,一半不再使用的内存,是不回收的,因为回收的成本比较大,当然实现起来也比较麻烦。

因此,Java中的垃圾回收,是以“对象”为基本单位的,一个对象,要么被回收,要么不被回收,不会出现一个对象被回收一半的情况。

4.垃圾回收到底是如何回收的?

垃圾回收的基本思想:先找出垃圾,再回收垃圾。因此,就要确保该某个对象不再被使用,就认为是垃圾。如果要是把正在使用的对象回收了,就会造成很严重的后果了。

如:一个游戏服务器,提供服务,其中有一个功能,玩家查询自己的账户余额。查询的时候肯定是需要把查询的结果保存到一个对象中,当用户尝试获取到这个结果的时候,结果对象正常来说会包含结果数据,但此时被回收了,此时查询的结果就是一个错误的结果。

因此相比于回收少了,回收多了(回收错了)是一个更严重的问题,对于GC 来说,级别垃圾的原则,宁可放过也不要乱回收。

5.如何找到垃圾?

如何找垃圾也可以称为(如何标记垃圾?/ 如何判定垃圾?) 。抛开Java来说,单纯GC 的话,判定垃圾有两种典型的方案。
a)引用计数
b)可达性分析

5.1 引用计数

先谈谈 引用计数:
引用计数,就是通过一个变量来保存当前的这个对象,被几个引用来指向。一个对象就会内置一个计数器记录它被几个变量所指向。

如:此时new Test() 这个对象就被三个变量所指向,因此里面的计数器就为3.

Test a = new Test();
Test b = a ;

func(a);
void func(Test t) {
  ...
}

但是引用计数有个致命的问题。当出现循环引用时:如:

class Test {
   Test t = null;
}

Test t1 = new Test();//1
Test t2 = new Test();//2
t1.t = t2;//3
t2.t = t2;//4

t1=null;//3
t2=null;//2

当代码运行完t2=null 的时候,按引用计数的情况来说,new Test() 里面的计数器为2,但是此时内存是不再使用的,它不被回收就会导致内存泄漏了。

因此,引用计数的优缺点:
优点:规则简单,实现方便,比较高效(程序运行的效率高)。
缺点:
1.空间利用率比较低(比较浪费空间,尤其是针对大量的小对象)。本来引用的次数就不多,而且还内置了计数器就比较浪费空间了(每一个int占4个字节)。
2.存在循环利用导致判定是否是垃圾出现了错误,从而无法回收。

因此在Java中没有使用引用计数去判定垃圾,而是第二种方式——可达性分析。

5.2 可达性分析

从一组初始位置出发,向下进行深度遍历,把所有能够访问到的对象都标记成“可达”,对应地,没有访问到(不能访问到) 的对象就没有标记,没有标记的就是垃圾

如:

有:

class TreeNode {
   char val;
   TreeNode left;
   TreeNode right;
}

TreeNode root = ...;

假设root 是一个方法中的局部变量,当前栈帧中的局部变量,也是进行可达性分析的一个初始位置,从此处就往下进行遍历。
默认情况下,整棵树都是可达的,都不是垃圾,但是如果有root.right.right=null,则f这个结点就不可达了,就成了垃圾。如果有root.right=null,此时c和f结点都不可达了,就都是垃圾了。

JVM中采取的方案是:在JVM 中就存在一个/一组线程,来周期性地,进行上述遍历的过程,不断地找出这些不可达的对象,由JVM进行回收。

可达性分析的初始位置有
1.栈上的局部变量表中的引用。
2.常量池里面的引用指向的对象。
3.方法区中,引用类型的静态成员变量。

基于上述过程,就完成了对垃圾的标记。和引用计数相比,可达性分析,确实更麻烦,同时实现可达性分析的遍历过程开销是比较大的。但是带来的好处是解决了引用指针的两个缺点:内存上不需要消耗太多的空间,也没有循环引用的问题。

不管是引用计数还是可达性分析,我们都可以发现,内存是否需要回收 是看 当前的对象是否有引用来指向。是在通过引用来决定对象的生死。

6.找到垃圾后如何去回收?

垃圾回收中的经典算法/策略:
a)标记-回收
b)复制算法
c)标记-整理

6.1 标记-回收

比如说:白色是正在使用的对象,灰色是已经被释放的空间。

虽然此处可以释放掉不再使用的内存空间,但是引入了一个问题——内存碎片。我们发现,空闲的内存和正在使用的内存,是交替出现的。

此时如果是申请一小块内存,那没什么问题。但如果是申请一大块连续的内存,此时可能就会分配失败。很多时候,申请的内存,是一块连续的空间(new byte[]),由于内存碎片的存在,整个空闲的内存有100M,此时申请50M的内存,仍然可能会分配失败。

内存碎片的问题,如果一直累计下去,就会导致:空闲的内存其实挺多的,但是不能够去使用,就很难受了。并且该问题在频繁地“申请释放” 的场景中更加常见。

6.2 复制算法

它是为了解决 标记-清除 的内存碎片问题的。把内存分为两部分。
开始:

此时假设1、3要被回收,那么就剩下了2,4了。就将2,4的内存复制到右遍的内存区域中。此时再回收掉左边的一整个内存区域。内存区域一次只用一个部分。

使用复制算法,就能够解决 标记-清除 内存碎片问题。
复制算法的缺点:
1.可用的内存空间,只有一半。
2.如果要回收的对象比较少,而剩下的对象比较多,复制内存的开销就很大了。

因此复制算法,适用于:对象会被快速回收,并且整体的内存不大的场景下。

6.3 标记-整理

能够解决复制算法的内存空间利用率的问题。它类似于顺序表的“删除”的搬运操作。
初始:假设此时要回收2,4,6 的内存空间。

就将3往2搬,因为4是需要回收的,它不动。5往第三个位置搬,6是需要回收的,不动。7往第四个位置搬,8往第五个位置搬。搬到最后6没有被覆盖,那么就回收6 。

最终结果:

这样的操作,能够有效避免内存碎片,同时也能提高内存利用率。
缺点:在搬运的过程中,是一个很大的开销,这个开销可能比复制算法里面的开销更大

6.4 分代算法

实际实现的垃圾回收算法,要能够结合上面的三种方式,取长补短。就有了分代算法。

它把内存中的对象分成了几种情况,每种情况下,采用不同的回收算法。

根据“年龄”去进行划分。年龄是如何来的?是根据GC 的次数来的,每次经历一个扫描周期,就认为“长了一岁”。在JVM中,垃圾回收扫描(可达性分析)是周期性地进行的。因此就根据不同的年龄,就采用不同的垃圾回收算法来处理了。

划分结构:

分代回收的过程:
1.一个新的对象,诞生于伊甸区。
2.如果活到一岁的对象(对象经历了一轮 GC 还没死),就拷贝到 生存区。

生存区的内存大小比较小,那么空间小能放下这么多对象吗?
答:根据经验规律,伊甸区的对象,绝大部分都是活不过一岁的,只有少数对象能够来到生存区,对象大部分都是“朝生夕死”的。注意:是大部分!!!

3.在生存区中,对象也要经历若干轮GC,每一轮GC 逃过的对象,都通过 复制算法 拷贝到另外的生存区里。这里面的对象来回拷贝,每一轮都会淘汰掉一批对象。

4.在生存区中,熬过一定轮次的GC 之后,这个对象如果还没有被回收的话,JVM就认为,这个对象未来能够更持久地存在下去。于是就将这样的对象拷贝到老年代了。

5.进入老年代的对象,JVM都认为是属于能够持久存在的对象。这些对象也需要使用GC 来扫描。但是扫描的频次就大大地降低了。老年代这里通常使用的是标记-整理算法。

特殊地,如果一个对象的内存特别大,它会直接放入老年代。因为如果把它放入到新生代,如果经过一轮GC没有被淘汰,就放到生存区中。在生存区中拷贝来拷贝去的开销会比较大,甚至有的对象的内存太大在生存区可能放不下,因此直接放入老年代更合适。

6.5 垃圾回收器(了解)

垃圾回收器,属于JVM 中GC 机制的具体实现。这些具体实现中,就应用到了上述的一些垃圾回收算法。

我们真正需要了解的有两个垃圾回收器。
1.CMS 。最主要的特点,是尽可能地降低STW ,使用标记-回收,先进行一个初步的标记(很快,会出现STW),接下来和业务线程并发的进行 深入的标记(不会STW),再进行一个重新的标记(很快,但是会STW),主要是对之前的标记进行简单地修正,最后进行回收。

2.G1 。最主要的特点,是将内存划分成了更多的小区域(不像上面所说的新生代和老年代),以小区域单位进行GC 。

相关文章