JVM之内存模型

发布时间 2023-10-10 09:51:39作者: 小新成长之路

前言

首先说明下JVM内存模型Java内存模型这是两个不同的概念,不要搞混淆了。
JVM内存模型定义了Java程序在运行时如何分配、使用和释放内存,跟存储和执行相关,也就是常说的运行时数据区域。
Java内存模型(Java Memory Model,简称 JMM)是一种规范,定义了线程和主内存之间的抽象关系,所有的JVM都有具体的实现,Java内存模型规范中规定了所有的变量都存储在主内存中,而每一个线程的执行在JVM中都有自己的工作内存,这就涉及主内存、工作内存之间数据的可见性、一致性、有效性等问题,Java内存模型就是规定如何正确处理这些问题的,由具体的JVM去实现。

本文基于Hotspot虚拟机讲解

JVM的概念

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是在操作系统之上重新虚拟出来的一套系统,是Java程序(不仅仅是java程序,只要是符合JVM字节码规范的程序,比如Kotlin、 Groovy、 Scala这些语言的程序也是可以运行的)的运行环境,负责将字节码转换为机器码并执行。

JVM的作用

3.1 write once,run anywhere(一次编写,到处运行)

学过java的同学应该都听过吧,Java号称一次编写,到处运行,其实真正实现到处运行的就是JVM,JVM提供的字节码程序运行环境屏蔽了不同操作系统(windows、macOS、linux系统等)的差异,由JVM去适配各个操作系统的接口,也就是说不同的操作系统有不同的JVM实现,从而实现java语言跨平台运行的能力,达到一次编写到处运行的效果。
JVM提供的是程序的运行环境,它并不关心你用的是什么语言,只要最终编译后的字节码符合JVM规范,它都可以运行,这样就使得代码和运行环境分开了,同一份代码编译后就可以在不同的操作系统上由对应的JVM去运行。开发的时候我们安装的是JDK,JDK包含了开发的一些工具集,也包含了JRE(Java Runtime Environment,java程序运行环境),这个JRE包含了JVM,而生产上一般只需要安装JRE去运行我们编译好的class字节码文件即可。

3.2 自动内存管理

C和C++语言都是通过手动操作主动去申请内存和释放内存,虽然灵活但是稍不注意没有释放就可能导致内存泄漏,而JVM有自己的内存管理机制,开发同学不用过多关注内存的申请和释放,集中精力放到业务实现上,内存的分配和回收都由JVM去管理(虽然JVM也可能会导致内存泄漏,更多的原因可能是代码问题或者内存分配及垃圾回收参数配置不合理,调优即可)。
在java程序中,对象占用的空间内存分配由JVM来分配,对象无任何引用不再使用时,由JVM选择合适的时机进行垃圾回收,自动释放所占用的空间。

JVM常见的实现版本

4.1 Hotspot

由Sun公司开发,后来被Oracle收购,目前使用最多的 Java 虚拟机。

4.2 JRockit

由BEA公司开发,曾号称世界上最快的 JVM,后被 Oracle 公司收购,合并于 Hotspot。

4.3 J9

由IBM开发, 主要是用在 IBM 产品(IBM WebSphere 和 IBM 的 AIX 平台上),市场定位与Hotspot接近,服务端、桌面、嵌入式都有应用。该虚拟机于2017年正式对外发布,名字为OpenJ9,并交给Eclipse基金会打理。

4.4 TaobaoVM

由阿里巴巴开发,实际上是 Hotspot 的定制版,基于OpenJDK VM专门为淘宝准备的,阿里、天猫都是用的这款虚拟机。

4.5 其他

其他的还有像LiquidVM、zing、Tencent Kona JDK11这样的虚拟机等。

JVM内存模型

整体大概执行流程:

  1. 代码文件编译成字节码文件。
  2. JVM类加载器加载字节码文件到JVM中。
  3. 由执行引擎解释执行。

具体流程图如下:

本文重点讲图中间JVM内存模型部分,共分为5块。
共享部分两块:堆、方法区(在JDK8之前版本这部分叫方法区,从JDK8开始没有方法区一说了,这部分叫元空间,其实所存储的内容一样,只不过之前的方法区占用JVM内存,而元空间占用的不是JVM内存,是系统的直接内存)
独享部分三块:程序计数器、本地方法栈、虚拟机栈。
下面详细介绍。

5.1 方法区 Method Area(元空间 Metaspace)

方法区(元空间)是所有线程都共享的区域。
早在JDK8版本之前叫方法区,也叫永久代,主要用来存放类数据,也就是类加载器加载进来的class文件。也用来存放常量、静态变量等信息,占用的是JVM内存。可通过参数设置方法区大小,-XX:PermSize设置初始大小,-XX:MaxPermSize设置最大可分配的空间,如果内存溢出会抛出错误:java.lang.OutofMemoryError:PermGen space。
但从JDK8版本开始(包括JDK8),有一个新的名字叫Metaspace,就是元空间的意思,元空间不占JVM内存,它直接使用的是物理内存,它保存的是类信息、常量、字段、方法信息,而常量池和静态变量这些保存在堆上了。可通过参数设置元空间大小,-XX:MetaspaceSize-XX:MaxMetaspaceSize设置元空间初始大小以及最大可分配的空间大小,如果内存溢出会抛出错误:java.lang.OutOfMemoryError:Metaspace。

