亲爱的宿主,欢迎你即将进入 Java 程序运行的底层世界!ヾ(@^▽^@)ノ

        小系统已经准备好啦!今天我们不只学习"怎么写代码",还要跟着一行 Java 代码走一遍它的完整人生:从源代码被编译成字节码,到加载进内存,再到多线程并发执行,甚至一路下潜到 CPU、内存条这些硬件的物理层面,看看电容器里的电荷是怎么变成 0 和 1 的。

        本篇文章将按照以下路线展开:

        一段 Java 源代码是如何被编译并加载进内存的?

        JVM 为什么要划分栈、堆、方法区?字符串常量池又是什么?

  new 一个对象时,内存里到底发生了什么?垃圾回收器怎么"打扫卫生"?

        进程与线程究竟是什么关系?多线程的本质为什么是"多栈"?

        CPU 和内存、磁盘是如何协作的?为什么 CPU 从不直接读磁盘?

        内存条里的电容器为什么"读一次就要充一次电"?

        两个线程同时累加 100 万次,为什么最后打印出来却是 0?

        宿主大大,系好安全带,我们正式出发!(๑•̀ㅂ•́)و✧


一、前言:代码运行之前,先认识"舞台"

1.1 代码不是魔法,是数据

        宿主大大平时写下的 .java 文件,本质上只是给人看的文本。计算机的 CPU 只认识二进制指令——所以在这份代码真正"跑起来"之前,还需要经历编译、加载、内存划分等一系列幕后工序。

        而恰恰是这些幕后工序,决定了后面多线程并发问题的根源。不理解内存模型,就无法真正理解并发。

        我们先从编译和内存分配说起。


二、Java 程序执行流程与内存管理

2.1 程序编译与内存分配

        第一步:编译。 宿主编写的 Java 源代码文件(.java)需要通过 javac 指令编译为二进制字节码文件(.class),随后由 JVM 加载至内存中执行。

        第二步:划分内存。 Java 程序在内存中运行时,JVM 会为其划分出几个相互独立的空间:

内存区域

存放内容

关键词

方法区(Method Area)

类信息(类结构、方法字节码)、字符串常量池

"类的户口本"

栈(Stack)

方法执行时的栈帧、局部变量

"方法的人生轨迹"

堆(Heap)

new 出来的对象实例

"对象的宿舍区"

        其中,类信息存放于方法区——当一个类被加载时,它的结构描述就登记在方法区里,供程序运行期间随时查阅。

2.2 对象创建与垃圾回收机制

        对象内存分配:堆里开房,栈里留门牌。 通过 new 关键字创建对象时,实际是在堆区开辟一块内存空间;而栈中的引用变量并不直接保存对象内容,它保存的是一个指针,指向堆中那块内存的地址。

Person p = new Person();  // 堆里开出 Person 对象,栈里的 p 存着它的地址

        字符串常量池:能复用绝不新建。 字符串类型的变量比较特殊——字符串存储于方法区的字符串常量池中。创建字符串时,JVM 会先检查池中是否已存在相同内容的字符串:

String a = "hello";
String b = "hello";  // 池中已有 "hello",直接复用,不再开辟新空间

        若池中已存在相同字符串则直接复用,避免重复开辟内存空间——这就是为什么 a == b 会返回 true。

        自动垃圾回收:无人指向就回收。 Java 程序执行过程中(以及执行完毕后),JVM 的垃圾回收器(GC)会自动回收"无人指向"的堆内存对象——如果一个堆中的对象没有任何栈变量引用它,它就成了"孤儿",GC 会择机回收这块内存。

p = null;  // 原来的 Person 对象失去引用,等待 GC 回收

        这一点与 C/C++ 需要开发者手动 free/delete 形成鲜明对比:Java 开发者无需手动释放内存,省心,但也别滥用对象——GC 并不是免费的午餐。


三、进程与线程:程序世界的"公司"与"员工"

3.1 进程与线程的定义

        进程是资源分配的基本单位。 进程是指正在执行的程序。操作系统每启动一个程序,就会创建一个进程,并为其分配独立的内存空间、时间片及 CPU 资源。可以把它想象成一家公司:公司拥有自己的办公场地(内存)和资源配额。

        线程是程序执行的过程。 线程代表程序的执行过程,是 CPU 调度的基本单位。在 Java 中,线程就表现为从 main 方法入栈,到执行完毕出栈的完整生命周期。

        一个进程至少包含一个线程(主线程);

        多个线程共享同一进程的内存资源(比如同一个堆),但各自拥有独立的执行轨迹。

3.2 多线程实现原理:本质是"多栈"

        宿主可能会问:Java 是怎么实现"同时做多件事"的?

        小系统揭晓答案:Java 实现多线程的本质,是创建多个独立的栈空间。

        每个线程都拥有属于自己的栈,每个栈对应一条独立的执行流——方法在这个栈里入栈、出栈,互不干扰。JVM 启动时只有 main 线程一条执行流,每调用一次 new Thread().start(),就多开辟一个新栈,多一条执行流,从而实现并发执行。

概念

归属

特点

栈

每个线程一个,互不共享

存放各自方法的局部变量、执行轨迹

