Java 内存模型

Java 的内存模型结构

运行时常量池

JVM 在类加载完成后,会把字节码文件中的常量池信息加载到运行时常量池(Runtime Constant Pool)中。每个类或接口在方法区(Java 8 之前叫 PermGen,之后在 Metaspace)都有一个运行时常量池。

栈帧中的内容

每个线程都有一个独立的虚拟机栈,而每一次方法调用,都会在这个栈中创建一个栈帧(Stack Frame)。当方法执行完毕,对应的栈帧就会被弹出。一个栈帧大致可以分为以下几个部分:

  • 局部变量表:存储方法的参数和方法体内部定义的局部变量

  • 操作数栈:字节码指令的计算中转区,一个后进先出的栈,用于方法执行时的中间计算

  • 动态链接:每个栈帧都有一个指向该类的运行时常量池的指针,用于支持符号引用 → 直接引用的转换;

  • 方法返回地址:方法执行结束后,程序计数器(PC)需要知道返回到哪条指令继续执行。

类的加载流程

一个例子

先看一段代码:class B 继承自 class A,两个类内部都有静态代码块实例代码块构造方法

public class A {
    static {
        System.out.println("static A");
    }

    {
        System.out.println("inner A");
    }

    public A() {
        System.out.println("cons A");
    }
}

public class B extends A {
    static {
        System.out.println("static B");
    }

    {
        System.out.println("inner B");
    }

    public B() {
        System.out.println("cons B");
    }
}

此时,通过 A 去创建一个 B 实例,输出结果是什么?

public static void main(String[] args) {
    A a = new B();
}

输出结果如下:

static A
static B
inner A
cons A
inner B
cons B

为什么会是这个顺序呢?这其实就是 Java 类加载和对象初始化的完整流程

A a = new B 做了什么?

这行代码包含两部分:

  • 右边的 new B() 表示创建对象,在堆上创建了一个 B 类型的对象实例;

  • 左边的 A a 只是一个引用变量,表示声明类型是 A,也就是说编译器认为它只能访问 A 中定义的方法和字段。这就是“父类引用指向子类对象”,也叫向上转型,是 Java 多态的核心。

所以,真正决定执行顺序的关键点在于 右侧的 new B()

从类加载到对象实例化

Java 从“类第一次被用到”到“对象真正创建”大致分为四步:

  1. 加载(Loading):

    • JVM 把 .class 字节码读入内存,生成 Class 对象,交给对应的类加载器(ClassLoader)管理;

    • 常见的加载器层级:Bootstrap → Platform(JDK9+)/Ext(JDK8) → App(Application)→ 自定义。

  2. 链接(Linking)

    • 验证(Verify):字节码合法性检查(结构、类型安全等);

    • 准备(Prepare):为静态字段分配内存并设默认值(零值),static final 的编译期常量会在此阶段赋值;

    • 解析(Resolve):把常量池中的符号引用转为直接引用(可能延迟到运行期才做);

  3. 初始化(Initialization):执行类或接口的 <clinit>()

    • 执行 <clinit>() 方法(由静态变量的显式赋值 + 静态代码块 static {} 按源码顺序拼接而成);

    • <clinit>() 只在类首次主动使用时执行一次,并且线程安全;

    • 先调用父类的 <clinit>() ,再调用子类的 <clinit>()

  4. 实例化(Instantiation):再执行构造方法 <init>() 之前,会依次执行以下步骤:

    • 父类实例变量赋值 + 父类实例代码块 {}

    • 父类构造方法

    • 子类实例变量赋值 + 子类实例代码块 {}

    • 子类构造方法

初始化的调用时机

仅仅“加载”类并不会执行 <clinit>;只有“初始化”才会执行。以下场景会触发类的主动使用,从而执行初始化:

  • 创建对象 new A()

  • 调用静态方法 invokestatic

  • 访问/修改 非编译期常量(static final)的静态字段 getstatic/putstatic

  • 反射调用:Class.forName()

  • 初始化主类(即 main 方法所在的类);

  • 使用 MethodHandle / VarHandle 等动态调用机制

注意:访问 编译期常量(例如 static final int X = 1;)不会触发初始化,因为编译器可能直接把值内联到字节码里。

双亲委派模型

Java 中的类加载器结构如下:

  • 启动类加载器(Bootstrap ClassLoader):最顶层的类加载器,负责加载 Java 的核心库,它是 C++ 编写的,是 JVM 的一部分。在 Java 程序中无法获得启动类加载器(null);

  • 扩展类加载器(Extension ClassLoader):Java 语言实现,继承 ClassLoader 类,负责加载拓展目录下的 jar 包和类库;

  • 应用程序类加载器(Application ClassLoader):Java 语言实现,负责加载用户 classpath 上指定的类库,是我们平时 Java 程序默认使用的类加载器;

  • 用户自定义类加载器

