第二周:Python3的内存管理

运行的Python3版本为3.6.4。

IDE为PyCharm2018.

首先

x = 20

这里的x是在Python3中是一个引用,指向对象20

其次,通过id()方法可以来查看对象的地址,该方法返回值为十进制数值。

那么

c = 2.0
d = 2.0
print(id(c), id(d), id(2.0)) # 2591934537544 2591934537544 2591934537544
print(c == d)  # True
print(c is d)  # True

c = 23456789.012345679
d = 23456789.012345679
print(id(c), id(d), id(23456789.012345679)) # 2591934537112 2591934537112 2591934537112
print(c == d)  # True
print(c is d)  # True

a = 123456789.0123456798
b = 123456789 + 0.0123456798
print(id(123456789.0123456798), id(a), id(b))  # 2207184187632 2207184187632 2207181987752
print(a == b)  # True 两者的值相同
print(a is b)  # False 
# 从输出来看地址不同,这说明通过计算得到相同结果b,这个结果b的地址不同于a
# 即动态计算后重新给结果分配地址

print("%x"%id(a), hex(id(a)))  # 201e66df0f0 0x201e66df0f0


str1 = "哈哈哈哈哈哈哈哈哈呵呵呵呵呵呵呵嘿嘿嘿嘿嘿嘿嘿"
str2 = "哈哈哈哈哈哈哈哈哈呵" + "呵呵呵呵呵呵嘿嘿嘿嘿嘿嘿嘿"
print(id(str1), id(str2), id("哈哈哈哈哈哈哈哈哈呵呵呵呵呵呵呵嘿嘿嘿嘿嘿嘿嘿"))
# 1978135986344 1978539980960 1978135986344
print(str1 == str2)  # True
print(str1 is str2)  # False

其中==是比较值,is是比较地址。

其实可以通过%x或者hex()来将十进制数转成十六进制,一般内存地址都用十六进制表示。

另外

print(id(-1024))  # 2112961157104
print(id(-512))  # 2112961157136
print(id(-256))  # 2112961157168
print(id(-128))  # 2112961157200
print(id(-64))  # 2112961157232
print(id(-32))  # 2112961157264
print(id(-16))  # 2112961157296
print(id(-8))  # 2112961157328
print(id(-6))  # 2112961157360
print(id(-5))  # 1775398176
print(id(-4))  # 1775398208
print(id(-3))  # 1775398240
print(id(-2))  # 1775398272 
print(id(-1))  # 1775398304 
print(id(0))  # 1775398336  
print(id(1))  # 1775398368  0x69d26de0
print(id(2))  # 1775398400  
print(id(3))  # 1775398432
print(id(4))  # 1775398464
print(id(8))  # 1775398592
print(id(16))  # 1775398848
print(id(32))  # 1775399360
print(id(64))  # 1775400384
print(id(128))  # 1775402432
print(id(256))  # 1775406528
print(id(257))  # 2106773662832  这是第二遍执行代码是的地址,所以与上面负值的地址有不同
print(id(258))  # 2106773662800
print(id(512))  # 2106773662672
print(id(1024))  # 2108514667280

print(id('a'))  # 1971266667328
print(id('b'))  # 1971266649200
print(id('c'))  # 1971265848688
print(id('A'))  # 1971267734136
print(id('B'))  # 1971266981648
print(id('C'))  # 1971266981536
print(id('+'))  # 1971269703528
print(id('-'))  # 1971265849472
print(id('*'))  # 1971265502488
print(id('/'))  # 1971265693096

Python3对于[-5, 256]内到小整数会提前缓存到内存中,为了重复使用,以提高效率。

但是在网上有看到说一些小的字符也会提前缓存,不过我不知道怎么能实验得到。

==和is

a = 1.0
b = 1.0
print(a == b)  # True
print(a is b)  # True

str1 = "这是字符串"
str2 = "这是字符串"
print(str1 == str2)  # True
print(str1 is str2)  # True


t1 = ()
t2 = ()
print(t1 == t2)  # True
print(t1 is t2)  # True

l1 = []
l2 = []
print(l1 == l2)  # True
print(l1 is l2)  # False

