Skip to content

理解 Python 的内存模型和性能瓶颈,是写出高效 Python 程序的关键。从内存管理到性能调优,考察的是你解决实际问题的能力。

Q1: Python的内存管理机制?引用计数、垃圾回收、内存池 「🟡 中级」

考察点:考察对 Python 内存管理全貌的理解,是否清楚引用计数、垃圾回收、内存池三者的关系和各自作用,筛选对底层原理一知半解的候选人。

参考答案

Python 的内存管理是一个分层的体系,由三大部分组成:引用计数垃圾回收内存池

┌─────────────────────────────────────────────┐
│              Python 内存管理体系              │
├─────────────────────────────────────────────┤
│  引用计数(实时回收)                        │
│      ↓ 无法处理循环引用                      │
│  垃圾回收(标记-清除 + 分代回收)            │
│      ↓ 管理对象层面                          │
│  内存池(pymalloc)                          │
│      ↓ 管理小对象内存                        │
│  操作系统内存分配(malloc/free)              │
└─────────────────────────────────────────────┘

一、引用计数(Reference Counting)

引用计数是 Python 最主要的内存管理机制,每个对象都维护一个引用计数,当计数归零时对象立即被回收。

工作原理

c
// CPython 中对象的引用计数(简化)
typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;  // 引用计数
    PyTypeObject *ob_type;
} PyObject;
  • 对象创建时,引用计数为 1
  • 对象被引用时,计数 +1
  • 引用被销毁时,计数 -1
  • 计数归零时,对象被销毁,内存被释放

引用计数变化的场景

python
a = [1, 2, 3]  # 引用计数 = 1
b = a          # 引用计数 = 2
del a          # 引用计数 = 1
b = None       # 引用计数 = 0 → 对象被回收
操作引用计数变化
对象创建+1
赋值给变量+1
作为函数参数+1
放入容器(list/dict等)+1
del 删除引用-1
引用离开作用域-1
容器被销毁-1

优点

  • 实时性:引用计数归零时立即回收,没有延迟
  • 简单:实现简单,逻辑清晰
  • 确定性:对象何时被回收是确定的

缺点

  • 循环引用问题:两个对象互相引用,计数永远不为零,无法回收
  • 额外开销:每个对象都要维护引用计数,频繁加减有开销
  • 不适合多线程:引用计数的原子操作需要锁(Python 用 GIL 简化了这个问题)

二、垃圾回收(Garbage Collection)

垃圾回收是引用计数的补充,主要解决循环引用问题。Python 使用标记-清除算法,并通过分代回收提高效率。

1. 标记-清除(Mark-Sweep)

问题场景

python
a = [1]
b = [2]
a.append(b)  # a 引用 b
b.append(a)  # b 引用 a
del a
del b
# 此时 a 和 b 形成循环引用,引用计数都不为0
# 但它们已经无法从外部访问到了 → 内存泄漏

标记-清除算法

  1. 标记阶段:从根对象(全局变量、栈上的引用等)出发,遍历所有可达的对象,标记为"存活"
  2. 清除阶段:遍历所有对象,回收未被标记的对象
可达对象(从根可达) → 标记为存活 → 保留
不可达对象(循环引用等) → 未标记 → 回收

注意:标记-清除是 STW(Stop-The-World)的,执行时会暂停所有业务线程。

2. 分代回收(Generational GC)

为了提高垃圾回收的效率,Python 将对象分为三代(Generation 0, 1, 2):

说明回收阈值
第 0 代(年轻代)新创建的对象700 个对象
第 1 代(中年代)经历过 0 代回收后存活的对象10 次 0 代回收
第 2 代(老年代)经历过 1 代回收后存活的对象10 次 1 代回收

分代回收的依据:大多数对象存活时间很短,朝生夕死。

  • 新创建的对象放在第 0 代
  • 第 0 代满了触发 0 代 GC,存活下来的晋升到第 1 代
  • 第 1 代积累一定数量后触发 1 代 GC,存活的晋升到第 2 代
  • 第 2 代的对象寿命最长,回收频率最低

这样,年轻代回收频率高但每次扫描的对象少,老年代回收频率低,整体效率更高。

python
import gc

# 查看各代的回收阈值
print(gc.get_threshold())  # (700, 10, 10)

# 手动触发垃圾回收
gc.collect()  # 回收所有代
gc.collect(generation=0)  # 只回收第 0 代

三、内存池(Pymalloc)

Python 有自己的小对象内存分配器——pymalloc,用于减少系统调用、提高内存分配效率。

为什么需要内存池

如果每次创建小对象都调用 malloc,会有以下问题:

  • 系统调用开销大
  • 频繁分配释放会产生内存碎片
  • 小对象分配效率低

分层结构

Python 的内存管理是分层的:

Arena(256KB)
  └── Pool(4KB,等于一个内存页)
        └── Block(8~512字节,8字节对齐)
层级大小说明
Arena256 KB向操作系统申请的大块内存
Pool4 KB一个内存页大小,管理相同大小的 block
Block8~512 字节实际分配给对象的内存单元

工作机制

  • 对于小于 512 字节的小对象,使用 pymalloc 分配器(从内存池中分配)
  • 对于大于等于 512 字节的大对象,直接使用系统的 malloc
  • 对象释放后,内存归还给内存池(不一定还给操作系统)
  • 当 pool 或 arena 完全空闲时,可能会归还给操作系统

好处

  • 减少系统调用次数(多个小对象共用一次 malloc)
  • 减少内存碎片
  • 提高分配和释放速度

四、总结:Python 内存管理全景

Python 对象的生命周期:

1. 对象创建

2. pymalloc 分配内存(小对象从内存池,大对象直接 malloc)

3. 引用计数管理(实时增减)

4. 引用计数归零 → 立即回收(大多数情况)

5. 循环引用的对象 → 垃圾回收器(标记-清除 + 分代回收)

6. 回收的内存 → 归还到内存池(小对象)或释放给系统(大对象)

三者的关系

  • 引用计数是主力,负责大多数对象的回收(实时、高效)
  • 垃圾回收是补充,解决循环引用问题(周期性、有开销)
  • 内存池是底层优化,减少系统调用,提高小对象分配效率

追问延伸

  • 引用计数有什么缺点?循环引用怎么解决?
  • 垃圾回收的触发时机是什么?
  • 分代回收的原理是什么?为什么能提高效率?
  • Python 的内存池是怎么工作的?
  • gc 模块有哪些常用方法?
  • 什么情况下会内存泄漏?怎么排查?
  • del 语句和垃圾回收有什么关系?

Q2: 什么是内存泄漏?Python中怎么排查内存泄漏? 「🔴 高级」

考察点:考察对内存泄漏的理解和实战排查能力,是否熟悉各种排查工具和方法论,筛选缺乏生产环境问题排查经验的候选人。

参考答案

一、什么是内存泄漏

内存泄漏(Memory Leak)是指程序中已经不再使用的对象无法被垃圾回收机制回收,导致内存占用持续增长,最终可能耗尽系统内存。

在 Python 中,由于有引用计数和垃圾回收,内存泄漏比 C/C++ 少见,但仍然可能发生。

二、Python 中常见的内存泄漏原因

1. 全局引用 / 长生命周期引用

全局变量、模块级变量、缓存等长生命周期对象持有了大量短生命周期对象的引用:

python
# 全局缓存无限制增长
cache = {}

def process_data(data):
    cache[id(data)] = data  # 不断往全局字典里加,从不清理
    return data * 2

2. 循环引用(且对象有 del 方法)

普通的循环引用可以被 GC 回收,但如果循环引用中的对象定义了 __del__ 方法,GC 无法确定安全的回收顺序,会放弃回收:

python
class A:
    def __del__(self):
        print("A deleted")

class B:
    def __del__(self):
        print("B deleted")

a = A()
b = B()
a.b = b
b.a = a
del a
del b
# a 和 b 形成循环引用,且都有 __del__,GC 无法回收

Python 3.4+ 对此有改进,但仍然可能在某些情况下出现问题。

3. 闭包中的引用

闭包会持有外部函数的局部变量,可能导致意外的内存泄漏:

python
def outer():
    big_data = [0] * 1000000  # 大数据
    
    def inner():
        return big_data[0]  # 闭包引用了 big_data
    
    return inner

# 即使只需要第一个元素,整个 big_data 都无法被回收
f = outer()

4. 未关闭的资源

文件句柄、数据库连接、网络连接等资源未正确关闭:

python
def read_file():
    f = open("large_file.txt")  # 用完不关闭
    data = f.read(100)
    return data
# 文件句柄泄漏,关联的缓冲区也无法释放

5. C 扩展中的泄漏

C 扩展(如 numpy、自定义 C 模块)中的内存泄漏,Python 的 GC 无法管理。

6. 监听器 / 回调未注销

注册了回调函数但忘记注销,回调持有对象引用:

python
class EventBus:
    def __init__(self):
        self.listeners = []
    
    def register(self, callback):
        self.listeners.append(callback)
    
    # 没有 unregister 方法!

bus = EventBus()

class MyClass:
    def on_event(self, data):
        pass

obj = MyClass()
bus.register(obj.on_event)  # 注册后,obj 永远不会被回收
del obj  # 无效,bus 还持有引用

三、内存泄漏排查工具

1. tracemalloc(Python 3.4+ 内置)

Python 标准库提供的内存跟踪工具,可以跟踪内存分配的位置和大小。

python
import tracemalloc

# 开始跟踪
tracemalloc.start()

# ... 执行业务代码 ...

# 获取当前内存分配统计
snapshot = tracemalloc.take_snapshot()

# 按行统计内存分配
top_stats = snapshot.statistics('lineno')
print("[Top 10 memory usage]")
for stat in top_stats[:10]:
    print(stat)

