Administrator
Published on 2025-12-01 / 1 Visits
0
0

Java 深入理解

引用变量和对象实例

示例

private static ThreadLocal<Object> threadLocal = new ThreadLocal<>();

threadLocal 是一个引用变量(Reference Variable),它存的是一个内存地址。

new ThreadLocal<>() 是一个对象实例(Object Instance),它是在内存中真实存在的实体。

threadLocal 这个引用变量,指向了 new ThreadLocal<>() 这个对象实例在内存中的地址。

  1. new ThreadLocal<>():

JVM 在堆内存中开辟了一块空间,创建了一个 ThreadLocal 类型的对象实例。

这个对象有它自己的状态(比如它内部的数据结构)。

  1. threadLocal:

JVM 在元空间里为这个类分配了一个位置,创建了一个名为 threadLocal 的引用变量。

这个引用变量本身只占很小的一点空间,专门用来存放一个地址。

  1. =:

JVM 将第一步创建的对象实例的内存地址,赋值给了第二步创建的引用变量 threadLocal。

从此以后,我们通过 threadLocal 这个名字,就能找到并操作那个在堆里的 ThreadLocal 对象。

当我们讨论内存泄漏时,我们担心的不是引用变量本身,而是对象实例是否被回收。

threadLocal = null时,对象实例和引用变量时怎么被回收的?

threadLocal 这个引用变量(在元空间中)的值被修改为 null。它不再指向任何对象了。

  1. 触发 GC:当下一次垃圾回收(特别是 Young GC 或 Full GC)发生时,GC 会从 GC Roots 开始进行可达性分析。

  2. 分析结果:ThreadLocal 对象实例现在不可达了,没有任何路径可以从 GC Roots 访问到它。

  3. 回收对象:GC 会判定这个 ThreadLocal 对象为垃圾,并将其回收,释放它占用的堆内存。

在这种情况下,对象实例被回收了,而引用变量 threadLocal 本身依然存在(因为它在元空间里,生命周期和类一样长,没有回收的概念,一直存在),只是它的值变成了 null。

引用变量本身占用的内存非常小,几乎可以忽略不计。即使有“很多”引用变量,它们也不会导致内存不足。

在 64位 JVM 中,如果开启了压缩普通对象指针(默认开启,-XX:+UseCompressedOops),一个引用变量占用 4 字节。

如果没有开启压缩,则占用 8 字节。

我们以最常见的 4 字节来计算:

1,000,000(一百万)个引用变量,占用的内存是:

1,000,000 * 4 bytes = 4,000,000 bytes ≈ 4 MB

是的,一百万个引用变量才占用大约 4MB 内存。对于一个动辄分配几 GB(几千 MB)堆内存的 Java 应用来说,这几乎是九牛一毛。

集合类

List<Object> list = new ArrayList<>();

Object obj = new Object();

list.add(obj);

obj 的引用变量是?

第一步:创建对象和引用

Object obj = new Object();

new Object():在堆内存中创建了一个 Object 的对象实例。假设它的地址是 0x1234。

Object obj:在虚拟机栈中创建了一个名为 obj 的引用变量。

=:将对象实例的地址 0x1234 赋值给引用变量 obj。

此时内存状态:

[栈] obj (引用变量) ---> [堆] Object 实例 @ 0x1234

第二步:将引用传递给 List

list.add(obj);

这行代码做了什么?它把 obj 中存储的引用值(地址 0x1234)复制了一份,然后交给了 ArrayList。

ArrayList 内部有一个数组,它会把 0x1234 这个地址存到自己的数组元素里。

此时内存状态:

[栈] obj (引用变量) ---+

|

+---> [堆] Object 实例 @ 0x1234

|

[堆] ArrayList 内部数组 ----+

内存角度举例分析

private static ThreadLocal<Object> threadLocal = new ThreadLocal<>();

假设我们的 JVM 运行在一个 64 位系统上,并且开启了压缩指针(UseCompressedOops),所以一个地址占用 4 字节。

