Skip to content

Java内存机制

更新: 5/6/2025 字数: 0 字 时长: 0 分钟

如果想要理解JVM,那么我们就要知道JVM存在的意义是什么。

在C语言中,如果我们主动的去申请了一段空间,如何申请,申请后那么就需要后续通过free的方式去将其释放掉。我相信大多数熟练的工程师应该不会忘掉free的这一步骤,但是这一步骤实在显得有些麻烦,且何时free也是一个问题。

为了简化工程师的思考,方便程序员进行编程,Java决定将这一过程交给JVM去处理,而本文则是去试图阐述这一过程。

存储

我们都知道,数据在我们是存储在我们的电脑内存中的,那么究竟是如何存储的呢?

首先,根据线程的关系,我们可以将其分为 线程私有和线程共有 这两部分。

其中

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

接下来我们试着依次去解释这些东西。

程序计数器——靠地址控制代码顺序

我们都知道,在并发环境下,多个线程是依次执行的,在线程切换的过程中,我们需要找一个区域去存放我们的程序现在执行到了何处/下一行代码该执行哪个,这就是程序计数器要完成的任务。

由于控制着下一条命令的选取,因此字节码解释器只需要对程序计数器进行修改就可以完成循环,判断等复杂操作。

由于记录的是当前程序要执行的下一条,因此程序计数器是线程私有的。

由于程序计数器维护的是一个动态变化的东西(即下一条指令是什么/只是记录一个固定量的东西),且会随着线程的结束而消亡,因此不会OOM

Java虚拟机栈——方法的顺序(“栈帧”:方法内东西的存放处)

刚刚提到程序计数器是用来维护“下一条”指令来完成指令的选取,只有程序计数器显然是远远不够的(很明显,程序计数器更加“瞬时”一点),我们还需要一个稍微相对程序计数器宏观,长远一点的东西来配合程序计数器,这就是——Java虚拟机栈。

当我们的线程调用一次方法(Native方法除外),就会被塞进来一个栈帧,栈帧中存放了一个方法执行所需的所有东西,其中包含局部变量表、操作数栈、动态链接、方法返回地址

当一个方法执行完后,我们就会将栈帧弹出。

接下来我们试着去解释一下虚拟机栈的各个部分。

  • 局部变量表:局部变量表存放该栈帧用到的所有局部变量(包含基本类型变量与对象类型变量的引用)。
  • 动态链路:在方法的使用时,常常会出现一个方法调用另一个方法的过程,这一过程需要我们从常量池中的方法引用(就像方法指针一样),转化为内存地址中的直接引用,这一过程由动态链路完成,又被称为动态连接。
  • 操作数栈:主要存放一些临时的变量(举个例子,我们再算1+1+1的过程其实是先算1+1=2,再算2+1=3,这一过程的2不会被一个局部变量表中的变量记住,因此就需要放到一个新的空间,即操作数栈)
  • 操作返回地址:记录了当前方法执行完后/异常退出后应该去到什么地方

本地方法栈

与虚拟机栈并无很大的不同,只是服务于本地方法而已。(在Hotpot虚拟机中该区甚至和Java虚拟机栈合二为一)

堆——实例的真正存放处/垃圾回收堆

之前提到过,局部变量表中存放的是对象的引用,那对象本身在什么地方呢?答案是堆。几乎所有的对象都是在堆上创建的

在JDK1.7之后随着 JIT 编译器的发展与逃逸分析技术逐渐成熟(1.7后默认开启逃逸分析),部分对象会在栈上创建。

由于我们的垃圾回收主要就是在堆上完成,因此堆又被成为GC堆(Garbage Collected Heap,垃圾回收堆),由于回收采取分代回收,因此又可分为新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)——内含Eden,S0,S1三块区域
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation)

JDK 1.8后 永久代被元空间(MetaSpace)取代,不再是堆的一部分,直接使用本地内存进行管理。

新生的对象被存放在Eden中,等待着垃圾回收的降临,在一次新生代垃圾回收完成后,若还存活,则对象进入S0或是S1区域(S->Survivor,幸存者),同时年龄+1,当Survivor区中的对象年龄达到一定的大小(一般为15岁),则会背晋升到老年代。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。不过,设置的值应该在 0-15,否则会爆出错误。之所以选择0~15,是因为年龄记录在对象头的前四位,四位二进制数最大为15。

