从JVM和JMM理解进程、线程和线程安全

4762 字
24 分钟
从JVM和JMM理解进程、线程和线程安全

背景#

面试的时候遇到了一组关于进程、线程和线程安全的连续追问:

  1. 什么是进程和线程?
  2. 什么是线程安全?
  3. 一个进程里的多个线程打开同一个文件,要不要关注线程安全?
  4. 不关注会出现什么问题?
  5. 多线程操作文件时,怎样保证线程安全?
  6. 多个线程操作一个类的数据成员,要不要关注线程安全?
  7. 函数的内部变量呢?
  8. 没加锁就一定不安全吗?
  9. 一个没有属性和方法的 Java 类,实例大小会不会是 0?

这些问题看起来比较散,其实考察的是同一条主线:

数据属于谁、数据存在哪里、数据是否被共享,以及多个线程同时访问共享数据时有什么保证。

回答这类问题时可以从操作系统、JVM 和 JMM 三个层面展开,但要注意它们解决的问题并不相同:

层面主要回答的问题
操作系统什么是进程和线程,它们怎样获得资源和被调度
JVM 运行时内存区域Java 数据大致存放在哪里,哪些区域由线程共享
JMM(Java 内存模型)多线程读写共享变量时,原子性、可见性和有序性怎样得到保证

进程和线程#

进程#

进程可以理解为一个正在运行的程序实例,也是操作系统进行资源分配和隔离的重要单位。

一个进程通常拥有自己独立的地址空间、打开的文件、网络连接等资源。不同进程的地址空间默认相互隔离,一个进程不能直接读写另一个进程的普通内存。

例如启动一个 Java 程序后,通常会产生一个 JVM 进程。这个进程里不只有我们自己创建的线程,还可能有 GC、JIT 编译等 JVM 内部线程。

线程#

线程是进程中的一条执行路径,也是操作系统进行 CPU 调度的基本单位之一。

同一个进程里的线程共享进程资源,例如堆内存和打开的文件,但每个线程又有自己的执行现场,例如程序计数器、栈和寄存器状态。

因此线程的创建和切换通常比进程轻量,但是线程之间因为共享数据,也更容易出现并发问题。

对比进程线程
定位资源分配和隔离的重要单位执行和调度的基本单位
地址空间不同进程通常相互隔离同一进程的线程共享地址空间
数据共享需要进程间通信可以直接访问共享对象
创建、切换成本通常较高通常较低
故障影响隔离性相对较强一个线程的严重错误可能影响整个进程

从 JVM 看线程之间共享了什么#

JVM 运行时数据区域中,有些是线程共享的,有些是线程私有的。

线程共享区域#

  • :对象和数组通常分配在堆中
  • 方法区的逻辑概念:类信息、运行时常量等由线程共享,HotSpot 中通常由元空间等具体实现承载

如果多个线程拿到了同一个堆对象的引用,它们就可以操作同一份对象数据,这正是大部分 Java 线程安全问题的来源。

线程私有区域#

  • 程序计数器:记录线程当前执行位置
  • Java 虚拟机栈:每次方法调用会创建自己的栈帧
  • 本地方法栈:为 Native 方法服务

因此,不同线程调用同一个方法时,各自会创建自己的栈帧。普通局部变量默认不会因为“调用的是同一个方法”就变成同一份变量。

不过这里只说明了数据大致放在哪里,不能单靠 JVM 内存区域推出并发操作一定安全。真正规定线程之间如何观察共享变量的是 JMM。

JMM 是什么#

JMM,全称 Java Memory Model,即 Java 内存模型。它不是 JVM 堆、栈、方法区那张内存结构图,也不是一块真实存在的内存。

JMM 是一组并发语义规范,它主要规定:

  • 一个线程对共享变量的修改,什么时候能被其他线程看到
  • 多个读写操作能否被重排序
  • 哪些操作具有原子性
  • 什么样的同步关系能够建立 happens-before

理解线程安全时,通常需要关注三个性质。

原子性#

一个操作要么全部完成,要么完全没有执行,中间状态不会被其他线程观察和破坏。

