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 从“类第一次被用到”到“对象真正创建”大致分为四步:
加载(Loading):
JVM 把
.class
字节码读入内存,生成Class
对象,交给对应的类加载器(ClassLoader)管理;常见的加载器层级:Bootstrap → Platform(JDK9+)/Ext(JDK8) → App(Application)→ 自定义。
链接(Linking)
验证(Verify):字节码合法性检查(结构、类型安全等);
准备(Prepare):为静态字段分配内存并设默认值(零值),
static final
的编译期常量会在此阶段赋值;解析(Resolve):把常量池中的符号引用转为直接引用(可能延迟到运行期才做);
初始化(Initialization):执行类或接口的
<clinit>()
执行
<clinit>()
方法(由静态变量的显式赋值 + 静态代码块static {}
按源码顺序拼接而成);<clinit>()
只在类首次主动使用时执行一次,并且线程安全;先调用父类的
<clinit>()
,再调用子类的<clinit>()
;
实例化(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
的工作流程是:
先用
hashCode()
定位桶(bucket);在该桶内再用
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:键仅被弱引用持有,键无强引用时条目自动清除;适合“可有可无”的派生数据/元数据缓存。