d1 = {}
d2 = {}
print(d1 == d2)  # True
print(d1 is d2)  # False

s1 = set()
s2 = set()
print(s1 == s2)  # True
print(s1 is s2)  # False


class A:
    pass

x = A()
y = A()
print(x == y)  # False
print(x is y)  # False

可以看到在Python3中对于不可变类型数字类型:int、bool、float、complex、long(Py2.x);字符串 str;元组 tuple定义时的值相同,那么都是对相同对象引用,即变量指向同一个对象

而对于可变类型列表 list;字典 dict;集合 set,哪怕值相同,也是新创建的对象,变量对不同对象(但值可能相同)的引用。

自定义类也类似。

引用计数

在Python中每个对象都存有指向该对象的引用总数,称作引用计数(reference count)。

对于引用计数,可以通过sys包中的getrefcount()函数来查看。但是使用某个对象的引用作为该函数的参数时,这个对象就又被引用了一次,所以减去1才能得到,代码中其他地方对该对象的引用次数。

from sys import getrefcount

# 引用计数
a = 1
b = a
c = b
print(getrefcount(a))  # 4646


d = [4, 5, 6]
print(getrefcount(d))  # 2
e = d
print(getrefcount(e))  # 3

其实这里对象[4, 5, 6]被 变量d 引用1次,被 变量e 引用1次,被getrefcount()内的参数引用了1次,所有最后是3次。

但是直接getrefcount([4, 5, 6])引用了1次,把[4, 5, 6]当作新对象了,id()查看地址可以发现二者地址不同

print(getrefcount([4, 5, 6]))  # 1
print(id(d), id([4, 5, 6]))  # 2345607282440 2345612824648 id()并不涉及对应引用

最后将 变量d 放进 变量f 中,变量d对应的 [4, 5, 6]又被引用了2次,但是对于 变量f 是一个新对象,也有自己的地址,只不过其内存空间内存了两个变量d的[4, 5, 6]的引用罢了。

f = [d, d]
print(getrefcount(d))  # 5
print(getrefcount(f))  # 2

Python中del关键字和del()函数可以删除某个引用

del d
print(getrefcount(e))  # 4 删除了d
del(e)
print(getrefcount(f[0]))  # 3 删除了e

换句话来说,就是在最刚开始创建的对象[4, 5, 6]

  • 被 变量d 引用1次

  • 被 变量e 引用1次

  • 被 变量f的列表 引用了2次

  • 通过del关键字或del()函数

    • 删除 变量d,对象[4, 5, 6]的引用 减1
    • 删除 变量e,对象[4, 5, 6]的引用 减1
    • 删除 变量f,对象[4, 5, 6]的引用 减2
    • 删除 f[0]或f[1],对象[4, 5, 6]的引用 减1
    • 当变量d/e/f或f[0]/f[1]引用了别的对象,对象[4, 5, 6]的引用都会相应的 减1
  • 使用getrefcount()传入变量(比如变量d)查看 变量d 引用的对象(即[4, 5, 6])的引用次数,函数内部的形参会再引用1次对象[4, 5, 6]。这1次,会算在返回的引用次数中

  • 另外如果直接getrefcount([4, 5, 6]),这里面的对象[4, 5, 6]与变量d/e/f引用的[4, 5, 6]不是同一个对象,而是一个新创建的对象,会被函数内部形参引用1次。

    • 因此最终输出的引用次数是,1。

变量引用了别的对象,原对象的引用计数减1。

对象引用对象

class A:
    def __init__(self, obj):
        self.obj = obj;

x = []
y = [x]
z = {1: x, 2: y}
a = A(z)

print(id(x), id(y[0]), id(z[1]))  # 1419887596040 1419887596040 1419887596040
print(id(y), id(z[2]))  # 1419887596296 1419887596296
print(id(z), id(a.obj))  # 1421627810224 1421627810224
print(getrefcount(x))  # 4
print(getrefcount(y))  # 3
print(getrefcount(z))  # 3