第 1 步:加载类与分配静态引用

当 JVM 第一次准备使用这个类时:

加载类信息:类加载器读取 .class 文件,将 ThreadLocal 类的元数据信息(方法、字段定义等)加载到元空间。

分配静态引用:JVM 在元空间中为这个类分配空间,其中就包括我们的静态引用变量 threadLocal。

此时内存状态:

[元空间]
+---------------------------------------------------+
| ThreadLocal类的元数据...                           |
|                                                   |
| threadLocal (引用变量)                             |
|   - 当前值: null (因为还没赋值,里面是空的)          |
|                                                   |
+---------------------------------------------------+

第 2 步:创建对象实例

接下来,JVM 执行 new ThreadLocal<>():

分配内存:JVM 在堆内存的新生代 Eden 区,寻找一块连续的、未被使用的空间。

假设分配成功:JVM 找到了一块从地址 0x1000 开始的内存空间,大小足够容纳一个 ThreadLocal 对象(假设是 32 字节)。

初始化对象:JVM 在这块内存上初始化 ThreadLocal 对象的各个字段,执行构造函数。

此时内存状态:

[元空间]
+---------------------------------------------------+
| threadLocal (引用变量)                            |
|   - 当前值: null                                  |
+---------------------------------------------------+

[堆内存 - Eden区]
+---------------------------------------------------+
| ... 其他对象 ...                                   |
|                                                   |
| 0x1000: [ ThreadLocal 对象实例 ]                   |
|   - field1 = ...                                  |
|   - field2 = ...                                  |
|   - ...                                           |
|                                                   |
+---------------------------------------------------+

现在,一个真实的 ThreadLocal 对象已经在堆里“安家落户”了,它的门牌号是 0x1000。

第 3 步:建立指向关系(赋值)

最后,JVM 执行 = 操作:

获取地址:JVM 知道刚刚创建的对象实例在地址 0x1000。

写入地址:JVM 将这个地址值 0x1000,写入到元空间里的 threadLocal 引用变量中。

最终内存状态:

[元空间]
+---------------------------------------------------+
| threadLocal (引用变量)                            |
|   - 当前值: 0x1000  <--- 指向关系建立!            |
+---------------------------------------------------+
          |
          | (引用/地址)
          |
          v
[堆内存 - Eden区]
+---------------------------------------------------+
| ... 其他对象 ...                                   |
|                                                   |
| 0x1000: [ ThreadLocal 对象实例 ]                   |
|   - field1 = ...                                  |
|   - field2 = ...                                  |
|   - ...                                           |
|                                                   |
+---------------------------------------------------+

指向完成! 现在,threadLocal 这个引用变量就成功地指向了堆内存中的 ThreadLocal 对象实例。

另外一个例子:

public void process() {

Object obj = new Object(); // A

List<Object> list = new ArrayList<>(); // B

list.add(obj); // C

}

存变化过程:

执行 A 行:

栈:在 process 方法的栈帧中创建局部变量 obj。

堆:创建一个 Object 实例,假设地址为 0x2000。

指向:将地址 0x2000 写入栈中的 obj 变量。

    [栈] obj (值: 0x2000) ---> [堆] Object实例 @ 0x2000
    

执行 B 行:

栈:创建局部变量 list。

堆:创建一个 ArrayList 实例,假设地址为 0x3000。

指向:将地址 0x3000 写入栈中的 list 变量。

    [栈] list (值: 0x3000) ---> [堆] ArrayList实例 @ 0x3000
    

执行 C 行 (list.add(obj)):

读取:JVM 从栈中读取 obj 的值,即地址 0x2000。

复制:将这个地址值 0x2000 复制一份。

写入:将这个复制的地址值写入到堆中 ArrayList 实例的内部数组里。

最终内存状态:

