理解JVM

发布于:2024-10-17 ⋅ 阅读:(11) ⋅ 点赞:(0)


前言

JVM 内部涉及到的内容是非常广泛的。咱们主要讨论三个方面的问题:
1.JVM 内存区域划分
2.JVM 中类加载的过程
3.JVM 中的垃圾回收机制


一、JVM 内存区域划分

一个运行起来的 java 进程,其实就是一个 JVM 虚拟机。需要从操作系统中申请一大块内存,把这个内存划分成不同的内存区域。

  • 1.方法区(1.7及其之前)/ 元数据区(1.8开始)
    这里存储的内容是类对象:.class 文件加载到内存之后就成了类对象。

  • 2.
    这里存储的内容是代码中 new 出来的对象(占据空间最大的区域)

  • 3.
    这里存储的内容是代码执行过程中,方法之间的调用关系

  • 4.程序计数器
    (比较小的空间)主要存放一个“地址”,表示下一条要执行的指令在内存中的哪个地方

大家可以看下面这张图形象地展示了 JVM 中的区域划分:

在这里插入图片描述
值得注意的是:
虚拟机栈和程序计数器都是每个线程都有一份的。
而元数据区和堆在 JVM 进程中是只有一份的。
所以,一个JVM 进程可能有多个线程。每个线程都有自己的程序计数器和栈空间,这些线程共用同一份堆和元数据区(方法区)

  • 常见面试题:

下面代码中 n,a,t 处于哪个区域?

在这里插入图片描述
答:一个变量处于哪个区域,和变量的形态密切相关。Test 类中定义的变量是成员变量,在下面main 方法中new 对象时就会包含这个属性,所以成员变量处在堆上。而下面的变量a 由于加了static 修饰就是静态变量,包含在类对象中,那么静态变量就是处在元数据区(方法区)上。最后下面main 方法中的局部变量t 是一个引用类型的变量,这里存的是一个对象的地址,不是对象本身。处于栈上的局部变量表中,所以,局部变量处在栈上

二、JVM 中类加载的过程

a.类加载的基本流程(熟练背诵)

java 代码会被编译成 .class 文件(里面包含了一些字节码),java 程序要想运行起来,就需要让 jvm 读取到这些 .class文件 ,并且把里面的内容构造成类对象保存到元数据区(方法区)中。所谓的“执行代码”其实就是调用方法,要想调用方法,就要先知道每个方法编译后生成的指令都是啥,而这些指令都是存储在 .class 文件中的。

  • 类加载的5个步骤:
    1.加载:通过“全限定类名”,在一些指定的目录范围内找到 .class 文件,打开并读取文件内容
    2.验证:验证当前读到的这个格式是否符合要求。.class 文件是一个二进制的格式(某些字节都是有某些特定含义的)。
  • 3.准备:给类对象分配内存空间(最终的目标是要构造出类对象)。这里只是分配内存空间,还没有初始化,此时这个空间上的内存的数值都是0。
  • 4.解析:针对类对象中包含的字符串常量进行处理,进行一些初始化操作
    java 代码中用到的字符串常量,在编译之后,也会进入 .class 文件中。字符串常量是引用,本质上保存的是地址,在 .class 文件中不涉及内存地址,所以就会先设置成一个“文件的偏移量”,当类真正被加载到内存中的时候,再把这个偏移量替换成真正的内存地址。
  • 5.初始化:针对类对象进行初始化,把类对象中需要的各个属性都设置好。需要初始化 static 成员,需要执行静态代码块以及还可能需要加载一下父类。

b.双亲委派模型

