引用计数
标记清除
分代收集

Python 内存池

  • 为什么需要内存池
    当创建大量消耗小内存的对象时,频繁调用new/malloc会导致大量的内存碎片,致使效率降低。内存池的作用就是预先在内存中申请一定数量的,大小相等的内存块留作备用,当有新的内存需求时,就先从内存池中分配内存给这个需求,不够之后再申请新的内存。这样做最显著的优势就是能够减少内存碎片,提升效率
  • Python中的内存管理机制为Pymalloc
  • 内存释放
    当一个对象的引用计数变为0时,Python就会调用它的析构函数。调用析构函数并不意味着最终一定会调用free来释放内存空间,如果真是这样的话,那频繁地申请、释放内存空间会使Python的执行效率大打折扣。因此在析构时也采用了内存池机制,从内存池申请到的内存会被归还到内存池中,以避免频繁地申请和释放动作

整数对象池和inter机制

整数对象池

当 int 是 [-5, 257) 这些小整数时,它会被定义在了对象池里。所以当引用小整数时会自动引用整数对象池里的对象

>>> a=1
>>> b=1
>>> id(a)==id(b)
True
>>> a=257
>>> b=257
>>> id(a)==id(b)
False

inter机制

string 对象也是不可变对象, python 有个 intern 机制, 简单说就是维护一个字典, 这个字典维护已经创建字符串 (key) 和它的字符串对象的地址(value), 每次创建字符串对象都会和这个字典比较, 没有就创建, 重复了就用指针进行引用就可以了

In [1]: a="hello"
In [2]: b="hello"

In [3]: id(a)
Out[3]: 4475338800
In [4]: id(b)
Out[4]: 4475338800

In [5]: del a
In [6]: del b

In [7]: c="hello"
In [8]: id(c)
Out[8]: 4584876208

如果上述例子你运行发现内存地址并没有变,尝试在ipython(pip install ipython)里面试试
python解释器

In [1]: a="hello world"
In [2]: b="hello world"

In [3]: id(a)
Out[3]: 4353970416
In [4]: id(b)
Out[4]: 4355158512

结论

  1. 小整数[-5,257)共用对象,常驻内存
  2. 单个字符共用对象,常驻内存
  3. 字符串(不含空格)不可修改,默认开启intern机制,共用对象,引用计数为0则销毁,
  4. 字符串(含有空格)不可修改,没开启intern机制,不共用对象,引用计数为0则销毁
  5. 数值类型和字符串类型在 Python 中都是不可变的,无法修改这个对象的值,每次对变量的修改,实际上是创建一个新的对象

补充阅读python 单例对象和inter机制

python的垃圾回收

先说结论,python采用的是引用计数机制为主,标记-清除(mark-sweep)和分代收集/分代回收(generational collection)两种机制为辅的策略

引用计数

Python 中一切皆对象。因此,你所看到的一切变量,本质上都是对象的一个指针。当这个对象的引用计数(指针数)为 0 的时候,说明这个对象永不可达,自然它也就成为了垃圾,需要被回收

  1. 计算当前python程序占用的内存
    安装第三方库psutil
pip install psutil

计算内存占用函数show_memory_info

def show_memory_info(hint):
    pid = os.getpid()
    p = psutil.Process(pid)

    info = p.memory_full_info()
    memory = info.uss/1024/1024
    print('{} memory used: {} MB'.format(hint, memory))
  1. 引用计数为0,垃圾回收后内存占用前后对比
def func():
    show_memory_info('initial')
    a = [i for i in range(10000000)]
    show_memory_info('after a created')

func()
show_memory_info('finished')
---------------------输出结果---------------
initial memory used: 5.90234375 MB
after a created memory used: 389.3828125 MB
finished memory used: 9.08203125 MB
  1. 修改如上代码,引用计数不为0
def func():
    show_memory_info('initial')
    global a
    a = [i for i in range(10000000)]
    show_memory_info('after a created')

func()
show_memory_info('finished')
----------------------输出结果-----------------------
initial memory used: 5.84765625 MB
after a created memory used: 389.3203125 MB
finished memory used: 389.3203125 MB

global a 表示将 a 声明为全局变量。那么,即使函数返回后,列表的引用依然存在,于是对象就不会被垃圾回收掉,依然占用大量内存

def func():
    show_memory_info('initial')
    a = [i for i in range(10000000)]
    show_memory_info('after a created')
    return a


a = func()
show_memory_info('finished')
-------------输出结果------------------
initial memory used: 5.87890625 MB
after a created memory used: 389.34765625 MB
finished memory used: 389.34765625 MB

a在主程序中接收,引用依然存在,垃圾回收就不会被触发,大量内存仍然被占用着

  • sys.getrefcount()
    这个函数可以以查看一个变量的引用次数,但getrefcount函数本身也会引入一次计数
import sys

a = []
print(sys.getrefcount(a))  # 2次

b = a
print(sys.getrefcount(a))  # 3次

c = b
d = b
e = c
f = e
g = d
print(sys.getrefcount(a))  # 8次

def func(a):
    # python的函数调用栈,函数参数
    print(sys.getrefcount(a))

func(a)  # 10次
print(sys.getrefcount(a))   # 8次

在函数调用发生的时候,会产生额外的两次引用,一次来自函数栈,另一个是函数参数

  • 手动释放内存
    先调用 del 来删除对象的引用;然后强制调用 gc.collect(),清除没有引用的对象,即可手动启动垃圾回收
import gc
import os

import psutil

def show_memory_info(hint):
    pid = os.getpid()
    p = psutil.Process(pid)

    info = p.memory_full_info()
    memory = info.uss / 1024 / 1024
    print('{} memory used: {} MB'.format(hint, memory))