例如 count++ 并不是一个不可分割的操作,它至少可以理解为:

读取 count
count 加 1
写回 count

两个线程同时执行时,可能都读到 10,最后都写入 11,本来应该增加两次,结果只增加了一次。这叫做丢失更新

可见性#

一个线程修改共享变量后,其他线程能否及时看到新值。

线程执行时,编译器、CPU 缓存和寄存器都会参与优化。没有正确同步时,不能想当然地认为一个线程刚写入,另一个线程就一定立即读到。

volatile、锁以及部分并发工具都可以建立相应的可见性保证。

有序性#

为了提高性能,编译器和处理器可能在不改变单线程结果的前提下调整操作顺序。但在多线程环境中,如果没有同步关系,另一个线程可能观察到与代码顺序不同的结果。

JMM 使用 happens-before 规则描述哪些写入必须对后续读取可见。例如:

  • 同一个线程中,前面的操作先行发生于后面的操作
  • 解锁先行发生于随后对同一把锁的加锁
  • 对一个 volatile 变量的写,先行发生于随后对它的读
  • Thread.start() 之前的操作,对新线程中的操作可见
  • 线程中的操作先行发生于其他线程从 Thread.join() 成功返回之后的操作

什么是线程安全#

线程安全不是简单的“代码加了锁”,而是:

一段代码或一个对象被多个线程同时使用时,不需要调用方额外进行错误的时序假设,程序仍然能保持正确结果和合法状态。

判断是否需要关注线程安全,可以先问两个问题:

  1. 数据是否会被多个线程共享?
  2. 是否至少有一个线程会修改它?

如果两个答案都是“是”,通常就需要考虑线程安全。

共享 + 可变 + 并发访问 = 需要重点关注线程安全

如果数据不共享,或者共享数据永远不修改,一般就不需要为了它加锁。

多个线程打开同一个文件,需要关注线程安全吗#

答案不是一律需要或者一律不需要,要看具体操作。

FilePath 只是对文件路径的抽象。多个线程各自“打开文件”以后,可能拥有不同的流或文件描述符,但它们最终操作的仍然可能是磁盘上的同一份数据。所以即使没有共享同一个 Java 对象,也可能共享同一个外部资源。

多个线程只读#

如果文件内容在读取期间不会改变,每个线程各自创建输入流,只读取文件,通常没有数据竞争问题。

如果多个线程共享同一个带有读取位置的流,则还要关注流本身是否允许并发访问,以及共享文件位置会不会导致读取内容错乱。

一个线程写,其他线程读#

读线程可能看到:

  • 旧内容
  • 只写到一半的内容
  • 格式不完整的内容
  • 因缓冲区还未刷新而暂时看不到新内容

例如写线程正在覆盖一个 JSON 文件,读线程可能正好读到只写了一半的 JSON,最终解析失败。

多个线程同时写#

这是最需要关注的情况。可能出现:

  • 内容相互覆盖,产生丢失更新
  • 多段内容交叉写入,文件格式损坏
  • 多个线程同时清空文件,最终结果取决于执行顺序
  • 各个流的缓冲区刷新顺序不同,结果不可预测
  • 依赖“先检查、再写入”的复合操作发生竞态条件

例如两个线程都执行:

读取余额文件,得到 100
在原值上增加 10
把 110 写回文件

正确结果应该是 120,但最后很可能只剩 110。

这里的问题不只是 JMM 中的堆变量可见性。文件是 JVM 外部资源,还要考虑 Java I/O 类的并发语义、操作系统文件系统语义、缓冲刷新和业务操作是否原子。

多线程操作文件,怎样保证安全#

使用 JVM 内的互斥锁#

如果只有一个 JVM 进程会访问文件,可以让所有读写操作使用同一把锁。

public class SafeFileStore {
private final Object lock = new Object();
private final Path path;
public SafeFileStore(Path path) {
this.path = path;
}
public String read() throws IOException {
synchronized (lock) {
return Files.readString(path);
}
}
public void write(String content) throws IOException {
synchronized (lock) {
Files.writeString(path, content);
}
}
}

