在学习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();
    }
}

执行拆解:

  1. main方法启动,在虚拟机栈创建栈帧;

  2. new Person()在堆开辟对象内存,栈中局部变量p保存对象堆地址;

  3. 调用sayHello(),新建栈帧入栈,方法内局部变量全部存在该栈帧;

  4. 方法执行结束,栈帧弹出销毁,堆内对象等待 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();
    }
}

执行过程:

  1. main 方法栈帧入栈

  2. 在堆中创建 Person 对象

  3. 先后创建 t1t2 对象,在堆中分配内存

  4. 执行 start() 方法:在操作系统中将线程加入到就绪队列

  5. 两个子线程开始交替执行(由CPU调度决定顺序)

  6. 输出结果一会儿是"线程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. 线程 1 抢占到对象锁,完整执行money++三步操作,执行完毕释放锁;

  2. 线程 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() 的线程执行完毕后再继续。用工厂来比喻:主线程是工头,它要等两个工人(子线程)干完活之后,才去验收成果。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