Pulpcode

捕获,搅碎,拼接,吞咽

0%

从python到java之整型常量池

最近有同事分享了一次java基础,里面提到了java基础类型的包装类型(比如Integer)和String都有自己的常量池,我突然想到在python中也有类似的东西,所以本来想写一篇关于分析python和java常量池的博客,但是后来我发现这里面的内容还挺多的,所以我决定先从整型常量池入手,下次再分析字符串的。

需要提到的是,我只是因为找到的有关python小整型池和大整型池的资料,才特意介绍python的。我并没有找到关于java大整型对象存储技术的文献,但是我猜都能猜到,如果java真有这方面的优化需求,是一定会有类似的东西,如果之后我了解到了,我会在自己的博客中再去介绍的。

java常量池

首先是java的常量池,我搜索了java的常量池,大部分提到了,“java中基本类型的包装类的大部分都实现了常量池技术”这句话,它们都提到了对于Interger这样的对象(Byte,Short,Long,Character类似)在值小于127的时候,会默认使用内存池中已经创建好的数据。

这也就是为什么:

1
2
Integer i1 = 100;
Integer i2 = 100;

的时候, i1 == i2会返回true,而在

1
2
Integer i2 = 200;
Integer i2 = 200;

的时候, i1 == i2会返回False。

这个很好理解,首先java中 == 对对象而言,代表是否是同一引用(equal用来比较是否值相等)。还有就是我们没有 new Integer,而是直接使用看上去像是赋值的操作,因为使用了装箱操作。

这里需要提到的是如果i1和i2都是int型的基础类型,那么他们无论有多大,比较起来都是相等的,因为这部分内存是放在栈的,它们仅仅是基础类型,而非引用堆中的对象。

python整型常量池

先说明的是,python中没有基础类型这种东西,万物皆对象,而且==在比较两个整型的时候就是在比较值大小,比较是否是同一对象的引用使用is关键字。

python中的整型常量池分为小整型对象池和大整型对象池。(这个说法出自python源码分析)

对于一些常用的整型,python也是提前初始化好的:

在[-5, 257)这个区间内的整数,被称为小整数对象,类似于java常量池,是一开始就初始化好的。

1
2
3
4
5
6
a = 1
b = 1
a == b # True
a is b # True
print id(a)
print id(b)

而对于超过此区间的对象,就需要在每次需要的时候创建了。

1
2
3
4
5
6
a = 1000
b = 1000
a == b # True
a is b # False
print id(a)
print id(b)

上面的例子是针对解释器运行,如果是运行脚本,还有不同的地方,后面会再解释。

但是这些要创建的大整型对象,并不是直接在一块堆内存上创建的(如果是那效率就太挫了),而是维护了一个专门的数据结构,我们称其为大整型对象池。

这个池的数据结构类似于一个单向链表,每一个节点是一个可以存放python Int对象的数组。然后在每次创建python大整型INT对象的时候,如果单向链表中没有空间可用,那就会创建一块新的python Int数组空间,链接到单向链表中。否则就直接使用数组中的内存就可以了。之所以要数组和链表结合使用,就是因为数组的查找要快,而链表的释放和创建自由灵活。这种模型在内存管理中很常见。

而且你应该知道python的对象回收机制,就是在引用计数减为0的时候,这个内存就会被回收。所以对我们刚才提到的大整型对象如果没有引用指向它,它就会被python虚拟机回收。但是坑的地方是,它并不会释放给操作系统,而仅仅是回收给这个大整型对象池的free区,用于再次使用。这也就是说你的这个大整型对象池,只增不减。这听上去很像是内存泄漏。

你可以试一试,如果创建了一大堆的python int对象,你的内存将飙高到几个G,然后即使你del 了这些用于存放int对象的容器,你的python进程的内存也没有变小,也就是说除非你的python进程结束,否则这些内存永远不会还给操作系统。

这是个问题?

这其实不是问题,首先它并不是内存泄漏,严格意义来讲,内存泄漏是指无法找到内存空间了,比如c++和c中的指针的作用域没了,访问那些内存的方式你找不到了。但是实际上对于大整型内存池而言,我们可以找到这些内存,它们也可以被我们重复使用。而且现代操作系统,内存是用缺页分配的。所以不会占用那么多“真实的内存”。

这是设计缺陷?

工程上的事,往往就是在做去权衡。还有就是那些认为这是设计缺陷的人,简直就是在说“我比python作者聪明”。我仔细想了想,这些可以重复使用的内存块,它们分散在链表上数组的各处,根本没什么好的办法释放它们,所以在运行效率上权衡,就只能设计成这样了。

证明

口说无凭,看看cpython的实现源码

1
2
3
4
typedef struct{
PyObject_HEAD;
long ob_ival;
} PyIntObject;

上面就是提到的PyIntObject的C底层数据结构。

1
2
3
4
5
6
7
8
9
10
#ifndef NSMALLPOSINTS
#define NSMALLPOSINTS 257
#endif
#ifndef NSMALLNEGINTS
#define NSMALLNEGINTS 5
#endif
#if NSMALLNEGINTS + NSMALLPOSINTS > 0
static PyIntObject *small_ints[NSMALLNEGINTS + NSMALLPOSINTS];
#endif

上面就是小整型常量池的大小定义

1
2
3
4
struct _intblock{
struct _intblock *next;
PyIntObject objects[N_INTOBJECTS];
};

上面就是我们说的用于给大整型对象的内存块节点,可以看到,每个节点是一个PyIntObject数组。

1
2
3
4
typedef struct _inblock PyIntBlock;

static PyIntBlock *block_list = NULL;
static PyIntObject *free_list = NULL;

其中block_list指针,就是大整型数组对象链表的头节点,而free_list就是可用内存(空闲内存)的头节点。

而且我们提到,被删除的PyIntObject对象,它的空间可以被重新使用,这种重新使用的方式就是指它们会以单链表的形式串连在一起,而表头就是free_list

遗留问题,解释器运行和python程序运行

前面提到的那段python代码,在python的解释器运行和作为python代码运行,结果不一样的,我猜测可能是因为解释器是逐条执行,而python代码则存在整体处理的过程。
最后我在网上找到这样一种解答:

“Cpython代码的编译单元是函数,也就是说每个函数会单独编译,对于同一个编译单元中出现相同值的常量,只会出现一份。对于不同单元的编译单元,值相同的常量不一定会应用到运行时的同一对象。”

写两个例子,就全都明白了

1
2
3
4
5
6
7
8
9
10
11
def m():
a = 1
print id(a)

def n():
b = 1
print id(b)

if __name__ == "__main__":
m()
n()
1
2
3
4
5
6
7
8
9
10
11
def m():
a = 1000
print id(a)

def n():
b = 1000
print id(b)

if __name__ == "__main__":
m()
n()

所以解释器逐条执行的,应该是不同的编译单元。

参考阅读

《python源码分析》