+-------------------------+      +-----------------------+
|      虚拟机栈           |      |          堆            |
|-------------------------|      |-----------------------|
| process() 栈帧:         |      | ArrayList实例 @ 0x3000 |
|   - obj (值: 0x2000) ---+----> |   - 内部数组[0] (值: 0x2000) ---+
|   - list (值: 0x3000) --+----> |                               |
+-------------------------+      |                               |
                                 |                               |
                                 +-----> Object实例 @ 0x2000 <----+

关键点:地址 0x2000 现在存在于两个地方:

栈中的 obj 变量。

堆中 ArrayList 的内部数组。

这就是为什么即使 obj 变量离开作用域(被销毁),只要 list 还在,那个 Object 实例就不会被回收的原因。

private static ThreadLocal<Object> threadLocal = new ThreadLocal<>();
为什么threadLocal 引用在元空间?

public void process() {
    Object obj = new Object();      // A
    List<Object> list = new ArrayList<>(); // B
    list.add(obj);                  // C
}
为什么obj 引用在栈?

Java 内存设计中一个最核心的原则:变量的生命周期决定了它的存放位置。

📍 问题一:为什么 threadLocal 引用在元空间?

核心答案:因为 threadLocal 是 static(静态)的。

static 关键字意味着这个变量不属于任何单个对象实例,而是属于类本身。

生命周期与类绑定:threadLocal 的生命周期从类被加载开始,到类被卸载结束。对于一个普通的 Java 应用,这个生命周期几乎等同于整个应用程序的生命周期,非常长。

存放位置:既然 threadLocal 是类的一部分,那么存放它的地方自然就是存放类信息的地方——元空间。元空间专门用来存放类的元数据(方法、字段定义等)和静态变量。

为什么不在栈里?

栈是线程私有的。每个线程都有自己的栈。static 变量是所有线程共享的,如果放在某个线程的栈里,其他线程就无法访问了,这违背了 static 的设计初衷。

为什么不在堆里?

虽然堆是所有线程共享的,但堆的主要职责是存放对象实例。把引用变量和对象实例混在一起管理,会让内存模型变得混乱。将类的“蓝图”(元空间)和“蓝图上的静态标记”(静态引用)放在一起,逻辑上更清晰。

一个比喻:

类:是一份建筑设计蓝图。

元空间:是存放所有蓝图的中央档案室。

static 变量:是蓝图上一个用红笔标出的永久性标记(比如“此楼总配电箱位置在此”)。

对象实例:是根据蓝图盖起来的一栋栋具体的楼,它们都在城市开发区(堆)里。

这个“永久性标记”自然要和蓝图一起存放在中央档案室(元空间),而不是放在某栋楼里(堆)或某个工人的口袋里(栈)。

🧪 问题二:为什么 obj 引用在栈?

核心答案:因为 obj 是一个局部变量,它的生命周期非常短暂。

生命周期与方法绑定:obj 是在 process() 方法内部定义的。它的生命周期从 process() 方法被调用开始,到 process() 方法执行完毕结束。当方法返回时,obj 这个引用变量就会被销毁。

存放位置:这种生命周期短、与线程执行紧密相关的变量,最适合存放在虚拟机栈里。每当一个方法被调用,JVM 就会为该线程创建一个栈帧,这个栈帧专门用来存放这个方法的局部变量(obj, list)、操作数等。

为什么不在元空间里?

元空间是为类的“长生命周期”成员准备的。把一个方法内部的临时变量放进去,就像在建筑蓝图上用铅笔写“张三今天来过工地”,用完还要擦掉,既没必要也浪费空间。

为什么不在堆里?

虽然 obj 指向的对象在堆里,但 obj 这个“遥控器”本身是临时的。如果把它也放在堆里,会增加 GC 的负担。GC 需要额外去追踪这个临时引用,而把它放在栈里,方法一结束,栈帧弹出,引用就自动消失了,非常高效,GC 根本不用管它。

