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

Java对象存活时间分析

2021-02-212.7k 阅读

Java对象存活时间基础概念

1. 堆内存与对象存储

在Java中,对象主要存储在堆内存中。堆是Java虚拟机(JVM)管理的最大一块内存区域,其目的就是存放对象实例。当我们使用 new 关键字创建一个对象时,JVM会在堆中为该对象分配内存空间。例如:

class MyClass {
    int data;
    public MyClass(int value) {
        this.data = value;
    }
}

public class Main {
    public static void main(String[] args) {
        MyClass obj = new MyClass(10);
    }
}

在上述代码中,new MyClass(10) 这行代码在堆中创建了一个 MyClass 类型的对象,对象包含一个 int 类型的成员变量 data 并初始化为 10

2. 栈内存与引用关系

栈内存主要用于存储局部变量和方法调用信息。当我们创建一个对象引用时,这个引用变量会被存储在栈中。以刚才的代码为例,MyClass obj 声明了一个 MyClass 类型的引用变量 obj,它存储在 main 方法对应的栈帧中。这个引用变量指向堆中的 MyClass 对象。引用变量就像是一个指针,它告诉JVM在哪里可以找到堆中的实际对象。

3. 对象存活的基本定义

从最基础的层面来说,当一个对象至少有一个引用指向它时,我们认为这个对象是存活的。只要有引用保持对对象的可达性,JVM就不会回收该对象所占用的堆内存。在上述代码中,只要 obj 变量在作用域内,与之关联的 MyClass 对象就是存活的。一旦 obj 变量超出作用域(例如 main 方法执行完毕),并且没有其他引用指向该 MyClass 对象,那么这个对象就可以被视为垃圾对象,等待垃圾回收器(GC)来回收其占用的内存。

影响Java对象存活时间的因素

1. 作用域

作用域是影响对象存活时间的一个关键因素。在Java中,变量的作用域决定了其生命周期,进而影响与之关联的对象的存活时间。

1.1 方法内局部变量作用域

对于方法内定义的局部变量,其作用域从声明处开始,到包含该变量声明的代码块结束。例如:

public class ScopeExample {
    public void method() {
        {
            MyClass localVarObj = new MyClass(20);
            // localVarObj 在这个代码块内是有效的
        }
        // 这里 localVarObj 已经超出作用域,与之关联的 MyClass 对象
        // 可能成为垃圾回收的候选对象(如果没有其他引用指向它)
    }
}

在上述代码中,localVarObj 变量的作用域仅限于内部代码块。一旦代码执行到代码块之外,localVarObj 就超出了作用域。如果此时没有其他引用指向 MyClass 对象,那么这个对象就有可能被垃圾回收器回收。

1.2 成员变量作用域

类的成员变量的作用域是整个类。只要包含成员变量的对象本身是存活的,成员变量所引用的对象也会保持存活。例如:

class OuterClass {
    MyClass memberObj;
    public OuterClass() {
        memberObj = new MyClass(30);
    }
}

public class Main2 {
    public static void main(String[] args) {
        OuterClass outer = new OuterClass();
        // 只要 outer 对象存活,outer.memberObj 所引用的 MyClass 对象就存活
    }
}

在这个例子中,memberObjOuterClass 的成员变量。只要 OuterClass 的实例 outer 是存活的,memberObj 所引用的 MyClass 对象就不会被垃圾回收,因为 outer 持有对 memberObj 的引用,进而保持了对 MyClass 对象的可达性。

2. 引用类型

Java中有四种引用类型:强引用、软引用、弱引用和虚引用,它们对对象存活时间的影响各不相同。

2.1 强引用(Strong Reference)

强引用是最常见的引用类型。当我们使用 new 关键字创建一个对象并赋值给一个变量时,就是创建了一个强引用。如前文的例子 MyClass obj = new MyClass(10); 中,obj 就是对 MyClass 对象的强引用。只要强引用存在,垃圾回收器永远不会回收被引用的对象。即使内存不足,JVM宁愿抛出 OutOfMemoryError 错误,也不会回收具有强引用的对象。

public class StrongReferenceExample {
    public static void main(String[] args) {
        MyClass strongRefObj = new MyClass(40);
        // 即使内存紧张,只要 strongRefObj 存在,对应的 MyClass 对象不会被回收
    }
}

2.2 软引用(Soft Reference)

