JVM 内存区域详解

基础知识

1、Java 程序的执行过程

一个 Java 程序, 首先经过 javac 编译成 .class 文件, 然后 JVM 将其加载到方法区, 执行引擎将会执行这些字节码。 执行时, 会翻译成操作系统相关的函数。 JVM 作为 .class 文件的翻译存在, 输入字节码, 调用操作系统函数。

过程如下: Java 文件->编译器>字节码->JVM->机器码。

JVM 全称 Java Virtual Machine, 也就是我们耳熟能详的 Java 虚拟机。 它能识别 .class 后缀的文件, 并且能够解析它的指令, 最终调用操作系统上的函数, 完成我们想要的操作。

2、JVM、 JRE、 JDK 的关系

JVM 只是一个翻译, 把 Class 翻译成机器识别的代码, 但是需要注意, JVM 不会自己生成代码, 需要大家编写代码, 同时需要很多依赖类库, 这个时候就需要用到 JRE。

JRE 是什么, 它除了包含 JVM 之外, 提供了很多的类库(就是我们说的 jar 包, 它可以提供一些即插即用的功能, 比如读取或者操作文件, 连接网络,

使用 I/O 等等之类的) 这些东西就是 JRE 提供的基础类库。 JVM 标准加上实现的一大堆基础类库, 就组成了 Java 的运行时环境, 也就是我们常说的 JRE(Java Runtime Environment) 。

但对于程序员来说, JRE 还不够。 我写完要编译代码, 还需要调试代码, 还需要打包代码、 有时候还需要反编译代码。 所以我们会使用 JDK, 因为 JDK还提供了一些非常好用的小工具, 比如 javac(编译代码) 、 java、 jar (打包代码) 、 javap(反编译<反汇编>) 等。 这个就是 JDK。

具体可以文档可以通过官网去下载: www.oracle.com/java/techno...

JVM 的作用是: 从软件层面屏蔽不同操作系统在底层硬件和指令的不同。 这个就是我们在宏观方面对 JVM 的一个认识。

3、跨平台

我们写的一个类, 在不同的操作系统上(Linux、 Windows、 MacOS 等平台) 执行, 效果是一样, 这个就是 JVM 的跨平台性。

跨语言( 语言无关性) : JVM 只识别字节码, 所以 JVM 其实跟语言是解耦的, 也就是没有直接关联, JVM 运行不是翻译 Java 文件, 而是识别 class文件, 这个一般称之为字节码。 还有像 Groovy 、 Kotlin、 Scala 等等语言, 它们其实也是编译成字节码, 所以它们也可以在 JVM 上面跑, 这个就是 JVM 的跨语言特征。 Java 的跨语言性一定程度上奠定了非常强大的 java 语言生态圈。

4、常见jvm实现

JVM内存模型

运行时数据区

运行时数据区的定义

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域

在 JVM 中, JVM 内存主要分为堆、 程序计数器、 方法区、 虚拟机栈和本地方法栈等。

同时按照与线程的关系也可以这么划分区域:

线程私有区域: 一个线程拥有单独的一份内存区域。

线程共享区域: 被所有线程共享, 且只有一份。

这里还有一个直接内存, 这个虽然不是运行时数据区的一部分, 但是会被频繁使用。 你可以理解成没有被虚拟机化的操作系统上的其他内存(比如操作系统上有 8G 内存, 被 JVM 虚拟化了 3G, 那么还剩余 5G, JVM 是借助一些工具使用这 5G 内存的, 这个内存部分称之为直接内存)

jdk 8:

程序计数器(Program Counter)

程序计数器是一块很小的内存空间, 主要用来记录各个线程执行的字节码的地址, 例如, 分支、 循环、 跳转、 异常、 线程恢复等都依赖于计数器。

由于 Java 是多线程语言, 当执行的线程数量超过 CPU 核数时, 线程之间会根据时间片轮询争夺 CPU 资源。 如果一个线程的时间片用完了, 或者是其它原因导致这个线程的 CPU 资源被提前抢夺, 那么这个退出的线程就需要单独的一个程序计数器, 来记录下一条运行的指令。