这里变量x引用的对象[]

  • 被 变量x 引用了1次

  • 被 变量y引用的对象[x] 引用了1次

  • 被变量z引用的对象{1: x, 2: y} 引用了1次

  • 这里 变量a引用的的对象A() 只是引用了 变量z引用的对象{1: x, 2: y},因此并不会直接对 变量x引用的对象[] 进行引用

    • 因此,引用计数不会再 加1

另外这也是深拷贝浅拷贝的原理所在。

垃圾回收

Python3垃圾回收机制(garbage collection)会将引用计数为0 的对象从内存上销毁。

x = {}  # 对象{}被创建,其引用计数为1
del x  # 删除对象{}的引用x,对象{}的引用计数减1,降为0

但是并不是引用计数降为0了,该对象就立即被销毁。

只有当Python3运行垃圾回收程序时,程序才会去扫描内存,并将引用计数为0 的对象从内存上销毁。

值得注意的是,Python在3运行垃圾回收程序时,无法进行其他任务,而且运行垃圾回收程序也会消耗资源,对象特别少也没必要清理。

因此Python3会在特定条件下,自动启动垃圾回收。

  • Python3在运行时,会记录其分配对象(object allocation)取消分配对象(object deallocation)的次数,当两者的差值高于某个阈值,垃圾回收才会启动。

    •   import gc
        print(gc.get_threshold())  # (700, 10, 10)
        
        print(gc.get_count())  # (1, 0, 2)  0代 1代 2代的数量
        gc.collect()  # 手动启动垃圾回收
        print(gc.get_count())  # (0, 0, 0)
      
    • 通过gc模块的get_threshold()方法可以查看阈值,其中两个10是与分代回收有关的阈值。700是垃圾回收启动的阈值。

    • 可以通过set_threshold()方法来重新设置垃圾回收启动的阈值。

  • 通过gc.disable()关闭自动的垃圾回收,改为手动。

  • 通过gc.collect()手动启动垃圾回收。

分代回收

Python3中还采用了分代(generation)回收策略。

对于存活越久的对象,越不可能在后续的程序中变成垃圾。

因此,处于信任和效率,用来描述这些对象在Python3中的存活时间。

  • 新创建的对象为0代对象

  • 0代对象经历一定次数的垃圾回收后存活,就变为1代对象

  • 1代对象经历一定次数的垃圾回收后存活,就变为2代对象

  • 上面打印的阈值(700, 10, 10)

    • 第二个阈值10为:0代经历垃圾回收10次后,会对1代进行1次垃圾回收
    • 第三个阈值10为:1代经历垃圾回收10次后,会对2代进行1次垃圾回收
  • 可以通过gc.set_threshold()来设置这三个阈值

  • 存活的越久被回收的概率就越低

  • 另外手动调用垃圾回收时gc.collect(generation)可以传递参数(0、1、2)

    • 输入0,对0代进行垃圾回收,重置阈值(700, 10, 10)中第一个阈值
    • 输入1,对0、1代进行垃圾回收,重置阈值(700, 10, 10)中第一、二阈值
    • 输入2,对0、1、2代进行垃圾回收,重置阈值(700, 10, 10)中三个阈值

引用环

Python中两个对象互相引用

x = []
y = [x]
x.append(y)
print(x)  # [[[...]]]
print(getrefcount(x))  # 3

# 或者 我引用我
z = []
z.append(z)
print(z)  # [[...]]
print(getrefcount(z))  # 3

这样就构成了一个引用环(reference cycle),或叫循环引用

这同时就会存在一个问题

x = []
y = [x]
x.append(y)
print(x)  # [[[...]]]
print(getrefcount(x))  # 3

# 当
del x
del y
  • 删除 变量x ,外层对象[]的引用 减1
  • 删除 变量y,内层对象[]的引用 减1
  • 但是由于内层对象[]和外层对象[]互相引用,因此二者最终的引用次数都为1。这就无法被垃圾回收掉。
    • 因此在代码中要避免出现引用环。

对象的__del__()方法

Python3中__del__()方法指出了在用del关键字消除对象时除了释放内存空间以外的操作。

对象的__del__()是对象在被垃圾回收起作用的方法。

from sys import getrefcount
class A:
    
    def __del__(self):
        print("销毁对象A")