软引用是用来描述一些还有用但并非必需的对象。在系统将要发生内存溢出之前,会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。软引用通常用于实现对内存敏感的缓存。我们可以通过 SoftReference 类来创建软引用。例如:

import java.lang.ref.SoftReference;

public class SoftReferenceExample {
    public static void main(String[] args) {
        MyClass realObj = new MyClass(50);
        SoftReference<MyClass> softRef = new SoftReference<>(realObj);
        realObj = null; // 切断强引用
        // 此时 MyClass 对象只有软引用,当内存不足时可能被回收
        MyClass objFromSoftRef = softRef.get();
        if (objFromSoftRef != null) {
            // 对象还没有被回收
        } else {
            // 对象已被回收
        }
    }
}

2.3 弱引用(Weak Reference)

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾回收器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。我们使用 WeakReference 类来创建弱引用。例如:

import java.lang.ref.WeakReference;

public class WeakReferenceExample {
    public static void main(String[] args) {
        MyClass realObj = new MyClass(60);
        WeakReference<MyClass> weakRef = new WeakReference<>(realObj);
        realObj = null; // 切断强引用
        // 下一次垃圾回收时,MyClass 对象很可能被回收
        MyClass objFromWeakRef = weakRef.get();
        if (objFromWeakRef != null) {
            // 对象还没有被回收
        } else {
            // 对象已被回收
        }
    }
}

2.4 虚引用(Phantom Reference)

虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。我们通过 PhantomReference 类来创建虚引用,并且需要配合 ReferenceQueue 使用。例如:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceExample {
    public static void main(String[] args) {
        MyClass realObj = new MyClass(70);
        ReferenceQueue<MyClass> queue = new ReferenceQueue<>();
        PhantomReference<MyClass> phantomRef = new PhantomReference<>(realObj, queue);
        realObj = null; // 切断强引用
        // 当 MyClass 对象被回收时,会将对应的 PhantomReference 放入 ReferenceQueue
        PhantomReference<MyClass> refFromQueue = (PhantomReference<MyClass>) queue.poll();
        if (refFromQueue != null) {
            // 表明 MyClass 对象已被回收
        }
    }
}

3. 对象之间的引用关系

对象之间的相互引用会影响它们的存活时间。这种引用关系可能形成复杂的对象图。

3.1 单向引用

单向引用是指一个对象持有对另一个对象的引用,但反之则没有。例如:

class Parent {
    Child child;
    public Parent() {
        child = new Child();
    }
}

class Child {
    // 没有对 Parent 的引用
}

public class OneWayReferenceExample {
    public static void main(String[] args) {
        Parent parent = new Parent();
        // 只要 parent 存活,parent.child 所引用的 Child 对象就存活
    }
}

在这个例子中,Parent 对象持有对 Child 对象的引用,只要 Parent 对象存活,Child 对象就存活。

3.2 双向引用

双向引用是指两个对象互相持有对方的引用。例如:

class Husband {
    Wife wife;
    public Husband(Wife wife) {
        this.wife = wife;
    }
}

class Wife {
    Husband husband;
    public Wife(Husband husband) {
        this.husband = husband;
    }
}

public class TwoWayReferenceExample {
    public static void main(String[] args) {
        Wife wife = new Wife(null);
        Husband husband = new Husband(wife);
        wife.husband = husband;
        // 只要 husband 或 wife 其中一个存活,另一个以及它们相互引用的对象都会存活
    }
}

在这个例子中,HusbandWife 对象互相持有对方的引用。只要其中一个对象存活,另一个对象以及它们相互引用的对象都会存活,因为它们形成了一个相互可达的对象图。

3.3 循环引用

循环引用是对象之间引用关系的一种特殊情况,多个对象之间形成一个闭环的引用链。例如:

class A {
    B b;
    public A() {
        b = new B(this);
    }
}

class B {
    A a;
    public B(A a) {
        this.a = a;
    }
}

public class CircularReferenceExample {
    public static void main(String[] args) {
        A a = new A();
        // a 和 a.b 以及 a.b.a 形成循环引用
        // 只要 a 存活,这个循环引用中的所有对象都会存活
    }
}

在这个例子中,AB 对象形成了循环引用。垃圾回收器在处理循环引用时,需要特殊的算法来判断这些对象是否真正不可达。在现代的垃圾回收算法中,循环引用的对象如果没有其他外部引用,最终也会被回收。

垃圾回收与对象存活时间

1. 垃圾回收机制概述

