从源码到锁:一文打通Java底层、JVM内存与多线程安全
在学习Java内存模型和多线程时,我心中冒出一连串底层疑问:我们写的.java源代码,计算机究竟是如何识别并运行的?程序入口main方法是怎么跑起来的?为什么 t1.start() 调用了,但代码好像"没执行"?两个线程同时对共享变量循环累加 100 万次,最终结果达不到 200 万又是为什么?带着这些问题,我一步步从底层追到了上层。
本文将从代码编译底层逻辑讲起,循序渐进梳理JVM内存分区、进程线程核心概念、CPU任务调度原理,最后结合真实代码案例拆解线程安全问题与解决方案,由浅入深打通多线程底层逻辑,希望这篇文章能帮到同样在啃多线程的你。
一、Java代码的完整旅程:从源代码到CPU执行
1.1 基础灵魂拷问:计算机看不懂 Java 代码
我们在IDEA里写的是 .java 文件,里面是 public class HelloWorld { ... } 这样的代码。
问题来了:计算机认识这些英文和花括号吗?
答案是:完全不认识。
.java 文件是源代码文件,是由英文关键字、花括号、变量组成的人类可读文本,计算机完全无法识别。计算机底层只认识由 0、1 组成的二进制机器码,想要让程序运行,必须经过一套完整的编译、加载、转换流程。
1.2 代码执行完整四步链路
.java源代码文件 --javac编译
--> .class字节码文件 --java命令启动JVM
--> 操作系统机器码 --CPU执行
--> 程序运行结果
配套流程图:

关键理解:.java 是给人看的,.class 是给JVM看的,而真正执行的是CPU能识别的二进制机器码。
1.3 五步拆解完整执行流程
第1步:编译(javac)
Javac编译器将.java 源文件编译为 .class 字节码文件。.class 文件是二进制文件,但它是JVM规范的指令集,还不是操作系统CPU的机器码。
第2步:启动JVM & 类加载
运行java Demo时,操作系统创建独立 JVM 进程,并分配专属内存空间; 类加载器 ClassLoader 读取.class文件,将类信息加载到方法区,存储内容包含:
- 类信息(类名、父类、接口)
- 静态变量
- 常量池
- 方法字节码
第3步:JVM运行时数据区内存划分
JVM 会把进程内存划分为 5 个独立区域,各区域职责、线程共享性完全不同:
| 区域 | 存储内容 | 线程是否共享 |
|---|---|---|
| 堆(Heap) | 存放所有对象实例和数组,是GC主要区域 | 共享 |
| 方法区(元空间) | 存放类信息、常量、静态变量、JIT编译后的代码 | 共享 |
| 虚拟机栈(VM Stack) | 每个方法调用创建一个栈帧,存局部变量、操作数栈、方法出口 | 私有 |
| 本地方法栈(Native Method Stack) | 为 native 方法服务 | 私有 |
| 程序计数器(PC Register) | 记录当前执行到的字节码行号 | 私有 |
第4步:执行引擎工作并运行指令
加载完成后,执行引擎负责解析字节码,包含两套执行机制:
-
解释器:逐条翻译字节码为机器码执行,启动速度快,但重复执行效率低;
-
JIT编译器:识别循环、高频调用的热点代码,一次性编译成本地机器码缓存,后续执行无需重复翻译,运行速度大幅提升。
执行过程中:
-
创建的对象放入堆
-
方法调用在虚拟机栈中压栈/出栈
-
类信息从方法区读取
第5步:进程销毁回收内存
主线程执行完毕,JVM 进程正常退出,操作系统回收该进程占用的全部内存资源。
配套流程图:

1.4 代码实例直观理解内存分布
public class Demo {
public static void main(String[] args) {
// 局部变量p存储在main方法栈帧(虚拟机栈)
// new Person()实例对象存放在堆内存
Person p = new Person();
// 调用方法,创建新栈帧压入虚拟机栈,方法执行完毕栈帧出栈销毁
p.sayHello();
}
}
执行拆解:
-
main方法启动,在虚拟机栈创建栈帧; -
new Person()在堆开辟对象内存,栈中局部变量p保存对象堆地址; -
调用
sayHello(),新建栈帧入栈,方法内局部变量全部存在该栈帧; -
方法执行结束,栈帧弹出销毁,堆内对象等待 GC 回收。
1.5 一句话总结
Java 源码经javac编译生成跨平台字节码,java命令启动独立 JVM 进程;类加载器将字节码载入方法区,JVM 划分堆、栈、方法区等内存区域管理数据;执行引擎结合解释器与 JIT 将字节码转为 CPU 可识别机器码执行,程序结束后操作系统回收全部内存。
二、进程与线程:工厂与工人的故事
2.1 进程:正在运行的程序
当你执行 java Demo,操作系统就创建了一个 进程。
进程 = 正在运行的程序,是操作系统分配资源(内存、文件句柄等)的基本单位,进程之间资源完全隔离。
2.2 线程:进程中的执行单元
一个进程内部可以包含多条线程。
线程 = 进程中的执行单元,是CPU调度的基本单位,是程序的执行过程,同一进程内所有线程共享进程资源。。
2.3 工厂生活化比喻
| 概念 | 类比对象 | 通俗解释 |
|---|---|---|
| 进程 | 工厂 | 独占厂房、设备、仓库等全部资源,工厂之间互不干涉 |
| 线程 | 工人 | 真正执行任务的主体,同一工厂内所有工人共用厂房设备 |
| CPU | 工厂里的机器 | 同一时刻一台机器只能被一个工人使用 |
| 多线程 | 多名工人同时开工 | 工人轮流抢占机器,交替完成任务 |
2.4 Java程序天然是多线程的
哪怕只写一行极简main方法,JVM 启动时也会自动创建多条线程:主线程 (main 线程)、垃圾回收 GC 线程、定时调度线程等,默认就是多线程程序。
三、CPU怎么处理多个任务?
3.1 核心矛盾
CPU在某一时刻,只能执行一个线程的指令。但我们的电脑上同时运行着浏览器、音乐播放器、IDE……这怎么做到的?
3.2 解决方案:时间片轮转
操作系统把CPU时间切成极短的时间片(通常是 10ms ~ 100ms),每条线程轮流占用 CPU 一小段时间。
时间轴示例:
[线程A] → [线程B] → [线程C] → [线程A] → [线程B] → ...
10ms 10ms 10ms 10ms 10ms
因为时间片足够短(毫秒级),人眼完全感觉不到切换,宏观上就产生了 “多个程序同时运行” 的错觉。
3.3 并发 vs 并行(重要概念)
| 概念 | 含义 | 条件 |
|---|---|---|
| 并发(Concurrent) | 宏观同时,微观交替 | 单核CPU即可实现 |
| 并行(Parallel) | 宏观和微观都同时 | 必须多核CPU |
并发(单核):
[线程A] → [线程B] → [线程A] → [线程B] → ...(交替执行)
并行(多核):
核心1:[线程A] → [线程A] → [线程A] → ...
核心2:[线程B] → [线程B] → [线程B] → ...(同时执行)
3.4 上下文切换的开销
线程切换时,操作系统需要保存当前线程的寄存器、程序计数器、局部变量等运行状态;等线程再次抢到时间片时,再恢复状态继续执行,这套操作叫做上下文切换。
时间片轮转越快,上下文切换越快,并不一定就越好。在切换过程中,需要保存中间状态值:
-
如果状态值数量少、值也小 → 保存在寄存器中
-
如果状态值数量多一些 → 保存在高速缓存中
不管放在哪里,线程在下次获得CPU时都需要恢复这些状态值,这些操作都需要时间。所以上下文切换一定会产生时间开销。
3.5 start() 方法的真正含义
很多初学者(包括我)会误以为 t.start() 一调用,线程就开始执行 run() 了。实际上:
new(新建) ──start()──→ 就绪(Runnable) ──抢到CPU时间片──→ 运行(Running)
↑ ↓
│ │
└────────────时间片用完/主动让出CPU──────────────┘
start() 的作用是:把线程放入"就绪队列",等待CPU调度。至于什么时候真正执行,由操作系统决定。只有操作系统分配 CPU 时间片后,线程才进入运行状态执行run()。
就绪队列的特点是先进先出,但实际调度还受优先级等因素影响。不管长任务还是短任务,都会被加载到就绪队列中,等待时间片分配,直到任务完成出队列。
3.6 多线程执行顺序完全不可预测
Thread t1 = new MyThread(1);
Thread t2 = new MyThread(2);
t1.start();
t2.start();
执行发现,输出结果是不确定的——可能是线程2先输出,也可能是线程1先输出。
原因:两条线程同时进入就绪队列,CPU 调度时机、操作系统当前负载、线程优先级都会影响执行顺序。多线程的执行顺序是不可预测的,这是线程安全问题产生的根本原因之一。
3.7 完整执行过程示例
public class Test {
public static void main(String[] args) {
// main主线程栈帧创建,Person对象存入堆
Person p1 = new Person();
// t1、t2线程对象在堆内存创建,此时仅新建状态
MyThread t1 = new MyThread(p1, 1);
MyThread t2 = new MyThread(p1, 2);
// start()将线程加入就绪队列,等待CPU调度
t1.start();
t2.start();
}
}
执行过程:
-
main方法栈帧入栈 -
在堆中创建
Person对象 -
先后创建
t1、t2对象,在堆中分配内存 -
执行
start()方法:在操作系统中将线程加入到就绪队列 -
两个子线程开始交替执行(由CPU调度决定顺序)
-
输出结果一会儿是"线程1"一会儿是"线程2"——顺序不确定
3.8 补充场景
package com.qcby.hbspringbootdemo;
public class Person {
public Integer money = 0;
public int getMoney() {
return money;
}
}
package com.qcby.hbspringbootdemo;
public class ThreadNew extends Thread{
private Person p1;
private int flag;
public ThreadNew(int flag){
this.flag = flag;
}
@Override
public void run() {
if(flag==1){
for (int i = 0;i<1000000;i++){
p1.money++;
}
}
if(flag==2){
for (int i = 0;i<1000000;i++){
p1.money++;
}
}
}
}
package com.qcby.hbspringbootdemo;
public class Test {
public static void main(String[] args) {
ThreadNew t1 = new ThreadNew(1);
ThreadNew t2 = new ThreadNew(2);
t1.start(); //这里的含义不是让线程执行
t2.start(); //而是将线程加入到就绪队列当中
System.out.println("main end");
}
}
有时打印看不到子线程输出,是因为主线程执行速度极快,主线程执行完毕直接退出JVM,就绪队列内的子线程还没分到CPU时间片就被终止。 因此图片的代码中,System.out.println 打印出来的是 0。
解决方案:调用join()让主线程阻塞,等待子线程全部执行完成再继续运行。
四、线程安全问题:两次累加 100 万,结果达不到 200 万?
4.1 代码示例
// 共享资源类
public class Person {
public int money = 0;
}
// 自定义线程类
public class MyThread extends Thread {
private Person person;
private int flag;
public MyThread(Person p, int flag) {
this.person = p;
this.flag = flag;
}
@Override
public void run() {
// 线程1累加100万次
if (flag == 1) {
for (int i = 0; i < 1000000; i++) {
person.money++;
}
}
// 线程2累加100万次
if (flag == 2) {
for (int i = 0; i < 1000000; i++) {
person.money++;
}
}
}
}
// 测试入口
public class Test {
public static void main(String[] args) throws InterruptedException {
Person p = new Person();
MyThread t1 = new MyThread(p, 1);
MyThread t2 = new MyThread(p, 2);
t1.start();
t2.start();
// 主线程等待两条子线程全部执行完毕
t1.join();
t2.join();
System.out.println(p.money);
}
}
4.2 预期 vs 实际
-
预期结果:两个线程各累加 100 万,最终输出
2000000; -
实际结果:多次运行结果均小于 200 万,数值随机波动。
4.3 核心根源:money++ 并非原子操作
我们直观认为money++是一步运算,底层 CPU 实际分为三步独立操作:
-
读取:从堆内存中将 money 的值加载到 CPU 寄存器;
-
运算:寄存器中执行 + 1 计算;
-
回写:把计算后的新值重新写回堆内存。
三步操作之间,CPU 随时可能发生线程切换,造成数据丢失。
4.4 问题复现
· 我们知道堆内存是线程共享的,两个子线程一开始拿到的 money 都是0。在并发执行过程中,会发生多次上下文切换:
时间线(两个线程同时操作 money=0):
线程1:读取 money → 得到 0
线程2:读取 money → 得到 0
线程1:计算 0+1 = 1 → 写入内存(money=1)
线程2:计算 0+1 = 1 → 写入内存(money=1)
↑
两次累加,结果只加了1次!❌
这就是 "丢失更新" 问题,也是线程不安全的典型表现。
4.5 核心结论
多个线程同时读写共享堆资源,且没有同步控制时,一定会出现线程安全问题。
五、线程安全解决方案:synchronized 互斥锁
5.1 核心思想:让线程排队
既然多个线程同时修改会出问题,那就让它们排队——同一时刻只允许一个线程操作共享资源。
5.2 synchronized 修饰方法实现同步
public class Person {
private int money = 0;
// 添加synchronized,同一对象同时只能一条线程执行该方法
public synchronized void addMoney() {
money++;
}
}
执行流程:
-
线程 1 抢占到对象锁,完整执行
money++三步操作,执行完毕释放锁; -
线程 2 阻塞等待锁,线程 1 释放后才能获取锁执行累加; 所有累加操作串行执行,最终稳定输出 2000000。
5.3 synchronized 三种使用方式
| 使用方式 | 锁对象 | 适用场景 |
|---|---|---|
| 修饰实例普通方法 | 当前实例对象 this | 保护实例成员变量 |
| 修饰 static 静态方法 | 类的 Class 对象 | 保护静态全局变量 |
| 同步代码块 synchronized (对象) | 自定义指定对象 | 精准缩小锁范围,提升并发性能 |
代码示例:
// 1. 实例方法锁
public synchronized void addMoney() {
money++;
}
// 2. 静态方法锁
public static synchronized void staticAdd() {}
// 3. 同步代码块(推荐,锁粒度更小)
public void addMoney() {
synchronized (this) {
money++;
}
}
5.4 join():等待子线程完成
加了锁以后,两个线程可以安全地累加,但主线程打印结果的时机还是不对——它不等子线程跑完就打印了。
解决方法:使用 join():
t1.start();
t2.start();
t1.join(); // 主线程阻塞,等待 t1 执行完毕
t2.join(); // 主线程阻塞,等待 t2 执行完毕
System.out.println(p1.getMoney()); // 此时稳定输出 2000000
join() 方法让当前线程(主线程)等待调用 join() 的线程执行完毕后再继续。用工厂来比喻:主线程是工头,它要等两个工人(子线程)干完活之后,才去验收成果。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)