关键不是每个方法里随便创建一把锁,而是所有线程必须竞争同一把锁。锁的范围还要覆盖完整的业务操作。对于“读取—修改—写回”,只分别锁住读取和写入仍然不够,三步应该作为一个整体保护。

也可以使用 ReentrantLock,读多写少时可以考虑 ReadWriteLock

使用文件锁#

如果可能有多个进程共同操作文件,可以使用 FileChannel.lock()tryLock() 申请文件锁。

try (FileChannel channel = FileChannel.open(
path,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE);
FileLock ignored = channel.lock()) {
// 在持有文件锁期间读写
}

文件锁适合进程之间协调,但它的具体效果会受操作系统和文件系统影响,很多平台上的文件锁属于协作式锁:其他程序如果完全不遵守同一套加锁约定,仍然可能直接操作文件。

单写线程加队列#

多个工作线程不直接写文件,而是把写入任务提交到线程安全队列,由一个专门线程顺序写入。

多个业务线程 -> BlockingQueue -> 单个写文件线程

这种方法能明显降低写入顺序和锁竞争的复杂度,日志框架的异步写入就经常使用类似思路。

临时文件加原子替换#

更新配置文件时,可以先把完整内容写入临时文件,刷新并关闭后,再使用 Files.move 替换目标文件。在文件系统支持的情况下,可以请求 ATOMIC_MOVE

这样至少可以尽量避免其他线程或进程读到“只写了一半”的文件。但原子移动是否支持仍然取决于文件系统,通常还要求源文件和目标文件位于同一文件系统。

使用更适合并发的数据存储#

如果文件承担的是账户余额、库存等需要事务和高并发更新的数据,继续堆叠文件锁往往不是最合适的方案。数据库可以提供事务、隔离级别和崩溃恢复能力,会更适合这类场景。

类的数据成员需要关注线程安全吗#

需要看这个类的实例是否被共享,以及成员是否可变。

public class Counter {
private int count;
public void increment() {
count++;
}
}

如果一个 Counter 实例被多个线程共享,多个线程同时调用 increment()count++ 会产生竞态条件,所以它不是线程安全的。

可以使用 synchronized

public synchronized void increment() {
count++;
}

也可以使用原子类:

private final AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}

但下面这些情况通常不需要因为该成员额外加锁:

  • 每个线程只使用自己独立创建的对象
  • 对象创建后不再修改,是不可变对象
  • 对象被限制在单个线程中,没有发布给其他线程

还要注意 static 字段属于类级别,天然更容易被多个实例和线程共同访问。

final 可以防止字段引用被重新赋值,也能提供一定的安全发布语义,但不代表引用指向的对象一定不可变:

private final List<String> names = new ArrayList<>();

这里不能把 names 改成另一个 List,但多个线程同时修改这个 ArrayList 仍然可能不安全。

函数内部变量需要关注线程安全吗#

普通局部变量一般是线程私有的。

public int addOne(int value) {
int result = value + 1;
return result;
}

多个线程同时调用 addOne 时,每次方法调用都有自己的栈帧,每个线程操作自己的 valueresult,互不影响,所以不需要加锁。

但是“变量本身是局部变量”不等于“它指向的对象一定线程私有”。

public void addName(List<String> names) {
List<String> local = names;
local.add("Java");
}

local 这个引用变量属于当前方法调用,是线程私有的;但它和参数 names 指向的 List 可能是多个线程共享的。多个线程同时调用这个方法并传入同一个 ArrayList,仍然有线程安全问题。

同样,方法内部如果访问成员变量、静态变量、共享缓存或文件,也不能因为方法里定义了局部变量就判断它是线程安全的。

private int count;
public void increment() {
int next = count + 1; // next 是局部变量,但 count 是共享成员变量
count = next;
}

没有加锁就一定不安全吗#

不一定。

锁只是保证线程安全的一种技术,不是判断线程安全的标准。以下方案都可能在不使用传统互斥锁的情况下保证安全:

  • 不共享数据,使用线程封闭
  • 使用不可变对象
  • 使用 AtomicInteger 等原子类
  • 使用 ConcurrentHashMap 等并发容器
  • 使用 volatile 保证特定场景下的可见性和有序性
  • 通过消息队列把共享写操作交给单一线程

