MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python内存管理与垃圾回收机制

2024-04-023.7k 阅读

Python内存管理基础

内存管理概述

在计算机编程中,内存管理是一个至关重要的环节。它负责分配和释放程序运行过程中使用的内存空间。在Python中,虽然开发者无需像在C或C++中那样手动管理大部分内存,但了解其内存管理机制对于编写高效、稳定的程序仍然十分必要。

Python采用了一种自动内存管理系统,这意味着Python会自动处理内存的分配和释放。当你创建一个新的对象时,Python会自动为其分配内存空间;当这个对象不再被使用时,Python会自动回收其占用的内存。这种自动化的机制大大减轻了开发者的负担,使他们能够更加专注于业务逻辑的实现。

Python中的对象与内存

在Python中,一切皆对象。每个对象都有其对应的内存表示。例如,当你创建一个整数对象时:

a = 10

Python会在内存中为这个整数对象分配一定的空间来存储值10。同时,a是一个引用,它指向这个内存中的对象。

Python中的对象有几个重要的属性:

  1. 引用计数:这是Python内存管理中的一个关键概念,每个对象都有一个引用计数,记录了指向该对象的引用数量。例如,在上述代码中,对象10的引用计数为1,因为变量a指向了它。
  2. 类型信息:每个对象都知道自己的类型,比如上述10int类型的对象。
  3. :即对象所存储的数据,如10这个整数值。

内存分配策略

Python的内存分配并不是每次都直接向操作系统请求内存。为了提高效率,Python采用了分层的内存分配策略。

  1. 小对象分配:对于一些小型对象(通常小于512字节),Python使用一种称为pymalloc的内存分配器。pymalloc在内部维护了一个内存池,当需要分配小对象时,它会首先尝试从内存池中获取内存。如果内存池没有足够的空间,它会向操作系统请求一块较大的内存,然后将其分割成小块供对象使用。这种方式减少了对操作系统的频繁请求,提高了分配效率。例如,当你创建多个小字符串对象时:
s1 = 'abc'
s2 = 'def'

这些小字符串对象的内存分配很可能是从pymalloc的内存池中获取的。

  1. 大对象分配:对于大于512字节的大对象,Python会直接向操作系统请求内存。例如,当你创建一个非常大的列表或数组时:
big_list = [i for i in range(1000000)]

这个大列表的内存分配会直接调用操作系统的内存分配函数。

Python引用计数

引用计数原理

引用计数是Python内存管理的基础机制之一。如前文所述,每个对象都有一个引用计数,用于记录指向该对象的引用数量。当一个对象的引用计数变为0时,意味着没有任何变量或数据结构指向它,Python会立即回收该对象占用的内存。

我们可以通过sys.getrefcount()函数来获取对象的引用计数(注意,由于函数调用本身会增加一次引用计数,所以返回值会比实际引用计数多1)。例如:

import sys
a = [1, 2, 3]
print(sys.getrefcount(a))  
b = a
print(sys.getrefcount(a))  
del b
print(sys.getrefcount(a))  

在上述代码中,首先创建列表a,此时其引用计数为1(加上getrefcount函数调用的额外一次引用)。然后将a赋值给b,引用计数增加到2(加上函数调用的一次引用)。当删除b后,引用计数变回1(加上函数调用的一次引用)。

引用计数的增减

  1. 引用计数增加
    • 变量赋值:当你将一个对象赋值给一个新的变量时,引用计数会增加。例如:
x = [4, 5, 6]
y = x  

此时列表对象[4, 5, 6]的引用计数增加1。 - 将对象作为参数传递给函数:在函数调用过程中,对象的引用计数会增加。例如:

def func(lst):
    pass
my_list = [7, 8, 9]
func(my_list)  

在函数func调用期间,my_list对象的引用计数增加。

  1. 引用计数减少
    • 变量被删除:使用del语句删除变量时,对象的引用计数会减少。例如:
z = [10, 11, 12]
del z  

此时列表对象[10, 11, 12]的引用计数减少1。 - 函数返回:当函数返回时,传递给函数的对象的引用计数会减少(如果函数内部没有增加额外的持久引用)。例如在上述func函数返回后,my_list对象的引用计数恢复到调用前的值。

引用计数的优缺点

  1. 优点

    • 即时回收:引用计数为0时,对象可以立即被回收,这使得内存能够快速得到释放,对于短期使用的对象非常高效。
    • 简单高效:实现相对简单,对于大多数常规的对象生命周期管理表现良好。
  2. 缺点

    • 循环引用问题:当两个或多个对象相互引用形成循环时,这些对象的引用计数永远不会变为0,导致内存泄漏。例如:
class A:
    def __init__(self):
        self.b = None
class B:
    def __init__(self):
        self.a = None
a = A()
b = B()
a.b = b
b.a = a
del a
del b  

