Java核心篇之JVM探秘:对象创建与内存分配机制

系列文章目录

第一章 Java核心篇之JVM探秘:内存模型与管理初探

第二章 Java核心篇之JVM探秘:对象创建与内存分配机制

第三章 Java核心篇之JVM探秘:垃圾回收算法与垃圾收集器

第四章 Java核心篇之JVM调优实战:Arthas工具使用及GC日志分析


目录

前言

一、对象创建过程

(1)类加载检查

(2)分配内存

1.对象创建与内存分配

2.对象填充

3.执行构造函数

4.并发问题处理

(3)初始化零值

(4)设置对象头

[Mark Word](#Mark Word)

[Type Pointer](#Type Pointer)

[Array Length (如果对象是数组)](#Array Length (如果对象是数组))

(5)执行方法

二、对象内存分配

(1)对象在栈上分配

示例:

(2)对象在Eden上分配

Eden区分配的特点:

示例:

(3)大对象直接进入老年代

(4)长期存活的对象将进入老年代

(5)对象动态年龄判断

(6)老年代空间分配担保机制

三、对象内存回收

(1)引用计数法

示例:

(2)可达性分析算法

(3)常见引用类型

(4)finalize()方法最终判定对象是否存活

(5)如何判断一个类是无用的类

总结


前言

Java虚拟机(JVM)是Java语言的核心组件之一,负责执行Java字节码。在JVM中,对象的创建和内存管理是一个复杂而精细的过程,涉及多个阶段和多种策略。本文将深入探讨JVM中的对象创建流程、内存分配机制以及它们如何影响程序性能。


一、对象创建过程

下边两张图分别是类加载机制和对象创建过程的流程图

(1)类加载检查

当程序请求创建一个新对象时,JVM首先会检查这个类是否已经被加载、解析和初始化过。如果尚未完成这些步骤,那么JVM会先执行类加载过程。

(2)分配内存

一旦类被确认可以使用,JVM会在堆内存中为新对象分配空间。对象的内存大小由其成员变量决定,包括实例变量和继承链上的变量。

在JVM中,内存分配主要发生在堆内存中,堆内存是所有线程共享的内存区域,用来存储所有Java对象实例和数组。内存分配的过程可以分为几个关键步骤:

1.对象创建与内存分配

当Java程序请求创建一个新对象时,JVM首先检查这个类是否已经被加载、解析和初始化过。如果类已经准备好,JVM会在堆中为新对象分配内存。内存分配的方式有两种主要策略:指针碰撞(Bump-the-Pointer)和空闲列表(Free List)。

  • 指针碰撞:如果堆内存是规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间维护一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
  • 空闲列表:如果堆内存是不规整的,已使用的内存和空闲的内存相互交错,那就需要维护一个列表,记录上面那些大小不一的空闲内存区间。

2.对象填充

分配好内存之后,JVM会对新对象进行填充,包括初始化对象的成员变量为默认值(例如,int类型为0,引用类型为null)。

3.执行构造函数

最后,JVM会执行对象的构造函数,完成对象的初始化。

4.并发问题处理

在多线程环境下,内存分配和对象创建可能会引发竞态条件和内存一致性问题。JVM采用了多种机制来解决这些问题:

  • 线程同步 JVM使用锁机制来保证在多线程环境下内存分配的原子性。当多个线程试图同时创建对象时,JVM会使用锁来确保一次只有一个线程能够进行内存分配,从而避免竞态条件。

  • 内存屏障 为了保证内存访问的有序性,防止指令重排序,JVM使用内存屏障(Memory Barrier)技术。内存屏障是一种特殊的指令,它可以阻止编译器和处理器对内存操作进行重排序,确保内存操作的顺序符合程序的预期。

  • 缓存一致性 在现代多核处理器中,每个CPU都有自己的缓存,为了保持缓存之间的一致性,处理器使用了一种称为MESI(Modified, Exclusive, Shared, Invalid)协议的缓存一致性协议。JVM利用硬件提供的缓存一致性协议来维持多线程环境下的内存一致性。

  • TLAB (Thread Local Allocation Buffer) 为了减少锁的使用,提高对象创建的效率,JVM提供了一个叫做TLAB(线程本地分配缓冲区)的概念。每个线程都有一个独立的TLAB,对象优先在自己的TLAB中分配,这样可以避免在多线程环境下频繁地获取锁,从而提高了对象创建的速度。

  • CAS (Compare and Swap) CAS是一种无锁编程技术,用于原子更新变量。JVM利用CAS操作来实现一些轻量级的同步机制,比如原子变量类(如java.util.concurrent.atomic包中的类)。

(3)初始化零值

分配完内存后,JVM会将对象的成员变量初始化为默认值,如整型为0,浮点型为0.0,引用类型为null等。这一步是为了确保对象在构造函数执行前处于一致状态。

(4)设置对象头

当JVM为新对象分配完内存后,在初始化零值之前,它会先设置对象头。对象头通常包含以下信息:

Mark Word

Mark Word是对象头中的一部分,主要用于存储对象的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁等。Mark Word的布局会根据对象的锁定状态动态改变,以便支持轻量级锁、偏向锁、重量级锁等不同的锁级别。

Type Pointer

这是指向对象所属类的元数据的指针,JVM通过这个指针确定对象所属的类。在某些JVM实现中,类型指针可能不在对象头中,而是通过其他方式(如类指针压缩)来存储。

Array Length (如果对象是数组)

如果创建的对象是一个数组,对象头还会额外包含一个长度字段,用于存储数组的长度。

(5)执行<init>方法

执行<init>方法,也就是执行对象的构造函数,这是程序员定义的用于初始化对象状态的方法。构造函数可以调用其他方法或访问静态变量,但不能直接访问或修改非初始化的实例变量。

二、对象内存分配

(1)对象在栈上分配

通常情况下,对象是在堆内存中分配的,这是因为对象的生命周期不可预测,可能需要长期存在。然而,在某些特殊情况下,对象可以分配在栈上,这种情况被称为栈上分配 (Stack Allocation)或标量替换(Scalar Replacement)。栈上分配主要应用于局部变量,尤其是那些在方法体内创建并很快就会被销毁的小型对象,这样可以显著减少垃圾收集的压力。

示例:

假设有一个简单的Java方法,其中创建了一个局部变量对象,这个对象不会被方法之外的代码引用,而且它的生命周期仅限于该方法的执行期间。在这种情况下,如果JVM启用了栈上分配并且经过逃逸分析确定该对象不会逃逸,那么该对象就有可能在栈上分配。

java 复制代码
public class StackAllocationExample {
    public void method() {
        User user = new User();
        // 使用user...
        // user仅在method方法的栈帧中存在
    }
}

class User {
    String name;
    int age;
}

在这个例子中,User对象只在method方法内部创建和使用,如果它满足栈上分配的条件,那么它将会在栈上分配,而不是在堆上。

(2)对象在Eden上分配

JVM的堆内存被划分为新生代和老年代。新生代又进一步分为一个Eden区和两个Survivor区(S0和S1)。Eden区是对象首次创建的地方,大部分对象在Eden区分配。

Eden区分配的特点

  • 对象创建时,首先尝试在Eden区内分配。
  • 如果Eden区没有足够的空间,或者对象太大,可能直接进入老年代。
  • 经过若干次垃圾回收(Minor GC),存活下来的对象会从Eden区晋升到Survivor区,或者直接晋升到老年代。

示例:

当一个对象创建时,默认情况下它会在Eden区内分配。Eden区是新生代的一部分,专门用于存放新创建的对象。如果对象在一次或多次垃圾回收后仍然存活,它将被移动到Survivor区,或者直接晋升到老年代。

java 复制代码
public class EdenAllocationExample {
    public static void main(String[] args) {
        User user = new User();
        // 使用user...
    }
}

class User {
    String name;
    int age;
}

在这个例子中,User对象创建在Eden区,如果它在接下来的垃圾回收过程中存活,那么它可能被晋升到Survivor区或老年代。

(3)大对象直接进入老年代

大对象指的是需要大量连续内存空间的对象,例如大的数组或长字符串。JVM为了减少新生代的碎片化,避免频繁的Minor GC,采取了以下策略:

  • 如果对象的大小超过了一定的阈值(通常是JVM的一个参数设置),这个对象会被直接分配到老年代中。
  • 直接进入老年代可以避免新生代的频繁垃圾回收,因为大对象通常生命周期较长,不易被回收。
  • 这种策略有助于提高大对象密集型应用的性能,减少垃圾收集的次数。

(4)长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在Eden出生并经过第一次Minor GC后仍然能够存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数--XX:MaxTenuringThreshold来设置。

(5)对象动态年龄判断

对象的年龄是指对象从创建开始到被垃圾收集器回收之间所经历的Minor GC次数。JVM给每个对象分配一个年龄计数器(Age Counter)。对象在Eden区创建后,如果在第一次Minor GC后依然存活,它会被移动到一个Survivor区,并且年龄计数器会加1。此后,每次经历Minor GC并且没有被回收,年龄计数器都会递增,直到达到一定的阈值(默认为15)。当对象的年龄达到这个阈值时,它将被提升到老年代。

对象年龄的判断机制有助于将长期存活的对象及时移动到老年代,避免新生代的频繁垃圾回收,同时也减少了老年代的垃圾回收压力,因为老年代的垃圾回收(Full GC或Major GC)成本更高。

(6)老年代空间分配担保机制

在进行Minor GC之前,JVM会检查老年代是否有足够的空间来接收可能从新生代提升过来的对象。如果老年代的空间不足以担保这次Minor GC后所有存活对象的迁移,JVM将触发一次Full GC,以腾出足够的空间。这种机制被称为老年代空间分配担保(Space Allocation Guarantee),其目的是避免在新生代进行垃圾回收时出现内存不足的情况,确保Minor GC的顺利进行。

如果担保机制检测到老年代空间不足,JVM会进行如下操作:

  1. 尝试压缩老年代,回收部分空间。
  2. 如果压缩后仍然不足,将触发Full GC,清理整个堆内存,包括老年代和永久代(在JDK 8中是方法区)。
  3. 如果Full GC后仍然不足,将抛出OutOfMemoryError异常。

三、对象内存回收

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断那些对象是没有被任何地方使用的。

(1)引用计数法

引用计数法是一种简单的内存管理策略,它通过跟踪指向一个对象的引用数量来确定对象是否可被回收。每当有一个地方引用一个对象时,它的引用计数器就会加1;当引用失效时,计数器减1。当一个对象的引用计数变为0时,表明没有任何引用指向它,此时它就可以被回收了。

优点:实现简单,运行时不需要进行额外的计算或全局性的搜索,即时性好。

缺点:无法处理循环引用的问题。如果有两个对象相互引用对方,即使它们不再被外部引用,引用计数法也无法正确识别它们为垃圾,从而导致内存泄露。

示例:

尽管Java的垃圾收集器不采用引用计数法,但我们可以通过一个类似场景的示例来说明,如果在引用计数法下,两个对象互相引用时可能导致的内存泄漏情况。下面是一个简化版的伪代码示例,用于说明这个问题:

java 复制代码
// 注意:以下代码仅用于演示,实际上Java的垃圾收集器不使用引用计数法
class Node {
    private Node reference;

    public Node(Node ref) {
        this.reference = ref;
    }

    public void setReference(Node ref) {
        this.reference = ref;
    }

    public Node getReference() {
        return reference;
    }
}

public class ReferenceCountingDemo {
    public static void main(String[] args) {
        Node nodeA = new Node(null);
        Node nodeB = new Node(nodeA);
        nodeA.setReference(nodeB);

        // 现在nodeA和nodeB互相引用,如果没有垃圾收集器,将导致它们都无法被回收
    }
}

在引用计数法中,nodeAnodeB互相引用对方,即使它们不再被任何外部引用持有,但由于它们相互之间的引用,它们的引用计数都不会降到0,因此按照引用计数法,这两个对象将永远不会被回收,导致内存泄漏。

(2)可达性分析算法

这是JVM中常用的垃圾回收算法。它基于一个基本思想:通过一系列称为"GC Roots"的根对象作为起始点,从这些根对象向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,即可判定此对象是不可达的,即不可能再被使用。

GC Roots通常包括:

  • 正在执行的方法中声明的局部变量对象。
  • 方法调用栈中引用的对象。
  • 本地方法栈中JNI(Native方法)引用的对象。
  • Java虚拟机内部引用的对象,如基本类型的Class对象,或者常量池中的引用。

(3)常见引用类型

Java中定义了四种强度不同的引用类型,它们分别是强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。

  • 强引用是最常用的引用类型,只要强引用存在,垃圾回收器就不会回收掉对象。
  • 软引用用于描述还有用但非必需的对象。当系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用比软引用的强度更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收发生之前。
  • 虚引用也称为幽灵引用或幻影引用,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

(4)finalize()方法最终判定对象是否存活

在Java中,finalize()方法是Object类的一个保护方法,允许在对象被垃圾回收前做一些必要的清理工作。当垃圾回收器准备回收一个对象时,如果发现这个对象中定义了finalize()方法,就会自动调用这个方法。但是,finalize()方法的调用并不是强制的,也不保证一定会被调用,且其执行时机不确定,不应依赖它进行资源释放,应使用try-finally或try-with-resources语句进行资源的显式释放。

示例:

在Java中 finalize()方法可以用来执行一些清理工作,如关闭文件、网络连接等资源。然而,finalize()方法的调用不是强制的,且其实现细节和调用时机由JVM决定,因此不应该依赖它来确保资源的释放。下面是一个使用finalize()方法的示例:

java 复制代码
class ResourceManagedObject {
    private boolean isClosed = false;

    protected void finalize() throws Throwable {
        if (!isClosed) {
            // 执行清理工作,例如关闭文件或网络连接
            System.out.println("Resource cleaned up by finalize()");
            isClosed = true;
        }
        super.finalize();
    }

    public void close() {
        // 显式关闭资源
        System.out.println("Resource closed explicitly");
        isClosed = true;
    }
}

public class FinalizeDemo {
    public static void main(String[] args) throws Exception {
        ResourceManagedObject obj = new ResourceManagedObject();
        // 使用obj...

        // 显式关闭资源
        obj.close();

        // 删除引用,使obj成为垃圾收集的目标
        obj = null;
        System.gc(); // 请求垃圾收集

        // 等待垃圾收集器运行
        Thread.sleep(1000); // 假设垃圾收集器会在1秒内运行
    }
}

在这个示例中,ResourceManagedObject类实现了finalize()方法,用于在对象被垃圾收集前执行资源清理工作。然而,最佳实践是不要依赖finalize()方法,而应该在不再需要对象时显式地调用close()方法来释放资源,这是因为finalize()方法的执行是不确定的,且其执行可能带来性能上的开销。此外,从Java 9开始,finalize()方法的使用已被弃用,建议使用其他资源管理技术,如try-with-resources语句或显式的资源关闭逻辑。

(5)如何判断一个类是无用的类

类的卸载(Class Unloading)在Java中并不常见,但在某些特定情况下,如Web容器中,可能需要卸载不再使用的类。判断一个类是否无用,通常考虑以下几点:

  • 类的所有实例都已经回收。
  • 没有任何引用指向该类的Class对象。
  • 类的加载器已经回收或可被回收。

总结

JVM中对象的创建和内存分配是一个多步骤、多策略的过程,涉及类加载、内存布局、垃圾回收等多个层面。理解和优化这一机制对于提升Java应用程序的性能至关重要。通过合理的设计和编码实践,我们可以最大限度地发挥JVM的优势,构建高效稳定的应用系统。

相关推荐
武子康20 分钟前
大数据-230 离线数仓 - ODS层的构建 Hive处理 UDF 与 SerDe 处理 与 当前总结
java·大数据·数据仓库·hive·hadoop·sql·hdfs
武子康21 分钟前
大数据-231 离线数仓 - DWS 层、ADS 层的创建 Hive 执行脚本
java·大数据·数据仓库·hive·hadoop·mysql
苏-言28 分钟前
Spring IOC实战指南:从零到一的构建过程
java·数据库·spring
界面开发小八哥35 分钟前
更高效的Java 23开发,IntelliJ IDEA助力全面升级
java·开发语言·ide·intellij-idea·开发工具
草莓base1 小时前
【手写一个spring】spring源码的简单实现--容器启动
java·后端·spring
Allen Bright1 小时前
maven概述
java·maven
编程重生之路1 小时前
Springboot启动异常 错误: 找不到或无法加载主类 xxx.Application异常
java·spring boot·后端
薯条不要番茄酱1 小时前
数据结构-8.Java. 七大排序算法(中篇)
java·开发语言·数据结构·后端·算法·排序算法·intellij-idea
努力进修1 小时前
“探索Java List的无限可能:从基础到高级应用“
java·开发语言·list
politeboy1 小时前
k8s启动springboot容器的时候,显示找不到application.yml文件
java·spring boot·kubernetes