垃圾回收(Garbage Collection,GC)是Java语言的一个重要特性,它自动管理堆内存,回收不再使用的对象所占用的内存空间。垃圾回收器会定期扫描堆内存,识别出那些不再被任何引用指向的对象(即垃圾对象),并回收它们占用的内存。这样可以避免手动管理内存带来的内存泄漏和悬空指针等问题。

2. 垃圾回收算法对对象存活时间的影响

2.1 标记 - 清除算法(Mark - Sweep)

标记 - 清除算法分为两个阶段:标记阶段和清除阶段。在标记阶段,垃圾回收器从根对象(例如栈中的引用、静态变量等)开始遍历,标记所有可达的对象。然后在清除阶段,回收所有未被标记的对象(即垃圾对象)所占用的内存空间。这种算法会导致内存碎片化,因为回收后的内存空间是不连续的。对于对象存活时间来说,只要对象在标记阶段被标记为可达,它就会继续存活,直到下一次垃圾回收。例如:

// 假设这里模拟标记 - 清除算法下对象的存活情况
class MarkSweepExample {
    public static void main(String[] args) {
        MyClass liveObj = new MyClass(80);
        MyClass deadObj = new MyClass(90);
        deadObj = null;
        // 下一次垃圾回收(标记 - 清除算法)时,deadObj 对应的 MyClass 对象
        // 如果未被其他引用指向,将被回收,而 liveObj 对应的对象会继续存活
    }
}

2.2 复制算法(Copying)

复制算法将内存分为两块相等的区域,每次只使用其中一块。当这块内存使用完后,垃圾回收器将存活的对象复制到另一块内存,然后清除原来那块内存。这种算法不会产生内存碎片化,但会浪费一半的内存空间。对于对象存活时间,存活的对象会被复制到新的内存区域,其存活状态得以延续。例如:

// 假设这里模拟复制算法下对象的存活情况
class CopyingExample {
    public static void main(String[] args) {
        MyClass obj1 = new MyClass(100);
        MyClass obj2 = new MyClass(110);
        obj2 = null;
        // 垃圾回收(复制算法)时,obj1 对应的对象会被复制到新区域继续存活,
        // 而 obj2 对应的对象如果未被其他引用指向,将不会被复制,从而被回收
    }
}

2.3 标记 - 整理算法(Mark - Compact)

标记 - 整理算法结合了标记 - 清除算法和复制算法的优点。在标记阶段,同样从根对象开始标记所有可达对象。然后在整理阶段,将存活的对象向内存的一端移动,最后清除边界以外的内存。这种算法避免了内存碎片化,同时也不会像复制算法那样浪费一半内存。对于对象存活时间,存活对象在整理阶段会被移动到新的位置,继续保持存活状态。例如:

// 假设这里模拟标记 - 整理算法下对象的存活情况
class MarkCompactExample {
    public static void main(String[] args) {
        MyClass obj3 = new MyClass(120);
        MyClass obj4 = new MyClass(130);
        obj4 = null;
        // 垃圾回收(标记 - 整理算法)时,obj3 对应的对象会被移动到新位置继续存活,
        // 而 obj4 对应的对象如果未被其他引用指向,将被清除
    }
}

2.4 分代收集算法(Generational Collection)

分代收集算法是目前大部分JVM采用的垃圾回收算法。它基于这样一个事实:不同生命周期的对象具有不同的特点。一般将堆内存分为新生代、老年代和永久代(在Java 8 及以后,永久代被元空间取代)。新生代存放新创建的对象,对象存活率低;老年代存放经过多次垃圾回收仍然存活的对象,对象存活率高。垃圾回收器针对不同代采用不同的回收算法。在新生代通常采用复制算法,在老年代通常采用标记 - 清除或标记 - 整理算法。对于对象存活时间,新创建的对象在新生代经历多次垃圾回收后,如果仍然存活,会被晋升到老年代。例如:

// 假设这里模拟分代收集算法下对象的存活与晋升情况
class GenerationalExample {
    public static void main(String[] args) {
        MyClass youngObj = new MyClass(140);
        // 年轻对象在新生代,经过几次垃圾回收后如果存活,可能晋升到老年代
        // 其存活时间会因为晋升而在不同代之间延续
    }
}

3. 垃圾回收的触发时机与对象存活时间

垃圾回收的触发时机有多种,不同的触发时机对对象存活时间有不同的影响。

3.1 主动触发

