Java 是一个跨平台语言,编写的程序可以不经修改地运行在不同的操作系统上。这得益于 Java 的运行机制:.java
源文件会被编译器编译成 .class
字节码文件,然后这些字节码文件运行在 Java 虚拟机(JVM)之上。各个平台都有其特定版本的 JVM 实现,虽然这些 JVM 底层实现不同,但它们都能遵循《Java虚拟机规范》来执行相同的 .class
文件,并产生一致的结果。
JVM 可以看作是《Java虚拟机规范》的具体产品实现。不同的厂商(如 Oracle, Azul, AdoptOpenJDK 等)会根据自己的需求和优势开发不同的 JVM 产品,但所有这些产品都必须符合《Java虚拟机规范》定义的标准。
JVM 的运行流程
.java
(文本文件) ---编译---> .class
(字节码文件)
JVM 上直接运行的是编译后的字节码文件。其大致运行流程和内存结构如下:
- 加载 .class 文件 :类加载器将
.class
文件从磁盘或其他来源加载到内存中。 - 方法区 (Method Area) :类的元信息(包括类结构、常量池、静态变量、即时编译器编译后的代码等)加载后存储在此区域。这里存放的类信息(
Class
对象)可以看作是创建对象的模板。 (注:在Java 8及以后版本,方法区的实现变为Metaspace,使用本地内存) - 堆 (Heap) :通过
new
关键字创建的对象实例几乎都存放在堆内存中。堆是JVM管理内存中最大的一块,也是垃圾回收的主要区域。 - Java 虚拟机栈 (JVM Stack):每个线程在创建时都会获得一个私有的虚拟机栈。每当一个方法被调用,就会创建一个栈帧(Stack Frame)压入栈中,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法调用结束,栈帧出栈。栈的生命周期与线程相同。
- 本地方法栈 (Native Method Stack):与虚拟机栈类似,但它是为执行本地(Native)方法服务的。
- 程序计数器 (Program Counter Register):每个线程都有一个独立的程序计数器。它记录了当前线程正在执行的字节码指令的地址(行号)。这是线程切换后能恢复到正确执行位置的基础。
类加载过程
JVM 将类加载过程主要分为三个阶段:加载(Loading)、连接(Linking)、初始化(Initialization)。
加载 (Loading)
- 通过类的全限定名找到对应的
.class
文件。 - 将
.class
文件中的二进制字节流读入内存。 - 将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
连接 (Linking)
连接阶段又细分为三个步骤:
- 验证 (Verification):确保加载的类信息符合《Java虚拟机规范》,没有安全隐患。例如,文件格式验证、元数据验证、字节码验证、符号引用验证。
- 准备 (Preparation) :为类的静态变量 分配内存,并设置其默认初始值 (例如,
int
为 0,boolean
为false
, 引用类型为null
)。注意,此时并不会执行 Java 代码中显式设置的初始值。 - 解析 (Resolution) :将常量池内的符号引用 替换为直接引用的过程。符号引用是以一组符号来描述所引用的目标,而直接引用是直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。
初始化 (Initialization)
- 这是类加载过程的最后一步。
- 在此阶段,JVM 才真正开始执行类中定义的 Java 程序代码(特别是类的静态初始化器
<clinit>()
方法)。 <clinit>()
方法是由编译器自动收集类中的所有静态变量的赋值动作 和**静态语句块 (static {}
块)**中的语句合并产生的。- JVM 保证一个类的
<clinit>()
方法在多线程环境中被正确地加锁、同步。
双亲委派模型 (Parents Delegation Model)
核心思想 :当一个类加载器收到加载类的请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每一层的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器(Bootstrap ClassLoader)中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
主要的类加载器:
- 启动类加载器 (Bootstrap ClassLoader) :负责加载 Java 核心库(如
JAVA_HOME/lib
目录下的,或者被-Xbootclasspath
参数所指定的路径中的类)。通常由 C++ 实现,是虚拟机自身的一部分。 - 扩展类加载器 (Extension ClassLoader) :负责加载
JAVA_HOME/lib/ext
目录下的,或者被java.ext.dirs
系统变量所指定的路径中的所有类库。 - 应用程序类加载器 (Application ClassLoader):也称为系统类加载器。它负责加载用户类路径(Classpath)上所指定的类库。开发者可以直接使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型的意义:
- 避免类的重复加载:父加载器加载过的类,子加载器不会再次加载。
- 保证安全性 :防止核心 API 库被随意篡改。例如,用户无法编写一个自定义的
java.lang.String
类来替代系统自带的类。
双亲委派模型的破坏:
虽然双亲委派模型是 Java 推荐的类加载机制,但在某些场景下,它会被"破坏"或绕过。一个典型的例子是 SPI (Service Provider Interface) 机制。
例如,Java 核心库定义了 JDBC
接口(如 java.sql.Driver
),这些接口由启动类加载器或扩展类加载器加载。但是,JDBC
的具体实现类(如 MySQL、Oracle 的驱动 Driver
实现)通常位于应用程序的 Classpath 下,需要由应用程序类加载器来加载。
为了让核心库(父加载器加载的类)能够调用到由子加载器加载的实现类,Java 引入了线程上下文类加载器 (Thread Context ClassLoader, TCCL)。父类加载器可以通过 TCCL 请求子类加载器去加载类,这在某种程度上"逆向"或"绕过"了标准的双亲委派流程。
垃圾回收 (Garbage Collection, GC)
GC 主要负责回收不再使用的对象所占用的内存空间,重点是堆内存,因为栈内存会随着线程的结束而自动销毁。
死亡对象的判断算法
-
引用计数算法 (Reference Counting)
- 原理:给每个对象设置一个引用计数器。当有一个地方引用它时,计数器加 1;当引用失效时,计数器减 1。任何时刻计数器为 0 的对象就是不可能再被使用的。
- 优点:实现简单,判断效率高。
- 缺点 :
- 难以解决循环引用问题:如果两个或多个对象相互引用,即使它们都再没有被外部引用,它们的计数器也不会变为 0,导致无法回收,造成内存泄漏。
- 维护计数器开销:在多线程环境下,对计数器的增减操作需要保证原子性,这会带来额外的性能开销。
- JVM 是否使用 :现代 JVM 不使用引用计数算法来判断对象存活。
-
可达性分析算法 (Reachability Analysis)
- 原理 :这是 JVM 主流使用的判断对象是否存活的方法。算法从一系列称为 "GC Roots" 的根对象开始,沿着引用链向下搜索、遍历。如果一个对象能够从任何一个 GC Root 通过引用链到达,则称该对象是可达的 (Reachable) ,即存活对象。如果一个对象不能从任何 GC Root 到达,则判定为不可达 (Unreachable),即死亡对象。
- GC Roots 通常包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 本地方法栈中 JNI (Native 方法) 引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 被同步锁 (
synchronized
关键字) 持有的对象。
- 缺点 :进行可达性分析时,通常需要暂停用户的所有执行线程(称为 "Stop-The-World", STW),以确保分析期间引用关系不会发生变化。STW 的时间长短是影响 GC 性能的关键因素。
垃圾回收算法
标记出需要回收的对象后,下一步就是执行回收。常见的回收算法有:
-
标记-清除算法 (Mark-Sweep)
- 过程 :
- 标记 (Mark):通过可达性分析,标记出所有存活的对象。
- 清除 (Sweep):遍历整个堆,回收所有未被标记的对象(即死亡对象)所占用的内存。
- 优点:是最基础的收集算法,实现相对简单。
- 缺点 :
- 效率问题:标记和清除两个阶段的效率都不算高。
- 空间问题 :清除后会产生大量不连续的内存碎片。碎片过多可能导致后续需要分配较大对象时,无法找到足够的连续内存而不得不提前触发下一次 GC。
- 过程 :
-
复制算法 (Copying)
- 目的 :主要解决标记-清除算法的效率和碎片问题,特别适用于对象存活率较低的场景(如新生代)。
- 过程 :
- 将可用内存按容量划分为大小相等的两块,例如 A 区和 B 区,每次只使用其中一块(比如 A 区)。
- 当 A 区内存用完需要进行 GC 时,将 A 区中仍然存活的对象复制到 B 区。
- 一次性清空整个 A 区的内存。
- 下次内存分配就在 B 区进行,当 B 区用完时,再将存活对象复制到 A 区,清空 B 区。如此循环。
- 优点 :
- 实现简单,运行高效(只需复制存活对象,然后清空整个区域)。
- 解决了内存碎片问题,分配内存时只需移动指针即可。
- 缺点 :
- 空间利用率低:实际可用的内存只有总空间的一半。
-
标记-整理算法 (Mark-Compact)
- 目的 :结合了标记-清除和复制算法的优点,适用于对象存活率较高的场景(如老年代)。
- 过程 :
- 标记 (Mark):与标记-清除算法一样,先标记出所有存活对象。
- 整理 (Compact) :不是直接清理未标记对象,而是将所有存活的对象向内存空间的一端移动。
- 清除:清理掉边界以外的内存区域。
- 优点 :
- 解决了内存碎片问题。
- 相比复制算法,空间利用率更高。
- 缺点 :
- 标记和移动对象的成本较高,尤其是在存活对象很多的情况下,效率可能低于复制算法。
分代收集 (Generational Collection)
现代 JVM 的垃圾回收大多采用分代收集策略,它基于一个经验法则:绝大多数对象都是"朝生夕死"的。
- JVM 将堆内存划分为 新生代 (Young Generation) 和 老年代 (Old Generation) 。
- 默认比例大约是 新生代 : 老年代 = 1 : 2。
- 新生代 :
- 通常又细分为一个 Eden 区 和两个 Survivor 区(称为 From 区 / S0 和 To 区 / S1)。Eden : S0 : S1 的默认比例通常是 8 : 1 : 1。
- 对象分配:新创建的对象绝大多数在 Eden 区分配。
- GC 过程 (Minor GC / Young GC) :
- 当 Eden 区满时,触发 Minor GC。
- 使用复制算法进行回收。
- 将 Eden 区和 From Survivor 区中存活的对象复制到 To Survivor 区。
- 清空 Eden 区和 From Survivor 区。
- 交换 From 和 To 区的角色:原来的 To 区变成下一次 GC 时的 From 区,原来的 From 区(现在是空的)变成下一次 GC 时的 To 区。
- 对象晋升:每次 Minor GC 后存活下来的对象,其年龄会增加 1。当对象的年龄达到一定阈值(默认为 15)时,会被晋升到老年代。
- 动态年龄判断:如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象会直接进入老年代。
- 大对象直接进入老年代:需要大量连续内存空间的 Java 对象(如长字符串、大数组)可能会直接在老年代分配,以避免在 Eden 区和 Survivor 区之间进行大量内存复制。
- 老年代 :
- 存放生命周期较长的对象,以及从新生代晋升过来的对象。
- 老年代的对象存活率通常较高。
- 当老年代空间不足时,会触发 Major GC / Full GC。
- 老年代 GC 通常采用标记-清除 或标记-整理算法。Full GC 通常会比 Minor GC 慢很多,并且可能同时回收新生代和老年代。
垃圾收集器 (Garbage Collectors)
垃圾收集器是垃圾回收算法的具体实现。不同的收集器有不同的特点和适用场景,优化的主要目标通常是减少 STW (Stop-The-World) 的时间。
以下是几个重要的垃圾收集器:
-
CMS (Concurrent Mark Sweep)
- 目标 :获取最短回收停顿时间(低延迟)。
- 特点 :大部分标记和清除工作可以与应用程序线程并发执行,显著减少 STW 时间。
- 新生代配合 :通常与
ParNew
(Parallel Scavenge 的并发版本)收集器配合使用。 - 算法基础 :基于标记-清除算法。
- 缺点 :
- 会产生内存碎片。
- 并发阶段会占用 CPU 资源,可能降低应用程序吞吐量。
- 可能出现 "Concurrent Mode Failure":在并发标记过程中,如果应用程序仍在快速分配内存,导致老年代空间不足,CMS 会失败,转而启用后备预案,使用 Serial Old 收集器进行一次耗时更长的 Full GC。
- 状态:在 JDK 9 中被标记为废弃,在后续版本中已被移除。
-
G1 (Garbage-First)
- 目标 :在可控的停顿时间 内,尽可能获得高的吞吐量。是面向服务端应用的下一代主流 GC。
- 特点 :
- 分区化堆内存 (Region):将整个 Java 堆划分为多个大小相等的独立区域(Region)。每个 Region 可以扮演 Eden、Survivor 或 Old 的角色。
- 可预测的停顿时间模型 :G1 维护一个优先列表,跟踪每个 Region 回收能获得的空间大小以及预估的回收耗时,优先回收价值最高(回收效益最大)的 Region("Garbage-First" 名称由来)。用户可以指定期望的最大停顿时间。
- 并发执行:大部分 GC 工作(如并发标记)可以与应用程序线程并发执行。
- 整理内存 :从整体上看是基于"标记-整理"算法,从局部(两个 Region 之间)看是基于"复制"算法,因此能有效减少内存碎片。
- 状态 :JDK 7 引入,JDK 9 及之后版本的默认垃圾收集器。
- 适用场景:大内存(通常建议 4G 以上)应用,要求低延迟和高吞吐量的场景。
JMM (Java Memory Model)
1. 什么是 Java 内存模型 (JMM)?
Java 内存模型(JMM)是一个抽象规范 ,它定义了 Java 程序中各种变量(实例字段、静态字段和构成数组对象的元素)的访问规则。注意,JMM 不关心局部变量和方法参数,因为它们是线程私有的,不存在并发访问问题。
更具体地说,JMM 描述了在多线程环境下,一个线程对共享变量 的写入操作何时对另一个线程可见。它屏蔽了底层各种硬件(CPU 缓存、内存控制器)和操作系统(内存管理)的内存访问差异,旨在为 Java 开发者提供一个统一、一致的内存可见性保证,使得 Java 程序在各种平台上都能表现出可预测的行为。
2. JMM 的核心概念:主内存与工作内存
JMM 定义了一个抽象的内存结构,包含两个主要部分:
- 主内存 (Main Memory) :
- 是所有线程共享的区域。
- 存储了所有的实例字段、静态字段、数组元素等共享变量。
- JMM 规定所有变量都存储在主内存中(这是一个逻辑上的概念,不一定完全对应物理内存)。
- 工作内存 (Working Memory) :
- 是每个线程私有的数据区域(可以理解为 CPU 缓存的抽象)。
- 存储了该线程需要使用的共享变量的副本。
- 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的变量。
- 不同线程之间无法直接访问 对方工作内存中的变量。线程间变量值的传递必须通过主内存来完成:线程 A 将修改后的共享变量副本写回主内存,线程 B 再从主内存读取该共享变量的最新值到自己的工作内存。
3. JMM 与 Java 关键字/机制的关系
JMM 的规则和保证通常通过 Java 语言提供的关键字和机制来实现:
-
volatile
:- 保证可见性 :当一个线程修改了
volatile
变量的值,JMM 会确保这个修改立即 被写回主内存,并且其他线程在使用该变量前会强制从主内存读取最新值。 - 禁止指令重排序 :
volatile
会在读写操作前后插入内存屏障 (Memory Barrier),阻止编译器和处理器对其进行可能影响可见性的重排序优化,从而在一定程度上保证有序性 (但不是完全的有序性,仅保证volatile
自身操作的有序性)。它满足 JMM 的volatile变量规则
。 - 不保证原子性 :对于复合操作(如
volatile_i++
,它包含读、改、写三个步骤),volatile
无法 保证其原子性。这类操作仍需使用synchronized
或原子类 (如AtomicInteger
) 来保证。
- 保证可见性 :当一个线程修改了
-
synchronized
:(以及java.util.concurrent.locks.Lock
)- 保证原子性 :
synchronized
块或方法会创建一个临界区,确保同一时刻只有一个线程能执行该区域内的代码(互斥性),从而保证了代码块的原子性。 - 保证可见性 :
- 当线程释放锁 (
unlock
) 时,JMM 会强制将该线程在synchronized
块/方法内对所有共享变量的修改刷新回主内存。 - 当线程获取锁 (
lock
) 时,JMM 会强制清空该线程工作内存中 关于共享变量的副本,使得它在使用共享变量时必须从主内存重新加载最新值。 - 它满足 JMM 的
管程锁定规则
。
- 当线程释放锁 (
- 保证有序性 :通过原子性和可见性的保证,
synchronized
间接保证了部分有序性。一个线程在释放锁之前的所有操作,对于后续获得同一个锁的另一个线程来说,都是可见且(看起来是)有序发生的(happens-before 关系)。
- 保证原子性 :
JMM 是理解 Java 并发编程底层原理的关键,它解释了 volatile
, synchronized
, final
等关键字以及并发包 (java.util.concurrent
) 中各种工具如何在多线程环境下确保数据的一致性和程序的正确性。