JVM内存管理

jvm(JavaVirtualMachine)java虚拟机,使得Java能够“一次编译,到处运行”

JVM由三部分组成:类加载子系统、运行时数据区、执行引擎。

内存管理

运行时数据区

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

细分为程序计数器虚拟机栈本地方法栈方法区等。

线程私有的: 程序计数器、虚拟机栈、本地方法栈

线程共享的: 堆、方法区

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。

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

⚠️ 注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java 虚拟机栈

生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

当线程执行一个方法时,会创建一个对应的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,然后栈帧会被压入栈中。当方法执行完毕后,栈帧会从栈中移除。

程序运行中栈可能会出现两种错误:

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

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

主要存放了 native 方法的局部变量、动态链接和方法出口等信息。当一个 Java 程序调用一个 native 方法时,JVM 会切换到本地方法栈来执行这个方法。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

从内存回收的角度来看,由于垃圾收集器大部分都是基于分代收集理论设计的,所以堆也会被划分为新生代老年代

新生代又被划分为 Eden 空间和两个 Survivor 空间(From 和 To)。

  • Eden 空间:大多数新创建的对象会被分配到 Eden 空间中。当 Eden 区填满时,会触发一次轻量级的垃圾回收(Minor GC),清除不再使用的对象。
  • Survivor 空间:每次 M
    inor GC 后,仍然存活的对象会从 Eden 区或From 区复制到 To 区。From 和 To 区交替使用。

堆区域划分

堆和栈的区别是什么?

堆属于线程共享的内存区域,几乎所有的对象都在堆上分配,生命周期不由单个方法调用所决定,可以在方法调用结束后继续存在,直到不再被任何变量引用,然后被垃圾收集器回收。

栈属于线程私有的内存区域,主要存储局部变量、方法参数、对象引用等,通常随着方法调用的结束而自动释放,不需要垃圾收集器处理。

方法区

方法区并不真实存在,属于 Java 虚拟机规范中的一个逻辑概念,用于存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等。

在 HotSpot 虚拟机中,方法区的实现称为永久代(PermGen),但在 Java 8 及之后的版本中,已经被元空间(Metaspace)所替代。

对象的创建过程

1、类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2、分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种。

3、初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4、设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5、执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

什么是指针碰撞?什么是空闲列表?

①、指针碰撞(Bump the Pointer)

假设堆内存是一个连续的空间,分为两个部分,一部分是已经被使用的内存,另一部分是未被使用的内存。

在分配内存时,Java 虚拟机维护一个指针,指向下一个可用的内存地址,每次分配内存时,只需要将指针向后移动(碰撞)一段距离,然后将这段内存分配给对象实例即可。

②、空闲列表(Free List)

JVM 维护一个列表,记录堆中所有未占用的内存块,每个空间块都记录了大小和地址信息。

当有新的对象请求内存时,JVM 会遍历空闲列表,寻找足够大的空间来存放新对象。

分配后,如果选中的空闲块未被完全利用,剩余的部分会作为一个新的空闲块加入到空闲列表中。

适用: 指针碰撞适用于管理简单、碎片化较少的内存区域(如年轻代),而空闲列表适用于内存碎片化较严重或对象大小差异较大的场景(如老年代)。

JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的?

会,假设 JVM 虚拟机上,每一次 new 对象时,指针就会向右移动一个对象 size 的距离,一个线程正在给 A 对象分配内存,指针还没有来的及修改,另一个为 B 对象分配内存的线程,又引用了这个指针来分配内存,这就发生了抢占。

有两种可选方案来解决这个问题:

  • 采用 CAS 分配重试的方式来保证更新操作的原子性

  • 每个线程在 Java 堆中预先分配一小块内存,也就是本地线程分配缓冲(Thread Local Allocation

    Buffer,TLAB),要分配内存的线程,先在本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。

对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

对象头包括两部分信息:

  1. 标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
  2. 类型指针(Klass Word):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

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

句柄

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

直接指针

如果使用直接指针访问,reference 中存储的直接就是对象的地址。

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

内存溢出和内存泄漏是什么意思?

内存溢出(Out of Memory,俗称 OOM)和内存泄漏(Memory Leak)是两个不同的概念,但它们都与内存管理有关。

①、内存溢出:是指当程序请求分配内存时,由于没有足够的内存空间满足其需求,从而触发的错误。在 Java 中,这种情况会抛出 OutOfMemoryError

内存溢出可能是由于内存泄漏导致的,也可能是因为程序一次性尝试分配大量内存,内存直接就干崩溃了导致的。

②、内存泄漏:是指程序在使用完内存后,未能释放已分配的内存空间,导致这部分内存无法再被使用。随着时间的推移,内存泄漏会导致可用内存逐渐减少,最终可能导致内存溢出。

在 Java 中,内存泄漏通常发生在长期存活的对象持有短期存活对象的引用,而长期存活的对象又没有及时释放对短期存活对象的引用,从而导致短期存活对象无法被回收。

对象什么时候会进入老年代?

对象通常会现在年轻代中分配,然后随着时间的推移和垃圾收集的处理,某些对象会进入到老年代中。

①、长期存活的对象将进入老年代

对象在年轻代中存活足够长的时间(即经过足够多的垃圾回收周期)后,会晋升到老年代。

每次 GC 未被回收的对象,其年龄会增加。当对象的年龄超过一个特定阈值(默认通常是 15),它就会被移动到老年代。这个年龄阈值可以通过 JVM 参数-XX:MaxTenuringThreshold来设置。

②、大对象直接进入老年代

为了避免在年轻代中频繁复制大对象,JVM 提供了一种策略,允许大对象直接在老年代中分配。

这些是所谓的“大对象”,其大小超过了预设的阈值(由 JVM 参数-XX:PretenureSizeThreshold控制)。直接在老年代分配可以减少在年轻代和老年代之间的数据复制。

③、动态对象年龄判定

除了固定的年龄阈值,还会根据各个年龄段对象的存活大小和总空间等因素动态调整对象的晋升策略。

比如说,在 Survivor 空间中相同年龄的所有对象大小总和大于 Survivor 空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代。

说一下对象有哪几种引用?

Java 中的引用有四种,分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用是最传统的引用的定义,是指在程序代码之中普遍存在的引用赋值,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
Object obj =new Object();
  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。 SoftReference 类来实现软引用。

  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。 WeakReference 类来实现弱引用。

  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。PhantomReference 类来实现虚引用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值