从JVM和JMM理解进程、线程和线程安全
背景
面试的时候遇到了一组关于进程、线程和线程安全的连续追问:
- 什么是进程和线程?
- 什么是线程安全?
- 一个进程里的多个线程打开同一个文件,要不要关注线程安全?
- 不关注会出现什么问题?
- 多线程操作文件时,怎样保证线程安全?
- 多个线程操作一个类的数据成员,要不要关注线程安全?
- 函数的内部变量呢?
- 没加锁就一定不安全吗?
- 一个没有属性和方法的 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++ 并不是一个不可分割的操作,它至少可以理解为:
读取 countcount 加 1写回 count两个线程同时执行时,可能都读到 10,最后都写入 11,本来应该增加两次,结果只增加了一次。这叫做丢失更新。
可见性
一个线程修改共享变量后,其他线程能否及时看到新值。
线程执行时,编译器、CPU 缓存和寄存器都会参与优化。没有正确同步时,不能想当然地认为一个线程刚写入,另一个线程就一定立即读到。
volatile、锁以及部分并发工具都可以建立相应的可见性保证。
有序性
为了提高性能,编译器和处理器可能在不改变单线程结果的前提下调整操作顺序。但在多线程环境中,如果没有同步关系,另一个线程可能观察到与代码顺序不同的结果。
JMM 使用 happens-before 规则描述哪些写入必须对后续读取可见。例如:
- 同一个线程中,前面的操作先行发生于后面的操作
- 解锁先行发生于随后对同一把锁的加锁
- 对一个
volatile变量的写,先行发生于随后对它的读 Thread.start()之前的操作,对新线程中的操作可见- 线程中的操作先行发生于其他线程从
Thread.join()成功返回之后的操作
什么是线程安全
线程安全不是简单的“代码加了锁”,而是:
一段代码或一个对象被多个线程同时使用时,不需要调用方额外进行错误的时序假设,程序仍然能保持正确结果和合法状态。
判断是否需要关注线程安全,可以先问两个问题:
- 数据是否会被多个线程共享?
- 是否至少有一个线程会修改它?
如果两个答案都是“是”,通常就需要考虑线程安全。
共享 + 可变 + 并发访问 = 需要重点关注线程安全如果数据不共享,或者共享数据永远不修改,一般就不需要为了它加锁。
多个线程打开同一个文件,需要关注线程安全吗
答案不是一律需要或者一律不需要,要看具体操作。
File、Path 只是对文件路径的抽象。多个线程各自“打开文件”以后,可能拥有不同的流或文件描述符,但它们最终操作的仍然可能是磁盘上的同一份数据。所以即使没有共享同一个 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 时,每次方法调用都有自己的栈帧,每个线程操作自己的 value 和 result,互不影响,所以不需要加锁。
但是“变量本身是局部变量”不等于“它指向的对象一定线程私有”。
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 是解释并发语义的工具,但具体到文件时,还必须继续考虑操作系统和文件系统。把这几个层面分清楚,再遇到类似的追问,就不会只停留在“共享变量要加锁”这一句话上。
