Данные в памяти: биты и байты

Как числа, дроби, строки и структуры лежат в памяти — и почему из-за этого 0,1 + 0,2 ≠ 0,3, счётчики уходят в минус, а "😀".length равно двум.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте на биты и кнопки, двигайте ползунки.

Биты, байты и hex

Память хранит только биты. Восемь битов — байт, 256 значений. Двоичная запись длинная, поэтому её пишут в шестнадцатеричной: одна hex-цифра — ровно 4 бита, байт — ровно две цифры.

  • 0xFF = 255 = 1111 1111, 0x100 = 256 — уже не влезает в байт.
  • Hex видно везде: адреса, цвета #6741d9, хеши, UUID, дампы xxd.

Нажимайте на биты 32-битного числа или введите своё. Каждая четвёрка битов — одна hex-цифра.

Память — это массив байтов

У каждого байта есть номер — адрес. Переменная — это адрес и размер, указатель — 8-байтное число, равное адресу. Тип знает только компилятор: процессор видит байты и читает их так, как велит инструкция.

Выберите переменную или нажмите на любой байт и прочитайте байты как другой тип. Одни и те же байты дают совсем разные значения.

Целые числа и дополнительный код

Беззнаковое число — просто сумма степеней двойки. Знаковое хранится в дополнительном коде: старший бит весит не +128, а −128. Поэтому −1 — это все единицы, а −x = ~x + 1.

Сложение одно и то же для обоих толкований — процессору всё равно, со знаком число или без. Значения ходят по кругу: за 255 идёт 0, за 127 — −128. Нажимайте на биты и кнопки.

Переполнение в жизни

  • Java int — 32 бита, Go int — 64 на 64-битных системах, C int — обычно 32, long — 32 на Windows и 64 на Linux. Python int не переполняется вовсе.
  • Java, Go, C# и Rust в release молча заворачивают. В C переполнение signed — неопределённое поведение: компилятор вправе выкинуть вашу проверку.
  • Классика: mid = (lo + hi) / 2 в двоичном поиске. На массиве больше 2³⁰ элементов сумма переполняется — ошибка жила в JDK девять лет.

Выберите тип счётчика и скорость, с которой он растёт.

Проверьте себя

Что вернёт Java?


Math.abs(Integer.MIN_VALUE)  // int, −2 147 483 648
В 32 битах есть −2³¹, но нет +2³¹. Модуль считается как −x = ~x + 1: 0x80000000 → 0x7FFFFFFF → 0x80000000. Получается то же отрицательное число. Так ломается Math.abs(hash) % n при выборе шарда — индекс выходит отрицательным. Безопасно: Math.floorMod(hash, n) или Math.absExact, который бросит исключение.

Дробные числа: IEEE 754

Число с плавающей точкой — это двоичная научная запись: знак, порядок и мантисса. double (Python float, JS number, Go float64) — 1 + 11 + 52 бита, около 16 значащих десятичных цифр.

0,1 в двоичной системе — бесконечная дробь, как ⅓ в десятичной, поэтому хранится ближайшее представимое число. Чем больше число, тем больше шаг между соседними значениями. Введите число или выберите пример.

Почему деньги не хранят во float

Каждая операция с плавающей точкой округляет результат. Ошибки маленькие, но копятся, а сравнение на равенство ломается сразу: 0.1 + 0.2 == 0.3 — false.

  • Деньги — целыми в минимальных единицах (копейки, центы) или десятичным типом: Python Decimal, Java BigDecimal, SQL numeric.
  • Дроби сравнивают с допуском: math.isclose(a, b).

Двигайте ползунок: сумма прибавляется N раз в разных представлениях.

В JavaScript все числа — double

Целые в number точны только до 2⁵³ = 9 007 199 254 740 992. Дальше шаг между соседними значениями становится 2, 4, 8… и нечётные числа просто пропадают.

  • Бэкенд на Go или Java отдаёт int64-идентификатор в JSON, JSON.parse в браузере молча округляет его — и запрос уходит за чужой записью. Поэтому Twitter отдаёт id_str.
  • Решение: большие ID — строками, арифметика — BigInt.
  • Побитовые операции в JS работают с 32-битными целыми: (2**32 | 0) === 0.

Number.MAX_SAFE_INTEGER  // 9007199254740991
2 ** 53 + 1              // 9007199254740992

JSON.parse('{"id": 1234567890123456789}').id
// 1234567890123456800

BigInt("1234567890123456789") + 1n
// 1234567890123456790n

0.1 + 0.2                // 0.30000000000000004
(1.005).toFixed(2)       // "1.00"
// 1.005 хранится как 1.00499999999999989…

Проверьте себя

Счётчик событий во float32 (так бывает в метриках, шейдерах и ML-коде). Что он покажет после 20 миллионов событий?


float n = 0;
for (int i = 0; i < 20000000; i++) n += 1;
printf("%.0f\n", n);
У float32 24 бита мантиссы. После 2²⁴ = 16 777 216 шаг между соседними числами равен 2, и 16 777 216 + 1 округляется обратно до 16 777 216. Счётчик замирает навсегда без всяких ошибок. В float64 то же случится на 2⁵³ — реже, но тоже бывает.

Порядок байтов

Многобайтное число можно положить в память старшим байтом вперёд (big-endian) или младшим (little-endian). x86 и ARM — little-endian. Сетевые протоколы и многие форматы файлов — big-endian, «сетевой порядок».


struct.pack('<I', n)   # little-endian
struct.pack('>I', n)   # big-endian (сеть)

В Go — binary.LittleEndian/BigEndian, в Java ByteBuffer по умолчанию big-endian, а JS DataView без третьего аргумента тоже читает big-endian. Переключите порядок и прочитайте байты «не тем» способом.