5.2 堆(Heap)

这部分空间是所有线程都共享的区域。
堆内存是Java程序中最大的一块内存区域,用于存储对象实例。
堆内存的大小可以通过-Xmx参数指定最大内存空间和-Xms参数指定最小内存空间。
堆内存可以进一步分为新生代(Young Generation)、老年代(Old Generation)和永久代(Perm Generation)(也叫方法区,JDK8之前堆上才有,包括JDK8版本之后的堆上就没有这个区域了)。
默认情况下,堆内存中,新生代大小 : 老年代大小比例为1 : 2,可通过–XX:NewRatio参数指定比例大小,默认是2。
如果内存溢出会抛出错误:java.lang.OutOfMemoryError:Java heap space

5.2.1 新生代(Young Generation)

用于存储新创建的对象。新生代又分为Eden区和两个Survivor区(From和To),大多数对象在Eden区出生并死亡。如果Eden区空间不足,将触发Minor GC, Eden区和Survivor其中一个正在使用的区中的存活对象会被移到Survivor另一个未被使用的区,如果另一个区也放不下,那么就会存放到老年代。
一般情况下,新生代垃圾回收使用的是复制算法(因为新生代基本都是朝生夕死的对象,存活对象较少,复制效率高),在Minor GC的时候,垃圾回收是在Eden区和其中一个使用的Survivor区中进行,将存活对象复制到Survivor区另一个未使用的区域中,当对象经历过指定次数时(默认15,可以通过参数-XX:MaxTenuringThreshold设置)仍存活,将会把存活的这个对象直接放入老年代,另外如果对象较大,新生代空间不够内存分配时,这个大对象也会直接进入老年代分配。
默认情况下,新生代中Eden区和两个Survivor区的内存空间大小占比是8 : 1 : 1,可通过参数-XX:SurvivorRatio(默认是8)设置比例。

5.2.2 老年代(Old Generation)

用于存储生命周期较长的对象,或者新生代空间不能满足对象大小分配时,也会直接在老年代分配空间,当老年代空间不足将触发Major GC,一般情况下,老年代垃圾回收使用的是标记整理算法(老年代要么是大对象,要么是经过多次Minor GC还存活的对象,存活对象较多,使用复制算法代价太大,所以常使用标记清除算法(会有内存碎片)或标记整理算法(空间连续,没有内存碎片))。

5.2.3 永久代(Perm Generation,方法区)

用于存储类的结构信息、静态变量、常量等。在JDK 8及之后的版本中,永久代被元空间(Metaspace)所取代。

5.3 虚拟机栈(JVM Stack)

用于存储方法调用和局部变量,每个线程在执行时都对应一个独立的栈,线程中每进入(或叫执行)一个方法时都会创建一个栈帧入栈,方法执行结束后就会从栈顶弹出该栈帧,栈帧中包含了局部变量、操作数栈、动态链接、方法返回地址等信息。
栈跟线程的生命周期相同,随着线程的创建而创建,随着线程的销毁而销毁,所以栈不涉及垃圾回收,每个栈中由多个栈帧组成,每个线程中只能有一个活动栈帧(对应当前正在执行的方法),所有栈帧都遵循后进先出的原则。
可通过-Xss参数设置栈的大小,一般1M左右基本就够用了。如果递归调用过多可能导致栈内存溢出抛出错误:java.lang.StackOverflowError

5.4 本地方法栈(Native Stack)

用于存储本地方法的调用和局部变量,与虚拟机栈的功能是类似的,区别就是调用本地native修饰的方法会创建本地方法栈,非native方法的调用会创建虚拟机栈。

5.5 程序计数器(Program Counter Register)

每个线程都有自己的程序计数器,用于存放下一条指令所在的地址,程序计数器在线程切换时会被保存和恢复,当线程执行的是非native方法,程序计数器记录的是下一条要执行的JVM字节码指令的地址,当执行native方法时,程序计数器为空。
当执行一条指令时,首先需要根据程序计数器中存放的指令地址,将指令由内存取到指令寄存器中,这就是取指令,与此同时,程序计数器中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令,完成第一条指令的执行,而后根据程序计数器取出第二条指令的地址,如此循环,执行每一条指令。
程序计数器是线程私有的,在JVM中唯一不会产生内存溢出的区域。

总结

通过对JVM内存模型的介绍,我们可以更好的理解Java程序内存管理机制,了解堆、栈、方法区、程序计数器等不同的内存区域的作用和特点,为后续理解垃圾回收的原理和算法打下基础,还可以帮助我们编写高效、稳定的Java程序。同时,合理调整JVM的内存参数和垃圾回收策略,可以优化程序的性能和资源利用。