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

Java中的对象生命周期与内存管理

2021-09-043.0k 阅读

Java对象的创建

在Java中,对象的创建是通过new关键字来完成的。当使用new关键字时,Java虚拟机(JVM)会在堆内存中为对象分配空间,并调用对象的构造函数进行初始化。

以下是一个简单的Java类及其对象创建的示例:

class Person {
    private String name;
    private int age;

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

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

在上述代码中,Person类有两个私有成员变量nameage,以及一个构造函数用于初始化这两个变量。在main方法中,通过new Person("Alice", 30)创建了一个Person对象,并将其引用赋值给person变量。

当执行new Person("Alice", 30)时,JVM会在堆内存中为Person对象分配足够的空间来存储nameage这两个成员变量。然后,调用Person类的构造函数,将"Alice"30作为参数传递进去,完成对象的初始化。

对象的初始化顺序

  1. 静态变量和静态代码块:在类被加载时,静态变量和静态代码块会按照它们在类中出现的顺序依次执行初始化。静态变量只会被初始化一次,无论创建多少个该类的对象。
class InitializationOrder {
    static int staticVar1 = 10;
    static {
        System.out.println("静态代码块1,staticVar1 = " + staticVar1);
    }
    static int staticVar2 = 20;
    static {
        System.out.println("静态代码块2,staticVar2 = " + staticVar2);
    }

    public InitializationOrder() {
        System.out.println("构造函数");
    }
}

public class Main {
    public static void main(String[] args) {
        InitializationOrder order1 = new InitializationOrder();
        InitializationOrder order2 = new InitializationOrder();
    }
}

在上述代码中,InitializationOrder类包含两个静态变量staticVar1staticVar2,以及两个静态代码块。当InitializationOrder类被加载时,staticVar1会被初始化为10,然后执行第一个静态代码块,输出"静态代码块1,staticVar1 = 10"。接着staticVar2被初始化为20,执行第二个静态代码块,输出"静态代码块2,staticVar2 = 20"。之后,每次创建InitializationOrder对象时,都会调用构造函数输出"构造函数"

  1. 实例变量和实例代码块:在创建对象时,实例变量和实例代码块会在构造函数之前按照它们在类中出现的顺序依次执行初始化。
class InstanceInitialization {
    int instanceVar1 = 10;
    {
        System.out.println("实例代码块1,instanceVar1 = " + instanceVar1);
    }
    int instanceVar2 = 20;
    {
        System.out.println("实例代码块2,instanceVar2 = " + instanceVar2);
    }

    public InstanceInitialization() {
        System.out.println("构造函数");
    }
}

public class Main {
    public static void main(String[] args) {
        InstanceInitialization initialization = new InstanceInitialization();
    }
}

在上述代码中,InstanceInitialization类包含两个实例变量instanceVar1instanceVar2,以及两个实例代码块。当创建InstanceInitialization对象时,instanceVar1会被初始化为10,然后执行第一个实例代码块,输出"实例代码块1,instanceVar1 = 10"。接着instanceVar2被初始化为20,执行第二个实例代码块,输出"实例代码块2,instanceVar2 = 20"。最后调用构造函数输出"构造函数"

对象的使用

一旦对象被创建并初始化,就可以通过对象的引用来访问其成员变量和调用其成员方法。

  1. 访问成员变量:可以使用点号(.)操作符来访问对象的成员变量。
class Rectangle {
    int width;
    int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
}

public class Main {
    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle(5, 3);
        System.out.println("矩形的宽度:" + rectangle.width);
        System.out.println("矩形的高度:" + rectangle.height);
    }
}

在上述代码中,通过rectangle.widthrectangle.height来访问Rectangle对象的widthheight成员变量。

  1. 调用成员方法:同样使用点号(.)操作符来调用对象的成员方法。