Выравнивание и паддинг

Процессор читает 8-байтное число быстрее и атомарно, если его адрес кратен 8. Поэтому компилятор вставляет между полями пустые байты, а размер структуры округляет до самого строгого выравнивания.

Go и C раскладывают поля в порядке объявления — порядок можно оптимизировать (линтер fieldalignment). JVM и Rust переставляют поля сами. Двигайте поля стрелками.

Массив значений и массив указателей

Go []int64, Java long[], NumPy — значения лежат подряд. Python list и Java Integer[] — массив указателей на отдельные объекты в куче, у каждого свой заголовок.

Процессор читает память строками кэша по 64 байта. Подряд — одна строка даёт 8 чисел, а предвыборка грузит следующие заранее. Через указатели — лишние строки и зависимые загрузки, особенно когда объекты разбросаны. Пройдите по массиву в каждом режиме. Подробнее про кэши — в презентации о процессоре.

Как Python хранит int

В CPython всё — объект PyObject: счётчик ссылок, указатель на тип и данные. Целое — это массив «цифр» по 30 бит, поэтому оно не переполняется, но даже 0 занимает 28 байт.

  • Числа от −5 до 256 созданы заранее и общие: 257 is 257 может оказаться False.
  • Каждый a + b — проверка типов и, скорее всего, новый объект. Отсюда сотни наносекунд на операцию против одной в Go.

Введите число и посмотрите, из чего состоит объект.

Проверьте себя

Сколько памяти займёт в CPython список из миллиона разных целых?


xs = list(range(1_000_000))
Сам список — 8 МБ указателей. Каждое число — отдельный объект: sys.getsizeof скажет 28 байт, а аллокатор выдаёт блоки кратно 16 — 32 байта. Итого 8 + 32 = 40 МБ (tracemalloc покажет именно столько). array('q') или numpy.arange уложатся в 8 МБ и считаются в десятки раз быстрее.

Строки: Unicode и UTF-8

Unicode нумерует символы: «Я» — U+042F, «😀» — U+1F600, всего до 1,1 млн кодовых точек. UTF-8 кодирует номер от 1 до 4 байтами: ASCII — один байт, кириллица — два, эмодзи — четыре.

  • Первый байт говорит длину (0…, 110…, 1110…, 11110…), остальные начинаются с 10. По любому байту видно, где начало символа.
  • То, что человек видит как одну букву, бывает несколькими кодовыми точками: «é» = e + U+0301, 👍🏽 = палец + тон кожи.

Наберите текст или выберите пример. Нажмите на символ, чтобы увидеть его биты.

Длина строки — какая?

единица"привет""😀"
Go len(s), Rustбайты UTF-8124
JS .length, Java, C#единицы UTF-1662
Python len(s)кодовые точки61
Глазами человекаграфемы61

JS и Java хранят строки в UTF-16: символы за пределами U+FFFF — пара суррогатов. Обрезка «по длине» рвёт символы, лимит VARCHAR(n) бывает в символах, а в байтах — у Redis и Kafka. MySQL utf8 — это 3 байта и без эмодзи, нужен utf8mb4. Двигайте ползунок обрезки.

Проверьте себя

Ограничиваем имя пользователя 10 символами. Что выведет Go?


name := "Владислав"
if len(name) > 10 {
    name = name[:10] + "…"
}
fmt.Println(name)
len в Go — байты: «Владислав» занимает 18 байт, а 10 байт — ровно 5 кириллических букв. Повезло, что граница чётная: name[:9] разрезал бы букву пополам, и получилось бы «Влад�». Считайте символы через utf8.RuneCountInString и режьте по []rune, а для эмодзи с модификаторами — по графемам.

Как это увидеть у себя

  • Байты файла или строки: xxd, hexdump -C, od -An -tx1.
  • Дробь как есть: Python Decimal(0.1) и (0.1).hex(), в JS x.toPrecision(20).
  • Размеры и смещения полей: Go unsafe.Sizeof/Offsetof, C pahole, Java JOL.
  • Память Python: sys.getsizeof (только сам объект), tracemalloc — реальные аллокации.
  • Порядок байтов машины: lscpu | grep Endian, sys.byteorder.
  • Длина в байтах: len(s.encode()), Buffer.byteLength(s), new TextEncoder().encode(s).length.

$ printf 'Hi, Мир 😀' | xxd -g1
00000000: 48 69 2c 20 d0 9c d0 b8 d1 80 20 f0 9f 98 80

>>> from decimal import Decimal
>>> print(Decimal(0.1))
0.1000000000000000055511151231257827021181583404541015625
>>> (0.1).hex()
'0x1.999999999999ap-4'

$ java -jar jol-cli.jar internals java.lang.Integer
 OFF  SZ   TYPE DESCRIPTION     VALUE
   0   8        (object header: mark)
   8   4        (object header: class)
  12   4    int Integer.value
Instance size: 16 bytes

Главное

  • В памяти только байты. Тип — договорённость о том, как их читать: целое, дробь, символ или адрес.
  • Целые фиксированного размера ходят по кругу. Знайте размер своих типов и проверяйте границы там, где растут счётчики, время и размеры.
  • Дробные числа — приближение с ~16 цифрами (у float32 — 7). Не сравнивайте на равенство и не храните во float деньги. В JS целые точны до 2⁵³.
  • Порядок байтов важен, как только байты покидают процесс: сеть, файлы, бинарные протоколы.
  • Поля выравниваются, объекты в Python и Java несут заголовки. Массив значений подряд меньше и быстрее массива указателей.
  • Строка — это байты в кодировке. Длина в байтах, в UTF-16 и в символах — три разных числа.

Компьютер не знает, что такое число. Он знает только байты и инструкцию, которая их читает.