字符串的 intern 机制是怎么一回事?

文摘   2024-07-17 08:30   北京  

上一篇文章我们介绍了字符串的底层结构,看到里面有一个 state 字段,该字段也是一个结构体,内部定义了很多的标志位。

如果字符串的 interned 标志位大于 0,那么虚拟机将为其开启 intern 机制。那什么是 intern 机制呢?在 Python 中,某些字符串也可以像小整数对象池里的整数一样,共享给所有变量使用,从而通过避免重复创建来降低内存使用、减少性能开销,这便是 intern 机制。

Python 的做法是在虚拟机内部维护一个全局字典,所有开启 intern 机制的字符串均会保存在这里,后续如果需要使用的话,会先尝试在全局字典中获取,从而实现避免重复创建的功能。


另外 intern 机制也分为多种。

// Include/cpython/unicode.h
#define SSTATE_NOT_INTERNED 0
#define SSTATE_INTERNED_MORTAL 1
#define SSTATE_INTERNED_IMMORTAL 2
#define SSTATE_INTERNED_IMMORTAL_STATIC 3

解释一下这几个字段:

  • SSTATE_NOT_INTERNED:字符串未开启 intern 机制;

  • SSTATE_INTERNED_MORTAL:字符串开启了 intern 机制,但它不是永久驻留的,在某些情况下可能会被回收;

  • SSTATE_INTERNED_IMMORTAL:字符串开启了 intern 机制,并且是永恒对象,会永远存活于内存中;

  • SSTATE_INTERNED_IMMORTAL_STATIC:和 SSTATE_INTERNED_IMMORTAL 类似,也是开启了 intern 机制并且不会被回收的字符串,但它表示的是程序在编译期间就已创建好的静态常量字符串;


这些字段定义了字符串在内存管理中的不同驻留状态,从未驻留短暂驻留永久驻留,帮助优化字符串的内存使用和管理。

// Objects/unicodeobject.c
void
PyUnicode_InternInPlace(PyObject **p)
{
    PyInterpreterState *interp = _PyInterpreterState_GET();
    _PyUnicode_InternInPlace(interp, p);
}

void
_PyUnicode_InternInPlace(PyInterpreterState *interp, PyObject **p)
{
    PyObject *s = *p;
    // PyUnicode_Check(s) -> isinstance(s, str)
    // PyUnicode_CheckExact(s) -> type(s) is str
    if (s == NULL || !PyUnicode_Check(s)) {
        return;
    }
    if (!PyUnicode_CheckExact(s)) {
        return;
    }
    // 执行到这儿,说明 s 一定指向字符串,那么检测它是否已经开启了 intern 机制
    // 这个函数的逻辑很简单,内部会获取 state.interned,看它是否大于 0
    if (PyUnicode_CHECK_INTERNED(s)) {
        return;
    }

    // 从全局缓存中获取,这里不用关注
    PyObject *r = (PyObject *)_Py_hashtable_get(INTERNED_STRINGS, s);
    if (r != NULL && r != s) {
        Py_SETREF(*p, Py_NewRef(r));
        return;
    }

    // 接下来查看 s 指向的字符串是否是编译期间就已经静态分配好的
    // 如果是,那么它一定也是永恒对象
    if (_PyUnicode_STATE(s).statically_allocated) {
        // 将 s 设置到 INTERNED_STRINGS 哈希表中,value 也是 s
        if (_Py_hashtable_set(INTERNED_STRINGS, s, s) == 0) {
            // 将它的 interned 标志位设置为 SSTATE_INTERNED_IMMORTAL_STATIC
            _PyUnicode_STATE(*p).interned = SSTATE_INTERNED_IMMORTAL_STATIC;
        }
        return;
    }

    // 如果字符串的内存不是编译期间静态分配的,那么获取 INTERNED_STRINGS 字典
    PyObject *interned = get_interned_dict(interp);
    // 将 s 设置到 INTERNED_STRINGS 字典中
    PyObject *t = PyDict_SetDefault(interned, s, s);
    //...
    // 标记为永恒对象
    _Py_SetImmortal(s);
    // 将 interned 标志位设置为 SSTATE_INTERNED_IMMORTAL
    _PyUnicode_STATE(*p).interned = SSTATE_INTERNED_IMMORTAL;
}

当一个字符串要开启 intern 机制时,会调用 PyUnicode_InternInPlace 函数。那么问题来了,什么样的字符串会开启 intern 机制呢?

1)如果字符串为 ASCII 字符串,并且长度不超过 4096,那么会开启 intern 机制。

>>> s1 = "a" * 4096
>>> s2 = "a" * 4096
# 会开启 intern 机制,s1 和 s2 指向同一个字符串
>>> s1 is s2
True
# 并且是永恒对象
>>> sys.getrefcount(s1)
4294967295

# 长度超过了 4096,所以不会开启 intern 机制
>>> s1 = "a" * 4097
>>> s2 = "a" * 4097
>>> s1 is s2
False
# 也不是永恒对象
>>> sys.getrefcount(s1)
2

2)如果一个字符串只有一个字符,并且码点小于 256(一个字节可以表示),那么也会开启 intern 机制。

>>> hex(128)
'0x80'
# s1 和 s2 指向同一个字符串,因为开启了 intern 机制
>>> s1 = chr(128)
>>> s2 = "\x80"
>>> s1 is s2
True
# 并且是永恒对象
>>> sys.getrefcount(s1)
4294967295

# ASCII 字符指的是码点小于 128 的字符,显然 s1 和 s2 不是 ASCII 字符串
# 虽然码点小于 256,但长度不等于 1,所以不会开启 intern 机制
>>> s1 = chr(128) + "x"
>>> s2 = chr(128) + "x"
>>> s1 is s2
False
# 不是永恒对象
>>> sys.getrefcount(s1)
2

实际上,存储单个字符这种方式有点类似于 bytes 对象的缓存池。是的,正如整数有小整数对象池、bytes 对象有字符缓存池一样,字符串也有其对应的缓存池。

当创建一个字符串时,如果字符串只有一个字符,且码点小于 256。那么会先对该字符串进行 intern 操作,再将 intern 的结果缓存到池子里。同样当再次创建字符串时,检测是不是只有一个字符,然后检查字符是不是存在于缓存池中,如果存在,直接返回。

所以 intern 机制并不是大家想的那样:先检测字符串是否已经存在,如果有,就不用创建新的,从而节省内存但其实不是这样的,事实上节省内存空间是没错的,可 Python 并不是在创建字符串的时候就通过 intern 机制实现了节省空间的目的。对于任何一个字符串,解释器总是会为它创建对应的结构体实例,但如果发现创建出来的实例在 intern 字典中已经存在了,那么再将它销毁。

最后关于 intern 机制,在 Python 里面可以通过 sys.intern 函数强制开启。

>>> s1 = "憨pi-_-||"
>>> s2 = "憨pi-_-||"
>>> s1 is s2
False
>>> 
>>> s1 = sys.intern("憨pi-_-||")
>>> s2 = sys.intern("憨pi-_-||")
>>> s1 is s2
True
>>> sys.getrefcount(s1)
4294967295
>>>

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章