双亲委派模型属于类加载中的第一个步骤,是“加载”过程其中的一个环节。负责根据全限定类名来找到 .class文件。

  • JVM 中内置了三个类加载器:BootStrap ClassLoader、Extension ClassLoader、Application ClassLoader

  • 类加载的过程(找 .class 文件的过程)
    1.给定一个类的全限定类名,形如 java.lang.String
    2.从 Application ClassLoader 作为入口,开始执行查找的逻辑
    3.Application ClassLoader 不会立即去扫描自己负责的目录(项目当前的目录和第三方库对应的目录),而是把查找的任务交给它的父亲—Extension ClassLoader
    4.Extension ClassLoader,也不会立即扫描自己负责的目录(JDK 中一些扩展的库对应的目录),也是把查找的任务交给它的父亲—BootStrap ClassLoader
    5.最后,BootStrap ClassLoader 只好扫描自己负责的目录(标准库中的目录)。如果能够找到,就执行后续的类加载操作,此时查找过程 也就结束了。
    6.但是,如果给定的雷不是标准库中的类,就会回到 Extension ClassLoader 来进行扫描扩展库的目录。
    7.如果还是没有扫描到,就会继续回到 Application ClassLoader 来扫描当前项目和第三方库的目录。
    8.最终,如果还是没有没找到,就会抛出一个 ClassNotFoundException 这样的异常。

在这里插入图片描述
之所以搞这一套流程,主要的目的是为了确保标准库的类被加载的优先级最高,其次是扩展库,再其次是自己写的类和第三方库

三、JVM 中的垃圾回收机制(GC)

GC就是让JVM 自行判断某个内存是否就不再使用了,如果这个内存后面确实不用了,JVM 就自动地把这个内存给回收掉。
GC 回收的目标是内存中的对象,对于 java 来说,就是 new 出来的对象

  • 在java 中,使用对象,必须要依靠引用。如果一个对象没有引用指向了,就可以视为是垃圾了。

  • GC 可以理解成两大步骤:

1.找到垃圾

有两种主流的方案:
a.引用计数(python,PHP):
new 出来的对象,单独安排一块空间,用作一个计数器来保存引用计数。当对象的引用计数为0时,此时代码中就不可能访问到这个对象,那么这个对象就可以视为是垃圾了。
存在的问题:
1.比较浪费内存
2.存在“循环引用”问题
b.可达性分析(java)
有一个/一组线程,周期性地扫描代码中所有的对象(从一些特定的对象出发,尽可能地进行访问的遍历,把所有能访问到的对象,都标记成“可达”。反之,经过扫描之后,未被标记的对象,就是垃圾了)。
可达性分析的出发点有很多,不仅仅是所有的局部变量,常量池中引用的对象,还有方法区中的静态引用类型引用的变量等等…
存在问题:可达性分析比较消耗系统资源,开销比较大

2.如何回收垃圾?

三种基本的思路:

  • 1.标记清除(简单粗暴的释放方式)
    把对应的对象直接释放掉
    在这里插入图片描述
    缺点:会产生很多的内存碎片,导致后续内存申请举步维艰。

  • 2.复制算法
    通过复制的方式把有效的对象归类到一起,再统一释放剩下的空间
    在这里插入图片描述
    缺点:内存要浪费一半,利用率不高。如果有效的对象非常多,拷贝开销就会很大。

  • 3.标记整理
    类似于顺序表进行删除时的搬运操作,让所有有效对象向一端移动。
    在这里插入图片描述
    缺点:搬运的开销仍然很大。

  • 实际上,JVM 采取的释放思路,是上述思路的结合体。

在这里插入图片描述

1)刚 new 出来的新的对象放到伊甸区,第一轮 GC 扫描,会把有效对象通过复制算法复制到幸存区中,伊甸区就可以整个释放了。
2)GC 线程会不断地继续扫描幸存区,把可达的对象拷贝到幸存区的另一部分。
3)当一个对象已经在幸存区存活过多轮GC 时,JVM 就会认为这个对象短时间内应该不会被释放掉,就会把这个对象拷贝到老年代。
4)进入老年代的对象,虽然也会被GC 扫描,但是扫描的频率就会比新生代低很多了。

注意点:
新生代中主要使用的是复制算法
老年代中主要使用标记整理

分代回收,是JVM 中回收的主要思想方法,但是在垃圾回收器具体实现的时候,可能还会有一些调整和优化


总结

我们主要了解 JVM 的三个方面:JVM 内存区域划分、JVM 中类加载的过程和 JVM 中的垃圾回收机制。