在上述代码中,AB类的实例ab相互引用,即使删除了ab变量,由于循环引用,这两个对象的引用计数都不会变为0,它们占用的内存无法被回收。 - 维护开销:每次引用计数的增减都需要额外的操作,对于大量对象的频繁引用计数变化,会带来一定的性能开销。

Python垃圾回收机制

垃圾回收概述

由于引用计数存在循环引用的问题,Python引入了垃圾回收机制(Garbage Collection,简称GC)来处理这种情况。垃圾回收机制会定期检查那些引用计数不为0但实际上已经无法访问的对象,并回收它们占用的内存。

Python的垃圾回收器是基于标记 - 清除(Mark - Sweep)算法和分代回收(Generational Collection)算法实现的。

标记 - 清除算法

  1. 原理:标记 - 清除算法分为两个阶段:标记阶段和清除阶段。
    • 标记阶段:垃圾回收器会从根对象(如全局变量、栈上的变量等)出发,遍历所有可达的对象,并为这些可达对象做上标记。在Python中,根对象是那些可以直接访问到的对象,比如全局命名空间中的对象、当前栈帧中的局部变量等。
    • 清除阶段:在标记完成后,垃圾回收器会遍历堆内存中的所有对象,对于那些没有被标记的对象(即不可达对象),认为它们是垃圾,并回收它们占用的内存。

例如,假设有以下代码:

class C:
    def __init__(self):
        pass
c1 = C()
c2 = C()
c1.next = c2
c2.prev = c1
del c1
del c2  

在上述代码中,c1c2形成了循环引用。垃圾回收器在标记阶段会从根对象(这里假设全局命名空间中没有其他对c1c2的引用)出发,无法访问到这两个相互引用的对象,所以它们不会被标记。在清除阶段,垃圾回收器会回收这两个对象占用的内存。

  1. 实现细节:Python在实现标记 - 清除算法时,会维护一个双向链表来记录所有可能存在垃圾的对象。在标记阶段,从根对象开始遍历,将可达对象从链表中移除。清除阶段则处理链表中剩余的未标记对象。

分代回收算法

  1. 原理:分代回收基于这样一个假设:新创建的对象很可能很快就不再被使用,而存活时间较长的对象更有可能继续存活。因此,Python将对象分为不同的代(Generation)。
    • 年轻代:新创建的对象通常位于年轻代。年轻代的对象在经过一定次数的垃圾回收后,如果仍然存活,会被晋升到更老的一代。
    • 老年代:存活时间较长的对象会被晋升到这里。

垃圾回收器会更频繁地检查年轻代,因为年轻代中的对象更有可能成为垃圾。这样可以提高垃圾回收的效率,减少整体的性能开销。

  1. 代的管理:Python默认有三代对象,分别是0代、1代和2代。每一代都有一个垃圾回收阈值。当某一代中的对象数量达到其垃圾回收阈值时,就会触发针对这一代的垃圾回收。例如,0代的阈值默认是700,当0代中的对象数量达到700时,会触发0代的垃圾回收。在垃圾回收过程中,存活的对象会根据其代的规则进行晋升。

垃圾回收的控制

  1. 手动触发垃圾回收:在Python中,你可以使用gc模块手动触发垃圾回收。例如:
import gc
# 手动触发垃圾回收
gc.collect()  

这在某些特定场景下非常有用,比如在程序执行一些大规模内存操作后,希望立即回收不再使用的内存。

  1. 设置垃圾回收参数:你可以通过gc模块的函数来设置垃圾回收的相关参数,如代的阈值等。例如,要设置0代的垃圾回收阈值:
import gc
gc.set_threshold(700, 10, 10)  

上述代码设置0代的阈值为700,1代和2代的阈值分别为10(这里1代和2代的阈值设置仅为示例,实际应用中可根据需求调整)。

Python内存管理的高级话题

弱引用

  1. 弱引用概念:在Python中,除了常规的强引用(即普通的变量引用),还存在弱引用。弱引用不会增加对象的引用计数。当对象的所有强引用都消失后,即使存在弱引用,对象也会被垃圾回收。弱引用主要用于在需要引用对象但又不希望影响其生命周期的场景。

  2. 使用弱引用:可以通过weakref模块来使用弱引用。例如:

import weakref
class D:
    def __init__(self):
        pass
d = D()
weak_ref = weakref.ref(d)
del d
# 尝试通过弱引用获取对象
obj = weak_ref()
if obj is not None:
    print('对象仍然存在')
else:
    print('对象已被回收')  

在上述代码中,创建了一个D类的实例d的弱引用weak_ref。当删除d(即所有强引用消失)后,通过弱引用尝试获取对象,如果对象已被回收,则weak_ref()会返回None

内存池优化

  1. 对象复用:除了pymalloc的内存池机制,Python在一些对象类型上还实现了对象复用。例如,对于小整数对象,Python会在启动时预先创建一定范围内的整数对象,并在程序运行过程中复用这些对象。默认情况下,范围为-5256的整数对象会被缓存复用。例如:
a = 10
b = 10
print(a is b)  

上述代码中,ab实际上指向同一个预创建的整数对象,所以a is b返回True

  1. 字符串驻留:对于字符串对象,Python也有类似的优化机制,称为字符串驻留。如果两个字符串具有相同的内容,并且满足一定条件(如字符串只包含字母、数字和下划线,且长度较短等),Python会让它们共享同一个内存对象。例如:
s3 = 'hello_world'
s4 = 'hello_world'
print(s3 is s4)  

在上述代码中,s3s4很可能指向同一个字符串对象,s3 is s4返回True

内存分析工具

  1. memory_profiler:这是一个用于分析Python程序内存使用情况的工具。你可以使用pip install memory_profiler安装它。使用时,在需要分析的函数或代码块前加上@profile装饰器(需在命令行中指定mprof run来运行脚本)。例如:
from memory_profiler import profile

@profile
def memory_intensive_function():
    data = [i for i in range(1000000)]
    return data

memory_intensive_function()  

运行mprof run script.py后,使用mprof plot可以生成内存使用情况的图表,直观地看到函数运行过程中的内存变化。

  1. objgraph:这个工具可以帮助你分析对象之间的引用关系,尤其是在查找循环引用时非常有用。你可以使用pip install objgraph安装它。例如,要查找所有list类型对象之间的引用关系:
import objgraph
lists = objgraph.by_type('list')
for lst in lists:
    print(objgraph.show_backrefs([lst], max_depth = 3))  

上述代码会打印出所有list对象及其最多三层的反向引用关系,有助于定位循环引用等问题。

编写高效内存使用的Python代码

优化数据结构使用

  1. 选择合适的数据结构:在Python中,不同的数据结构在内存占用和性能上有很大差异。例如,如果你需要频繁插入和删除元素,deque(双端队列)可能比list更合适。deque在两端进行插入和删除操作的时间复杂度为O(1),而list在头部插入的时间复杂度为O(n)。
from collections import deque
# 使用deque
dq = deque()
dq.append(1)
dq.appendleft(2)
# 使用list
lst = []
lst.append(1)
# 在list头部插入元素效率较低
lst.insert(0, 2)  

如果需要快速查找元素,setdict通常是更好的选择,因为它们基于哈希表实现,查找操作的平均时间复杂度为O(1),而list的查找时间复杂度为O(n)。

  1. 避免不必要的数据复制:在对数据进行操作时,要注意避免不必要的数据复制。例如,在对列表进行切片操作时,如果不指定步长,切片操作会创建一个新的列表对象。如果你只是想遍历列表的一部分,可以使用生成器表达式来避免创建新的列表。
my_list = [1, 2, 3, 4, 5]
# 切片操作创建新列表
new_list = my_list[1:3]
# 使用生成器表达式
gen = (i for i in my_list if i > 2)  

生成器与迭代器

  1. 生成器的优势:生成器是一种特殊的迭代器,它在生成数据时不会一次性将所有数据存储在内存中,而是按需生成。例如,如果你需要生成一个非常大的数字序列,使用生成器可以大大节省内存。
def number_generator(n):
    for i in range(n):
        yield i
gen = number_generator(1000000)
for num in gen:
    print(num)  

在上述代码中,number_generator是一个生成器函数,它不会一次性生成100万个数字并存储在内存中,而是在每次迭代时生成一个数字。

  1. 迭代器的使用:迭代器对象实现了__iter____next__方法,允许你逐个访问数据。许多Python内置函数和数据结构都返回迭代器,如range()函数返回的是一个可迭代对象(在Python 3中),而不是像Python 2中那样返回一个列表。使用迭代器可以避免一次性加载大量数据到内存。
# range返回可迭代对象
for i in range(1000000):
    pass  

及时释放资源

  1. 使用with语句:在处理文件、数据库连接等资源时,使用with语句可以确保资源在使用完毕后及时关闭和释放。例如,在处理文件时:
with open('test.txt', 'r') as f:
    data = f.read()  

with语句块结束后,文件对象f会自动关闭,释放相关资源。

  1. 手动释放资源:在一些情况下,可能需要手动释放资源。例如,在使用numpy数组进行大规模数值计算后,如果不再需要这些数组占用的内存,可以使用del语句删除数组对象,促使Python回收内存。
import numpy as np
arr = np.array([1, 2, 3, 4, 5])
# 使用完数组后删除
del arr  

通过深入理解Python的内存管理和垃圾回收机制,并合理运用上述优化技巧,开发者可以编写出更加高效、内存友好的Python程序,提升程序的性能和稳定性。无论是处理大规模数据的科学计算,还是开发高并发的网络应用,良好的内存管理都是关键所在。同时,不断关注Python内存管理机制的更新和优化,也有助于我们在不同版本的Python环境中更好地利用其特性。