因为 JVM 是虚拟机, 内部有完整的指令与执行的一套流程, 所以在运行 Java 方法的时候需要使用程序计数器(记录字节码执行的地址或行号) , 如果是遇到本地方法(native 方法) , 这个方法不是 JVM 来具体执行, 所以程序计数器不需要记录了, 这个是因为在操作系统层面也有一个程序计数器,这个会记录本地代码的执行的地址, 所以在执行 native 方法时, JVM 中程序计数器的值为(Undefined)。

另外程序计数器也是 JVM 中唯一不会 OOM(OutOfMemory)的内存区域。

虚拟机栈

每个线程私有的, 线程在运行时, 在执行每个方法的时候都会打包成一个栈帧, 存储了局部变量表, 操作数栈, 动态链接, 方法出口等信息, 然后放入栈。 每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。 方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。

虚拟机栈的作用: 在 JVM 运行过程中存储当前线程运行方法所需的数据, 指令、 返回地址。 其实在我们实际的代码中, 一个线程是可以运行多个方法的。

这段代码, 就是起一个 main 方法, 在 main 方法运行中调用 A 方法, A 方法中调用 B 方法, B 方法中运行 C 方法。

我们把代码跑起来, 线程 1 来运行这段代码, 线程 1 跑起来, 就会有一个对应 的虚拟机栈, 同时在执行每个方法的时候都会打包成一个栈帧。

比如 main 开始运行, 打包一个栈帧送入到虚拟机栈。

栈的数据结构: 先进后出(FILO)的数据结构,

虚拟机栈是基于线程的: 哪怕你只有一个 main() 方法, 也是以线程的方式运行的。 在线程的生命周期中, 参与计算的数据会频繁地入栈和出栈, 栈的生命周期是和线程一样的。

虚拟机栈的大小缺省为 1M, 可用参数 --Xss 调整大小, 例如-Xss256k。

栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。

栈帧: 在每个 Java 方法被调用的时候, 都会创建一个栈帧, 并入栈。 一旦方法完成相应的调用, 则出栈。

栈帧组成:

1、局部变量表

用于存放我们的局部变量的(方法中的变量) 。 首先它是一个 32 位的长度, 主要存放Java 的八大基础数据类型, 一般 32 位就可以存放下, 如果是 64 位的就使用高低位占用两个也可以存放下, 如果是局部的一些对象, 比如我们的 Object 对象, 我们只需要存放它的一个引用地址即可。(基本数据类型、 对象引用、 returnAddress 类型)

2、 操作数据栈:

操作数栈是执行引擎的一个工作区,类似于缓存

存放 java 方法执行的操作数的, 它就是一个栈, 先进后出的栈结构, 操作数栈, 就是用来操作的, 操作的的元素可以是任意的 java 数据类型, 一个方法刚刚开始的时候, 这个方法的操作数栈就是空的。

3、 动态连接:

主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接

栈空间虽然是有限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。

除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

简单总结一下程序运行中栈可能会出现两种错误:

  • StackOverFlowError 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

4、 返回地址:

正常返回(调用程序计数器中的地址作为返回) 、 异常的话(通过异常处理器表<非栈帧中的>来确定

正常返回: (调用程序计数器中的地址作为返回)

三步曲:

恢复上层方法的局部变量表和操作数栈、

把返回值(如果有的话) 压入调用者栈帧的操作数栈中、

调整程序计数器的值以指向方法调用指令后面的一条指令、

异常的话: (通过异常处理表<非栈帧中的>来确定)

栈帧执行对内存区域的影响

java 复制代码
public class Person {
    public  int work()throws Exception{
        int x =1;
        int y =2;
        int z =(x+y)*10;
        return  z;
    }
    public static void main(String[] args) throws Exception{
        Person person = new Person();//person 栈中--、  new  Person  对象是在堆
        person.work();
​
        person.hashCode();
​
    }
}

work方法对应指令

 0 iconst_1
 1 istore_1
 2 iconst_2
 3 istore_2
 4 iload_1
 5 iload_2
 6 iadd
 7 bipush 10
 9 imul
10 istore_3
11 iload_3
12 ireturn

具体指令含义查看:java虚拟机 JVM字节码 指令集 bytecode 操作码 指令分类用法 助记符

大概执行过程:先把数据压入到操作数栈中,然后存储到局部变量表中或者通知操作引擎进行指令计算(运算后的结果自动入栈),最终出栈

本地方法栈

本地方法栈跟 Java 虚拟机栈的功能类似, Java 虚拟机栈用于管理 Java 函数的调用, 而本地方法栈则用于管理本地方法的调用。 但本地方法并不是用 Java 实现的, 而是由 C 语言实现的(比如 Object.hashcode 方法)。

本地方法栈是和虚拟机栈非常相似的一个区域, 它服务的对象是 native 方法。 你甚至可以认为虚拟机栈和本地方法栈是同一个区域。

虚拟机规范无强制规定, 各版本虚拟机自由实现 , HotSpot 直接把本地方法栈和虚拟机栈合二为一 。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

方法区

方法区(Method Area) 是可供各线程共享的运行时内存区域。 它存储了每一个类的结构信息, 当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

方法区是 JVM 对内存的"逻辑划分" , 在 JDK1.7 及之前很多开发者都习惯将方法区称为"永久代", 是因为在 HotSpot 虚拟机中, 设计人员使用了永久代来实现了 JVM 规范的方法区。 在 JDK1.8 及以后使用了元空间来实现方法区。

1、Class 常量池(静态常量池)

在 class 文件中除了有类的版本、 字段、 方法和接口等描述信息外, 还有一项信息是常量池 (Constant Pool Table), 用于存放编译期间生成的各种字面量和符号引用。

字面量: 给基本类型变量赋值的方式就叫做字面量或者字面值。

比如: String a="b" , 这里"b"就是字符串字面量, 同样类推还有整数字面值、 浮点类型字面量、 字符字面量。

符号引用 : 符号引用以一组符号来描述所引用的目标。 符号引用可以是任何形式的字面量, JAVA 在编译的时候一个每个 java 类都会被编译成一个 class文件, 但在编译的时候虚拟机并不知道所引用类的地址(实际地址), 就用符号引用来代替, 而在类的解析阶段(后续 JVM 类加载会具体讲到) 就是为了把这个符号引用转化成为真正的地址的阶段。

一个 java 类(假设为 People 类) 被编译成一个 class 文件时, 如果 People 类引用了 Tool 类, 但是在编译时 People 类并不知道引用类的实际内存地址, 因此只能使用符号引用(org.simple.Tool) 来代替。 而在类装载器装载 People 类时, 此时可以通过虚拟机获取 Tool 类的实际内存地址, 因此便可以既将符号org.simple.Tool 替换为 Tool 类的实际内存地址。

符号引用主要包括:

  • 被模块导出或者开放的包(package)
  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符
  • 方法句柄和方法类型
  • 动态调用点和动态常量

常量表中的数据结构:

2、运行时常量池

运行时常量池( Runtime Constant Pool) 是每一个类或接口的常量池( Constant_Pool) 的运行时表示形式, 它包括了若干种不同的常量: 从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。

编译期生成的各种字面量和符号引用,这部分内容在类加载后存放到方法区的运行时常量池。

运行时常量池是方法区的一部分。 运行时常量池相对于 Class 常量池的另外一个重要特征是具备动态性 。

在 JDK1.8 中, 使用元空间代替永久代来实现方法区, 但是方法区并没有改变, 变动的只是方法区中内容的物理存放位置, 但是运行时常量池和字符串常量池被移动到了堆中。 但是不论它们物理上如何存放, 逻辑上还是属于方法区的。

3、字符串常量池

以 JDK1.8 为例, 字符串常量池是存放在堆中, 并且与 java.lang.String 类有很大关系。 设计这块内存区域的原因在于: String 对象作为 Java 语言中重要的数据类型, 是内存中占据空间最大的一个对象。 高效地使用字符串, 可以提升系统的整体性能。

所以要彻底弄懂, 我们的重心其实在于深入理解 String。

堆是JVM 上最大的内存区域, 我们申请的几乎所有的对象, 都是在这里存储的。 我们常说的垃圾回收, 操作的对象就是堆。

堆空间一般是程序启动时, 就申请了, 但是并不一定会全部使用。 堆一般设置成可伸缩的。

随着对象的频繁创建, 堆空间占用的越来越多, 就需要不定期的对不再使用的对象进行回收。 这个在 Java 中, 就叫作 GC( Garbage Collection) 。那一个对象创建的时候, 到底是在堆上分配, 还是在栈上分配呢? 这和两个方面有关: 对象的类型和在 Java 类中存在的位置。

Java 的对象可以分为基本数据类型和普通对象。对于普通对象来说, JVM 会首先在堆上创建对象, 然后在其他地方使用的其实是它的引用。 比如, 把这个引用保存在虚拟机栈的局部变量表中。对于基本数据类型来说( byte、 short、 int、 long、 float、 double、 char), 有两种情况。当你在方法体内声明了基本数据类型的对象, 它就会在栈上直接分配。 其他情况, 都是在堆上分配。

从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

关于垃圾回收在后文会讲到。

JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。

堆这里最容易出现的就是 OutOfMemoryError 错误

直接内存( 堆外内存)

直接内存有一种更加科学的叫法, 堆外内存。

JVM 在运行时, 会从操作系统申请大块的堆内存, 进行数据的存储; 同时还有虚拟机栈、 本地方法栈和程序计数器, 这块称之为栈区。 操作系统剩余的内存也就是堆外内存。

直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。

它不是虚拟机运行时数据区的一部分, 也不是 java 虚拟机规范中定义的内存区域; 如果使用了 NIO,这块区域会被频繁使用,在 java 堆内可以用directByteBuffer 对象直接引用并操作;这块内存不受 java 堆大小限制, 但受本机总内存的限制, 可以通过

-XX:MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常。

1、 直接内存主要是通过 DirectByteBuffer 申请的内存, 可以使用参数"MaxDirectMemorySize" 来限制它的大小。

2、 其他堆外内存, 主要是指使用了 Unsafe 或者其他 JNI 手段直接直接申请的内存。

堆外内存的泄漏是非常严重的, 它的排查难度高、 影响大, 甚至会造成主机的死亡。 同时, 要注意 Oracle 之前计划在 Java 9 中去掉 sun.misc.Unsafe API。 这里删除 sun.misc.Unsafe 的原因之一是使 Java 更加安全, 并且有替代方案。

目前我们主要针对的 JDK1.8, JDK1.9 暂时不放入讨论范围中, 我们大致知道 java 的发展即可。

栈和堆区别

功能

以栈帧的方式存储方法调用的过程, 并存储方法调用过程中基本数据类型的变量(int、 short、 long、 byte、 float、 double、 boolean、 char 等) 以及对象的引用变量, 其内存分配在栈上, 变量出了作用域就会自动释放;

而堆内存用来存储 Java 中的对象。 无论是成员变量, 局部变量, 还是类变量, 它们指向的对象都存储在堆内存中;

线程独享还是共享

栈内存归属于单个线程, 每个线程都会有一个栈内存, 其存储的变量只能在其所属线程中可见, 即栈内存可以理解成线程的私有内存。

堆内存中的对象对所有线程可见。 堆内存中的对象可以被所有线程访问。

空间大小

栈的内存要远远小于堆内存

总结

本文讲解了 JVM 内存区域划分,要掌握 JDK 8 实现方式,JDK 1.7了解即可,JVM 内存区域包括程序计数器、虚拟机栈、本地方法栈、堆、元空间、直接内存,掌握内存划分,对后续学习垃圾回收算法很有必要!

JVM 垃圾回收详解

相关推荐
旧日之血_Hayter18 分钟前
docker里的jenkins迁移
java·docker·jenkins
Duck Bro1 小时前
MySQL:表的增删改查(CRUD)
android·java·数据库·c++·mysql
BAGAE2 小时前
tomcat,appche,nginix,jboss区别
java·linux·数据库·ubuntu·tomcat
GGBondlctrl2 小时前
【Spring MVC】如何获取cookie/session以及响应@RestController的理解,Header的设置
java·spring·mvc·cookie·session·header·restcontroller
大梦百万秋2 小时前
Spring Boot开发实战:从入门到构建高效应用
java·spring boot
数懒女士2 小时前
Java常见的锁策略
java·算法
大只因bug2 小时前
基于Springboot的流浪宠物管理系统
java·spring boot·mysql·流浪宠物管理系统·在线宠物管理系统源码·javaweb宠物管理系统·宠物网站系统源码
疯一样的码农2 小时前
Maven 如何配置忽略单元测试
java·单元测试·maven
老牛源码2 小时前
Z2400024基于Java+SSM+mysql+maven开发的社区论坛系统的设计与实现(附源码 配置 文档)
java·mysql·maven·ssm
zhangj11252 小时前
Java部分新特性
java·开发语言