# 对比两个快照,找出增长
snapshot1 = tracemalloc.take_snapshot()
# ... 运行一段时间 ...
snapshot2 = tracemalloc.take_snapshot()
stats = snapshot2.compare_to(snapshot1, 'lineno')
for stat in stats[:10]:
    print(stat)

优点:内置、无需安装、开销较小 缺点:只能看到分配位置,看不到引用关系

2. objgraph(对象引用图)

objgraph 可以展示对象的引用关系,帮助找到内存泄漏的根源。

python
import objgraph

# 查看增长的对象类型
objgraph.show_growth()  # 显示自上次调用以来增长最多的对象类型

# 显示某个对象的引用链
objgraph.show_backrefs(some_object, max_depth=5)

# 查找特定类型的对象
objs = objgraph.by_type('MyClass')
print(f"MyClass 实例数: {len(objs)}")

典型排查流程

  1. 调用 show_growth() 记录基线
  2. 执行业务操作
  3. 再调用 show_growth(),看哪些类型的对象增长了
  4. 对增长的对象类型,用 by_type() 获取实例
  5. show_backrefs() 查看引用链,找到为什么没有被回收

优点:可以看到对象引用链,定位泄漏根源 缺点:需要安装,生成图片需要 graphviz

3. memory_profiler(逐行内存分析)

memory_profiler 可以逐行分析函数的内存使用情况。

python
from memory_profiler import profile

@profile
def my_func():
    a = [1] * (10 ** 6)
    b = [2] * (2 * 10 ** 7)
    del b
    return a

my_func()

运行方式:

bash
python -m memory_profiler script.py

优点:逐行显示内存变化,非常直观 缺点:性能开销大,只适合分析单个函数

4. py-spy(采样分析)

py-spy 是一个非侵入式的采样分析工具,可以查看内存占用。

bash
# 查看进程的内存火焰图
py-spy record -o profile.svg --pid <pid>

# 实时查看
py-spy top --pid <pid>

优点:不侵入代码、性能开销小 缺点:只能看到函数级别的内存使用,不够精细

5. guppy3 / Heapy(堆分析)

guppy3 可以对 Python 堆进行详细分析。

python
from guppy import hpy

hp = hpy()
hp.setrelheap()  # 设置基线

# ... 执行业务代码 ...

print(hp.heap())  # 打印堆使用情况

优点:详细的堆统计信息 缺点:API 较复杂,文档较少

四、内存泄漏排查步骤

1. 复现问题

2. 监控内存(确认是内存泄漏,不是正常使用)

3. 定位对象类型(什么对象在增长?)
   ├── tracemalloc → 看分配位置
   └── objgraph → 看对象类型增长

4. 找引用链(为什么这些对象没有被回收?)
   └── objgraph.show_backrefs() → 查看是谁引用了这些对象

5. 修复

6. 验证(修复后内存是否稳定)

