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

Java编程中的内存使用规范

2021-04-154.4k 阅读

Java内存区域概述

在深入探讨Java编程中的内存使用规范之前,我们首先需要了解Java运行时的内存区域划分。Java虚拟机在执行Java程序的过程中会把它管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途、创建和销毁时间。

  1. 程序计数器(Program Counter Register): 这是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则为空(Undefined)。此内存区域是线程私有的,生命周期与线程相同。

  2. Java虚拟机栈(Java Virtual Machine Stack): 线程私有的,它的生命周期和线程相同。每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。 例如,下面这段简单的Java代码:

public class StackExample {
    public static void main(String[] args) {
        int num = 10;
        StackExample example = new StackExample();
        example.addNumbers(num, 20);
    }

    public int addNumbers(int a, int b) {
        int result = a + b;
        return result;
    }
}

main方法中,num变量会存储在局部变量表中,example对象引用也在局部变量表。当调用addNumbers方法时,又会创建一个新的栈帧,abresult等变量存储在这个新栈帧的局部变量表中。

  1. 本地方法栈(Native Method Stack): 与Java虚拟机栈所发挥的作用非常相似,其区别不过是Java虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。有些虚拟机(譬如Sun HotSpot虚拟机)直接把本地方法栈和Java虚拟机栈合二为一。本地方法栈也是线程私有的,生命周期与线程相同。

  2. Java堆(Java Heap): Java虚拟机所管理的内存中最大的一块,被所有线程共享。此区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。 例如,创建一个简单的对象:

public class HeapExample {
    public static void main(String[] args) {
        Person person = new Person("John", 30);
    }
}

class Person {
    String name;
    int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

person对象就会被分配在Java堆上,其成员变量nameage也存储在堆上对象的内存空间中。

  1. 方法区(Method Area): 也是线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non - Heap(非堆),目的应该是与Java堆区分开来。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 例如,下面代码中的常量:
public class MethodAreaExample {
    public static final String CONSTANT_STRING = "Hello, Method Area";
    public static void main(String[] args) {
        System.out.println(CONSTANT_STRING);
    }
}

CONSTANT_STRING常量就存储在方法区的运行时常量池中。

Java内存分配与回收策略

  1. 对象的创建 当Java程序执行到创建对象的语句时,会在堆上为对象分配内存空间。首先,虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。 例如:
Object obj = new Object();

当执行这行代码时,虚拟机首先检查Object类是否已加载,如果未加载则进行加载。然后在堆上为Object对象分配内存空间。

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

    • 对象头:对象头包含两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、对象分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“Mark Word”。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
    • 实例数据:这部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
    • 对齐填充:对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(32位虚拟机中对象头是4字节,64位虚拟机中对象头是8字节),当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
  2. 对象的访问定位 建立对象就是为了使用对象,我们的Java程序通过栈上的reference数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种:

    • 句柄访问:如果使用句柄访问,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
    • 直接指针访问:如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中直接存储的就是对象地址。 这两种访问方式各有优势,使用句柄访问方式的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。使用直接指针访问方式最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
  3. 垃圾回收策略 垃圾回收(Garbage Collection,GC)的主要任务是回收堆上不再使用的对象所占用的内存空间。判断对象是否存活一般有两种算法:

    • 引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。这种算法实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,但是Java虚拟机中并没有选用引用计数算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。例如:
public class ReferenceCountingProblem {
    public static void main(String[] args) {
        MyObject objA = new MyObject();
        MyObject objB = new MyObject();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        // 此时objA和objB互相引用,即使它们外部引用都被置为null,引用计数算法也无法回收它们
    }
}

class MyObject {
    MyObject instance;
}
- **可达性分析算法**:这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。在Java语言中,可作为GC Roots的对象包括以下几种:
    - 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    - 方法区中类静态属性引用的对象,例如Java类的引用类型静态变量。
    - 方法区中常量引用的对象,例如字符串常量池(String Table)里的引用。
    - 本地方法栈中JNI(即通常所说的Native方法)引用的对象。

Java内存使用规范