class Circle {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

public class Main {
    public static void main(String[] args) {
        Circle circle = new Circle(4.0);
        double area = circle.calculateArea();
        System.out.println("圆的面积:" + area);
    }
}

在上述代码中,通过circle.calculateArea()调用Circle对象的calculateArea方法来计算圆的面积。

对象的生命周期与可达性分析

对象在Java中的生命周期从创建开始,到不再被任何引用指向,最终被垃圾回收器回收结束。JVM使用可达性分析算法来判断对象是否还存活。

  1. 可达性分析算法:该算法以一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,即该对象可以被回收。

GC Roots通常包括以下几类对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象:例如方法中的局部变量所引用的对象。
  • 方法区中类静态属性引用的对象:如类的静态成员变量所指向的对象。
  • 方法区中常量引用的对象:例如字符串常量池中的字符串对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象
  1. 示例说明可达性分析
class ObjectLifeCycle {
    Object reference;

    public ObjectLifeCycle() {
    }
}

public class Main {
    public static void main(String[] args) {
        ObjectLifeCycle obj1 = new ObjectLifeCycle();
        ObjectLifeCycle obj2 = new ObjectLifeCycle();
        obj1.reference = obj2;
        obj2.reference = obj1;

        // 此时obj1和obj2通过相互引用,并且在栈中有局部变量引用,都是可达的

        obj1 = null;
        obj2 = null;
        // 此时obj1和obj2不再被栈中的局部变量引用,且它们之间的相互引用形成的环不足以使它们可达
        // 经过可达性分析,它们将被判定为不可达,可以被垃圾回收
    }
}

在上述代码中,最初obj1obj2相互引用,并且在main方法的栈中有局部变量obj1obj2引用它们,所以它们是可达的。当将obj1obj2赋值为null后,它们不再被栈中的局部变量引用,虽然它们之间存在相互引用,但这种引用环不足以使它们通过GC Roots可达,因此经过可达性分析后,它们将被判定为不可达,可以被垃圾回收。

Java中的内存区域

  1. 堆(Heap):堆是Java虚拟机所管理的内存中最大的一块,是所有对象实例以及数组的存储区域。堆被所有线程共享,在JVM启动时创建。堆内存又可以细分为新生代(Young Generation)和老年代(Old Generation)。
  • 新生代:新生代主要用于存放新创建的对象,它又分为一个较大的Eden区和两个较小的Survivor区(通常称为Survivor0和Survivor1)。大多数对象在Eden区中创建,当Eden区满时,会触发一次Minor GC,将Eden区和其中一个Survivor区中存活的对象复制到另一个Survivor区,然后清空Eden区和之前的Survivor区。
  • 老年代:在新生代中经历了多次Minor GC仍然存活的对象会被移动到老年代。老年代存放的对象生命周期较长,占用空间较大。当老年代空间不足时,会触发Major GC(也称为Full GC),对整个堆进行垃圾回收。
  1. 方法区(Method Area):方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。它也是被所有线程共享的区域。在JDK 1.8之前,方法区的实现通常被称为永久代(PermGen),但从JDK 1.8开始,永久代被移除,取而代之的是元空间(Metaspace),元空间使用本地内存而不是堆内存。

  2. 虚拟机栈(Java Virtual Machine Stack):每个线程在创建时都会创建一个虚拟机栈,它用于存储栈帧。栈帧包含局部变量表、操作数栈、动态链接、方法出口等信息。每当一个方法被调用时,就会在虚拟机栈中压入一个新的栈帧,当方法执行完毕后,栈帧会被弹出。虚拟机栈的大小可以通过-Xss参数设置。

  3. 本地方法栈(Native Method Stack):本地方法栈与虚拟机栈类似,只不过它是为Native方法服务的。当Java程序调用Native方法时,就会使用本地方法栈。

  4. 程序计数器(Program Counter Register):程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。每个线程都有自己独立的程序计数器,因为线程是轮流执行的,当一个线程被切换回来继续执行时,需要通过程序计数器来确定下一条执行的字节码指令。

垃圾回收机制