a = A()
del a  # 打印 销毁对象A

class B:
    
    def __del__(self):
        print("销毁对象B")
        del self
        print("销毁了吗")


b = B()
# 或 B.__del__(b)
b.__del__()  # 打印 销毁对象B 销毁了吗
print(id(b))  # 打印 1903121153384
print(getrefcount(b))  # 2

# ... 其他代码

#  如果在 其他代码前执行了del b 然后再 gc.collect() 就会再次打印 销毁对象B 销毁了吗
#  不然会在 其他代码全执行完后(程序结束前)打印了 销毁对象B 销毁了吗
#  都是在打印过后才真正在内存上销毁对象

可以看到命名执行了__del__()方法却输出了两次销毁对象B 销毁了吗

  • del关键字并不会主动调用__del__()方法,只有在引用计数为0时,__del__()才会被执行。

  • 所以在直接b.__del__()主动调用该方法,但是这时变量b还在引用的对象B()

    • 只有通过del关键字或者del()函数才会删除对象的引用。
  • 在代码中del self执行,并没有删除了变量b,这时对象B()的引用计数还为1

    • 在下面的既能打印对象B()的地址,还能输出引用次数print(getrefcount(b)) # 2
    • 调用一次del self,并不会抛异常。(笔者并不知道这步会发生什么作用
  • 而程序执行完成之后(或者对象B()的引用计数将为0了后)垃圾回收程序启动,就还会执行对象B()的__del__()方法,才会又打印了一次销毁对象B 销毁了吗

    • 这之后对象B()才真正的在内存上被销毁
    • 这里再调用一次del self,也不会抛异常。

所以,并不是只要调用了__del__()方法就会销毁对象。

而是,销毁对象前一定会调用__del__()方法。

但是,自定义了__del__()方法,会带来一些问题

class C:
    cname = "C啊"
    def __del__(self):
        print("父类")

class D(C):
    def __del__(self):
        print(super().cname)
        print("子类")
        # super().__del__()  调用该行才能销毁C类对象
        
d = D()
del d

会打印

C啊
子类

D类继承了C类,创建D类对象时,也创建C类对象,并由D类对象引用C类对象。

但这时自定义了__del__()方法,垃圾回收D类对象时正常,但是C类对象无法被正常回收,也无法被使用。这就导致了内存泄漏。这是垃圾回收机制无法解决的。

因此需要在D类的__del__()方法中调用父类的__del__()方法。

Python3的垃圾回收也无法解决自定义__del__()方法后循环引用引起的内存泄漏。

因为Python3无法判断调用它们的__del__()方法时不会调用到对方那个对象

  • 比如假设已经调用过B.__del__()把b销毁掉了(这里假设A中没有调用A的资源)

  • 再调用A.__del__()时可能会在方法内用到_b(其中_b是B中的一个属性)

  • 这时如果B已经被销毁了,无法找到_b就会抛异常

标记-清除的回收机制

对于上面所提到的引用环(循环引用)

场景1:对象A引用了对象B,对象B引用了对象A。外部没有对对象A/B的引用了。

那么

  • 从对象A出发,沿着引用找到对象B,把对象B的引用计数 减1
  • 然后沿着对象B对对象A的引用回到A,把对象A的引用计数 减1

这样就把循环引用的关系给去掉了。

注意:这种方法应该只是减引用计数,并不是去掉对象A和对象B之间的引用关系。(笔者不太确定)

场景2:对象A引用了对象B,对象B引用了对象A。变量b还引用对象B。

这时如果还按场景1的解决方法来做,对象B的引用计数为1,对象A的引用计数为0

如果把对象A销毁了,因为对象B还存在,对象B内引用了对象A,这时对象A就成了不可达到的对象!

这就冲突了!

因此为了应对这种情况,Python3有一个标记-清除的回收机制,会把存引用计数的内存块一分为二

  • 即为两个链表,root链表unreachable链表

  • 对于场景2按场景1的方法

    • 对象B引用计数为1,信息仍在root链表内,标记为reachable
    • 将对象A引用计数减为0后,把对象A的信息放进unreachable链表中,标记为unreachable
      • 当在unreachable链表中标记对象A为unreachable时,
      • 检查root链表内的对象是否有对对象A的引用,
      • 有的话就把对象A的信息移到root链表内,即对象A无法被销毁
      • 直到对象B引用计数也为0了放进unreachable链表中,对象AB才都能被销毁。
  • 因此,标记-清除回收机制是在考虑

    • unreachable链表中的对象
      • 可能直接或间接
        • root链表中的对象引用
    • 这样的对象是不会被回收的
      • 当在标记过程中,发现了这样的对象,就将其从unreachable链表中移到root链表
      • 当完成标记后,unreachable链表中剩下的所有对象都是肯定要被垃圾回收的
        • 即这种场景下,垃圾回收只需要销毁unreachable链表中的对象即可

对于没有被垃圾回收掉的对象和导致内存泄漏的对象,Python程序关闭后会将其无差别的销毁。

内存池机制

Python3中分为大内存小内存

  • 256K为界限来区分大小内存

  • 大内存使用malloc进行分配

  • 小内存使用内存池进行分配

这样做的初衷是:创建大量内存花费少的对象时,频繁的调用malloc会导致大量的内存碎片,使得效率较低。

为此就用到了内存池机制

  • 内存池就是先在内存中申请一定数量的大小相等(256K)的内存块。
  • 当有新的小内存需求时,先从内存池中分配内存块给这个需求
    • 当这些预先申请好的内存块全被分配完后,还有新的小内存需求时,就会再创建这样的256K的内存块
  • 当需求处理完后,打算释放内存空间时,并不会真的释放这些内存块,而是将其归还给内存池
  • 另外还有一些机制维护着内存池中内存块的数量和大小,这里不深入。

Python3内存的金字塔结构:

  • 第3层:最上层,由用户对Python3对象的直接操作

  • 第2层第1层内存池,由Python3的接口函数PyMem_Malloc实现

    • 若请求分配的内存在1~256字节之间就是用内存池管理系统进行分配
    • 调用malloc函数分配内存,但是每次指挥分配一块大小为256K的内存块,不会调用free函数释放这块内存,将该内存块留在内存池中,以便下次使用。
  • 第0层大内存

    • 若请求分配的内存大于256K,malloc函数分配内存,free函数释放内存,不保留。
  • 第-1层第-2层:操作系统进行操作

Python3的栈空间和堆空间

栈空间里存放变量名引用对象的地址

堆空间里存放的是具体的对象

另外Python3会对匿名对象以及短字符串创建缓冲区。

print(id([1, 2, 3]), id([1, 2, 3]))  # 2362108854024 2362108854024
print(id("哈哈哈哈"), id("哈哈哈哈"))  # 2361673366520 2361673366520
print(id("这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊"), id("这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊这时一串字符串啊"))
# 2362108671872 2362084502016 长字符串还是单独开辟新空间存储

这时这些对象没有被引用,用完后续会被垃圾回收掉

但是小整数池(常量池)[-5, 256]有独立分配的内存空间,不会新创建内存来存储,也不会被销毁而释放掉内存。

  • 没办法主动使用del数字、字符串、字典,但可以主动del空列表空元组(似乎没什么用)。

强烈注意!!!

在Python3命令行中执行上述代码不一定和PyCharm中执行的结果一致

因为PyCharm自带对变量和对象进行优化管理,这种优化管理不是真正Python3内存管理的效果

x = 1000
y = 1000
print(x == y)  # True
print(x is y)  # 命令行False PyCharm True

参考

https://www.cnblogs.com/cccy0/p/9061799.html

https://www.cnblogs.com/franknihao/p/7326849.html

https://www.cnblogs.com/geaozhang/p/7111961.html

https://www.cnblogs.com/tingguoguoyo/p/10725891.html

笔者水平有限,对于Python3更底层的源码并没有太深入的了解,并且网上一些文章也有很多这方面的细节描述的不一致。

因此建议读者若想要了解更深入,可以去看官方的文档和阅读源码。

posted on 2020-11-16 18:59  Yi-27  阅读(393)  评论(0编辑  收藏  举报

导航