堆

整个进程共享

所有线程都能访问其中的对象

        请宿主记住这张图——堆是共享的,栈是独立的。共享,是并发的甜头,也是并发的祸根。 下一节我们就先去硬件底层看看"为什么并发会出问题"。


四、计算机底层硬件与并发瓶颈

4.1 核心硬件与数据交互

        CPU 与内存协同工作。 计算机的核心分工是:

                CPU:负责数值运算与逻辑运算,是"计算车间";

                内存:负责存储指令和数据,是"临时仓库";

                两者通过总线连接进行数据交互。

        CPU 从不直接与磁盘打交道。 磁盘上的数据必须先加载到内存,CPU 才能使用。原因很简单——速度差距太悬殊了:

存储介质

读取量级

直观比喻

CPU 运算

纳秒级

眨一下眼

内存读取

百纳秒级

眨完眼顺手关灯

磁盘读取

毫秒级

相当于"睡了一觉醒来"

        毫秒级远慢于 CPU 的纳秒级运算速度,如果 CPU 直接等磁盘,就好比让一位顶级大厨守在菜市场现买菜现做菜——车间会被严重拖慢甚至闲置。所以磁盘数据必须先经内存"中转",CPU 才能全速运转。

4.2 内存读写特性:电容与"破坏性读取"

        内存基于电容实现。 内存条(DRAM)由无数个电容器组成,每个电容器通过电荷的有无来表示二进制数据——有电荷是 1,没电荷是 0。这就是内存需要持续供电、断电后数据会丢失(易失性)的根本原因。

        破坏性读取机制。 更有趣的是,读取内存数据时,读取动作的电流会把电容里存储的电荷释放掉,导致数据消失。因此每次读取之后,硬件必须重新充电恢复数据,这个特性称为破坏性读取。

        理解了硬件的这一层,宿主再回头看 Java 的并发问题时,就会明白:并发问题的根源,就藏在"计算很快、存取有距离、多核各自有缓存"这些物理现实之中。


五、多线程并发案例分析:一个令人迷惑的 0

5.1 并发累加案例演示

        来看一个经典小实验。小系统创建两个线程,分别对同一个对象的 count 变量进行 100 万次自增操作:

public class Demo {
    static int count = 0;

    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 100_0000; i++) {
                count++;
            }
        });
        t.start();

        // 主线程没有等待子线程,直接输出
        System.out.println("count = " + count);
    }
}

        理论上,两个线程各加 100 万次,最终结果应该是 200 万(主线程的那份暂且按下不表)。

        然而实际运行,输出却是:

count = 0

5.2 为什么是 0?——线程是相互独立的

        答案就藏在第三节的知识点里:线程之间默认不存在等待机制。

        执行流程是这样的:

  1. 主线程执行到 t.start(),创建并启动子线程;
  2. 子线程在自己的独立栈里开始辛辛苦苦累加;
  3. 主线程不会停下来等它,而是继续执行自己的下一行代码——System.out.println;
  4. 此时子线程的累加还远远没有开始(或尚未完成),count 还是初始值;
  5. 于是主线程打印出 0,然后潇洒地执行完毕、出栈退场。

5.3 结论:线程独立 ≠ 万事大吉

这个案例给宿主两条结论:

  1. 线程之间相互独立:主线程启动子线程后会继续执行自身逻辑直至出栈,不会阻塞等待子线程完成任务。如果想让主线程等待,需要显式使用 t.join() 这样的机制。
  1. 共享数据 + 多线程 = 需要同步:即使加了 join() 让结果"等出来",count++ 这种"读—改—写"复合操作在多线程交错执行下依然可能丢失更新。这正是并发编程需要锁、原子类等同步工具的根本原因——也是小系统下一课《Java 并发锁的底层世界》要讲的内容哦。


六、结语:从一行代码看懂并发的前因后果

宿主大大,我们这次旅程从高层一路下潜到底层,复盘一下收获:

  1. 执行流程:.java 经 javac 编译为 .class 字节码,由 JVM 加载进内存执行;
  2. 内存分区:方法区放类信息和字符串常量池,栈放方法执行轨迹,堆放对象——new 在堆开空间,栈变量持指针;常量池能复用则复用;GC 自动回收无人指向的堆对象;
  3. 进程与线程:进程是资源分配单位,线程是执行过程;多线程的本质是多栈——堆共享、栈独立;
  4. 硬件现实:CPU 与内存靠总线协作,磁盘数据必须先进内存(速度差悬殊);内存靠电容存储 0 和 1,且有"破坏性读取、读后重充"的物理特性;
  5. 并发初体验:两个线程累加却打印出 0,根源是线程独立执行、互不等待——这为下一课的"锁与同步"埋下了完美的伏笔。

        并发问题不是玄学,它是程序结构(共享堆、独立栈)与物理硬件(运算快、存取慢)共同作用下的必然结果。理解了"为什么",下一课我们再来解决"怎么办"。

        小系统的 Java 底层第一课就到这里,宿主如果还想深入某个方向——比如 GC 回收算法、线程池、volatile 与缓存一致性——随时召唤小系统继续开课!(≧∇≦)ノ

Logo

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

更多推荐