  1. 合理创建对象
    • 避免频繁创建短期对象:频繁创建短期对象会增加垃圾回收的压力。例如,在一个循环中创建大量临时对象:
public class FrequentObjectCreation {
    public static void main(String[] args) {
        for (int i = 0; i < 1000000; i++) {
            String temp = new String("temp" + i);
            // 这里只是简单示例,实际可能对temp做一些操作
        }
    }
}

在这个例子中,每次循环都创建一个新的String对象,这些对象很快就会变成垃圾,给垃圾回收器带来很大压力。更好的做法是尽量复用对象,例如可以使用StringBuilder来构建字符串:

public class StringBuilderExample {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 1000000; i++) {
            sb.append("temp").append(i);
            // 这里只是简单示例,实际可能对sb构建的字符串做一些操作
            sb.setLength(0);
        }
    }
}
- **对象池的使用**:对于一些创建开销较大且经常复用的对象,可以使用对象池技术。例如数据库连接池,数据库连接的创建需要消耗较多资源,通过连接池可以复用已创建的连接,减少创建开销。下面是一个简单的对象池示例:
import java.util.ArrayList;
import java.util.List;

public class ObjectPool<T> {
    private List<T> pool;
    private int initialSize;

    public ObjectPool(int initialSize) {
        this.initialSize = initialSize;
        pool = new ArrayList<>(initialSize);
        for (int i = 0; i < initialSize; i++) {
            pool.add(createObject());
        }
    }

    protected T createObject() {
        // 具体对象创建逻辑,这里以示例简单返回null
        return null;
    }

    public synchronized T borrowObject() {
        if (pool.isEmpty()) {
            T newObject = createObject();
            return newObject;
        }
        return pool.remove(pool.size() - 1);
    }

    public synchronized void returnObject(T object) {
        pool.add(object);
    }
}
  1. 正确使用引用类型
    • 强引用:这是最常见的引用类型,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。例如:
Object strongRef = new Object();

这里strongRef就是一个强引用,只要strongRef不被置为null,这个Object对象就不会被回收。 - 软引用:软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。可以使用SoftReference类来创建软引用,例如:

SoftReference<String> softRef = new SoftReference<>(new String("Soft Referenced String"));
String value = softRef.get();
if (value != null) {
    // 使用value
} else {
    // 软引用对象已被回收
}
- **弱引用**:弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。可以使用`WeakReference`类来创建弱引用,例如:
WeakReference<String> weakRef = new WeakReference<>(new String("Weak Referenced String"));
String value = weakRef.get();
if (value != null) {
    // 使用value
} else {
    // 弱引用对象已被回收
}
- **虚引用**:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。可以使用`PhantomReference`类来创建虚引用,例如:
ReferenceQueue<String> queue = new ReferenceQueue<>();
PhantomReference<String> phantomRef = new PhantomReference<>(new String("Phantom Referenced String"), queue);
  1. 避免内存泄漏 内存泄漏是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。常见的内存泄漏场景有:
    • 静态集合类引起的内存泄漏:如果静态集合类(如HashMapArrayList等)中存放了大量对象引用,而这些对象不再被其他地方使用,但由于静态集合类的生命周期与应用程序相同,这些对象无法被回收,从而导致内存泄漏。例如:
import java.util.ArrayList;
import java.util.List;

