整数在计算机里到底怎么表示?从补码到溢出彻底搞懂
带新人的时候我经常发现一个奇怪的现象很多能写复杂业务逻辑的工程师面对整数在计算机里是怎么表示这个问题会突然变得含糊其辞。直到有一次一个实习生跑完统计任务拿着-1949672955这个计数值来找我说自己怀疑数据源坏了。我让他打印一下循环的计数器他这才意识到问题出在哪——一个int计数器根本装不下几亿次累加的结果。这件事让我特别有感触我们每天都在写整数、用整数但真正理解整数表示原理的人远比想象中少。这篇内容想把这件事讲透。它不是什么高深的前沿技术恰恰相反它藏在每一门编程语言的第一章、每一本《计算机组成原理》的教材里但翻开互联网上的讨论你会发现大量关于32位有符号整数C次方怎么表示16位整数转换16位浮点数整数奇偶排序的困惑都源于同一个根子没有把整数的底层表示真正理解到位。如果你是学生这篇文章能帮你把那块最难啃的硬骨头啃下来如果你是写业务代码的工程师看完之后你至少能明白那些线上诡异 bug 的来龙去脉。1. 整数表示所有编程语言的第一块基石1.1 一个让全组人沉默的统计结果当时那个实习生的任务很简单读一个大文件统计某个事件出现的次数。文件大概有 10 个 G他写了个 Java 程序循环遍历每行命中就count。跑了好几个小时最后打印出来的结果是个负数。他问我是不是文件里有损坏的字节我让他看代码。代码逻辑完全正确文件本身也没有问题。真正的问题是int类型的最大值是2147483647也就是大约 21 亿。一旦计数超过这个值整数的表示方式就会从正数区间回绕到负数区间计数器变成负数。这个场景我后来在面试里讲过很多次。很多人第一反应是这题太简单了int 溢出而已但你再追问一句为什么恰好是 21 亿为什么回绕之后不是报错而是变成负数负数的最小值为什么比正数的最大值大 1 能完整答上来的人就很少了。大多数人上课时记住了int 是 32 位、范围 -2147483648 到 2147483647但记这些数字没有任何意义因为下次遇到long、遇到数据库里的BIGINT、遇到 Python 里看起来可以无限加的整数数字又全变了。真正应该记住的是这张表背后的推导逻辑。1.2 搜索记录暴露了真实的困惑我写这篇文章之前专门翻了翻和这个主题相关的高频搜索词。很有意思整数排序、bash脚本定义整数变量、32位有符号整数、计算机组成原理、16位整数转换16位浮点数、C次方怎么表示、MySQL可以存储整数数值的是、整数奇偶排序……这些词看似分散其实都指向同一个缺口。比如C次方怎么表示表面上是忘了一个函数名实际上是没理解整数的位运算和浮点数的幂运算在底层是两套完全不同的机制。16位整数转换16位浮点数本质上是没搞清整数和浮点数在内存中的位模式截然不同。bash脚本定义整数变量是因为发现 shell 里所有变量默认都是字符串想强行走整数运算却踩了类型转换的坑。这些表面上的 API 问题底层全是表示问题。所以你去看再多的函数手册不如把整数的底子打牢。接下来我用最通俗的方式把这套底子一层层拆开。2. 从位权到补码整数表示的底层逻辑2.1 为什么计算机偏偏选中了二进制人类发明了十进制但计算机内部用的是二进制。为什么因为实现只有两种状态的物理器件比实现有十种状态的器件容易太多也可靠太多。你想想一个开关要么打开、要么关闭一个晶体管要么导通、要么截止一块磁盘表面要么有磁性、要么没有磁性。所有物理媒介天然都是二态的。如果要做一个能表示 0 到 9 十种状态的器件你得精确控制十个电压区间抗干扰能力会差到没法用。所以计算机用二进制不是爱好是物理现实。一个二进制位称为 1 bit它是信息的最小载体。8 个 bit 组成 1 个字节byte这是大多数处理器的基本寻址单位。整数在计算机里本质就是一段固定长度的 bit 序列比如 32 位就是一个由 32 个 0 或 1 组成的串。给这些 bit 赋予数值靠的是位权weight规则。二进制的每一位都有对应的权值从右往左依次是 1、2、4、8、16……也就是 2 的幂。比如二进制1101按位权展开是1×2^3 1×2^2 0×2^1 1×2^0 8 4 0 1 13这套规则和十进制一模一样十进制从右往左是 1、10、100、1000二进制不过是把基数换成了 2。到这里正整数的二进制表示已经清楚了。接下来麻烦的是负数。2.2 原码、反码、补码三代方案的演进人类的第一直觉是用一位来专门表示符号0 代表正1 代表负剩下的位表示绝对值。这就是原码。比如用 8 位表示 13 是00001101-13 是10001101。原码看起来友好用起来全是问题。首先是 0 有两种表示00000000和10000000都表示 0这让判断是否为零变得复杂。更致命的是直接拿原码做加法是错乱的0000110113加上10001101-13结果是10011010这显然不是 0。于是有了反码正数不变负数在原码基础上把符号位之外的位全部取反。-13 的反码是11110010。反码解决了正负数相加的部分问题但 0 还是有正负两种表示。最终的方案是补码正数不变负数在反码基础上再加 1。-13 的补码就是11110011。补码的一大功劳是0 只有一种表示00000000另一个功劳是加法器可以直接处理减法CPU 不需要专门设计一套减法电路。你把00001101和11110011相加试试00001101 11110011 ---------- 100000000结果是 9 位最高位的 1 被 8 位寄存器丢掉剩下00000000刚好是 0。13 加 -13 等于 0完美。2.3 补码的本质模运算下的统一加法补码为什么要取反加一很多人背了这个规则却完全不知道为什么。我来给你一个真正好理解的角度模运算。你想想钟表。钟面只有 12 个小时过了 12 就回到 1。在这个世界里9 点加 4 个小时等于 1 点因为9 4 1313 对 12 取余是 1。那么请问在钟表世界里怎么表示减 4 小时答案是加 8 小时因为12 - 4 8。减 4 等价于加 8这就是模运算的等价关系。n 位二进制的情况一模一样。n 位能表示的状态总数是2^n所以它的模就是2^n。任何运算结果如果超出 n 位就相当于对2^n取模超出的高位直接丢弃。于是减 x就等价于加2^n - x。2^n - x怎么算二进制里一个技巧2^n - x等于(2^n - 1) - x 1。而(2^n - 1)是 n 位全是 1 的数减去 x恰好等于把 x 的每一位取反。所以-x 的补码 取反 1这就是取反加一的来源。它不是什么魔法只是模运算的必然结果。补码的真正价值是把减法统一成了加法让 CPU 只造一个加法器就能完成所有整数加减运算硬件因此极大地简化。3. 有符号整数的边界最大值、最小值与溢出回绕3.1 -2147483648 到 2147483647 是怎么算出来的既然补码要存储负数那么符号位还要不要单独留答案是要但符号位已经融合在补码规则里了最高位为 1 的数一律解释为负数。换句话说补码体系里的符号位只是位模式的一种自然属性它不再是独立的一位。拿 8 位来说。能表示的位模式一共2^8 256种。其中最高位是 0 的是00000000到01111111对应 0 到 127共 128 个数。最高位是 1 的是10000000到11111111它们在补码规则下分别对应 -128 到 -1也是 128 个数。所以 8 位有符号整数的范围是-128 ~ 127。推广到 32 位范围就是-2^31 ~ 2^31-1。正数这边因为要把 0 也算进去所以最大值只能是2^31 - 1 2147483647负数这边没有这样的限制最小值是-2^31 -2147483648。于是出现了那个著名的不对称负数的最小值比正数的最大值在绝对值上大 1。不理解这个推导的人容易把2147483647当成一个需要死记硬背的常数。而掌握了位权的人随时能从32 位、补码表示这几个词还原出全部边界。顺手记一下64 位有符号整数也就是long范围是-9223372036854775808 ~ 9223372036854775807。3.2 溢出后的回绕为什么结果是负数而不是报错回到文章开头的实习生例子。int加法一旦越过2147483647结果会从-2147483648重新开始。这是补码位模式的必然结果01111111...再加 1所有位进位变成10000000...解释为负数最小值。这种回绕行为在 Java 中有一套明确定义但 C 和 C 里有符号整数溢出属于未定义行为undefined behavior编译器可以合理假设你永远不会溢出然后基于这个假设做各种激进的优化。这就可能带来更隐蔽的问题代码在 Debug 模式下正常在开优化后行为大变。我见过一个真实的线上事故问题出在二分查找int mid (left right) / 2;当left和right都接近 21 亿时left right直接溢出变成负数mid变成一个完全错误的索引程序陷入死循环或者数组越界。类似的问题还出现在循环控制变量、事件计数器、自增主键等所有可能越过 21 亿的场景。经典修复是把参数写成相减的形式int mid left (right - left) / 2;这样right - left永远不超范围加法也安全得多。这段代码看起来只是一个小改动背后的含义却是你彻底理解了整数溢出的机制而不是祈祷数据不会触及边界。3.3 防御溢出的几种实用手段我从实际项目里总结了几层防溢出措施按成本从低到高排列改大类型。Java 里int换longC 里改用int64_t数据库从INT换BIGINT。这是最直接有效的办法但要注意Java 的long也有上限只是那个数字大到绝大多数业务到不了。变化运算写法。像二分查找那样把加号改成减号加除号从根上缩小中间量。用语言提供的安全运算。比如 Java 的Math.addExact、Math.multiplyExact溢出时直接抛ArithmeticException。Go 语言没有内置溢出检查得靠 go vet 之类工具辅助。对金额和 ID 这类关键数据测试用例强制覆盖边界值。比如测试计数器达到2147483647后再加 1 会怎样测试订单号到达9.2e18会怎样。多数公司不会测一旦出问题就是大事故。提示不要迷信我们业务量永远到不了 21 亿。互联网产品的峰值是突然来临的而数据一旦存进数据库类型想改就要经历漫长的迁移。4. 不同语言对整数的态度决定了你踩坑的方式4.1 C/C 的 int祖先留下的宽松规则C 语言对整数的定义给足了自由度int至少是 16 位在绝大多数现代平台上是 32 位但标准没有写死。long更麻烦在 Windows 上是 32 位在 Linux 64 位系统上是 64 位。char到底有没有符号也是由实现定义。这种宽松性源于 C 语言追求跨平台效率的初衷。对系统级编程来说它是必要的但对应用层工程师来说它成了大量坑的源头。你在一台机器上调试好的代码换个编译器、换个平台溢出行为可能就不一样。C里还有一个和整数表示强相关的经典迷惑^不是幂运算而是按位异或。新手写a ^ b想算 a 的 b 次方结果得到一个完全不是预期的整数因为在二进制位模式下异或只是逐位比较 0 和 1。想要幂得调pow但pow返回double对大整数有精度损失所以对整数场景更可靠的做法是手写快速幂long long quickPow(long long a, int b) { long long result 1; while (b) { if (b 1) result * a; a * a; b 1; } return result; }这段代码里用到b 1判断奇偶、b 1做除 2全是位运算与整数的直接配合。只有理解了补码和位权你才能读懂这类写法为什么不依赖浮点也不会丢精度。4.2 Java 把行为写死在规范里Java 选择了和 C 相反的路int永远 32 位long永远 64 位不管你在什么平台上跑。代价是 JVM 承担了跨平台适配的工作但换来的是程序行为的高度可预期。对绝大多数业务开发来说这个取舍非常值得。Java 里还有个细节char是 16 位无符号整数byte是 8 位有符号整数。当你把byte转成int时会发生符号扩展。比如一个byte的0xFF代表 -1直接赋给int会变成0xFFFFFFFF也代表 -1。如果你期望得到255就得用b 0xFF掩掉符号位。这些都是面试常考点也是实际编码中容易踩的坑。另一个常见的 Java 整数问题是hashCode可能是负数。比如自定义对象作为 HashMap 的 key 时如果hashCode直接取整数属性负数天然可能出现。HashMap 内部用hash (table.length - 1)来求桶下标这种位运算能正确应对负数。但你如果自己写类似hashCode() % bucketCount的代码取模结果可能是负数导致数组越界。正确做法是Math.floorMod(hash, bucketCount)或hash 0x7fffffff % bucketCount这类先归正再取模的方式。这种问题看起来是取模的写法问题根子还是对补码符号位理解不透。4.3 Python 的任意精度整数方便还是负担Python 3 的整数是任意精度的随你怎么加都不会溢出。它是把整数拆成多个 30 位的小段digit按位拼接存储段数会随着数值增大而动态增加。所以2**1000在 Python 里直接就能算出来这在 C 和 Java 里无从谈起。好处显而易见写算法题的人再也不用担心大数溢出不需要手写高精度运算。坏处也很明显任意精度是靠堆内存换来的每个大整数对象都有额外开销加法的复杂度不再恒定而是和数值的位数成正比。在性能敏感的场景比如批量流式计算、高频交易、图像处理里Python 的整数开销会成为明显的瓶颈。顺便一提Python 的**也是整数幂运算它背后是一套复杂度不错的快速幂算法但如果你需要一个超大的数并频繁取模还是得用专门的大数库或直接用 C 扩展。4.4 数据库里的整数INT 与 BIGINT 的选型MySQL里INT占 4 字节有符号范围约 -21 亿到 21 亿无符号范围 0 到 42 亿BIGINT占 8 字节范围约 -9.22e18 到 9.22e18。开发中我见过最多的表结构问题就是主键用了INT然后业务增长到千万甚至亿级后开始焦虑。我的建议很简单凡是主键、订单号、流水号这类会只增不减的 ID直接上BIGINT。虽然它比INT多占 4 字节但在现代磁盘和内存面前这点代价可以忽略换来的是未来十年的安心。状态码、枚举值、年龄、数量这类有明确上限的字段才适合INT甚至TINYINT。还有一个容易错的点不要把手机号、身份证号这类看起来像数字的值存成整数。它们不参与数值运算而且位数可能超过 32 位存成INT必然溢出或需要用BIGINT和字符串转换自找麻烦。正确做法是存成字符串。5. 硬件视角整数运算在电路里怎么跑5.1 加法器一切整数运算的地基CPU 里负责整数运算的核心模块叫 ALU算术逻辑单元。它的心脏是加法器。加法器怎么工作先看一位加法两个数 A 和 B 相加得到本位和 S 以及向高位的进位 C。半加器没有低位进位输入只能处理一位加法。全加器多了一个进位输入端Cin可以把两个一位数以及低位的进位一起相加。把 32 个全加器串联起来就构成了 32 位加法器每一位的结果依赖低一位传来的进位。有了补码减法就变成了被减数加上减数的补码也就是加一个负数。于是同一个加法器既能做加法又能做减法这是补码在硬件层面的价值。5.2 进位链为什么加法有延迟串行进位也叫行波进位是最直观也最慢的加法器结构。问题是进位的传播路径最低位产生的进位要逐步传递到最高位最坏情况下进位信号要穿过整整 32 级加法器每一级都有门延迟加起来就是不可忽视的时间开销。这就引出了组间串行进位这类优化思路。把 32 位分成若干个小组每组内部用超前进位逻辑快速算出组内的和与组间进位组与组之间再串行传递进位。组内并行组间串行速度远快于纯串行这就是计算机组成原理教科书里那类题目的意义。你看到热搜词里的组间串行进位就是在问这个结构。理解进位链对写代码的启发是在高性能算法和并发编程里那些看起来简单的加法和乘法在硬件层面并不便宜。尤其是在循环体里编译器优化会尝试转换运算形式但你不应该把加法很快当成永久真理。5.3 移位运算与乘除法的关系左移一位相当于乘以 2右移一位相当于除以 2 再向下取整。这是二进制位权的直接推论。逻辑左移x 1所有位向左移最低位补 0。逻辑右移x 1所有位向右移最高位补 0。算术右移最高位保持原来的符号位不变所以负数右移仍然保持负数性质。C 和 C 对有符号整数的右移定义为算术右移还是逻辑右移标准并没有完全写死但绝大多数编译器的行为都是算术右移。Java 则用表示算术右移、表示逻辑右移定义清晰。编译器经常把乘以常量改成移位加加法。比如x * 10可能被优化成(x 3) (x 1)因为 CPU 移位单元比乘法器快得多。你在写代码时如果能主动意识到这一点写出来的算法效率会明显更高。6. 高频整数题背后的考察点从数 1 到奇偶排序6.1 统计 1 到 n 中数字 1 的个数题目是给定一个十进制正整数 n写下从 1 到 n 的所有整数然后统计其中出现的数字 1 的个数。很多人第一反应是循环int countOnes(int n) { int count 0; for (int i 1; i n; i) { int x i; while (x) { if (x % 10 1) count; x / 10; } } return count; }这个写法在 n 较小时没问题但 n 到亿级别就会非常慢。更好的做法是按位统计分别计算个位、十位、百位……上出现 1 的次数加起来。核心思想是把每个数位拆开利用整数表示中的位权规律。这类题表面上是考编程实际上是考你对十进制位数与位权关系的理解。你把一个整数拆成高低位时本质上就是在操作它的位模式只不过基数是 10 而已。理解了整数在计算机里的二进制表示后你会发现这类拆位统计题是相通的二进制里数 1 的个数也可以逐位统计或查表加速。6.2 整数奇偶排序奇偶本质是二进制最低位经典题目1181: 整数奇偶排序给定 10 个整数要求奇数在前、偶数在后并且奇数和偶数的内部顺序要保持输入时的相对顺序。初学者最容易踩的坑是用不稳定的排序比如std::sort直接排序结果破坏了相对顺序。正确解法是开两个数组扫描一遍原数组奇数放前面数组偶数放后面数组最后合并。判断奇偶的标准是什么x % 2可以但更底层的判断是x 1。一个整数的二进制最低位是 1 就是奇数是 0 就是偶数。这个视角在性能敏感场景很有用因为位运算比取模快。这类题目的价值在于它让你意识到奇偶不是数学上的抽象概念而是整数的二进制表示中最直观的一个属性。当你习惯了从二进制角度看整数很多题目会变成一眼题。6.3 幂运算与进制转换的表示陷阱C次方怎么表示这个搜索词折射出不止一个困惑。在 C 里^是按位异或不是幂。如果你真想算整数幂应该好好想想底数和指数都是整数中间会不会溢出浮点pow的返回值是否能精确表示结果另一个类似主题是进制转换。十进制转二进制、十六进制转二进制本质都是位权展开的逆操作。16位整数转换16位浮点数这个热搜词很有代表性很多初学者以为把整数的小数点挪一挪就变成浮点数了其实完全没有理解浮点数使用的是另一套表示法——符号位 指数位 尾数位。整数 16 在内存里是0000000000010000而浮点数 16.0 的位模式完全是另一回事。从这个角度看理解整数表示是理解浮点表示的必经之路。7. 我在生产环境踩过的整数坑7.1 时间戳、订单号、金额的 64 位时刻Unix 时间戳是一个以秒为单位的整数32 位有符号整数的上限到 2038 年就会溢出这是 IT 行业人尽皆知的话题。但现实中更多项目的问题是根本没有意识到某些字段会触及 32 位边界。我见过一个订单系统早期用INT存订单号单量冲到日均百万后没多久就告警了。虽然当时还在 21 亿以内但增长率一算明年就爆。这种问题如果等爆了再改只能停机做表结构迁移压力极大。所以设计表的时候就要有预判边界的思维凡是主键、订单号、会话 ID、累计计数一律BIGINT。金额问题更隐蔽——很多系统拿浮点存金额然后算着算着出现 0.1 0.2 不等于 0.3 的诡异问题。金额应该用整数存分最小货币单位或者用定点数类型而不是浮点。这又一次验证了表示的重要性选错表示法代价会在意想不到的地方冒出来。7.2 hashCode 的符号问题与分区计算Java 的hashCode()返回int可以是负数。如果拿它直接做取模路由比如hashCode() % 10负数结果会导致请求路由到负数下标典型的数组越界或分区错乱。正确姿势是先对哈希值做无符号化处理再取模int bucket (hash 0x7fffffff) % bucketCount; // 或者 int bucket Math.floorMod(hash, bucketCount);这里用 0x7fffffff把符号位强制改成 0让整个整数变成一个非负数但会丢失负数的范围信息。如果要保留全部 32 位的分布更好的做法是把int转成long再取模int bucket (int) (Integer.toUnsignedLong(hash) % bucketCount);这类问题的根子还是对最高位是符号位、负数在补码下位模式长什么样不够敏感。一旦你真正消化了补码这种 bug 在你眼里会是透明的。7.3 隐式类型转换中的符号扩展陷阱C 语言里有个经典坑char c 0xFF; // 如果 char 是有符号的这里等于 -1 if (c 255) { // 永远进不来 } if (c 0) { // 也永远进不来 }原因很简单有符号char的0xFF在补码表示下是 -1。当它参与比较时会先发生整型提升变成int的0xFFFFFFFF仍然是 -1。要得到 255必须写成(unsigned char)c或者c 0xFF。无符号与有符号混合比较是另一个雷区int a -1; unsigned int b 1; if (a b) { // 你会以为进得来其实进不来 }为什么因为 C 的隐式转换规则会把a从有符号转成无符号-1 的无符号表示是4294967295跟 1 比较自然大于。看着是小于 1实际变成了最大的无符号数大于 1。这种坑对写过几年 C/C 的程序员来说司空见惯但对新手而言足够劝退。理解了补码和符号扩展你就能解释每一个位的变化而不是死记不要拿有符号和无符号比较这种结论。我自己的习惯是遇到整数参与的运算先问三个问题这个值的可能范围是什么运算中间量会不会越过当前类型的边界有没有隐式转换在悄悄改变语义把这几个问题过一遍绝大多数整数相关的坑都能在写代码阶段提前堵住。如果你现在还觉得整数嘛能加能减就不就完了希望这篇文章之后你能换个视角看这个老朋友。