show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')

del a
gc.collect()

show_memory_info('finish')
# print(a)
-----------输出结果---------------
initial memory used: 5.90625 MB
after a created memory used: 389.40625 MB
finish memory used: 8.9296875 MB
  • 引用计数的缺点
  1. 维护引用计数消耗资源
  2. 循环引用
import os
import psutil


def show_memory_info(hint):
    pid = os.getpid()
    p = psutil.Process(pid)

    info = p.memory_full_info()
    memory = info.uss / 1024 / 1024
    print('{} memory used: {} MB'.format(hint, memory))


def func():
    show_memory_info('initial')
    a = [i for i in range(10000000)]
    b = [i for i in range(10000000)]
    show_memory_info('after a, b created')
    a.append(b)
    b.append(a)


func()
show_memory_info('finished')
--------------输出结果-------------
initial memory used: 5.85546875 MB
after a, b created memory used: 721.30859375 MB
finished memory used: 721.30859375 MB

a 和 b 互相引用,并且,作为局部变量,在函数 func 调用结束后,a 和 b 这两个指针从程序意义上已经不存在了。但是,依然有内存占用,这是因为互相引用,导致它们的引用数都不为 0
只有容器对象才会产生循环引用的情况,比如列表、字典、用户自定义类的对象、元组等。而像数字,字符串这类简单类型不会出现循环引用。作为一种优化策略,对于只包含简单类型的元组也不在标记清除算法的考虑之列

标记-清除(mark-sweep)

Python采用了“标记-清除”(Mark and Sweep)算法,解决容器对象可能产生的循环引用问题,标记-清除垃圾回收分两步:

  1. 标记阶段,遍历所有的对象,如果是可达的(reachable),也就是还有对象引用它,那么就标记该对象为可达
  2. 清除阶段,再次遍历对象,如果发现某个对象没有标记为可达,则就将其回收

上面描述的垃圾回收的阶段,会暂停整个应用程序,等待标记清除结束后才会恢复应用程序的运行

  1. 示例
import sys
import os
import psutil


def show_memory_info(hint):
    pid = os.getpid()
    p = psutil.Process(pid)

    info = p.memory_full_info()
    memory = info.uss / 1024 / 1024
    print('{} memory used: {} MB'.format(hint, memory))


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

b.append(a)
print(sys.getrefcount(a))
a.append(b)
print(sys.getrefcount(b))

show_memory_info('before')
del a
del b
show_memory_info('after')
############### 输出结果 #############
2
2
3
3
before memory used: 6.1328125 MB
after memory used: 6.1328125 MB
  • a引用b,b引用a,此时两个对象各自被引用了2次(去除getrefcout()的临时引用)
  • 执行del之后,对象a,b的引用次数都-1,此时各自的引用计数器都为1,陷入循环引用
  • 标记阶段:找到其中的一端a,因为它有一个对b的引用,则将b的引用计数-1;再沿着引用到b,b有一个a的引用,将a的引用计数-1,此时对象a和b的引用次数全部为0,被标记为不可达(Unreachable)
  • 清除阶段:被标记为不可达的对象就是真正需要被释放的对象

上面描述的垃圾回收的阶段,会暂停整个应用程序,等待标记清除结束后才会恢复应用程序的运行。为了减少应用程序暂停的时间,Python 通过“分代回收”(Generational Collection)以空间换时间的方法提高垃圾回收效率

分代收集/分代回收(generational collection)

通过如上分析,我们知道循环引用对象的回收中,整个应用程序会被暂停,为了减少应用程序暂停的时间,Python 通过“分代回收”(Generational Collection)以空间换时间的方法提高垃圾回收效率

分代回收是基于这样的一个统计事实,对于程序,存在一定比例的内存块的生存周期比较短;而剩下的内存块,生存周期会比较长,甚至会从程序开始一直持续到程序结束。生存期较短对象的比例通常在 80%~90%之间。 因此,简单地认为:对象存在时间越长,越可能不是垃圾,应该越少去收集。这样在执行标记-清除算法时可以有效减小遍历的对象数,从而提高垃圾回收的速度,是一种以空间换时间的方法策略

python gc给对象定义了三种世代(0,1,2),每一个新生对象在generation zero中,如果它在一轮gc扫描中活了下来,那么它将被移至generation one,在那里他将较少的被扫描,如果它又活过了一轮gc,它又将被移至generation two,在那里它被扫描的次数将会更少

当某一世代中被分配的对象与被释放的对象之差达到某一阈值的时候,就会触发gc对某一世代的扫描。当某一世代的扫描被触发的时候,比该世代年轻的世代也会被扫描。也就是说如果世代2的gc扫描被触发了,那么世代0,世代1也将被扫描,如果世代1的gc扫描被触发,世代0也会被扫描

import gc

# get_threshold(): Return the current collection thresholds.
print(gc.get_threshold())  # (700, 10, 10)
  • 700=新分配的对象数量-释放的对象数量,第0代gc扫描被触发
  • 第一个10:第0代gc扫描发生10次,则第1代的gc扫描被触发
  • 第二个10:第1代的gc扫描发生10次,则第2代的gc扫描被触发

总结

总体而言,python通过内存池来减少内存碎片化,提高执行效率。主要通过引用计数来完成垃圾回收,通过标记-清除解决容器对象循环引用造成的问题,通过分代回收提高垃圾回收的效率

调试内存泄漏

有了自动回收机制,但这也不是万能的,难免还是会有漏网之鱼,我们可以使用第三方包objgraph来可视化引用关系

更多使用方式可参考objgraph官方文档

参看及扩展阅读