五、内存泄漏的预防

  1. 合理使用缓存:设置缓存大小上限(如 functools.lru_cache(maxsize=128)
  2. 及时注销监听器:注册和注销成对出现
  3. 使用弱引用:不需要强引用的地方用 weakref
  4. 关闭资源:使用 with 语句确保资源释放
  5. 避免全局状态:尽量减少全局变量的使用
  6. 定期 GC:对于长运行进程,可以定期调用 gc.collect()
python
# 使用弱引用避免内存泄漏
import weakref

class EventBus:
    def __init__(self):
        self.listeners = []
    
    def register(self, callback):
        # 使用弱引用,不会阻止对象被回收
        self.listeners.append(weakref.ref(callback))

追问延伸

  • 你在项目中遇到过内存泄漏吗?怎么排查的?
  • tracemalloc 和 objgraph 有什么区别?分别适合什么场景?
  • 如何判断是内存泄漏还是内存正常增长?
  • 循环引用一定会导致内存泄漏吗?
  • 什么是弱引用?什么场景下使用?
  • 长连接服务(如 Web 服务)容易出现哪些内存泄漏?
  • 如何监控 Python 进程的内存使用?

Q3: 有哪些Python性能优化的常用技巧? 「🟡 中级」

考察点:考察对 Python 性能优化的整体认知,是否掌握从算法层面到语言层面的各种优化手段,筛选只会写功能代码而不关注性能的候选人。

参考答案

Python 性能优化是一个体系化的工作,需要从多个层面入手。以下是常用的优化技巧,按效果从大到小排列。

一、算法和数据结构层面(最重要)

选择合适的算法和数据结构是最有效的优化手段,往往能带来数量级的性能提升。

python
# 例子:成员查找
# 列表查找 O(n)
if x in my_list:  # 慢,列表大的时候尤其慢
    pass

# 集合查找 O(1)
if x in my_set:  # 快,哈希查找
    pass

# 字典查找 O(1)
if x in my_dict:  # 快
    pass
操作listdictset
查找O(n)O(1)O(1)
插入末尾O(1)O(1)O(1)
插入中间O(n)--
删除O(n)O(1)O(1)

优化原则

  • 需要频繁查找 → 用 dict 或 set
  • 需要有序且随机访问 → 用 list
  • 需要去重 → 用 set
  • 大数据排序 → 用内置的 sorted(Timsort 算法,非常快)

二、使用内置类型和标准库

Python 的内置类型(list、dict、set、tuple 等)和标准库函数都是用 C 实现的,比纯 Python 代码快得多。

python
# 不好的做法:自己实现
def my_sum(lst):
    total = 0
    for x in lst:
        total += x
    return total

# 好的做法:用内置函数
result = sum(lst)  # C 实现,快很多

常用的高性能内置/标准库

  • sum()max()min()all()any() 等内置函数
  • itertools 模块:迭代器工具(C 实现)
  • collections 模块:dequedefaultdictCounter
  • heapq 模块:堆操作
  • bisect 模块:二分查找
  • operator 模块:函数式操作

三、列表推导式 vs for 循环

列表推导式(List Comprehension)比普通的 for 循环更快。

python
# 普通 for 循环(慢)
result = []
for i in range(1000000):
    if i % 2 == 0:
        result.append(i * i)

# 列表推导式(快)
result = [i * i for i in range(1000000) if i % 2 == 0]

为什么更快

  • 列表推导式在底层优化了 append 调用(减少了属性查找和函数调用开销)
  • 字节码更紧凑

性能对比(大致):

  • 列表推导式 > for + append > while 循环
  • 生成器表达式(内存友好)

四、生成器和惰性计算

处理大数据时,使用生成器可以节省大量内存。

python
# 不好的做法:一次性生成所有数据
def get_data(n):
    result = []
    for i in range(n):
        result.append(process(i))
    return result

# 全部加载到内存,n 很大时可能 OOM
data = get_data(1000000)

# 好的做法:生成器,一次只生成一个
def get_data(n):
    for i in range(n):
        yield process(i)

# 迭代时才生成,内存占用极小
for item in get_data(1000000):
    process_item(item)

适用场景

  • 处理大数据集
  • 处理文件流、网络流
  • 只需要遍历一次的数据
  • 无限序列

五、局部变量比全局变量快

Python 的名字查找是有开销的,局部变量(LOCAL)比全局变量(GLOBAL)更快。

python
# 慢:每次循环都要查找全局的 math.sin
import math

def compute_slow(data):
    result = []
    for x in data:
        result.append(math.sin(x))  # 每次找 math.sin
    return result

# 快:提前赋值给局部变量
def compute_fast(data):
    result = []
    sin = math.sin  # 局部变量
    append = result.append  # 局部变量
    for x in data:
        append(sin(x))
    return result

名字查找顺序:Local → Enclosing → Global → Built-in(LEGB 规则)

六、使用内置函数和标准库(C 实现)

凡是标准库提供的,都不要自己实现:

  • 字符串处理:用内置方法,不用自己遍历
  • 正则表达式:用 re 模块(C 实现)
  • 序列化:用 jsonpickle(C 加速版)
  • 压缩:用 zlibgzip(C 实现)

七、缓存(Memoization)

对于有重复计算的函数,使用缓存可以大幅提高性能。

python
from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

# 不加缓存:O(2^n),加了缓存:O(n)
print(fib(100))

适用场景

  • 纯函数(相同输入得到相同输出)
  • 函数调用频繁且有重复输入
  • 计算开销大

八、向量化计算(numpy/pandas)

数值计算用 numpy/pandas 代替 Python 循环,性能提升可达几十到上百倍。

python
import numpy as np

# Python 循环(慢)
result = [x * 2 + 1 for x in range(1000000)]

# numpy 向量化(快很多,C 实现)
arr = np.arange(1000000)
result = arr * 2 + 1

为什么快

  • numpy 底层是 C 实现,避免了 Python 循环的开销
  • 连续内存存储,缓存友好
  • 支持 SIMD 指令

九、C 扩展和 JIT

如果以上优化都不够,可以考虑:

1. Cython

Python 的超集,支持静态类型,编译成 C 扩展。

python
# .pyx 文件
def sum_array(double[:] arr):
    cdef:
        double total = 0.0
        int i
        int n = len(arr)
    for i in range(n):
        total += arr[i]
    return total

2. PyPy

使用 PyPy 解释器,自带 JIT 编译,纯 Python 代码可提速几倍到几十倍。

3. Numba

使用装饰器即时编译 Python 函数为机器码。

python
from numba import jit

@jit(nopython=True)
def sum_array(arr):
    total = 0.0
    for x in arr:
        total += x
    return total

十、并行化

如果任务可以并行,可以利用多核提高性能:

  • CPU 密集型 → 多进程(multiprocessingProcessPoolExecutor
  • IO 密集型 → 多线程或协程(threadingasyncio

优化原则总结

  1. 不要过早优化:先测量,找到瓶颈再优化
  2. 优先优化算法:算法优化 > 语言层面优化 > 底层加速
  3. 用内置类型和库:C 实现的永远比 Python 实现的快
  4. 用测量数据说话:不要凭感觉优化,用 profile 工具定位瓶颈
  5. 优化热点代码:把时间花在执行频率高的代码上

追问延伸

  • 列表推导式为什么比 for 循环快?
  • 生成器的优缺点是什么?
  • 你做过哪些 Python 性能优化?效果如何?
  • 什么是向量化?为什么 numpy 比 Python 循环快?
  • lru_cache 的实现原理是什么?
  • 如何选择优化手段?有什么优先级?
  • 什么是"过早优化是万恶之源"?你怎么理解?

Q4: 如何分析Python程序的性能瓶颈? 「🟡 中级」

考察点:考察性能分析的方法论和工具使用经验,是否懂得"先测量再优化",筛选凭感觉优化、缺乏系统化分析能力的候选人。

参考答案

性能分析的核心原则是:不要猜,先测量。在不知道瓶颈在哪里之前就优化,往往是浪费时间。

一、性能分析方法论

1. 分析步骤

1. 定义性能目标
   (如:接口响应时间 < 100ms,任务处理 < 5s)

2. 整体性能测量
   (确定是否有性能问题,问题有多严重)

3. 定位热点函数
   (哪个函数最慢?占用时间最多?)

4. 逐行分析热点
   (函数内哪一行最慢?)

5. 优化

6. 验证优化效果
   (对比优化前后的性能数据)

2. 阿姆达尔定律(Amdahl's Law)

优化的收益取决于被优化部分在总时间中的占比。

  • 如果某个函数只占总时间的 1%,即使把它优化到 0,整体也只提升 1%
  • 应该优先优化占比最高的热点代码

二、性能分析工具

1. cProfile:函数级性能分析(标准库)

cProfile 是 Python 标准库提供的性能分析工具,可以统计每个函数的调用次数和耗时。

python
import cProfile

def slow_function():
    total = 0
    for i in range(1000000):
        total += i
    return total

# 方式一:直接运行
cProfile.run('slow_function()')

# 方式二:保存结果到文件,后续分析
cProfile.run('slow_function()', 'profile.stats')

输出解读

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.035    0.035    0.050    0.050 script.py:1(slow_function)
  1000000    0.015    0.000    0.015    0.000 {method 'append' of 'list' objects}
        1    0.000    0.000    0.050    0.050 <string>:1(<module>)
列名说明
ncalls调用次数
tottime函数本身耗时(不含子函数调用)
percall每次调用平均耗时(tottime/ncalls)
cumtime累计耗时(含子函数调用)
percall每次调用平均累计耗时

使用 pstats 分析结果

python
import pstats

p = pstats.Stats('profile.stats')
p.sort_stats('cumtime')  # 按累计耗时排序
p.print_stats(20)  # 打印前 20 个
p.print_callers('func_name')  # 查看谁调用了这个函数
p.print_callees('func_name')  # 查看这个函数调用了谁

适用场景:初步定位哪个函数慢(第一级分析)。

2. line_profiler:逐行性能分析

line_profiler 可以逐行统计函数的执行时间,找到函数内的瓶颈。

python
# 安装:pip install line_profiler

from line_profiler import profile

@profile
def slow_function():
    total = 0
    data = []
    for i in range(100000):
        data.append(i)
        total += i
    return total

slow_function()

运行方式:

bash
kernprof -l -v script.py

输出示例

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     1                                           @profile
     2                                           def slow_function():
     3         1            2      2.0      0.0      total = 0
     4         1            1      1.0      0.0      data = []
     5    100001        23240      0.2     23.1      for i in range(100000):
     6    100000        41234      0.4     41.0          data.append(i)
     7    100000        36521      0.4     35.9          total += i
     8         1            2      2.0      0.0      return total

可以清楚看到每一行的耗时占比。

适用场景:已经定位到热点函数,需要知道函数内哪一行慢(第二级分析)。

3. memory_profiler:内存分析

memory_profiler 逐行分析内存使用情况。

python
from memory_profiler import profile

@profile
def my_func():
    a = [1] * (10 ** 6)
    b = [2] * (2 * 10 ** 7)
    del b
    return a

运行方式:

bash
python -m memory_profiler script.py

适用场景:内存问题排查。

4. py-spy:采样式性能分析

py-spy 是一个非侵入式的性能分析工具,不需要修改代码,可以附加到运行中的进程。

bash
# 实时查看函数耗时排行
py-spy top --pid <进程ID>

# 生成火焰图
py-spy record -o profile.svg --pid <进程ID>
py-spy record -o profile.svg -- python script.py

火焰图

  • 横向宽度表示耗时占比
  • 纵向表示调用栈深度
  • 越宽的函数,耗时越多

优点

  • 不侵入代码,不需要修改源程序
  • 性能开销小(采样式)
  • 可以附加到运行中的生产环境进程
  • 支持生成火焰图,直观

适用场景:生产环境排查、不想修改代码的场景。

5. timeit:小代码片段计时

timeit 适合比较小段代码的性能。

python
import timeit

# 方式一:命令行
# python -m timeit 's = sum(range(1000))'

# 方式二:代码中调用
t = timeit.timeit('sum(range(1000))', number=10000)
print(f"总耗时: {t:.4f}s, 平均: {t/10000*1000:.4f}ms")

# 比较两种写法
setup = 'data = list(range(1000))'
t1 = timeit.timeit('[x*2 for x in data]', setup=setup, number=10000)
t2 = timeit.timeit('list(map(lambda x: x*2, data))', setup=setup, number=10000)
print(f"列表推导: {t1:.4f}s")
print(f"map: {t2:.4f}s")

适用场景:微基准测试、比较不同写法的性能。

三、工具选择指南

分析目标推荐工具说明
整体性能,找热点函数cProfile标准库,第一步用
函数内逐行分析line_profiler定位到函数后,找具体哪行慢
内存使用分析memory_profiler / tracemalloc内存泄漏或内存优化
生产环境排查py-spy不侵入代码,附加到进程
微基准测试timeit比较小段代码的性能
火焰图py-spy / pyinstrument直观展示调用栈和耗时

四、性能分析的常见误区

  1. 凭感觉优化:不测量就瞎优化,往往优化的不是瓶颈
  2. 只看单次结果:单次运行有随机性,要多次运行取平均
  3. 忽略启动开销:注意区分启动时间和运行时间
  4. 过度优化:为了微小的性能提升牺牲代码可读性
  5. 不验证优化效果:优化后一定要重新测量,确认有提升

五、优化流程示例

假设一个 Web 接口响应慢:

  1. 整体测量:用日志或监控工具记录接口总耗时,确认确实慢
  2. 函数级分析:用 cProfile 分析接口处理函数,找到耗时最多的函数
  3. 假设:可能是数据库查询慢,或者计算逻辑复杂
  4. 逐行分析:用 line_profiler 分析热点函数,定位到具体行
  5. 发现:某行数据库查询没有走索引,或者循环内重复计算
  6. 优化:加索引 / 优化算法 / 加缓存
  7. 验证:重新测量,确认性能提升

追问延伸

  • cProfile 的 tottime 和 cumtime 有什么区别?
  • 采样式分析(py-spy)和插桩式分析(cProfile)有什么区别?
  • 火焰图怎么看?如何从火焰图中找到性能瓶颈?
  • 你平时怎么排查性能问题?说一个具体案例。
  • line_profiler 的原理是什么?
  • 如何对线上服务做性能分析而不影响用户?
  • 什么是"性能分析的观察者效应"?

Q5: CPython 为什么慢?有什么办法加速? 「🔴 高级」

考察点:考察对 Python 性能瓶颈的深度理解,是否清楚 CPython 慢的根本原因,以及各种加速方案的原理和适用场景,筛选只会用 Python 而不理解其本质的候选人。

参考答案

一、CPython 为什么慢

CPython 慢是多种因素共同作用的结果:

1. 动态类型(Dynamic Typing)

Python 是动态类型语言,变量的类型在运行时才能确定,每个操作都要做类型检查和分发。

python
def add(a, b):
    return a + b

这个 + 操作在 C 中编译后就是一条加法指令,而在 Python 中:

  1. 检查 a 的类型
  2. 检查 b 的类型
  3. 查找 a__add__ 方法
  4. 调用 __add__ 方法
  5. 检查返回值的类型

每一步都有开销,而且无法在编译期优化。

2. 解释执行(Interpreted)

Python 源码先编译成字节码,然后由虚拟机解释执行,不是直接运行机器码。

源码 → 词法分析 → 语法分析 → AST → 字节码 → 虚拟机解释执行

虽然有字节码,但虚拟机解释执行仍然比直接执行机器码慢很多。

对比:

  • C:源码 → 编译 → 机器码 → CPU 直接执行
  • Python:源码 → 字节码 → 虚拟机逐条解释执行
  • Java:源码 → 字节码 → JVM 解释 + JIT 编译 → 机器码

3. 一切皆对象(Everything is an Object)

Python 中一切都是对象,包括整数、字符串等简单类型,每个对象都有很大的开销。

c
// CPython 中一个整数对象的结构(简化)
typedef struct {
    PyObject_HEAD  // 引用计数 + 类型指针,约 16 字节
    long ob_digit[1];  // 实际的整数值
} PyLongObject;
  • 一个 C 的 int 只占 4 字节
  • 一个 Python 的 int 对象占几十字节
  • 每次操作都要通过指针间接访问
  • 内存开销大,缓存不友好

4. 引用计数(Reference Counting)

每个对象都要维护引用计数,每次赋值、传参、函数返回都要增减引用计数,带来额外开销。

虽然引用计数的单次操作开销很小,但因为非常频繁,累积起来也很可观。

5. GIL(全局解释器锁)

GIL 使得 Python 多线程在 CPU 密集型任务上无法真正并行,无法有效利用多核 CPU。

注意:GIL 不影响单线程的速度,影响的是多线程的并行能力。

6. 其他因素

  • 内存管理:垃圾回收有开销
  • 函数调用开销:Python 的函数调用比 C 重很多
  • 循环开销:Python 的 for 循环很慢(每次迭代都有类型检查等开销)

二、加速方法

方法一:使用内置类型和标准库(推荐指数:★★★★★)

Python 的内置类型(list、dict、set 等)和标准库很多是用 C 实现的,比纯 Python 快得多。

python
# 慢:纯 Python 循环
total = 0
for x in data:
    total += x

# 快:内置函数 sum(C 实现)
total = sum(data)

适用场景:所有场景,优先考虑。 加速效果:几倍到几十倍。

方法二:向量化计算(numpy/pandas)(推荐指数:★★★★★)

数值计算用 numpy/pandas 代替 Python 循环。

python
import numpy as np

# 慢:Python 循环
result = [x * 2 + 1 for x in range(1000000)]

# 快:numpy 向量化
arr = np.arange(1000000)
result = arr * 2 + 1

为什么快

  • 底层是 C 实现,避免 Python 循环开销
  • 连续内存存储,缓存友好
  • 支持 SIMD 指令(单指令多数据)

适用场景:数值计算、数据处理。 加速效果:几十到上百倍。

方法三:Cython(推荐指数:★★★★☆)

Cython 是 Python 的超集,支持静态类型注解,可以编译成 C 扩展。

python
# .pyx 文件
def sum_list(list numbers):
    cdef:
        double total = 0.0
        int i
        int n = len(numbers)
    for i in range(n):
        total += numbers[i]
    return total

工作流程

.pyx → Cython 编译器 → .c → C 编译器 → .so/.pyd

加速效果:加了类型标注后,可达几十到上百倍加速。 适用场景:计算密集型循环、封装 C/C++ 库。

方法四:PyPy(推荐指数:★★★★☆)

PyPy 是另一个 Python 解释器,使用 JIT(即时编译)技术,可以把热点代码编译成机器码。

加速效果:纯 Python 代码通常有几倍到几十倍的加速。 适用场景:长时间运行的 CPU 密集型纯 Python 程序。 限制

  • C 扩展支持不好(兼容性问题)
  • 启动慢(JIT 预热需要时间)
  • 内存占用大

方法五:Numba(推荐指数:★★★★☆)

Numba 是一个 JIT 编译器,使用装饰器就可以把 Python 函数编译成机器码。

python
from numba import jit

@jit(nopython=True)
def sum_array(arr):
    total = 0.0
    for x in arr:
        total += x
    return total

加速效果:数值计算可达 C 语言级别的速度。 适用场景:数值计算、循环优化。 限制:主要支持 numpy 和数值操作,对 Python 标准库支持有限。

方法六:多进程并行(推荐指数:★★★★☆)

用多进程绕过 GIL,利用多核 CPU。

python
from multiprocessing import Pool

def cpu_bound_task(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    with Pool(4) as p:
        results = p.map(cpu_bound_task, [10**6] * 10)

加速效果:理论上接近线性加速(受核数限制)。 适用场景:CPU 密集型、可并行的任务。

方法七:C 扩展(推荐指数:★★☆☆☆)

直接用 C 语言编写扩展模块。

加速效果:最高,可达 C 语言级别。 缺点:开发成本高,维护困难,有内存安全问题。

三、加速方案对比

方案加速效果开发成本适用场景兼容性
内置函数/库几倍~几十倍极低通用最好
numpy/pandas几十~上百倍数值计算
Cython几十~上百倍计算密集型
PyPy几倍~几十倍极低纯Python长运行C扩展差
Numba几十~上百倍数值计算一般
多进程接近线性可并行CPU密集
C扩展最高极致性能

四、优化建议

  1. 先用对数据结构和算法:这是最重要的,也是收益最大的
  2. 再用内置函数和标准库:零成本,收益大
  3. 数值计算用 numpy/pandas:向量化是关键
  4. 热点函数考虑 Cython/Numba:针对性加速
  5. 可并行用多进程:利用多核
  6. 最后才考虑 PyPy 或 C 扩展:成本高,兼容性问题多

追问延伸

  • Python 和 C 比慢在哪里?能具体到指令层面吗?
  • 什么是 JIT?PyPy 的 JIT 和 Java 的 JIT 有什么区别?
  • numpy 为什么快?除了 C 实现还有什么原因?
  • GIL 是 Python 慢的主要原因吗?
  • Cython 的工作原理是什么?
  • 你在项目中用过哪些加速方案?效果如何?
  • 什么是"向量化"?为什么向量化比循环快?
  • Python 未来会不会变快?有哪些优化方向?

Q6: slots 是什么?为什么能节省内存? 「🟡 中级」

考察点:考察对 Python 对象内存布局的理解,是否清楚 __slots__ 的原理和使用场景,筛选对 Python 对象模型不熟悉的候选人。

参考答案

一、什么是 __slots__

__slots__ 是 Python 类的一个特殊属性,用来显式声明类的实例可以拥有哪些属性。

python
# 普通类
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

# 使用 __slots__ 的类
class PointSlots:
    __slots__ = ('x', 'y')  # 声明实例只能有 x 和 y 属性
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

二、为什么能节省内存

要理解 __slots__ 为什么省内存,需要先了解 Python 普通对象的内存布局。

1. 普通对象的内存布局

默认情况下,Python 对象用 __dict__(一个字典)来存储实例属性:

对象 obj
├── ob_refcnt (引用计数)
├── ob_type (类型指针)
├── __dict__ → 字典
│             ├── 'x': 1
│             ├── 'y': 2
│             └── ... (可以动态添加任意属性)
└── __weakref__ (弱引用列表)

字典本身有很大的开销:

  • 哈希表结构,需要预留空间(装载因子约 2/3)
  • 每个条目存储 key、hash、value(至少 3 个指针)
  • 即使只有 2 个属性,字典的内存开销也远大于 2 个指针

2. 使用 __slots__ 的内存布局

使用 __slots__ 后,实例不再有 __dict__,属性直接存储在对象的固定槽位中:

对象 obj
├── ob_refcnt (引用计数)
├── ob_type (类型指针)
├── x (直接存储,相当于 C 结构体的字段)
└── y (直接存储)

相当于 C 语言的结构体:

c
struct PointSlots {
    PyObject_HEAD
    PyObject *x;
    PyObject *y;
};

节省的内存

  • 省去了 __dict__ 字典本身的开销(几十到上百字节)
  • 省去了 __weakref__ 的开销(一个指针)
  • 属性直接存储,不需要哈希查找

对于有大量实例的类,内存节省非常可观(通常 40%~50%,甚至更多)。

三、内存节省效果对比

python
import sys

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class PointSlots:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

p1 = Point(1, 2)
p2 = PointSlots(1, 2)

print(sys.getsizeof(p1))           # 实例对象大小
print(sys.getsizeof(p1.__dict__))  # __dict__ 字典大小
# 总内存 = 实例对象 + __dict__ + 字典条目

print(sys.getsizeof(p2))           # 使用 __slots__ 的实例大小
# 总内存 = 实例对象(包含 x、y 的槽位)

大致对比(64 位 Python 3.11):

类型实例大小dict 大小总大小(约)
普通类(2属性)56 B184 B+~240 B+
slots(2属性)48 B0~48 B

节省比例:约 80%(具体数字因 Python 版本和属性数量而异)

如果有 100 万个实例:

  • 普通类:约 240 MB+
  • slots:约 48 MB
  • 节省:约 192 MB

四、__slots__ 的限制

  1. 不能动态添加属性
python
p = PointSlots(1, 2)
p.z = 3  # AttributeError: 'PointSlots' object has no attribute 'z'
  1. 实例没有 __dict__
python
print(p.__dict__)  # AttributeError
  1. 实例没有 __weakref__(除非在 __slots__ 中显式声明)
python
import weakref
weakref.ref(p)  # TypeError,除非 __slots__ 包含 '__weakref__'
  1. 多继承有限制

如果父类有 __slots__,子类多继承时,多个父类不能有非空的 __slots__(否则槽位布局冲突)。

  1. 子类不自动继承 __slots__
python
class PointSlots:
    __slots__ = ('x', 'y')

class Point3D(PointSlots):
    pass  # 子类没有定义 __slots__,仍然会有 __dict__

class Point3DSlots(PointSlots):
    __slots__ = ('z',)  # 只需要声明新增的属性

子类如果定义了 __slots__,会继承父类的槽位。

五、使用场景

适合使用 __slots__ 的场景

  1. 大量实例的类:如 ORM 模型、数据对象、游戏中的粒子等
  2. 属性固定的类:确定不会动态添加属性
  3. 内存敏感的场景:嵌入式设备、大数据量处理

不适合的场景

  1. 需要动态添加属性的类
  2. 使用 mixin 或多继承的类(可能有冲突)
  3. 实例数量很少的类(节省的内存可以忽略)

六、__slots__ 的其他影响

除了节省内存,__slots__ 还有一些附加效果:

  1. 属性访问更快:直接通过偏移量访问,不需要字典查找(但提升不大,通常只有几个百分点)
  2. 更安全:防止意外添加属性(拼写错误会报错而不是静默创建新属性)

七、命名元组(namedtuple)也是类似思路

collections.namedtupletyping.NamedTuple 也是用元组存储属性(没有 __dict__),内存占用也很小:

python
from collections import namedtuple

PointTuple = namedtuple('PointTuple', ['x', 'y'])
p = PointTuple(1, 2)

# 优点:内存小、不可变
# 缺点:不可变、不能继承

对比:

类型内存可变方法继承
普通类支持支持
__slots__支持有限制
namedtuple有限不推荐

追问延伸

  • Python 对象的内存布局是怎样的?
  • __slots__ 除了省内存,还有什么好处?
  • 为什么普通对象要用 __dict__ 存属性?
  • __slots__ 的实现原理是什么?
  • 什么情况下不能用 __slots__
  • 你在项目中用过 __slots__ 吗?效果如何?
  • __slots__@dataclass 能一起用吗?
  • namedtuple 和 __slots__ 有什么区别?

Q7: Python中的迭代器和生成器为什么省内存? 「🟢 校招/初级」

考察点:考察对迭代器和生成器的理解,是否清楚惰性计算的概念和内存优势,筛选基础不扎实的初级候选人。

参考答案

一、迭代器(Iterator)

迭代器是一个实现了迭代器协议的对象,即实现了 __iter__()__next__() 方法。

python
# 自定义迭代器
class MyRange:
    def __init__(self, n):
        self.n = n
        self.i = 0
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.i < self.n:
            result = self.i
            self.i += 1
            return result
        else:
            raise StopIteration

# 使用
for x in MyRange(5):
    print(x)  # 0, 1, 2, 3, 4

特点

  • 惰性计算:一次只生成一个值
  • 只能遍历一次
  • 省内存:不需要存储所有元素

二、生成器(Generator)

生成器是一种特殊的迭代器,使用 yield 关键字创建,写法更简洁。

python
def my_range(n):
    i = 0
    while i < n:
        yield i
        i += 1

# 使用
for x in my_range(5):
    print(x)

生成器也可以用生成器表达式创建:

python
gen = (x * x for x in range(10))  # 生成器表达式,返回生成器对象

三、为什么省内存

核心原因:惰性计算(Lazy Evaluation)

普通列表:一次性生成所有元素,全部存在内存中。

python
# 列表:一次性生成 100 万个元素,全部在内存中
big_list = list(range(1000000))
print(sys.getsizeof(big_list))  # 约 8MB(每个指针 8 字节)
# 加上整数对象本身,内存更大

生成器:一次只生成一个值,用完就丢(如果不保存的话)。

python
# 生成器:只保存状态,不保存所有元素
big_gen = (x for x in range(1000000))
print(sys.getsizeof(big_gen))  # 约 200 字节(只存状态)

对比

类型1000 个元素100 万个元素1 亿个元素
列表约 8 KB约 8 MB约 800 MB(可能 OOM)
生成器约 200 B约 200 B约 200 B

生成器的内存占用是常数级的,和数据量无关,因为它只需要保存当前状态。

四、内存节省的原理

列表的内存模型

列表对象
├── 长度: 1000000
├── 容量: 约 100 多万
└── 指针数组 → [*obj0, *obj1, *obj2, ..., *obj999999]

                                  整数对象们(每个约 28 字节)

内存开销:

  • 列表本身:约 8MB(100 万 * 8 字节指针)
  • 整数对象:约 28MB(100 万 * 28 字节)
  • 总计:约 36MB+

生成器的内存模型

生成器对象
├── 帧对象(当前执行状态)
├── 当前值 i = ?
├── 指令指针
└── 局部变量

内存开销:

  • 生成器对象本身:约 200 字节
  • 和元素数量无关!

五、生成器的工作过程

my_range(5) 为例:

python
def my_range(n):
    i = 0
    while i < n:
        yield i  # 在这里暂停,返回 i
        i += 1

gen = my_range(5)  # 创建生成器对象,函数体不执行

print(next(gen))  # 0 → 执行到第一个 yield,暂停
print(next(gen))  # 1 → 从 yield 处恢复,i+=1,下一个 yield,暂停
print(next(gen))  # 2
print(next(gen))  # 3
print(next(gen))  # 4
print(next(gen))  # StopIteration 异常

每次调用 next(),生成器:

  1. 从上次暂停的位置恢复执行
  2. 执行到下一个 yield
  3. 返回 yield 的值
  4. 暂停,等待下一次调用

六、适用场景和代价

适用场景

  1. 大数据集:数据量太大,内存装不下
  2. 数据流:文件读取、网络流处理
  3. 只遍历一次:不需要随机访问
  4. 无限序列:不可能全部生成(如斐波那契数列、计数器)
python
# 无限序列:斐波那契数列
def fib():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# 可以一直取下去,不会内存溢出
f = fib()
for _ in range(10):
    print(next(f))

代价(缺点)

  1. 只能遍历一次:遍历完就空了,不像列表可以反复遍历
  2. 不能随机访问:不能用索引访问第 n 个元素
  3. 不能用 len():不知道有多少元素
  4. 有一定的调用开销:每次 next() 都有函数调用开销(但内存节省的收益更大)

七、列表推导式 vs 生成器表达式

python
# 列表推导式:方括号,返回列表
list_comp = [x * x for x in range(1000000)]
# 内存:约 40MB+

# 生成器表达式:圆括号,返回生成器
gen_exp = (x * x for x in range(1000000))
# 内存:约 200 字节

怎么选

  • 数据量小、需要多次访问、需要索引 → 列表
  • 数据量大、只遍历一次、流式处理 → 生成器

八、总结

迭代器和生成器省内存的根本原因是惰性计算——不是一次性把所有数据都生成出来放在内存里,而是需要的时候才生成一个,用完就可以丢弃。

这是一种典型的时间换空间的策略:

  • 牺牲:不能随机访问、只能遍历一次、每次取值有调用开销
  • 获得:极大的内存节省,可以处理超大数据集

追问延伸

  • 迭代器和生成器有什么区别和联系?
  • 生成器的工作原理是什么?yield 是怎么实现暂停和恢复的?
  • 什么情况下应该用生成器而不是列表?
  • 生成器可以遍历多次吗?为什么?
  • 什么是惰性计算?有什么优缺点?
  • 你在项目中用过生成器吗?解决了什么问题?
  • range 是迭代器还是生成器?(都不是,它是可迭代对象)

Q8: 什么是字节码(Bytecode)?怎么查看和理解? 「🟡 中级」

考察点:考察对 Python 执行模型的理解,是否清楚字节码的概念和作用,筛选对 Python 底层执行机制不了解的候选人。

参考答案

一、什么是字节码

字节码(Bytecode)是 Python 源码编译后的中间表示,是一种介于源码和机器码之间的指令格式,由 Python 虚拟机(PVM,Python Virtual Machine)解释执行。

Python 源码 (.py)
    ↓ 编译
字节码 (Bytecode)
    ↓ 解释执行
Python 虚拟机

机器码(由 CPU 执行)

Python 通常被称为"解释型语言",但实际上它有编译过程——编译成字节码,然后由虚拟机解释字节码。

二、.pyc 文件

.pyc 文件是编译后的字节码缓存文件,存在 __pycache__ 目录中。

script.py → 编译 → __pycache__/script.cpython-311.pyc

为什么要有 .pyc 文件

  • 避免每次运行都重新编译
  • 提高启动速度
  • 当源码修改时间晚于 .pyc 时,会重新编译

注意.pyc 文件包含的是字节码,不是机器码,仍然需要虚拟机解释执行。

三、查看字节码:dis 模块

Python 标准库的 dis 模块可以反汇编 Python 代码,查看对应的字节码指令。

python
import dis

def add(a, b):
    c = a + b
    return c

dis.dis(add)

输出示例

  2           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD
              6 STORE_FAST               2 (c)

  3           8 LOAD_FAST                2 (c)
             10 RETURN_VALUE

输出解读

说明
第一列(数字)源码行号
第二列(偏移量)字节码指令的偏移量(字节)
第三列指令名称
第四列指令参数
第五列(括号内)参数的实际含义

四、常用字节码指令

指令说明
LOAD_FAST var加载局部变量
STORE_FAST var存储局部变量
LOAD_GLOBAL var加载全局变量
LOAD_CONST const加载常量
LOAD_ATTR name加载属性
BINARY_ADD二进制加法
BINARY_SUBTRACT二进制减法
CALL_FUNCTION n调用函数(n 个位置参数)
RETURN_VALUE返回值
POP_TOP弹出栈顶
JUMP_FORWARD offset无条件跳转
POP_JUMP_IF_FALSE offset弹出,假则跳转
FOR_ITER offsetfor 循环迭代
MAKE_FUNCTION创建函数

五、Python 虚拟机:栈式虚拟机

Python 虚拟机是基于栈的虚拟机,所有操作都通过操作数栈(Evaluation Stack)完成。

a + b 为例:

指令: LOAD_FAST a       操作数栈: [a]
指令: LOAD_FAST b       操作数栈: [a, b]
指令: BINARY_ADD        操作数栈: [a+b]  (弹出两个,相加,压入结果)
指令: STORE_FAST c      操作数栈: []     (弹出结果,存入 c)

执行步骤:

  1. LOAD_FAST a:把局部变量 a 的值压入栈
  2. LOAD_FAST b:把局部变量 b 的值压入栈
  3. BINARY_ADD:弹出栈顶两个元素,相加,把结果压回栈
  4. STORE_FAST c:弹出栈顶元素,存入局部变量 c

六、通过字节码理解性能差异

分析字节码可以帮助理解为什么某些写法更快。

例子 1:局部变量 vs 全局变量

python
import dis

global_var = 100

def test_global():
    return global_var

def test_local(local_var):
    return local_var

print("=== global ===")
dis.dis(test_global)

print("=== local ===")
dis.dis(test_local)

输出:

=== global ===
  5           0 LOAD_GLOBAL              0 (global_var)
              2 RETURN_VALUE

=== local ===
  8           0 LOAD_FAST                0 (local_var)
              2 RETURN_VALUE
  • LOAD_GLOBAL 需要查找全局字典,慢
  • LOAD_FAST 直接通过索引访问局部变量数组,快
  • 这就是为什么局部变量比全局变量快

例子 2:列表推导式 vs for 循环

python
def list_comp():
    return [x * 2 for x in range(10)]

def for_loop():
    result = []
    for x in range(10):
        result.append(x * 2)
    return result

列表推导式的字节码更紧凑,而且避免了每次循环的属性查找(result.append),所以更快。

例子 3:属性访问

python
def access_attr(obj):
    return obj.value

字节码:

  2           0 LOAD_FAST                0 (obj)
              2 LOAD_ATTR                0 (value)
              4 RETURN_VALUE

LOAD_ATTR 需要在对象的 __dict__ 中查找属性(可能还要查类的继承链),开销比局部变量大很多。这也是为什么频繁访问属性时,先赋值给局部变量更快。

七、其他相关模块

模块作用
dis反汇编,查看字节码
compile编译源码为 code 对象
marshal序列化/反序列化 code 对象(.pyc 文件用)
types.CodeType字节码对象类型
python
# 手动编译
code = compile('x = 1 + 2', '<string>', 'exec')
print(type(code))  # <class 'code'>
print(code.co_code)  # 字节码的原始字节
print(code.co_names)  # 使用的名字
print(code.co_consts)  # 常量

八、理解字节码的意义

  1. 深入理解 Python 执行模型:知道 Python 代码是怎么跑起来的
  2. 性能优化:通过字节码分析性能差异的根本原因
  3. 调试疑难问题:某些诡异问题可以通过字节码找到原因
  4. 元编程:动态生成代码、修改字节码(高级技巧)

追问延伸

  • Python 是解释型语言还是编译型语言?为什么?
  • Python 虚拟机是栈式的还是寄存器式的?有什么区别?
  • .pyc 文件是什么?什么时候生成?
  • LOAD_FAST 和 LOAD_GLOBAL 有什么区别?为什么前者快?
  • 为什么列表推导式比 for 循环快?能用字节码解释吗?
  • 你在什么情况下会去看字节码?
  • Python 的字节码是跨平台的吗?

Q9: Python的编译过程是怎样的?AST和字节码? 「🟡 中级」

考察点:考察对 Python 编译执行全过程的理解,是否清楚从源码到字节码的完整流程,筛选对 Python 执行机制只有模糊认识的候选人。

参考答案

一、Python 代码的执行流程

Python 代码从源码到执行结果,经历以下阶段:

源码 (Source Code)
    ↓ ① 词法分析(Lexical Analysis)
Tokens(词法单元)
    ↓ ② 语法分析(Syntactic Analysis)
AST(抽象语法树)
    ↓ ③ 编译(Compilation)
字节码 (Bytecode) → 存入 .pyc 文件
    ↓ ④ 解释执行(Interpretation)
Python 虚拟机(栈式虚拟机)

执行结果

常说 Python 是"解释型语言",但更准确的说法是:Python 先编译成字节码,再由虚拟机解释执行字节码。它不像 C 那样直接编译成机器码。

二、各阶段详解

阶段 1:词法分析(Lexing / Tokenizing)

将源码字符串分割成一个个词法单元(Token)。

python
# 源码
x = 1 + 2 * 3

# 词法分析后得到的 tokens:
# NAME 'x'
# OP '='
# NUMBER '1'
# OP '+'
# NUMBER '2'
# OP '*'
# NUMBER '3'
# NEWLINE
# ENDMARKER

可以用 tokenize 模块查看:

python
import tokenize
import io

code = "x = 1 + 2 * 3"
tokens = list(tokenize.generate_tokens(io.StringIO(code).readline))
for tok in tokens:
    print(tok)

词法分析器负责:

  • 识别关键字、标识符、运算符、字面量等
  • 跳过空格、注释
  • 记录行号位置(用于报错)

阶段 2:语法分析(Parsing)

根据语法规则,将 Token 序列组织成抽象语法树(AST)。

python
# 源码
x = 1 + 2 * 3

# AST 结构(简化):
# Module
#   Assign
#     targets: [Name('x')]
#     value: BinOp
#       left: Constant(1)
#       op: Add()
#       right: BinOp
#         left: Constant(2)
#         op: Mult()
#         right: Constant(3)

可以用 ast 模块查看:

python
import ast

code = "x = 1 + 2 * 3"
tree = ast.parse(code)
print(ast.dump(tree, indent=2))

语法分析器负责:

  • 根据 Python 语法规则构建 AST
  • 检查语法错误(如括号不匹配、缺少冒号等)
  • 运算符优先级在这一阶段确定(如 *+ 优先级高)

阶段 3:编译(Compilation)

将 AST 编译成字节码(Bytecode),生成 PyCodeObject 对象。

AST → 编译器 → 字节码指令序列

编译阶段完成的工作:

  • 遍历 AST,生成字节码指令
  • 符号表分析(确定变量的作用域)
  • 常量折叠(如 1 + 2 * 3 直接计算为 7
  • 简单的优化
python
import dis

def f():
    x = 1 + 2 * 3
    return x

dis.dis(f)

输出:

  2           0 LOAD_CONST               1 (7)  # 常量折叠,直接是 7
              2 STORE_FAST               0 (x)

  3           4 LOAD_FAST                0 (x)
              6 RETURN_VALUE

注意:1 + 2 * 3 在编译时就被计算成了 7(常量折叠优化)。

阶段 4:解释执行

虚拟机逐条解释执行字节码指令。Python 虚拟机是栈式虚拟机,所有操作通过操作数栈完成。

x = 1 + 2 * 3 为例(假设没有常量折叠):

1. LOAD_CONST 1    → 栈: [1]
2. LOAD_CONST 2    → 栈: [1, 2]
3. LOAD_CONST 3    → 栈: [1, 2, 3]
4. BINARY_MULTIPLY → 栈: [1, 6]
5. BINARY_ADD      → 栈: [7]
6. STORE_FAST x    → 栈: [],x = 7

三、PyCodeObject(字节码对象)

编译后的字节码存储在 PyCodeObject 中,也就是 .pyc 文件中存储的内容。

一个 code 对象包含:

属性说明
co_code字节码指令的原始字节
co_consts常量元组
co_names名字元组(全局变量、属性等)
co_varnames局部变量名元组
co_argcount参数个数
co_nlocals局部变量个数
co_stacksize操作数栈大小
co_filename文件名
co_firstlineno第一行行号
co_lnotab行号表(字节码偏移量到行号的映射)
python
def add(a, b):
    c = a + b
    return c

print(add.__code__.co_varnames)  # ('a', 'b', 'c')
print(add.__code__.co_consts)    # (None,)
print(add.__code__.co_nlocals)   # 3

四、相关模块

模块作用
ast解析和操作抽象语法树
dis反汇编字节码
compile编译源码为 code 对象
tokenize词法分析
marshalcode 对象的序列化(.pyc 文件用)
python
# ast 模块的常见用法
import ast

# 解析源码为 AST
tree = ast.parse("x = 1 + 2")

# 修改 AST
class MyTransformer(ast.NodeTransformer):
    pass  # 可以修改 AST 节点

# 编译 AST 为字节码
code = compile(tree, '<string>', 'exec')

# 执行
exec(code)

五、常见问题

1. Python 是解释型语言还是编译型语言?

这个问题的答案取决于你怎么定义。严格来说:

  • Python 有编译:源码编译成字节码
  • Python 是解释执行:字节码由虚拟机解释执行,不直接编译成机器码

通常把 Java、C# 这类"编译成字节码 + 虚拟机执行"的语言称为"解释型语言"或"半编译半解释",而 C、C++ 是"编译型语言"(直接编译成机器码)。

2. 为什么不直接编译成机器码?

  • 跨平台:字节码可以在任何有 Python 虚拟机的平台上运行
  • 动态性:Python 的动态特性使得静态编译成机器码非常困难
  • 简单:虚拟机实现比代码生成简单,可移植性好

3. JIT 是什么?和编译有什么关系?

JIT(Just-In-Time,即时编译)是在运行时把热点字节码编译成机器码,兼顾启动速度和执行速度。PyPy 就是带 JIT 的 Python 实现。

六、总结

Python 的执行过程:源码 → 词法分析 → 语法分析 → AST → 编译 → 字节码 → 虚拟机解释执行

  • AST:源代码的抽象语法结构的树状表示,关注语法结构
  • 字节码:虚拟机的指令序列,关注执行操作
  • 两者之间是编译的过程

理解这个过程,有助于深入理解 Python 的执行模型、性能特性和一些诡异行为。

追问延伸

  • AST 和字节码有什么区别和联系?
  • Python 的编译器做了哪些优化?
  • 什么是常量折叠?Python 还有哪些编译期优化?
  • .pyc 文件的格式是怎样的?
  • Python 虚拟机为什么用栈而不是寄存器?
  • 你用过 ast 模块吗?做过什么?
  • 什么是装饰器?它在哪个阶段执行?
  • Python 的语法分析器用的是什么算法?(LL(1) / PEG)

Q10: 什么是 Just-In-Time(JIT)编译?PyPy为什么快? 「🔴 高级」

考察点:考察对 JIT 编译技术的理解,是否清楚 PyPy 快的根本原因以及其局限性,筛选对 Python 性能优化缺乏深度认知的候选人。

参考答案

一、什么是 JIT 编译

JIT(Just-In-Time Compilation,即时编译)是一种运行时编译技术,结合了解释执行静态编译的优点。

三种执行方式对比

方式过程启动速度执行速度代表
纯解释源码 → 解释器直接执行早期的 BASIC
静态编译源码 → 编译 → 机器码 → 执行慢(编译耗时)C、C++
JIT 编译源码 → 字节码 → 解释执行 + 热点编译成机器码快(接近编译型)Java、PyPy

JIT 的工作原理

程序开始运行

解释执行字节码

监控代码执行频率

发现热点代码(频繁执行的部分)

将热点代码编译成机器码并优化

后续执行时直接运行机器码

核心思想

  • 大部分代码只执行一次,解释执行就够了
  • 小部分热点代码(循环、频繁调用的函数)执行很多次,值得花时间编译优化
  • 把时间花在刀刃上

二、PyPy 为什么快

PyPy 是一个 Python 解释器,使用 追踪 JIT(Tracing JIT) 技术,通常比 CPython 快几倍到几十倍。

1. 追踪 JIT(Tracing JIT)

PyPy 使用的是追踪 JIT,和 Java 的方法 JIT 不同。

方法 JIT(Method JIT):以函数/方法为单位编译。

追踪 JIT(Tracing JIT):以循环为单位,追踪循环的执行路径,编译热路径。

循环开始

解释执行循环,记录执行路径

循环执行次数达到阈值

把追踪到的热路径编译成机器码

后续循环直接执行机器码

为什么追踪 JIT 适合 Python

  • Python 很多时间花在循环上
  • 追踪 JIT 可以针对具体的类型和路径做激进优化
  • 可以内联很多操作,消除 Python 的抽象开销

2. 消除解释器开销

CPython 中,每个字节码指令都要由解释器分发执行,有很大的解释开销。

PyPy 的 JIT 把热点循环编译成机器码后:

  • 不再有字节码分发开销
  • 直接是机器指令执行
  • 这部分可以带来几倍的速度提升

3. 类型特化(Type Specialization)

Python 慢的一大原因是动态类型——每个操作都要做类型检查。

JIT 可以观察到变量的实际类型,然后生成针对特定类型的机器码:

python
# Python 代码
def sum_numbers(lst):
    total = 0
    for x in lst:
        total += x
    return total

CPython 执行时:

  • 每次 += 都要检查 totalx 的类型
  • 调用对应的 __add__ 方法
  • 开销很大

PyPy JIT 编译后(假设都是整数):

  • 直接生成整数加法的机器码
  • 类型检查被移动到循环外面(守卫)
  • 循环内部几乎和 C 一样快

4. 其他优化

  • 逃逸分析:分析对象是否逃逸出作用域,未逃逸的可以分配在栈上(减少 GC 压力)
  • 循环不变量外提:把循环中不变的计算移到循环外
  • 函数内联:把小函数的代码直接嵌入调用点,减少函数调用开销
  • 内存优化:更高效的对象内存布局

三、PyPy 的性能表现

任务类型PyPy vs CPython
纯 Python 循环(CPU 密集)快 10~100 倍
数值计算(纯 Python)快 5~50 倍
字符串处理快 2~10 倍
大量使用 C 扩展(如 numpy)差不多,甚至更慢
IO 密集型差不多(瓶颈在 IO)
启动速度慢(JIT 预热需要时间)

注意:PyPy 的优势在纯 Python 代码,如果程序大部分时间在执行 C 扩展(如 numpy),PyPy 优势不大甚至更慢(因为 C 扩展兼容性差)。

四、PyPy 的局限性

1. C 扩展兼容性差

这是 PyPy 最大的问题。很多常用库依赖 C 扩展:

  • numpy:支持但性能不如 CPython + numpy
  • pandas:支持有限
  • 很多其他 C 扩展:不支持或有问题

PyPy 有 CPyExt 层来兼容 CPython 的 C API,但性能和兼容性都不理想。

2. 启动慢

JIT 编译需要时间预热,短时间运行的脚本反而可能比 CPython 慢。

3. 内存占用大

JIT 编译后的代码占用内存,加上运行时数据结构,内存占用比 CPython 大。

4. 生态兼容性

一些依赖 CPython 内部实现的库可能在 PyPy 上无法运行。

五、PyPy 的适用场景

适合用 PyPy 的场景

  • 长时间运行的服务(如 Web 服务、后台任务)
  • CPU 密集型的纯 Python 代码
  • 算法计算、数据处理(不依赖 numpy/pandas)
  • 脚本执行时间长,JIT 预热成本可以忽略

不适合的场景

  • 短时间运行的脚本(启动开销大)
  • 大量使用 C 扩展(numpy、pandas 等)
  • 内存受限的环境
  • 依赖 CPython 特定行为的代码

六、其他 JIT 方案

1. Numba

Numba 是一个 JIT 编译器,使用装饰器把 Python 函数编译成机器码。

python
from numba import jit

@jit(nopython=True)
def sum_array(arr):
    total = 0.0
    for x in arr:
        total += x
    return total
  • 适合数值计算
  • 和 numpy 配合良好
  • 不需要换解释器

2. Cython

不是 JIT,是预编译(AOT,Ahead-of-Time),但效果类似:

  • 把 Python 代码(加类型标注)编译成 C 扩展
  • 编译后运行速度接近 C
  • 需要预先编译,部署略复杂

3. Pyston

基于 CPython 的 JIT 实现,兼容性更好,但性能不如 PyPy。

七、总结

维度CPythonPyPy
执行方式解释字节码解释 + JIT 编译
纯 Python 速度基准快几倍到几十倍
C 扩展兼容性最好
启动速度慢(预热)
内存占用
生态兼容性最好较好

PyPy 快的根本原因是追踪 JIT + 类型特化,把热点循环编译成高度优化的机器码,消除了 Python 解释器和动态类型的开销。但 C 扩展兼容性是它最大的软肋。

追问延伸

  • JIT 和 AOT(预编译)有什么区别?各有什么优缺点?
  • 追踪 JIT 和方法 JIT 有什么区别?
  • PyPy 的 JIT 和 Java 的 JIT 有什么不同?
  • 为什么 PyPy 对 C 扩展支持不好?
  • 什么是类型特化?为什么能带来加速?
  • 你用过 PyPy 吗?有什么坑?
  • 什么是 JIT 预热?为什么会有预热?
  • Python 官方(CPython)会加入 JIT 吗?

Q11: 什么是 Cython?和 Python 的区别? 「🟡 中级」

考察点:考察对 Cython 的了解程度,是否清楚其原理、使用方法和适用场景,筛选缺乏性能优化实战经验的候选人。

参考答案

一、什么是 Cython

Cython 是 Python 的超集,它在 Python 语法的基础上增加了静态类型支持,可以将代码编译成 C 扩展模块,从而获得接近 C 语言的性能。

Cython 代码 (.pyx)
    ↓ Cython 编译器
C 代码 (.c)
    ↓ C 编译器 (gcc/clang)
Python C 扩展模块 (.so / .pyd)

可以像普通 Python 模块一样 import 使用

二、Cython 和 Python 的区别

特性PythonCython
语法Python 语法Python 语法 + 类型扩展
执行方式解释执行字节码编译成 C 扩展,编译执行
类型系统动态类型支持静态类型
性能基准加类型后可达 C 级别(几十~上百倍加速)
编译不需要(或编译成字节码)需要先编译成 C,再编译成扩展
部署直接运行 .py需要编译,不同平台要重新编译
调试方便较麻烦(需要调试 C 代码)

三、Cython 的核心概念

1. 静态类型声明

Cython 允许声明变量的类型,编译器可以生成更高效的 C 代码。

python
# 纯 Python 版本(慢)
def sum_list(numbers):
    total = 0
    for x in numbers:
        total += x
    return total

# Cython 版本(快很多)
def sum_list(list numbers):
    cdef int total = 0  # 静态类型
    cdef int x
    for x in numbers:
        total += x
    return total

关键类型声明关键字:

  • cdef:定义 C 级别的变量、函数、类型
  • cpdef:同时生成 C 函数和 Python 包装(C 内部调用快,Python 也能调用)
  • def:普通 Python 函数(和 Python 一样)

2. cdef / cpdef / def 的区别

关键字调用方式性能可见性
defPython 调用慢(Python 函数调用)Python 可见
cdef只能 C 内部调用快(C 函数调用)Python 不可见
cpdef两者都可以C 调用快,Python 调用稍慢Python 可见
python
# 只能在 Cython 内部调用,Python 看不到
cdef int fast_func(int x, int y):
    return x + y

# Python 可以调用,内部调用也快
cpdef int medium_func(int x, int y):
    return x + y

# 普通 Python 函数
def slow_func(x, y):
    return x + y

3. 类型化内存视图(Typed Memoryviews)

用于高效操作 numpy 数组等缓冲区:

python
import numpy as np

def sum_array(double[:] arr):  # 类型化内存视图
    cdef:
        double total = 0.0
        int i
        int n = len(arr)
    for i in range(n):
        total += arr[i]
    return total

# 使用
arr = np.array([1.0, 2.0, 3.0])
print(sum_array(arr))

类型化内存视图可以直接访问数组的底层数据,避免 Python 级别的索引开销。

四、加速效果对比

以计算 100 万元素数组的和为例(大致估计):

实现方式时间加速比
Python for 循环~50 ms1x
Python sum() 内置函数~5 ms10x
Cython(无类型)~30 ms1.7x
Cython(加类型)~0.5 ms100x
numpy sum~0.3 ms160x
纯 C~0.2 ms250x

结论

  • Cython 不加类型,提升有限(减少了一点解释器开销)
  • 加了静态类型后,提升非常显著(几十到上百倍)
  • 对于数值计算,numpy 已经很快了,但 Cython 可以优化更复杂的逻辑

五、Cython 的工作流程

1. 编写 .pyx 文件

python
# fib.pyx
def fib(int n):
    cdef int a = 0, b = 1, i
    for i in range(n):
        a, b = b, a + b
    return a

2. 编写 setup.py

python
from setuptools import setup
from Cython.Build import cythonize

setup(
    ext_modules=cythonize("fib.pyx")
)

3. 编译

bash
python setup.py build_ext --inplace

生成 fib.cpython-311-x86_64-linux-gnu.so(Linux)或 fib.cp311-win_amd64.pyd(Windows)。

4. 使用

python
import fib
print(fib.fib(100))

就像普通 Python 模块一样使用。

六、Cython 的适用场景

适合使用 Cython 的场景

  1. 计算密集型循环:Python 循环慢,用 Cython 加速
  2. 封装 C/C++ 库:把已有的 C/C++ 库包装成 Python 模块
  3. numpy 性能瓶颈:numpy 不够快的复杂计算逻辑
  4. 关键路径优化:profile 后找到的热点函数,用 Cython 重写
  5. 混合 Python 和 C:大部分用 Python 快速开发,热点用 Cython 优化

不适合的场景

  1. IO 密集型:瓶颈在 IO,优化计算没用
  2. 简单逻辑:不值得花时间写 Cython
  3. 快速原型:Cython 需要编译,开发效率低
  4. 跨平台部署:每个平台都要编译一次

七、Cython 的优缺点

优点

  • 性能提升大(加类型后可达 C 级别)
  • 语法和 Python 接近,学习成本低
  • 可以逐步优化(先写纯 Python,再加类型)
  • 生态好,和 numpy 等库配合良好
  • 可以调用 C/C++ 库

缺点

  • 需要编译,开发迭代慢
  • 部署复杂(每个平台要编译)
  • 调试困难(C 级别的 bug)
  • 类型系统有学习成本
  • 不是所有 Python 特性都支持

八、Cython vs 其他加速方案

方案加速效果开发成本适用场景
numpy高(数值计算)数值计算、向量化
Numba高(数值计算)数值循环
Cython高(通用)通用计算、封装 C 库
PyPy中~高极低纯 Python 长运行
C 扩展最高极致性能

追问延伸

  • Cython 是解释型还是编译型?
  • cdef、cpdef、def 有什么区别?
  • Cython 为什么快?根本原因是什么?
  • Cython 和 PyPy 有什么区别?怎么选?
  • Cython 和 Numba 有什么区别?怎么选?
  • 你用过 Cython 吗?做过什么优化?效果如何?
  • 什么是类型化内存视图?有什么用?
  • Cython 编译后的扩展模块能反编译回 Python 吗?

Q12: Python 中的 copy 和 deepcopy 的区别和实现原理? 「🟢 校招/初级」

考察点:考察对浅拷贝和深拷贝的理解,是否清楚两者的区别和底层实现,筛选基础不扎实的初级候选人。

参考答案

一、什么是浅拷贝和深拷贝

Python 中,对象的拷贝分为两种:浅拷贝(shallow copy)深拷贝(deep copy)

浅拷贝(copy.copy)

创建一个新对象,但新对象内部的元素是原对象内部元素的引用(只拷贝第一层)。

python
import copy

a = [1, 2, [3, 4]]
b = copy.copy(a)  # 浅拷贝

print(a is b)      # False(外层是新对象)
print(a[2] is b[2])  # True(内层是同一个对象的引用)

# 修改内层对象,两者都会变
a[2].append(5)
print(a)  # [1, 2, [3, 4, 5]]
print(b)  # [1, 2, [3, 4, 5]]  ← 也变了!

# 修改外层(重新赋值),互不影响
a.append(6)
print(a)  # [1, 2, [3, 4, 5], 6]
print(b)  # [1, 2, [3, 4, 5]]  ← 没变

深拷贝(copy.deepcopy)

创建一个新对象,并递归地拷贝所有层级的对象(完全独立的副本)。

python
import copy

a = [1, 2, [3, 4]]
b = copy.deepcopy(a)  # 深拷贝

print(a is b)         # False
print(a[2] is b[2])   # False(内层也是新对象)

# 修改内层对象,互不影响
a[2].append(5)
print(a)  # [1, 2, [3, 4, 5]]
print(b)  # [1, 2, [3, 4]]  ← 没变!

二、对比总结

特性浅拷贝 copy.copy()深拷贝 copy.deepcopy()
外层对象新对象新对象
内层对象引用同一个递归复制,全新对象
速度慢(递归遍历)
内存占用
修改内层元素互相影响互不影响
循环引用不处理正确处理

三、实现原理

1. copy.copy() 的实现原理

copy.copy(x) 的逻辑大致如下:

  1. 首先尝试调用 x.__copy__() 方法(如果对象定义了的话)
  2. 如果没有 __copy__,根据对象类型使用不同的方式:
    • 列表:list(x)x[:]
    • 字典:dict(x)
    • 集合:set(x)
    • 自定义类:创建一个新实例,然后拷贝 __dict__
python
# 列表的浅拷贝本质上就是:
new_list = old_list[:]
# 或者
new_list = list(old_list)

# 字典的浅拷贝:
new_dict = old_dict.copy()

浅拷贝只是创建一个新的容器对象,容器内的元素还是原来的引用。

2. copy.deepcopy() 的实现原理

copy.deepcopy(x) 要复杂得多,需要递归拷贝所有层级。

核心逻辑:

  1. 维护一个 memo 字典,记录已经拷贝过的对象 → 拷贝后的对象的映射
    • 作用:防止循环引用导致无限递归;保证同一对象只拷贝一次
  2. 首先尝试调用 x.__deepcopy__(memo) 方法
  3. 如果没有,根据对象类型递归拷贝:
    • 基本类型(int、str、tuple 等不可变对象):直接返回原对象(因为不可变,不需要拷贝)
    • 列表:创建新列表,递归拷贝每个元素
    • 字典:创建新字典,递归拷贝每个 key 和 value
    • 自定义类:创建新实例,递归拷贝 __dict__ 中的每个属性

伪代码

python
def deepcopy(x, memo=None):
    if memo is None:
        memo = {}
    
    # 已经拷贝过了,直接返回(处理循环引用和同一对象的多次引用)
    if id(x) in memo:
        return memo[id(x)]
    
    # 不可变对象直接返回
    if isinstance(x, (int, float, str, bool, type(None))):
        return x
    
    # 处理列表
    if isinstance(x, list):
        new_list = []
        memo[id(x)] = new_list  # 先放入 memo,防止循环引用
        for item in x:
            new_list.append(deepcopy(item, memo))
        return new_list
    
    # 处理字典
    if isinstance(x, dict):
        new_dict = {}
        memo[id(x)] = new_dict
        for k, v in x.items():
            new_dict[deepcopy(k, memo)] = deepcopy(v, memo)
        return new_dict
    
    # ... 其他类型 ...

memo 字典的作用

防止循环引用导致的无限递归

python
a = []
a.append(a)  # 循环引用:a[0] 就是 a 自己

# deepcopy 可以正确处理
import copy
b = copy.deepcopy(a)
print(b)       # [[...]]
print(b[0] is b)  # True(循环关系也被复制了)

如果没有 memo,deepcopy 会无限递归下去,导致栈溢出。

保证同一对象只拷贝一次

python
inner = [1, 2]
outer = [inner, inner]  # 两个元素引用同一个列表

import copy
new_outer = copy.deepcopy(outer)
print(new_outer[0] is new_outer[1])  # True(还是同一个对象,关系被保留)

四、不可变对象的特殊情况

对于不可变对象(int、str、tuple、frozenset 等),浅拷贝等于自身:

python
import copy

s = "hello"
print(copy.copy(s) is s)       # True(不可变,不需要拷贝)
print(copy.deepcopy(s) is s)   # True(不可变,深拷贝也返回自身)

t = (1, 2, 3)
print(copy.copy(t) is t)       # True

原因:不可变对象无法修改,所以拷贝没有意义,直接返回原对象即可,节省内存和时间。

但是,如果元组内部包含可变对象,深拷贝会递归处理:

python
t = (1, [2, 3])
t2 = copy.deepcopy(t)
print(t is t2)        # False(元组内有可变对象,深拷贝创建了新元组)
print(t[1] is t2[1])  # False(内部列表也被拷贝了)

五、常见的浅拷贝方式

除了 copy.copy(),还有很多方式可以做浅拷贝:

python
# 列表
new = old[:]
new = list(old)
new = old.copy()

# 字典
new = old.copy()
new = dict(old)

# 集合
new = old.copy()
new = set(old)

# 通用
import copy
new = copy.copy(old)

这些都是浅拷贝,效果类似。

六、什么时候用浅拷贝,什么时候用深拷贝

用浅拷贝的场景

  • 只需要一个新的容器,不需要修改内部元素
  • 内部元素都是不可变的(如数字、字符串)
  • 性能敏感,深拷贝太慢
  • 确定不会修改内部对象

用深拷贝的场景

  • 需要完全独立的副本,修改任何层级都不影响原对象
  • 对象内部有复杂的嵌套结构
  • 对象包含可变的内部元素
  • 不确定内部元素是否会被修改

七、性能和内存对比

python
import copy
import time

# 创建一个大的嵌套列表
big = [[i for i in range(1000)] for _ in range(1000)]

# 浅拷贝
start = time.time()
shallow = copy.copy(big)
print(f"浅拷贝耗时: {time.time() - start:.6f}s")  # 很快

# 深拷贝
start = time.time()
deep = copy.deepcopy(big)
print(f"深拷贝耗时: {time.time() - start:.6f}s")  # 慢很多

深拷贝的时间和内存开销都远大于浅拷贝,因为需要递归遍历和复制所有层级。

追问延伸

  • 浅拷贝和深拷贝的区别是什么?举个例子说明。
  • deepcopy 中的 memo 字典有什么用?
  • 不可变对象的深拷贝是什么样的?为什么?
  • 除了 copy 模块,还有哪些方式可以做浅拷贝?
  • 什么情况下用浅拷贝就够了?什么情况下必须用深拷贝?
  • deepcopy 能处理所有类型的对象吗?
  • 自定义类的拷贝是怎么实现的?可以自定义拷贝行为吗?
  • 你在项目中遇到过因为浅拷贝/深拷贝导致的 bug 吗?