反过来,代码中出现了锁也不代表一定正确。例如不同线程锁住了不同对象、锁的范围没有覆盖完整复合操作,仍然可能不安全。

还要特别注意,volatile 不能替代所有锁。它可以保证变量的可见性和特定的有序性,但不能让 count++ 这种复合操作整体变成原子操作。

private volatile int count;
public void increment() {
count++; // 仍然不是线程安全的
}

Java 空对象的大小会不会是 0#

不会。

public class Empty {
}
Empty empty = new Empty();

虽然 Empty 没有定义实例字段和方法,但实例仍然需要对象头。对象头通常需要记录或关联这些信息:

  • 运行时类型信息
  • GC 年龄、哈希码、锁状态等 Mark Word 信息
  • 数组对象还需要额外记录数组长度

另外 JVM 通常还会按照一定字节数进行内存对齐。因此空对象的实例大小不可能是 0。

在常见的 64 位 HotSpot JVM、开启压缩类指针、对象按 8 字节对齐的情况下,一个普通空对象通常可以这样估算:

Mark Word:8 字节
类型指针:4 字节
实例字段:0 字节
对齐填充:4 字节
总计:16 字节

这个 16 字节 不是 Java 语言规范规定的固定答案。关闭压缩指针、更改对象对齐方式、使用不同 JVM 实现时,结果可能不同。实际分析可以使用 JOL(Java Object Layout)查看。

System.out.println(ClassLayout.parseInstance(new Empty()).toPrintable());

还要区分引用变量大小对象实例大小

Empty empty = new Empty();

empty 是引用,new Empty() 才是堆中的对象实例。引用本身通常是 4 字节或 8 字节,取决于 JVM 位数和是否开启压缩普通对象指针;类元数据也不会在每个实例中完整复制一份。

面试时可以怎样回答#

可以先给出一个判断框架:

进程是资源分配和隔离的重要单位,线程是进程内的执行和调度单位。同一进程里的线程共享堆和外部资源,每个线程有自己的栈和程序计数器。只要存在共享、可变数据,并且会被并发访问,就要关注线程安全。JVM 内存区域告诉我们数据大致在哪里,JMM 则通过原子性、可见性、有序性和 happens-before 规则规定多线程读写的语义。

然后再根据追问落到具体对象:

  • 文件:即使每个线程使用不同的流,底层也可能操作同一个外部资源;并发写要考虑覆盖、交叉写和读取半成品,可以使用同一把 JVM 锁、文件锁、单写线程队列或原子替换
  • 成员变量:同一个实例被多线程共享并且字段可变时需要关注;不同实例或不可变对象通常不需要
  • 局部变量:每次调用的普通局部变量属于各自栈帧,通常安全;但局部引用指向的对象仍然可能共享
  • 是否加锁:不能只看有没有 synchronized,还可以通过不可变、线程封闭、原子类和并发容器实现线程安全
  • 空对象:实例大小不会为 0,因为至少有对象头和内存对齐;常见 HotSpot 配置下一般是 16 字节,但应以实际 JVM 配置为准

总结#

这一组问题真正想考察的不是能否背出“堆共享、栈私有”,而是能不能沿着下面的顺序分析:

数据在哪里
是否被多个线程共享
是否会发生修改
一次业务操作是不是原子的
线程之间有没有可见性和有序性保证
应该使用锁、原子类、不可变对象、线程封闭,还是改变整体设计

JVM 的堆栈结构是分析的起点,JMM 是解释并发语义的工具,但具体到文件时,还必须继续考虑操作系统和文件系统。把这几个层面分清楚,再遇到类似的追问,就不会只停留在“共享变量要加锁”这一句话上。

从JVM和JMM理解进程、线程和线程安全
https://putao.ink/posts/backend/从jvm和jmm理解进程线程和线程安全/
作者
葡萄成熟时
发布于
2026-09-03
许可协议
CC BY-NC-SA 4.0

文章目录