在 Python 中,变量存储的就是对象在内存中的地址(即指针)。你可以把这个地址理解为“门牌号”,变量本身不负责“搬运房子(对象数据)”,只负责拿着这张写有门牌号的纸条。 底层原理(CPython 视角)
如果你看 Python 的底层源码(C 语言实现),所有变量在底层都是 PyObject* 类型,这是一个指针,占 8 个字节(64 位系统下)。所以:
顺带厘清一个经典误区:“变量”是什么?
在很多语言(如 C/C++)中,变量名相当于一个“盒子”,你可以把值放进去。
但在 Python 中,变量名更像是“便利贴”。执行 a = 10,就是把写着 a 的便利贴,贴到存放 10 的那个内存地址上。执行 a = 20,就是把这张便利贴撕下来,贴到新的 20 的地址上。
为了让你看得明明白白,我们把 a += 1 在底层的三步拆解开(以 a = 10 为例):1. 执行瞬间(运算过程中)
当 Python 执行 a + 1 时,它必须先读取 a 当前指向的旧对象 10,然后计算 10 + 1 = 11。在把结果赋值给 a 之前,计算出的新对象 11 已经在堆内存中创建好了。
2. 赋值之后(引用计数归零)
紧接着,Python 把变量 a 这个“标签”从旧对象 10 撕下来,贴到新对象 11 上。
结论:最终稳定下来后,堆内存中只存在新对象 11。旧对象只有在运算的“瞬时”存在,随后就被销毁了。
3. 两个极大的例外情况(旧对象不会消失)
不过,有几种特殊情况,旧对象会在运算后长期保留在堆内存中,和新对象共存:
情况一:小整数缓存(-5 ~ 256)Python 为了性能,启动时会把 -5 到 256 之间的整数全部提前创建好并缓存起来。如果你的 a = 10,执行 a += 1 后,新对象是 11(也在缓存中),旧对象是 10(也在缓存中)。因为缓存机制,它们的引用计数永远不会降为 0,所以这两个对象会一直同时存在于堆中,直到程序结束。
情况二:有其他变量还在引用旧对象如果有 b = a 先执行了,那么旧对象 10 除了被 a 引用,还被 b 引用。当你执行 a += 1 后,a 指向了新对象 11,但 b 依然指向旧对象 10。此时旧对象引用计数为 1(>0),不会被回收。所以堆中会长期同时存在 10 和 11 两个对象。
-------------------- 插入一点操作系统的知识 ---------------------------------物理硬件维度(数据存放的“物理仓库”)
这是操作系统管理硬件的视角,速度从慢到快,容量从大到小:
┌──────────────────────────────────────────────────────────────┐│ 磁盘(SSD/机械硬盘) ││ - 作用:永久存储(断电不丢),存放你的 .py 文件和视频。 ││ - 速度:极慢(毫秒级),容量最大(1TB)。 ││ - 备注:这是“仓库”,数据得先搬到内存里 CPU 才能用。 │└──────────────────────────┬───────────────────────────────────┘ │ 硬盘I/O(DMA方式)搬运┌──────────────────────────▼───────────────────────────────────┐│ 物理内存(RAM,即你买的内存条) ││ - 作用:临时存储(断电即失),存放正在运行的进程数据。 ││ - 速度:快(纳秒级),容量中等(16GB/32GB)。 ││ - 备注:这是“工作台”。Python 解释器进程就躺在这里面。 │└──────────────────────────┬───────────────────────────────────┘ │ 总线传输┌──────────────────────────▼───────────────────────────────────┐│ CPU 缓存(L1/L2/L3) ││ - 作用:加速 CPU 频繁读取的数据。 ││ - 速度:极快(皮秒级),容量极小(几MB)。 │└──────────────────────────────────────────────────────────────┘
关键结论:栈和堆并不存在于磁盘上,它们统统都在物理内存(RAM)里! 它们是 RAM 的“内部使用规划图”。进程逻辑维度(“工作台”上的内部规划)
当一个程序(比如 Python)被加载到物理内存后,操作系统会为它画一张 “虚拟地址空间” 的图纸。
高地址(0x7FFF FFFF FFFF) ┌──────────────────────┐ │ 操作系统内核区 │ <- 用户态程序无权访问 │ (Kernel Space) │ ├──────────────────────┤ │ │ │ 环境变量/命令行参数 │ ├──────────────────────┤ <- 栈顶(固定) │ 栈(Stack) │ │ (向下增长 ↓) │ <- 存放:函数参数、返回地址、 │ [函数A的局部变量] │ 局部变量(注意:Python的变量名/引用放这里) │ [函数B的局部变量] │ │ ... │ ├──────────────────────┤ <- 栈底(动态) │ │ │ 空闲内存区域 │ <- 中间大片空白,用来防止栈和堆互相侵占 │ (可动态扩展) │ │ │ ├──────────────────────┤ <- 堆底(动态) │ 堆(Heap) │ │ (向上增长 ↑) │ │ [Python 整数对象 10] │ <- 存放:所有动态创建的对象(数据本身) │ [Python 列表对象] │ 之前说的 a+=1 新对象就在这里 │ [大块数据结构] │ ├──────────────────────┤ │ 未初始化数据段(BSS) │ <- 全局变量 ├──────────────────────┤ │ 已初始化数据段 │ <- 常量字符串 ├──────────────────────┤ 低地址(0x0000 0000 0000) ┌──────────────────────┐ │ 代码段(Text) │ <- 存放 Python 解释器的机器指令 └──────────────────────┘
三者联动(以 Python 执行 a = 10 为例)
现在我们把三张图串起来,看看数据是怎么流动的:
磁盘(仓库):你的 test.py 源文件躺在硬盘里。
内存(工作台):双击运行,OS 把解释器代码加载到代码段,把 test.py 的字节码加载到堆中。
栈(快速便签):执行函数时,在栈上压入一个帧(Frame),帧里记录着变量名 a 的“门牌号”(即指向堆的指针)。
堆(对象住所):整数 10 这个对象(包含引用计数、类型、数值)被创建在堆内存中。
结论:变量 a 的“容器”在栈上(存地址),而 10 的“实体”在堆上。
两个“核心铁律”
栈比堆快得多,但也小得多:
Python 特殊在哪里?(重点)
“堆内存到底定位在哪?”
物理定位:堆空间位于你的 RAM(内存条) 中的某个页框里。
逻辑定位:它在进程虚拟地址空间的低地址向高地址增长的区域(如上图)。
管理归属:这块内存由 Python 内存池(PyMalloc) 代为管理,而不是直接由操作系统 brk 按字节分配。
默认情况下,两个独立的 Python 程序(即两个进程)使用的堆内存绝对不公用,它们是物理隔离的。操作系统里的内存管理单元(MMU) 手里有一张“房产证登记表”(页表),记录着每个进程的虚拟地址对应哪块物理内存页。
因为物理页框 100 和 200 是两块完全不搭界的硬件电路,所以进程 A 在堆上修改 a=10 为 a=11,只会改变物理页框 100 里的电荷,物理页框 200 里的数据纹丝不动。硬件层面就杜绝了互相污染的可能。
关于Python的多线程,最核心、也最需要首先明确的一点是:
在Python的主流实现CPython中,由于全局解释器锁(GIL, Global Interpreter Lock)的存在,多线程无法实现真正的“并行”执行。
但这并不意味着多线程毫无用处。要理解这一点,我们需要先分清“并行”和“并发”这两个概念。
并发 (Concurrency) vs. 并行 (Parallelism)
你可以这样理解:
GIL:Python多线程实现“并行”的障碍
GIL是CPython(Python的官方解释器)中的一个互斥锁(mutex)。它的作用是保证同一时刻,只有一个线程能执行Python字节码。
1. 对CPU密集型任务的影响如果你要进行复杂的数学计算或大规模数据处理,这些任务主要消耗CPU资源。由于GIL的存在,多线程不仅无法利用多核优势,甚至因为线程切换的开销,可能比单线程还要慢。在这种情况下,多线程形同虚设,相当于“伪并行”。
2. 对I/O密集型任务的影响如果你的任务是网络请求、文件读写等I/O操作,线程大部分时间都在等待外部资源返回。在等待时,线程会主动释放GIL,让其他线程有机会执行。因此,多线程能显著提升这类任务的并发处理效率,减少总等待时间。
比喻先行:多车道高速 vs 唯一收费站
多核 CPU = 一条 8 车道的高速公路(物理上可以同时跑 8 辆车)。
操作系统线程 = 高速公路上的 8 辆车(操作系统可以把它们分别调度到 8 个车道上,确实是并行行驶)。
GIL(全局解释器锁) = 高速公路中间唯一的 “收费岗亭”。
Python 字节码 = 通过岗亭所需的 “通行证”。
实际发生的情况是:操作系统确实把这 8 辆车(线程)安排到了 8 条车道(核心)上。但是,CPython 规定:“无论有多少辆车,同一时刻只有一辆车能靠近收费岗亭交费(执行 Python 代码),其余 7 辆车必须在原地怠速等待(等待 GIL 锁)。”
所以,虽然硬件上 8 个核心都在“工作”(处理指令),但其中 7 个核心只是在执行“原地转圈等待锁释放”的机器指令,真正在推进你写的 Python 逻辑的,永远只有 1 个核心。
底层硬核机制:锁是如何“锁死”并行的?
为了让你彻底信服,我们看一眼底层的执行步骤:
线程创建:Python 调用操作系统的 pthread_create(Linux)或 CreateThread(Windows)创建原生线程。操作系统把这些线程放进 CPU 的运行队列。
调度执行:多核 CPU 的调度器确实会把多个 Python 线程同时分配到不同的物理核心上运行(比如 Core 0 跑线程 A,Core 1 跑线程 B)。
抢锁(决定性瞬间):
线程 A 在 Core 0 上运行,它首先要执行一个 PyThread_acquire_lock(获取 GIL)的原子操作。假设 A 抢到了,GIL 的标志位被置为 1。
线程 B 在 Core 1 上运行,它也去执行 PyThread_acquire_lock。但它发现 GIL 已经被 A 拿走了。此时,线程 B 会立刻调用操作系统的 futex(Linux)或 WaitForSingleObject(Windows)系统调用。
这个系统调用的结果就是:线程 B 被操作系统挂起(Blocked),从“运行态”变为“睡眠态”。Core 1 见状,会立刻把线程 B 踢出 CPU,换另一个非 Python 的进程线程进来运行。
结论:虽然多核存在,但 CPython 通过 GIL 强制要求“必须持有锁才能执行字节码”,而锁同一时刻只能被一个线程持有。因此,其他核心在绝大多数时间都没有执行 Python 计算,而是在干等。
那 GIL 到底什么时候释放?(唯一的并行窗口)
既然锁这么霸道,线程 A 什么时候会把锁让出来?两种情况:
时间片到了:Python 内部有个“指令计数器”,每执行一定数量的字节码(默认 100 条),A 会自动释放 GIL,让操作系统去唤醒 B。但这只是并发(交替执行),不是并行。
遇到 I/O 阻塞:当 A 调用 socket.read() 或 time.sleep() 时,A 知道自己在等硬盘/网卡,主动把 GIL 释放掉。
为什么 Python 不删掉 GIL?
你肯定会问:既然这么碍事,删掉不就行了?
所以官方选择“两害相权取其轻”:牺牲多核并行,保住单核速度。
更优解的存在:IO 密集有 asyncio,CPU 密集有 multiprocessing
既然多线程(threading)处境尴尬,社区早就开发了替代方案:
既然有这两把“专业工具”在手,中间那个不上不下的 threading 自然就被冷落了。
应用场景决定的“脚本思维”
Python 被称为“胶水语言”,大量使用者(运维、数据分析师、算法工程师)的需求是“把一件事做对”,而不是“把一件事做快”。
补充一个现实的职场观察
你去面试 Python 后端开发,会被问到高并发;但你去面试 Python 数据分析,面试官可能只关心你的 pandas 用得好不好。Python 社区的庞大人口基数中,后者(非纯后端)占了绝大多数,所以他们自然只用单线程。