Bên trong một object Python: PyObject, kích thước và bố cục bộ nhớ
Bài này mở đầu mục Python Nâng Cao. Mục tiêu là trả lời câu hỏi: khi bạn viết x = 1000, thực sự có gì nằm trong RAM? Hiểu được điều này, bạn sẽ giải thích được vì sao Python “tốn bộ nhớ”, vì sao list chứa số lại nặng hơn array, và vì sao một chuỗi có emoji lại to gấp 4 lần chuỗi ASCII.
Tất cả các chi tiết trong bài là của CPython (bản Python chuẩn tải từ python.org). Số liệu đo trên CPython 3.13, máy 64-bit. Các bản khác (PyPy, …) có thể khác.
Trong bài này, bạn sẽ học:
- Header
PyObjectmà mọi object đều có: refcount và con trỏ type - Vì sao biến trong Python chỉ là “cái tên” trỏ tới object, và
id()thực chất là gì - Immortal object (Python 3.12+) và lợi ích của nó
- Cách
intlưu số lớn tuỳ ý bằng các “chữ số” 30-bit - Cách
strchọn 1, 2 hoặc 4 byte cho mỗi ký tự (PEP 393) và string interning - Vì sao
sys.getsizeofgây hiểu lầm và cách đo kích thước thật
1. Mọi thứ đều là PyObject
Phần tiêu đề “1. Mọi thứ đều là PyObject”Trong mã nguồn C của CPython, mọi object đều bắt đầu bằng cùng một “phần đầu” (header) gọi là PyObject:
typedef struct _object { Py_ssize_t ob_refcnt; // 8 byte: số tham chiếu đang trỏ tới object PyTypeObject *ob_type; // 8 byte: con trỏ tới type (int, list, ...)} PyObject;Với object có độ dài thay đổi (như int, tuple, str…), còn có thêm trường ob_size:
typedef struct { PyObject ob_base; // 16 byte header Py_ssize_t ob_size; // 8 byte: số phần tử} PyVarObject;Nghĩa là: object nhỏ nhất trong Python cũng đã tốn 16 byte chỉ cho phần header. Đây là cái giá của việc “mọi thứ đều là object”.
Biến x (trong namespace) Heap┌─────────┐ ┌──────────────────────┐│ x ────┼──────────────► │ ob_refcnt = 1 │ 8 byte└─────────┘ │ ob_type = &int │ 8 byte │ ob_size = 1 │ 8 byte │ digit[0] = 1000 │ 4 byte └──────────────────────┘ = 28 byteBiến trong Python không chứa giá trị, nó chỉ là một cái tên gắn với một con trỏ (8 byte) trỏ tới object trên heap. Và id(x) trong CPython chính là địa chỉ bộ nhớ của object đó.
Nhìn trực tiếp vào header bằng ctypes
Phần tiêu đề “Nhìn trực tiếp vào header bằng ctypes”Ta có thể đọc trường ob_refcnt ngay tại địa chỉ id(obj):
import ctypes
a = []b = a # thêm 1 tham chiếu
# ob_refcnt nằm ở đầu object, tức là tại địa chỉ id(a)print(ctypes.c_ssize_t.from_address(id(a)).value) # 2sys.getrefcount(a) cũng cho biết số này nhưng luôn lớn hơn 1, vì chính việc truyền a vào hàm đã tạo thêm một tham chiếu tạm thời.
Immortal objects (Python 3.12+)
Phần tiêu đề “Immortal objects (Python 3.12+)”Từ Python 3.12 (PEP 683), một số object như None, True, False, các số nguyên nhỏ… được đánh dấu là bất tử (immortal): refcount của chúng không bao giờ thay đổi và chúng không bao giờ bị giải phóng.
import sysprint(sys.getrefcount(None)) # 4294967295 (một giá trị "ma thuật", không phải số tham chiếu thật)print(sys.getrefcount(1)) # 4294967295Lợi ích: nhiều thread/tiến trình dùng chung các object này mà không phải ghi vào bộ nhớ của chúng (không làm “bẩn” trang bộ nhớ khi fork, không tranh chấp cache CPU).
2. int - số nguyên có độ dài tuỳ ý
Phần tiêu đề “2. int - số nguyên có độ dài tuỳ ý”Python cho phép số nguyên lớn tuỳ ý (2**1000 vẫn chạy). Bí mật: int được lưu dưới dạng mảng các “chữ số” 30-bit.
import sysprint(sys.int_info)# sys.int_info(bits_per_digit=30, sizeof_digit=4, ...)
print(sys.getsizeof(0)) # 28print(sys.getsizeof(2**30 - 1)) # 28 -> 1 chữ số 30-bitprint(sys.getsizeof(2**30)) # 32 -> 2 chữ sốprint(sys.getsizeof(2**60)) # 36 -> 3 chữ sốprint(sys.getsizeof(10**100)) # 72Công thức: 24 byte header + 4 byte × số chữ số. Mỗi khi số vượt qua một bội của 2^30, object tăng thêm 4 byte.
Hệ quả thực tế:
- Một số nguyên nhỏ trong Python tốn 28 byte, trong khi
int32trong C chỉ tốn 4 byte - gấp 7 lần. - Phép toán trên số lớn không còn O(1). Nhân hai số có hàng nghìn chữ số là phép toán đắt.
- Chuyển số rất lớn sang chuỗi bị giới hạn (
default_max_str_digits=4300) để tránh tấn công DoS - đó là lý dostr(10**5000)báoValueError.
Cache số nguyên nhỏ
Phần tiêu đề “Cache số nguyên nhỏ”CPython tạo sẵn các số từ -5 đến 256 khi khởi động và dùng lại chúng. Vì vậy mọi phép tính cho ra 100 đều trả về cùng một object. Xem thêm bài Integer caching.
3. float, bool, None
Phần tiêu đề “3. float, bool, None”import sysprint(sys.getsizeof(1.5)) # 24 = 16 header + 8 byte doubleprint(sys.getsizeof(1+2j)) # 32 = 16 header + 2 × 8 byteprint(sys.getsizeof(True)) # 28 - bool là lớp con của int!print(sys.getsizeof(None)) # 16 - chỉ có headerfloat luôn là số thực 64-bit IEEE 754, không có “float32” trong Python thuần. Nếu cần tiết kiệm, hãy dùng array('f') hoặc NumPy.
4. str - chuỗi “co giãn” theo ký tự lớn nhất (PEP 393)
Phần tiêu đề “4. str - chuỗi “co giãn” theo ký tự lớn nhất (PEP 393)”Đây là chi tiết ít người biết nhất. Từ Python 3.3, CPython lưu chuỗi theo 3 kiểu mã hoá nội bộ, chọn theo ký tự có mã lớn nhất trong chuỗi:
| Ký tự lớn nhất trong chuỗi | Mỗi ký tự tốn | Ví dụ |
|---|---|---|
| ≤ U+00FF (ASCII / Latin-1) | 1 byte | "hello", "café" |
| ≤ U+FFFF (BMP) | 2 byte | "Việt Nam", "€" |
| > U+FFFF | 4 byte | "😀" |
import sysprint(sys.getsizeof("a" * 100)) # 141 -> 1 byte/ký tựprint(sys.getsizeof("é" * 100)) # 157 -> vẫn 1 byte (Latin-1) nhưng header khác ASCIIprint(sys.getsizeof("€" * 100)) # 258 -> 2 byte/ký tựprint(sys.getsizeof("😀" * 100)) # 460 -> 4 byte/ký tự
# Chỉ CẦN MỘT emoji, cả chuỗi chuyển sang 4 byte/ký tựprint(sys.getsizeof("a" * 99 + "😀")) # 460Hệ quả:
- Tiếng Việt có dấu (như
ệ= U+1EC7) làm cả chuỗi dùng 2 byte/ký tự. - Một emoji trong một văn bản dài 1 MB ký tự làm chuỗi đó phình lên ~4 MB trong RAM.
- Nhờ độ dài cố định mỗi ký tự,
s[i]vàlen(s)vẫn là O(1) - thứ mà UTF-8 không làm được.
String interning
Phần tiêu đề “String interning”Các chuỗi trông giống định danh (chỉ gồm chữ, số, _) xuất hiện trong mã nguồn được intern - dùng chung một object. Chuỗi tạo ra lúc chạy thì không:
import sys
s1 = "hello_world"s3 = "".join(["hello", "_world"]) # tạo lúc chạyprint(s1 == s3) # Trueprint(s1 is s3) # False - hai object khác nhauprint(s1 is sys.intern(s3)) # TrueỨng dụng: khi xử lý hàng triệu bản ghi có trường lặp lại (tên thành phố, mã trạng thái…), sys.intern() giúp mọi bản ghi dùng chung một object chuỗi, vừa tiết kiệm RAM vừa giúp so sánh dict key nhanh hơn (so sánh con trỏ trước khi so sánh nội dung).
5. sys.getsizeof chỉ đo “cái vỏ”
Phần tiêu đề “5. sys.getsizeof chỉ đo “cái vỏ””sys.getsizeof chỉ trả về kích thước của chính object đó, không tính các object nó trỏ tới:
import sys
x = [1, 2, 3]y = [x, x]print(sys.getsizeof(y)) # 72 - chỉ gồm header list + 2 con trỏy chỉ chứa 2 con trỏ tới cùng một list x. Để đo “kích thước thật” bạn phải duyệt đệ quy và tránh đếm trùng:
import sysfrom collections import deque
def deep_getsizeof(obj): """Tổng kích thước của obj và mọi thứ nó tham chiếu (không đếm trùng).""" seen = set() total = 0 todo = deque([obj]) while todo: o = todo.pop() if id(o) in seen: continue seen.add(id(o)) total += sys.getsizeof(o) if isinstance(o, dict): todo.extend(o.keys()) todo.extend(o.values()) elif isinstance(o, (list, tuple, set, frozenset)): todo.extend(o) elif hasattr(o, "__dict__"): todo.append(vars(o)) return total
data = {"names": ["An", "Bình", "Chi"], "ages": [20, 21, 22]}print(sys.getsizeof(data)) # 184print(deep_getsizeof(data)) # tổng thật, lớn hơn nhiềuTrong thực tế, để đo bộ nhớ của cả chương trình, tracemalloc (xem bài Bộ cấp phát bộ nhớ & GC) là công cụ chính xác hơn.
6. Ví dụ tổng hợp: vì sao list số lại nặng?
Phần tiêu đề “6. Ví dụ tổng hợp: vì sao list số lại nặng?”import sysfrom array import array
nums_list = list(range(1000))nums_array = array("i", range(1000))
print(sys.getsizeof(nums_list)) # 8056 (chỉ là mảng con trỏ!)print(sys.getsizeof(nums_array)) # 4200 (4 byte/số + một ít chỗ dự trữ)Nhưng 8056 byte của list mới chỉ là mảng con trỏ. Mỗi con trỏ còn trỏ tới một object int riêng 28 byte (trừ các số -5..256 được cache). Tổng cộng list ~ 8 KB + 1000 × 28 B ≈ 36 KB, so với 4 KB của array.
list: [ptr][ptr][ptr]... array('i'): [ 0][ 1][ 2]... │ │ │ 4 byte mỗi số, liền nhau ▼ ▼ ▼ int int int (mỗi cái 28 byte, rải rác trên heap)Đây là lý do NumPy nhanh và gọn: nó lưu số liên tục như array, không có header cho từng phần tử, và CPU đọc bộ nhớ liền mạch hiệu quả hơn nhiều (cache-friendly).
Bài tập
Phần tiêu đề “Bài tập”- Viết hàm
int_digits(n)trả về số “chữ số 30-bit” CPython dùng để lưun, rồi kiểm tra với công thức(sys.getsizeof(n) - 24) // 4. - Tạo một chuỗi tiếng Việt dài 1 triệu ký tự và một chuỗi ASCII cùng độ dài. So sánh
sys.getsizeofcủa chúng và giải thích. - Mở rộng hàm
deep_getsizeofđể hỗ trợ object dùng__slots__(gợi ý: duyệttype(o).__slots__).
Kết luận
Phần tiêu đề “Kết luận”Bạn đã đi qua những kiến thức cốt lõi của bài này:
| Kiến thức | Ý nghĩa thực tế |
|---|---|
| Mọi object có header 16 byte (refcount + type) | Object nhỏ trong Python rất “đắt” |
| Biến chỉ là con trỏ 8 byte | Gán b = a không copy dữ liệu |
int = mảng chữ số 30-bit |
Số lớn tuỳ ý nhưng mỗi số ≥ 28 byte |
str dùng 1/2/4 byte mỗi ký tự |
Một emoji có thể làm chuỗi to gấp 4 |
sys.getsizeof không tính object con |
Dùng hàm đệ quy hoặc tracemalloc |
| Immortal objects (3.12+) | None, số nhỏ… không bao giờ bị giải phóng |
Bài tiếp theo: List và Tuple trong bộ nhớ.