  1. 垃圾回收的作用:垃圾回收(Garbage Collection,GC)是Java自动内存管理的核心机制,其主要作用是回收堆内存中不再被使用的对象所占用的空间,以避免内存泄漏,并提高内存的利用率。

  2. 垃圾回收算法

  • 标记 - 清除算法(Mark - Sweep):该算法分为两个阶段,标记阶段和清除阶段。在标记阶段,从GC Roots开始遍历所有对象,标记出所有可达的对象。在清除阶段,回收所有未被标记的对象,即不可达对象。此算法的缺点是会产生大量不连续的内存碎片,可能导致在分配大对象时因无法找到足够连续的内存空间而提前触发垃圾回收。
  • 复制算法(Copying):复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活的对象复制到另一块上面,然后把已使用过的内存空间一次性清理掉。这种算法不会产生内存碎片,但会浪费一半的内存空间。新生代中的Eden区和Survivor区就是基于复制算法的原理设计的,只是比例不是1:1,通常Eden区与Survivor区的比例为8:1:1,这样可以减少内存浪费。
  • 标记 - 整理算法(Mark - Compact):标记 - 整理算法在标记 - 清除算法的基础上进行了改进。在标记阶段之后,不是直接清理不可达对象,而是将所有存活的对象向一端移动,然后直接清理掉边界以外的内存,这样就不会产生内存碎片。老年代通常使用标记 - 整理算法。
  1. 垃圾回收器
  • Serial收集器:Serial收集器是最基本、最古老的垃圾回收器,它是单线程的收集器,在进行垃圾回收时,必须暂停其他所有的工作线程,直到垃圾回收结束。它适用于单CPU环境下的客户端应用,因为它简单高效,没有线程交互的开销。
  • ParNew收集器:ParNew收集器是Serial收集器的多线程版本,它使用多线程进行垃圾回收,与Serial收集器相比,在多核环境下可以显著提高垃圾回收的效率。它主要用于新生代的垃圾回收,并且经常与CMS收集器配合使用。
  • Parallel Scavenge收集器:Parallel Scavenge收集器也是用于新生代的多线程收集器,它的目标是达到一个可控制的吞吐量。吞吐量是指运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。通过调整垃圾收集的时间,可以控制吞吐量。
  • CMS(Concurrent Mark Sweep)收集器:CMS收集器是一种以获取最短回收停顿时间为目标的收集器,它适用于对响应时间要求较高的应用。CMS收集器的工作过程分为四个阶段:初始标记(Initial Mark)、并发标记(Concurrent Mark)、重新标记(Remark)和并发清除(Concurrent Sweep)。初始标记和重新标记阶段需要暂停所有工作线程,而并发标记和并发清除阶段可以与用户线程并发执行。
  • G1(Garbage - First)收集器:G1收集器是一种面向服务端应用的垃圾回收器,它可以同时兼顾垃圾回收的停顿时间和吞吐量。G1收集器将整个堆内存划分为多个大小相等的Region,它不再区分新生代和老年代,而是将Region动态地根据需要分配为新生代或老年代。G1收集器的垃圾回收过程包括初始标记、并发标记、最终标记和筛选回收等阶段。

内存优化与调优

  1. 对象创建优化:尽量减少不必要的对象创建,例如可以使用对象池技术来复用对象。以数据库连接池为例,数据库连接的创建开销较大,通过连接池可以预先创建一定数量的数据库连接对象,当需要使用数据库连接时,从连接池中获取,使用完毕后再放回连接池,而不是每次都创建新的连接对象。

  2. 内存分配优化:合理设置堆内存的大小,避免设置过大或过小。如果堆内存设置过大,会导致垃圾回收时间变长,影响应用的响应时间;如果设置过小,会频繁触发垃圾回收,甚至可能导致OutOfMemoryError。可以通过-Xms(设置初始堆大小)和-Xmx(设置最大堆大小)参数来调整堆内存大小。