public class StaticCollectionMemoryLeak {
    private static List<Object> staticList = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 10000; i++) {
            Object obj = new Object();
            staticList.add(obj);
        }
        // 这里假设之后不再使用这些对象,但由于staticList的存在,它们无法被回收
    }
}
- **监听器和回调引起的内存泄漏**:如果注册了监听器或回调,而在不再使用时没有注销,就会导致被监听或回调的对象无法被回收。例如:
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class ListenerMemoryLeak {
    private Frame frame;
    private Button button;

    public ListenerMemoryLeak() {
        frame = new Frame("Listener Memory Leak");
        button = new Button("Click Me");
        button.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                // 处理逻辑
            }
        });
        frame.add(button);
        frame.pack();
        frame.setVisible(true);
    }

    // 如果没有在合适的时候移除监听器,当frame不再被使用时,监听器中的引用会阻止frame及其内部组件被回收
}
- **资源未关闭引起的内存泄漏**:例如文件流、数据库连接等资源,如果没有正确关闭,会导致内存泄漏。例如:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class ResourceLeak {
    public static void main(String[] args) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader("example.txt"));
            String line;
            while ((line = reader.readLine()) != null) {
                // 处理文件内容
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
  1. 优化内存使用
    • 使用合适的数据类型:选择合适的数据类型可以有效减少内存占用。例如,对于存储较小整数的场景,如果范围在byte(-128到127)内,使用byte类型而不是int类型,因为byte只占1个字节,而int占4个字节。
    • 对象复用:除了前面提到的对象池技术,在方法内部也可以复用对象。例如,在一个需要频繁进行字符串拼接的方法中,可以复用StringBuilder对象,而不是每次都创建新的:
public class StringBuilderReuse {
    private StringBuilder sb = new StringBuilder();

    public String buildString(String part1, String part2) {
        sb.setLength(0);
        sb.append(part1).append(part2);
        return sb.toString();
    }
}
- **合理使用缓存**:对于一些计算开销较大且结果不经常变化的数据,可以使用缓存。例如,对于一些复杂的数学计算结果,可以缓存起来,下次需要时直接从缓存中获取,而不是重新计算。
import java.util.HashMap;
import java.util.Map;

public class CacheExample {
    private static Map<Integer, Double> cache = new HashMap<>();

    public static double expensiveCalculation(int input) {
        if (cache.containsKey(input)) {
            return cache.get(input);
        }
        double result = Math.pow(input, 2) + Math.sqrt(input);
        cache.put(input, result);
        return result;
    }
}
  1. 内存监控与调优
    • 使用JVM监控工具:可以使用jconsolejvisualvm等工具来监控Java应用程序的内存使用情况。这些工具可以实时显示堆内存的使用情况、垃圾回收次数和时间等信息。例如,通过jvisualvm连接到正在运行的Java进程,可以直观地看到堆内存的变化趋势,判断是否存在内存泄漏或内存使用不合理的情况。
    • 设置合理的JVM参数:根据应用程序的特点和需求,设置合理的JVM参数可以优化内存使用。例如,-Xms-Xmx参数分别用于设置堆内存的初始大小和最大大小。如果应用程序在启动时就需要大量内存,可以适当增大-Xms的值,避免频繁的内存扩展操作。又如,-XX:NewRatio参数用于设置新生代和老年代的比例,对于不同类型的应用程序,合理调整这个比例可以提高垃圾回收的效率。例如,对于大多数Web应用程序,由于对象创建和销毁比较频繁,可以适当增大新生代的比例,如设置-XX:NewRatio=2,表示新生代占堆内存的1/3。

深入理解内存使用规范的意义

  1. 提高应用程序性能 合理的内存使用规范可以显著提高Java应用程序的性能。通过避免频繁创建短期对象,减少了垃圾回收的频率和时间,从而提高了程序的响应速度。例如,在一个高并发的Web应用程序中,如果每个请求都创建大量短期对象,垃圾回收器可能会频繁工作,导致响应时间变长。而通过复用对象或者使用对象池技术,可以有效减少垃圾回收压力,提高系统的吞吐量。同样,合理使用引用类型,根据对象的实际使用场景选择强引用、软引用、弱引用或虚引用,可以确保对象在合适的时机被回收,避免不必要的内存占用,进一步提升性能。

  2. 增强应用程序稳定性 避免内存泄漏是保证应用程序稳定性的关键。内存泄漏会导致应用程序占用的内存不断增加,最终可能耗尽系统内存,导致应用程序崩溃。例如,在一个长期运行的后台服务中,如果存在静态集合类引起的内存泄漏,随着时间的推移,内存占用会持续上升,当达到系统内存极限时,服务将无法正常运行。通过遵循内存使用规范,及时发现并修复内存泄漏问题,可以确保应用程序长时间稳定运行。

  3. 优化资源利用 在服务器环境中,资源通常是有限的。合理的内存使用规范可以优化资源利用,使得应用程序在有限的内存资源下能够高效运行。例如,通过使用合适的数据类型,减少不必要的内存浪费,可以在相同的内存空间中存储更多的数据,提高内存利用率。同时,通过合理设置JVM参数,根据应用程序的负载特点分配堆内存,能够更好地利用服务器的内存资源,避免因内存分配不合理导致的性能瓶颈。

  4. 便于代码维护与扩展 遵循内存使用规范的代码具有更好的可读性和可维护性。例如,在代码中清晰地使用不同类型的引用,使得代码逻辑更加清晰,开发人员能够更容易理解对象的生命周期和内存管理方式。当需要对代码进行扩展时,基于良好的内存使用规范编写的代码更容易进行修改和优化,减少引入新的内存问题的风险。

总结

Java编程中的内存使用规范涵盖了内存区域的理解、对象的创建与回收策略以及一系列实际的编程规范。通过深入理解这些规范并在实际编程中遵循它们,开发人员能够编写出性能更高、稳定性更强、资源利用更合理的Java应用程序。从对象的合理创建和复用,到正确使用引用类型避免内存泄漏,再到借助内存监控工具和合理设置JVM参数进行优化,每个环节都对应用程序的质量有着重要影响。在不断发展的Java技术生态中,持续关注和优化内存使用将始终是保证Java应用程序高效运行的关键所在。