进而我们来介绍以下JVM中一个对象真正的样子。

对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding) 其中,对象头包括两部分:标记字段(Mark Word)和类型指针(Klass Word)

而我们的年龄就是放在标记字段当中。

有关垃圾回收何时进行与如何进行,我们将会在另一篇文章中讲到

方法区——类信息存放处

在讲永久代之前我们先来说一下方法区。

我们写的代码会被编译成class文件,而class文件在使用时并不会使用一次解析一次,而是会在解析完后将解析出的内容存入一个区域,进而减少解析与读取的次数。

这个存放的区域就是方法区。

方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

方法区和永久代有什么关系呢?

方法区实际上是《Java虚拟机规范》定义的一个标准,该文章要求每一个Java虚拟机上都应该有永久代这么一个空间,用来存放虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。但是如何完成这一过程《规范》并不关心。

永久代就是HotSpot虚拟机对于方法区的实现方式,而在JDK1.8后,这个实现又被改成了元空间。

运行时常量池

**常量池(Constant Pool)主要存放的是字面量(Literal Constants)符号引用(Symbolic References **

字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。

举个例子

java
int a=1;  // 这个1就是所谓的直面量
boolean isJavaFun = true;  //同样的这个true也是
String str="123"; //字符串字面量:“123”

不难发现这些量都能直接反应其代表的值,需要注意的是,null和.class获取的数据也是字面量

这些数据被存放到运行时常量池中。

符号引用(Symbolic References): 这些不是具体的对象实例,而是对类、方法、字段等的间接引用,比如类的全限定名、字段的名称和类型描述符、方法的名称、参数和返回值描述符等。这些会在运行过程中被解析为直接引用(内存地址)。

字符串常量池

在我们创建字符串时,一个字面量其实就相当于创建了一个字符串作为中间变量,为了解决时间,Java开辟了一个空间来存放之前创过的字符串,这个空间就是字符串常量池。

JDK1.7前,字符串常量池存放在永久代中,但为了提高GC的效率,JDK1.7及后续版本将字符串常量池移入了堆中。

直接内存

那么Java就没有能完全越过JVM直接沟通内存的方式了吗?

有的兄弟,有的。

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

这一过程的意义在于越过了JVM,实现了真正的高风险(容易内存泄漏),高效率(再见JVM)。

我们的NIO与Netty框架之所以有如此高的效率也是得益于直接内存区的存在。

对象的创建

说了这么多,Java的对象究竟是如何创建出来的呢?

  1. 类加载检查 Java会先检查你new的这个对象是否已经被加载过(被放入了元空间),如果已经加载过了,那么直接使用,否则去加载
  2. 分配内存 加载完成后,JVM会尝试为新创建的类寻找一块空白的空间去使用,分配方式有 “指针碰撞” 和 “空闲列表” 两种,由堆来选择使用哪种方式
    • 指针碰撞: 使用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
    • 空闲列表: 虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录
  3. 初始化零值 内存分配完成后,虚拟机会先将这段内存用零来完全填充满(不包括对象头),这一步保证了实例对象即使不符初值仍然能直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 初始化对象头 赋完零后,Java虚拟机就要堆对象进行一些必要的设置,比如存放对象的年龄,对象的Hash值,对象属于的类,对象的数据信息等,这些信息均放在对象头中
  5. 执行init方法 在上面的方法都完成后,对于虚拟机来说其实一个对象已经创建完成了,但对于一个Java程序来说,对象的创建这时才刚刚刚开始,< init >fan方法还没有执行,所有字段均是初值,只用进行了< init >方法,对象才能按照程序员的意愿进行初始化

对象的访问定位

对象是创建在内存上的,而Java 程序通过栈上的 reference 数据来操作堆上的具体对象。(即引用)

对象的访问方式往往由JVM来决定,目前主流的方式为: 使用句柄直接指针

这两者都存在于堆上

句柄

如果使用句柄,那么 Java 堆中将会划分出一块内存来作为句柄池,reference先指向句柄在句柄池中的地址,而句柄中包含了对象实例数据(堆上,有时被称为实例池)与对象类型数据各自的具体地址信息(元空间中的数据)。

直接指针

如果使用直接指针访问,reference 中存储的直接就是对象的地址。(省掉了句柄与句柄池)

句柄相对直接指针的优势在于reference是稳定的,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。

本站访客数 人次      本站总访问量