MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java JVM内存分配策略解析

2023-09-064.0k 阅读

Java JVM内存分配策略解析

在Java开发中,理解JVM(Java Virtual Machine)的内存分配策略至关重要。它不仅关系到程序的性能,还与内存泄漏、OutOfMemoryError等问题的排查和解决紧密相关。

JVM内存区域概述

JVM将其管理的内存划分为不同的区域,每个区域都有特定的用途。

  1. 程序计数器(Program Counter Register): 这是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在JVM的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。每个线程都有独立的程序计数器,所以它是线程私有的。例如,当一个线程执行一个Java方法时,程序计数器记录的是正在执行的虚拟机字节码指令的地址;如果是Native方法,这个计数器值则为空(Undefined)。

  2. Java虚拟机栈(Java Virtual Machine Stack): 同样是线程私有的,它与线程生命周期相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

下面通过一段简单的Java代码来理解栈帧的概念:

public class StackFrameExample {
    public static void main(String[] args) {
        int a = 10;
        int b = 20;
        int result = add(a, b);
        System.out.println("Result: " + result);
    }

    public static int add(int x, int y) {
        int sum = x + y;
        return sum;
    }
}

在上述代码中,当main方法被执行时,会在虚拟机栈中创建一个main方法的栈帧。在main方法中调用add方法时,又会在栈中创建add方法的栈帧。add方法执行完毕后,其栈帧出栈,main方法继续执行。

  1. 本地方法栈(Native Method Stack): 与Java虚拟机栈类似,不过它是为虚拟机使用到的Native方法服务的。有些JVM实现中,会将本地方法栈和Java虚拟机栈合二为一。比如在使用JNI(Java Native Interface)调用本地C或C++方法时,就会用到本地方法栈。

  2. Java堆(Java Heap): 这是JVM所管理的内存中最大的一块,被所有线程共享。Java堆是存放对象实例以及数组(不管是对象数组还是基本类型数组)的地方。在JVM启动时创建,几乎所有的对象实例都在这里分配内存。从内存回收的角度看,Java堆还可以细分为新生代和老年代;再细致一点,新生代又可以分为Eden空间、From Survivor空间和To Survivor空间。

例如:

public class HeapAllocationExample {
    public static void main(String[] args) {
        // 创建一个对象,该对象会被分配到Java堆上
        MyObject obj = new MyObject();
    }
}

class MyObject {
    private int data;

    public MyObject() {
        data = 100;
    }
}

上述代码中创建的MyObject实例就存放在Java堆中。

  1. 方法区(Method Area): 也是被所有线程共享的区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。在JDK 7及之前,习惯上把方法区称为永久代(PermGen),但从JDK 8开始,移除了永久代,取而代之的是元空间(Metaspace),元空间使用本地内存。

例如,以下代码中的常量和静态变量会存储在方法区:

public class MethodAreaExample {
    public static final String CONSTANT_STR = "Hello, Method Area";
    private static int staticVar = 100;

    public static void main(String[] args) {
        System.out.println(CONSTANT_STR);
        System.out.println(staticVar);
    }
}

CONSTANT_STR常量和staticVar静态变量会被存储在方法区。

对象在Java堆中的分配策略

  1. 对象优先在Eden区分配: 大多数情况下,新创建的对象会被分配在新生代的Eden区。当Eden区没有足够空间进行分配时,JVM将发起一次Minor GC。

下面通过代码演示对象在Eden区的分配:

public class EdenAllocationExample {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        // 此时Eden区可能已经没有足够空间,会触发Minor GC
        allocation4 = new byte[4 * _1MB];
    }
}

在上述代码中,前三个2MB的数组分配在Eden区,当分配第四个4MB的数组时,Eden区可能空间不足,从而触发Minor GC。

  1. 大对象直接进入老年代: 所谓大对象,是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串或者元素数量很多的数组。大对象对内存分配的影响较大,为了避免在Eden区及两个Survivor区之间发生大量的内存复制(因为复制操作是需要时间和资源的),大对象直接进入老年代。

例如:

public class LargeObjectAllocationExample {
    private static final int _10MB = 10 * 1024 * 1024;

    public static void main(String[] args) {
        // 创建一个10MB的大数组,直接进入老年代
        byte[] largeArray = new byte[10 * _10MB];
    }
}

上述代码中创建的10MB数组会直接分配到老年代。

  1. 长期存活的对象将进入老年代: 为了能更好地适应不同程序的内存状况,JVM为每个对象定义了一个年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认是15岁,通过-XX:MaxTenuringThreshold参数可以设置)时,就会被晋升到老年代。

以下代码展示对象年龄增长及晋升到老年代的过程:

public class AgeBasedPromotionExample {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4];
        // 第一次Minor GC,allocation1存活并进入Survivor区,年龄设为1
        allocation2 = new byte[4 * _1MB];
        // 第二次Minor GC,allocation1年龄加1,假设Survivor区空间足够
        allocation3 = new byte[4 * _1MB];
        // 多次Minor GC后,allocation1年龄达到15,晋升到老年代
    }
}
  1. 动态对象年龄判定: 为了能更好地适应不同程序的内存状况,JVM并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

例如,假设Survivor空间大小为10MB,当某个年龄(如5岁)的对象总和达到5MB时,5岁及以上的对象都会直接晋升到老年代。

  1. 空间分配担保: 在发生Minor GC之前,JVM会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。

以下代码可以通过设置JVM参数来观察空间分配担保的情况:

public class SpaceAllocationGuaranteeExample {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        // 通过设置JVM参数,如 -XX:HandlePromotionFailure 来观察空间分配担保
    }
}

方法区的内存分配

  1. 常量池的分配: 运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量与符号引用。在JDK 7之前,字符串常量池存放在方法区(永久代),从JDK 7开始,字符串常量池被移到了堆中。

例如:

public class StringConstantPoolExample {
    public static void main(String[] args) {
        String str1 = "Hello";
        String str2 = new String("Hello");
        String internedStr = str2.intern();
        System.out.println(str1 == internedStr); // true,因为intern方法会将字符串放入常量池并返回常量池中的引用
    }
}

在上述代码中,str1直接指向常量池中的字符串对象,str2是在堆中创建的对象,通过intern方法,str2所代表的字符串会被放入常量池(JDK 7及之后在堆中的字符串常量池),并返回常量池中的引用,所以str1 == internedStrtrue

  1. 类的元数据分配: 在JDK 8及之后,类的元数据存放在元空间中。元空间使用本地内存,其大小只受本地内存大小限制。当类被加载时,相关的元数据,如类的结构信息、方法信息、字段信息等会被分配到元空间。

例如,加载一个自定义类时:

public class MyClass {
    private int data;

    public MyClass() {
        data = 10;
    }

    public int getData() {
        return data;
    }
}

当加载MyClass时,其类的元数据就会被分配到元空间,包括类的定义、方法签名、字段类型等信息。

直接内存的分配

直接内存并不是JVM运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域,但它也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

在Java中,通过DirectByteBuffer可以分配直接内存。直接内存的分配和回收成本较高,但读写性能高,适合在I/O操作频繁的场景下使用,比如NIO(New I/O)。

以下是使用DirectByteBuffer分配直接内存的代码示例:

import java.nio.ByteBuffer;

public class DirectMemoryAllocationExample {
    public static void main(String[] args) {
        // 分配10MB的直接内存
        ByteBuffer directBuffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);
        // 使用完后手动释放直接内存,否则可能导致内存泄漏
        ((DirectBuffer) directBuffer).cleaner().clean();
    }
}

在上述代码中,通过ByteBuffer.allocateDirect方法分配了10MB的直接内存。由于直接内存不受JVM垃圾回收管理,使用完后需要手动调用cleaner方法来释放内存,否则可能会造成内存泄漏。

理解JVM的内存分配策略对于编写高效、稳定的Java程序至关重要。通过合理的对象分配、内存使用和参数调优,可以提升程序的性能,避免内存相关的问题。同时,不同的JVM实现可能在内存分配的细节上有所差异,需要根据具体情况进行分析和优化。