JVM 内存区域

1 运行时数据区

JDK 1.8 之前 :
在这里插入图片描述

JDK 1.8 之后 :
在这里插入图片描述

1.1 线程私有:

  1. 程序计数器
  2. 虚拟机栈
  3. 本地方法栈

1.1.1 程序计数器

程序计数器是一块较小的内存空间,存储着当前线程下一步要执行的字节码的内存地址。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

1.1.2 java虚拟机栈

与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。

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

在这里插入图片描述

  • 局部变量表: 存放了编译期可知的各种数据类型 (boolean、byte、char、short、int、float、long、double)和对象引用
  • 操作数栈: 用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
  • 动态链接: 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。

在这里插入图片描述

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

Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

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

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

1.1.3 本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

1.2 线程共享

  1. 方法区
  2. 直接内存(非运行数据区的一部分)

1.2.1 堆

是Java虚拟机管理的内存中最大的一块,所有线程共享堆堆的唯一目的就是存储对象实例,几乎所有对象的实例以及数组都是在这里分配内存

从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。

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

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation)

在这里插入图片描述
JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存

大部分情况下,对象会初始化在Eden区,在一次新生代垃圾回收后,若对象还存活的话就会将该对象的年龄+1,并进入S0或S1。在servivor区中,“Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值”。

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  • java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  • java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。

2.2 方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。

Java虚拟机内存模型中的方法区(Method Area)主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。具体包含以下内容:

  • 类信息:包括类的完整结构、父类、接口、字段、方法等信息。
  • 运行时常量池:用于存储编译期生成的字面量(如字符串、数字等)和符号引用(如类和方法的引用等)。
  • 静态变量:存储类级别的变量,即所有实例共享的变量。
  • 即时编译器编译后的代码:在某些情况下,虚拟机会将部分代码编译成本地机器代码,以提高程序的执行效率。这些代码通常被存储在方法区中。
  • 常量:如枚举、final常量等。

方法区和永久代以及元空间是什么关系呢?

方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。下面是一些常用参数:

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

2.2.1 运行时常量池

运行时常量池(Runtime Constant Pool)是Java虚拟机在加载类时为每个类分配的一块内存区域,用于存储该类中的常量池信息,包括编译时常量运行时生成的常量。运行时常量池中包含以下信息:

  1. 字面量:包括字符串字面量、数字字面量、布尔型字面量等。
int num = 10;  // 整数字面量
double pi = 3.14;  // 浮点数字面量
char ch = 'A';  // 字符字面量
String str = "Hello, world!";  // 字符串字面量
  1. 符号引用:包括类和接口的全限定名、字段和方法的名称和描述符。
java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lang/StringBuilder;

在这个符号引用中,java/lang/StringBuilder是被引用的类的全限定名,append是被引用的方法的名称,(Ljava/lang/String;)(Ljava/lang/StringBuilder;)是被引用方法的参数类型和返回值类型的描述符。

  1. 运行时常量:包括运行时生成的常量,例如通过String类的intern()方法生成的字符串常量。
  2. 方法和字段的引用:包括类的方法和字段的引用。

在这里插入图片描述
常量池表会在类加载后存放到方法区的运行时常量池中。

2.3 字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true

HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 本质上就是一个HashSet<String> ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置)。

StringTable 中保存的是字符串对象的引用字符串对象的引用指向堆中的字符串对象

JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池从永久代移动了 Java 堆中。

在这里插入图片描述

2.4 直接内存

直接内存(Direct Memory)是Java NIO(New I/O)中的一种高效的I/O处理方式,它是一种使用Native Memory(本地内存)的内存分配方式,与Java堆内存分配方式不同。直接内存分配的是本地内存,而不是Java堆内存,它不会受到Java堆内存大小的限制,因此可以分配更大的内存空间,并且可以提高I/O操作的效率。

直接内存通过使用Java NIO中的ByteBuffer类进行分配和操作。在使用ByteBuffer分配直接内存时,它会在Java堆内存中创建一个DirectByteBuffer对象,但是真正的内存空间是在本地内存中分配的。当调用ByteBufferget()put()等方法时,它会直接读写本地内存中的数据,而不需要进行额外的数据拷贝操作,因此可以提高I/O操作的效率。另外,直接内存也可以通过调用Unsafe类提供的allocateMemory()方法进行分配,但是这种方式不够安全,不建议使用。

需要注意的是,直接内存虽然能够提高I/O操作的效率,但是它也存在一些问题。例如,直接内存的分配和释放比Java堆内存更加复杂,需要调用Native方法来实现。此外,直接内存不受Java虚拟机的内存管理机制控制,因此需要开发人员手动释放直接内存,否则可能会导致内存泄漏问题。

3 HotSpot虚拟机对象探秘

3.1 对象的创建

  1. 类加载检查:new 指令-》是否能在常量池中定位到这个类的符号引用-》检查这个符号引用代表的类是否已被加载过、解析和初始化过-》如果没有,那必须先执行相应的类加载过程

  2. 分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来:

    • 指针碰撞(Bump the Pointer):指针碰撞是一种常用的堆内存分配方法,它将堆内存分为两个部分,一部分是已分配的对象区域,另一部分是未分配的空闲区域。当分配对象时,Java虚拟机会将指向空闲区域的指针向前移动,直到找到一个足够大的空间来分配对象。(堆内存规整(即没有内存碎片)的情况下。)
    • 空闲列表:是一种常用的堆内存分配方法,它将堆内存划分为一系列大小不等的空间块,并使用一个列表来维护已分配和未分配的内存块。当分配对象时,Java虚拟机会在空闲列表中寻找一个足够大的空间块,并将其分配给新对象 (堆内存不规整的情况下。)

    选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。

    内存分配并发问题:虚拟机采用两种方式来保证线程安全:

    • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
    • TLAB为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
  3. 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

  4. 设置对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,对象头包含:

    • 对象的哈希码
    • 对象的GC信息:包括对象的分代信息、是否可回收标识
    • 对象的锁信息:包括对象的锁状态、持有该对象锁的线程ID等
    • 类型指针:指向对象所属类的Class对象
    • 对象的数组长度:只有数组对象才会包含该信息。
  5. 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, init方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行init方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

3.2 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。

  • 对象头上文以及阐述
  • 实例数据是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
  • 对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。

3.3 对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针

句柄

如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
在这里插入图片描述

直接指针

如果使用直接指针访问,reference 中存储的直接就是对象的地址。
在这里插入图片描述

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值