  3. 垃圾回收器选择与调优:根据应用的特点选择合适的垃圾回收器。如果应用对响应时间要求较高,如Web应用,可以选择CMS或G1收集器;如果应用对吞吐量要求较高,如批处理应用,可以选择Parallel Scavenge收集器。同时,可以通过调整垃圾回收器的相关参数来优化垃圾回收的性能,例如设置新生代与老年代的比例、调整垃圾回收的线程数等。

  4. 避免内存泄漏:内存泄漏是指程序中已分配的内存空间在不再使用时没有被释放,导致内存浪费。常见的内存泄漏场景包括静态集合类中对象的引用未及时清除、资源未及时关闭等。例如,在使用完数据库连接、文件流等资源后,必须及时关闭,否则可能会导致内存泄漏。

以下是一个可能导致内存泄漏的示例:

import java.util.ArrayList;
import java.util.List;

public class MemoryLeakExample {
    private static List<Object> list = new ArrayList<>();

    public static void addObject() {
        Object obj = new Object();
        list.add(obj);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 1000000; i++) {
            addObject();
        }
        // 此时list中积累了大量的Object对象,即使这些对象在后续代码中不再使用,
        // 由于list是静态的,这些对象不会被垃圾回收,从而导致内存泄漏
    }
}

在上述代码中,list是一个静态的ArrayList,每次调用addObject方法都会向list中添加一个新的Object对象。由于list是静态的,即使这些Object对象在后续代码中不再使用,它们也不会被垃圾回收,从而导致内存泄漏。要避免这种情况,可以在适当的时候清除list中的对象,例如:

import java.util.ArrayList;
import java.util.List;

public class MemoryLeakFixedExample {
    private static List<Object> list = new ArrayList<>();

    public static void addObject() {
        Object obj = new Object();
        list.add(obj);
    }

    public static void clearList() {
        list.clear();
    }

    public static void main(String[] args) {
        for (int i = 0; i < 1000000; i++) {
            addObject();
        }
        clearList();
        // 调用clearList方法清除list中的对象,避免内存泄漏
    }
}

通过调用clearList方法,可以清除list中的对象,避免内存泄漏。

  1. 弱引用与软引用的使用:在某些情况下,可以使用弱引用(WeakReference)和软引用(SoftReference)来管理对象的生命周期。弱引用的对象在垃圾回收器扫描到它时,如果该对象只被弱引用指向,就会被回收。软引用的对象只有在内存不足时才会被回收。这两种引用类型可以用于实现缓存等功能,在内存紧张时可以自动释放不再使用的对象,避免内存泄漏。

以下是一个使用弱引用的示例:

import java.lang.ref.WeakReference;

public class WeakReferenceExample {
    public static void main(String[] args) {
        Object obj = new Object();
        WeakReference<Object> weakRef = new WeakReference<>(obj);
        obj = null;
        System.gc();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Object weakObj = weakRef.get();
        if (weakObj == null) {
            System.out.println("弱引用的对象已被回收");
        } else {
            System.out.println("弱引用的对象仍然存在");
        }
    }
}

在上述代码中,创建了一个Object对象,并使用WeakReference对其进行引用。然后将obj赋值为null,使Object对象只被弱引用指向。调用System.gc()尝试触发垃圾回收,等待一段时间后通过weakRef.get()获取弱引用指向的对象。如果对象已被回收,get()方法将返回null

软引用的使用方式与弱引用类似,只是在内存不足时才会回收对象。通过合理使用弱引用和软引用,可以在一定程度上优化内存管理。

通过对Java中对象生命周期和内存管理的深入理解,并采取相应的优化措施,可以提高Java应用程序的性能和稳定性,避免内存相关的问题。在实际开发中,需要根据应用的特点和需求,灵活运用这些知识,不断优化内存的使用。同时,还需要关注JVM的最新特性和垃圾回收器的改进,以更好地适应不同场景下的内存管理需求。