我们可以通过调用 System.gc() 方法来主动建议JVM进行垃圾回收。但需要注意的是,这只是一个建议,JVM并不一定会立即执行垃圾回收。例如:

public class ManualGCExample {
    public static void main(String[] args) {
        MyClass obj = new MyClass(150);
        obj = null;
        System.gc();
        // 调用 System.gc() 后,obj 对应的 MyClass 对象如果没有其他引用,
        // 可能会在这次垃圾回收中被回收,其存活时间可能就此结束
    }
}

3.2 被动触发

被动触发是指当JVM检测到堆内存空间不足时,会自动触发垃圾回收。例如,当新生代内存空间不足时,会触发新生代的垃圾回收(Minor GC);当老年代内存空间不足时,会触发老年代的垃圾回收(Major GC 或 Full GC)。在这种情况下,对象的存活时间取决于垃圾回收时对象是否仍然可达。如果在垃圾回收前对象仍然有引用指向,它将继续存活;否则,它将被回收。例如:

public class AutoGCExample {
    public static void main(String[] args) {
        MyClass[] objects = new MyClass[100000];
        for (int i = 0; i < 100000; i++) {
            objects[i] = new MyClass(i);
        }
        // 随着对象不断创建,可能导致堆内存不足,触发垃圾回收
        // 垃圾回收时,不可达的 MyClass 对象将被回收,存活对象继续存活
    }
}

分析Java对象存活时间的工具与实践

1. 分析工具

1.1 VisualVM

VisualVM是一款免费的、集成了多个JDK命令行工具的可视化工具,它可以用来监控、分析Java应用程序。通过VisualVM,我们可以查看堆内存的使用情况、对象的数量和大小等信息,从而帮助我们分析对象的存活时间。例如,我们可以通过VisualVM的“监视”标签页查看堆内存的实时变化,通过“对象”标签页查看特定类的对象实例数量。如果某个对象的数量持续增加且没有减少的趋势,可能意味着存在对象存活时间过长的问题。

1.2 YourKit Java Profiler

YourKit Java Profiler是一款功能强大的Java性能分析工具。它可以详细地分析对象的创建、引用关系和存活时间。通过其内存分析功能,我们可以追踪对象的生命周期,找到长时间存活的对象及其引用链。例如,它可以展示哪些对象持有对某个对象的强引用,帮助我们判断对象存活的原因。如果发现一个对象存活时间过长且不应该存活,我们可以通过分析引用链找到问题所在,比如是否存在不必要的静态引用导致对象无法被回收。

2. 优化对象存活时间的实践

2.1 及时释放引用

在对象不再使用时,及时将引用设置为 null,这样可以让垃圾回收器更早地回收对象。例如:

public class ReleaseReferenceExample {
    public static void main(String[] args) {
        MyClass obj = new MyClass(160);
        // 使用 obj
        obj = null; // 及时释放引用,让对象可以被垃圾回收
    }
}

2.2 避免不必要的对象创建

尽量复用对象,而不是频繁创建新对象。例如,在一个循环中,如果每次都创建一个新的对象,会增加内存压力并且可能导致对象存活时间过长。我们可以将对象的创建移到循环外部,然后在循环中复用该对象。例如:

public class ReuseObjectExample {
    public static void main(String[] args) {
        MyClass obj;
        for (int i = 0; i < 1000; i++) {
            if (i == 0) {
                obj = new MyClass(i);
            } else {
                // 复用 obj,更新其状态
                obj.data = i;
            }
            // 使用 obj
        }
    }
}

2.3 合理使用引用类型

根据对象的实际使用场景,选择合适的引用类型。如果对象是缓存数据,且内存紧张时可以被回收,那么可以使用软引用;如果对象只是在某些临时场景下使用,且可以随时被回收,那么可以使用弱引用。例如,在实现一个图片缓存功能时,如果内存不足时可以丢弃图片缓存,就可以使用软引用来存储图片对象。

import java.lang.ref.SoftReference;
import java.awt.image.BufferedImage;

public class SoftReferenceForCacheExample {
    private SoftReference<BufferedImage> imageRef;

    public BufferedImage getImage() {
        if (imageRef != null) {
            return imageRef.get();
        }
        // 如果对象已被回收,重新加载图片
        BufferedImage image = loadImage();
        imageRef = new SoftReference<>(image);
        return image;
    }

    private BufferedImage loadImage() {
        // 实际的图片加载逻辑
        return null;
    }
}