当一个类加载器收到类加载的请求时,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。

  • 保证类的唯一性:避免不同类加载器重复加载相同类

  • 保证安全性:防止用户自定义类覆盖核心库,保证了 Java 标准库的唯一性和稳定性;

  • 简化加载流程、具有层次划分

应用场景:Android 热修复,线上 bug 修复,避免重新发版。核心逻辑是:使用新的 ClassLoader 加载新的类版本,替换旧的业务逻辑。

HashMap

HashMap 的原理

JDK 1.8 HashMap 采用数组 + 链表 + 红黑树的数据结构。当链表长度大于等于阈值时,会将链表转为红黑树,以减少搜索时间。插入时如果存放的数据大于容量的 75%,就进行扩容操作。

扩容机制

扩容后,n 变为 2 倍。因此元素在计算 hash 时,n-1 的 mask 范围在高位多了 1bit。所以:

  • 元素的位置要么是在原位置(多的一位为 0)

  • 要么是在原位置再移动 oldCap 的位置(多的一位是 1)

这个设计确实非常的巧妙,既省去了重新计算 hash 值的时间,又可以均匀的把之前的冲突的节点分散到新的桶中。

重写 hashCode 和 equals 方法

Java 规定了对象相等性与哈希值的契约:

  • a.equals(b) == true,则 必须a.hashCode() == b.hashCode()

  • a.equals(b) == false,两者 可以 有相同的 hashCode()(哈希碰撞允许)。

而诸如 HashMap/HashSet/HashTable 的工作流程是:

  1. 先用 hashCode() 定位桶(bucket);

  2. 在该桶内再用 equals() 做精确匹配。

所以如果你只重写了 equals() 而没有重写 hashCode(),相等的两个对象可能落在不同桶里,集合就找不到它们是同一个元素,出现查找失败重复插入等问题。

集合先哈希后判等;既然你定义了“怎么相等”,就必须保证“相等者同哈希”。因此重写 equals() 必须重写 hashCode();实践中两者总是成对出现。

多线程环境下的 HashMap

  • 数据丢失:多个线程同时 put,可能覆盖彼此的数据。

  • 读取不一致:线程 A 在 put,线程 B 在 get,可能读到旧数据或 null

  • 扩容死循环:HashMap 扩容时会触发 rehash:把链表上的节点重新分配到新的数组。JDK 1.7 时,在多线程下,多个线程同时扩容,可能把链表迁移成环形链表。调用 get 时在环形链表里无限遍历。

Hashtable

Hashtable 是 Java 1.0 中就存在的遗留类。虽然它仍然可用,但在现代 Java 开发中通常不推荐直接使用。因为:

  • 它的所有公共方法都被 synchronized 关键字修饰,带来性能开销;

  • 不允许 null 键和 null 值。


Java 线程

Java 线程的状态

  • NEW:Thread 被 new 创建,但是还没有调用 start 方法;

  • RUNNING:调用 start() 方法,线程开始执行,只有此阶段才会获得 CPU 资源;

  • BLOCKED:遇到 synchronized 关键字,等待获得锁,线程进入阻塞状态;

  • WAITING:Object.wait()、Object.join() 线程进入等待状态;

  • TIME_WAITING:调用 Thread.sleep(long) Object.wait(long) 后线程进入超时等待状态,被提前唤醒或超时时间结束,线程重新进入 RUNNING 状态;

  • TERMINATED:线程完成执行,进入终止状态。

Java 创建线程的方式

一、直接 new Thread

二、

垃圾回收机制

GC 毛刺问题

GC 回收毛刺问题就是由于垃圾回收的 Stop-The-World 暂停引发的系统短暂停顿,在对实时性敏感的场景下表现为 UI 卡顿、响应延迟尖刺,需要通过 GC 算法选择 + 内存调优 + 减少对象分配 来缓解。

Java 四种引用

弱引用的使用场景

  • 避免内存泄漏

    • Handler/Callback 持有 Activity:使用 static + WeakReference<Activity>,避免消息队列导致 Activity 泄漏。

    • 监听器/回调集合:用弱引用保存 listener,外部不再强持有即自动回收。

  • 临时性缓存

    • WeakHashMap:键仅被弱引用持有,键无强引用时条目自动清除;适合“可有可无”的派生数据/元数据缓存。