内存

  • Java 堆内存(Heap Memory)是 Java 虚拟机(JVM)管理的内存中最重要的部分之一,专门用来存放对象实例和数组。用 new 关键字创建的东西,都存放在这里。

  • 堆内存是整个 JVM 实例中所有线程共享的区域,无论你创建多少个线程,它们访问的都是同一个堆。

  • Java 的自动内存管理,主要就是通过垃圾回收机制(GC)来管理堆内存。GC 会自动回收那些不再被引用的对象,释放空间。

  • 堆的大小可以在 JVM 启动时通过参数设置:

    -Xms    # 初始堆大小
    -Xmx    # 最大堆大小
    java -Xms512m -Xmx2g YourApp

堆的区域划分

典型结构是使用“分代模型”(Generational Model):

  1. 新生代
    存放新创建的对象。

  2. 老年代
    存放长期存活的对象。

  3. 元空间(JDK 8 之前叫永久代)
    存放类的元数据信息、常量池、静态变量等。

GC和内存泄漏

概述

内存泄漏的本质,是某些不再需要的对象,却仍然被“无意地”持有引用,导致 GC 无法回收它们。

当一个本该被快速回收的对象(比如一个临时对象),被一个生命周期很长的对象(比如静态变量、长期存活的线程)引用时,这个临时对象就无法被回收了。随着时间推移,这类“僵尸对象”越积越多,最终耗尽内存。

泄漏场景

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

//引用变量list指向的对象实例new ArrayList<>()一直在变大,没有移除等操作,导致内存泄漏

public class StaticLeak {
    // 这个 List 是静态的,生命周期与 JVM 相同
    private static final List<Object> list = new ArrayList<>();

    // 每次调用这个方法,都会向 list 中添加一个对象,但从不移除
    public void addObject(Object obj) {
        list.add(obj); // obj 被静态变量持有,无法被回收
    }

    public static void main(String[] args) {
        StaticLeak leak = new StaticLeak();
        for (int i = 0; i < 100000; i++) {
            leak.addObject(new Object()); // 这些 Object 对象永远不会被 GC
        }
    }
}
import java.io.FileInputStream;
import java.io.IOException;

//资源没释放,导致泄漏

public class ResourceLeak {
    public void readFile() {
        try {
            FileInputStream fis = new FileInputStream("somefile.txt");
            byte[] buffer = new byte[1024];
            fis.read(buffer);
        } catch (IOException e) {
            e.printStackTrace();
        }
        // fis.close() 缺失,导致文件句柄泄漏
    }
}

GC回收机制能处理的情况

//引用变量显式置为 null

public void method() {
    Object obj = new Object(); // 1. obj 是 GC Root,对象可达
    System.out.println(obj.hashCode());

    obj = null; // 2. 切断引用,对象不再被任何 GC Root 引用
    // 3. 在下一次 GC 时,这个 Object 对象就会被回收
}
//引用变量超出作用域(自动发生)

public void method() {
    if (someCondition) {
        Object localObject = new Object(); // localObject 是局部变量,是 GC Root
        // ... 使用 localObject
    } // <--- localObject 的作用域在这里结束

    // 从这里开始,localObject 这个引用变量已经不存在了。
    // 因此,它指向的 Object 实例就变得不可达,可以被 GC 回收。
    // JVM 会自动处理这个过程,你不需要手动写 localObject = null;
}
//引用变量被重新赋值

public void method() {
    Object obj1 = new Object(); // 对象 A 被创建
    Object obj2 = new Object(); // 对象 B 被创建

    obj1 = obj2; // obj1 现在指向了对象 B

    // 此时,对象 A 不再被任何活跃的引用指向,变得不可达,可以被回收。
    // 注意:这里并没有 obj1 = null;
}
//集合类中的对象被移除

List<Object> list = new ArrayList<>();
Object obj = new Object();
list.add(obj); // 对象被 list 引用,可达

// ... 一段时间后 ...

list.remove(obj); // 对象从集合中被移除,如果此时没有其他地方引用它,它就变得不可达

// 或者,整个集合被销毁
list = null; // 集合本身不可达,那么它里面的所有元素自然也变得不可达


Comment