上周同事找我排查一个线上问题用户的连续签到天数统计突然变成了负数。代码很简单就是一个计数器每天加一逻辑上完全挑不出毛病。我看了一眼字段类型int再一看数值已经涨到了接近 22 亿。一切就都解释通了——int存不下那么多天它溢出了。这个问题问得特别好它说明很多同学对 Java 八种基本类型的理解只停留在“能背出来”的层面并没有真正搞懂每种类型的边界、精度和底层逻辑。尤其是六种数字类型选错类型、踩错转换规则线上事故就是这么来的。这篇就把 Java 的八种基本类型特别是三类整数和两类浮点数从头到尾捋一遍该给的表给全该讲的原理讲透最后再聊聊我这些年踩过的实际坑。1. 八种基本类型全景字节数、取值范围与几个反直觉细节先上那张最基础的表格。八种基本类型分别是byte、short、int、long、float、double、char、boolean。其中前六种就是标题里说的六种数字类型char算半个数字类型它本质上是无符号整数而boolean是纯粹的逻辑类型。类型字节数位数取值范围默认值byte18-128 ~ 1270short216-32768 ~ 327670int432-2147483648 ~ 21474836470long864-9223372036854775808 ~ 92233720368547758070Lfloat432约 ±3.4E387 位有效数字0.0fdouble864约 ±1.7E30815 位有效数字0.0dchar2160 ~ 65535\u0000boolean未明确定义至少 1 位true / falsefalse1.1 为什么 byte 的范围是 -128 到 127而不是对称的很多初学者会问8 个二进制位能表示 256 种状态怎么正数只到 127负数却能到 -128这是因为 Java 的整数类型默认用补码表示。补码的核心规则是正数的补码就是它本身的二进制负数的补码是“原码取反加一”。在这种表示法下最高位是符号位0 表示非负1 表示负数。正数从000000000到01111111127负数从11111111-1到10000000-128。注意10000000这个二进制数如果按无符号数看是 128但在补码规则下它表示 -128因为最高位 1 表示负数剩余 7 位全 0。所以 -128 的存在不是偶然而是补码编码的自然结果它也解释了为什么负数范围比正数范围“多一个数”。1.2 char 为什么占 2 个字节一个早期的历史包袱char是 Java 里唯一一个无符号的基本类型范围 0 到 65535。它占 2 个字节是因为 Java 设计之初就决定用 Unicode 字符集当时认为 16 位足够容纳所有字符编码。后来 Unicode 字符数量超过了 65535出现了增补字符需要两个char来表示这就是代理对。因为char本质上是无符号 16 位整数所以它能参与算术运算char c A; // 65 c; // 变成 B System.out.println(c); // B这在实际项目中不常用但理解这一点能帮你避开某些乱码问题的排查方向。1.3 boolean 到底占几个字节官方规范给了一个模糊答案面试里经常问这个问题。Java 虚拟机规范里没有明确说boolean占几个字节只说boolean类型的值在编译后会映射成int类型来处理。具体到不同虚拟机的实现上单独的boolean变量通常会占 4 个字节对齐到栈上的一个 slotboolean[]数组在 HotSpot 里每个元素占 1 个字节但这是实现细节不是规范要求。我见过有人拿这个去争辩“boolean 必须占 1 字节”其实没必要规范层面根本没有定死。知道“boolean 和 int 关系密切”就够了。1.4 成员变量有默认值局部变量没有一个易被忽略的差异基本类型作为类的成员变量时JVM 会给它分配默认值比如int默认是 0boolean默认是false。但方法里的局部变量如果没有初始化就直接使用编译就会直接报错。public class Demo { int x; // 默认 0编译通过 public void test() { int y; // System.out.println(y); // 编译错误可能尚未初始化 } }这个机制引导我们去做一件好事局部变量必须显式初始化。它逼着你在写代码时就想清楚这个变量应该从什么值开始。2. 六种数字类型的选择逻辑先想边界再谈精度六种数字类型里byte、short、int、long是整数float、double是浮点数。选型没有想象中复杂核心就两条规则先看数值会不会超出范围再看精度能不能满足需求。2.1 int 的上限二十亿门槛跨过去就是负数int最大是 2147483647大约 21.47 亿。看起来很大但现代互联网系统里这个门槛非常容易触到。举几个真实场景某个 App 的总用户数、某个活动的累计参与次数、设备的累计上报条数。只要业务持续增长int的 21 亿上限几年就能耗尽。我见过一个真实案例某系统的订单序号用int存跑了两三年后某天新增订单的 ID 突然变成了负数下游的所有逻辑全部错乱。排查到最后就是把int改成long但因为历史数据已经是负数还得先做数据订正。我的建议很简单凡是会持续增长、且跨年累计的业务标识一律用long不要用int赌增长率。2.2 浮点数为什么不精确二进制里的无限循环小数float和double的精度问题是很多人第一次接触时的“三观粉碎点”。看这段代码double a 0.1; double b 0.2; System.out.println(a b); // 0.30000000000000004直觉上应该是 0.3但结果是 0.30000000000000004。原因是十进制小数 0.1 转成二进制时是一个无限循环小数就像十进制里 1/3 0.3333… 永远写不完一样。而计算机只能用有限位存储必然要截断误差就这么来了。IEEE 754 标准把浮点数分成三部分符号位、指数位、尾数位。float是 1 位符号 8 位指数 23 位尾数有效十进制数字约 7 位double是 1 位符号 11 位指数 52 位尾数有效数字约 15 到 16 位。所以当我们用double做加减法结果只能在第 16 位左右出现误差日常展示看不出来但金额、精度敏感的科学计算就会出事。2.3 一个容易被误解的点范围大不代表精度高float的范围能到 10 的 38 次方double能到 10 的 308 次方看起来大得吓人。但范围大只是指数部分撑起来的跟精确到什么程度完全是两回事。举个例子用float存 123456789 这个数打印出来可能是 123456792。因为 123456789 需要至少 8 到 9 位有效数字才能完全表达而float只有 7 位。你以为存进去的是精确值实际上存的是一个“离它最近的浮点数”。这就是为什么大数用float显示会“自动变整”的真相。2.4 不同业务场景的选型速查表场景推荐类型理由循环计数器int单次循环几乎不可能超过 21 亿次时间戳毫秒long当前毫秒时间戳约 17 亿int 已经装不下自增主键 / 订单号long防止业务增长导致 ID 溢出金额计算BigDecimal浮点数有精度损失不符合金融合规科学计算、统计指标double性能高允许轻微精度误差网络协议字段 / 文件头byte / short省空间节省带宽和存储数据库自增主键映射long对应 BIGINT兼容性最好2.5 数据库类型和 Java 类型的对应注意点做后端开发的还要注意一层对应关系。MySQL 的INT对应 Java 的intBIGINT对应longFLOAT对应floatDOUBLE对应double。有个大坑MySQL 支持INT UNSIGNED无符号类型最大值能到 42.9 亿但 Java 的int没有无符号概念直接映射会溢出。遇到这种字段稳妥的做法是用long来接或者干脆设计表结构时避免无符号类型。3. 类型转换与运算提升那些编译不过的代码在提醒你什么类型转换的规则本质上就是一句话能保证数据不丢失的转换编译器允许自动进行可能丢失数据的转换必须由开发者显式强转。3.1 隐式转换链byte → short → int → long → float → doubleJava 里存在一条自动类型提升链顺着这个方向赋值不用强转byte b 10; int i b; // 没问题 long l i; // 没问题 float f l; // 没问题但注意精度可能丢失 double d f; // 没问题为什么int转float也算自动因为float的指数位范围比int大得多正常情况下int的整数都能被float装下。但“装下”不等于“精确装下”——这就是上节说的有效数字问题。Java 编译器认为这种转换“范围上安全”所以放行但精度损失的问题要开发者自己心里有数。3.2 强转的本质截断二进制位而不是四舍五入很多人以为int强转byte是把 128 的一半取 128 对 128 取模其实底层逻辑是截断高位只保留低 8 位。int a 128; byte b (byte) a; System.out.println(b); // -128128 的 32 位二进制是00000000 00000000 00000000 10000000截断成 8 位后是10000000。在 byte 的补码规则下这个值就是 -128。所以强转不是数学意义上的取模而是位级别的截断结果可能完全出乎意料。浮点数强转成整数同理(int) 3.99的结果是 3直接丢弃小数部分不是四舍五入。3.3 运算中的隐藏升级short short 为什么是 intJava 在计算时会把byte、short、char自动提升为int再参与运算。也就是说short s1 100; short s2 200; // short s3 s1 s2; // 编译报错 int s3 s1 s2; // 正常原因有两层一是 CPU 的通用寄存器以 32 位为单位short参与运算前本来就要扩展二是防止运算结果溢出却没有提示。如果允许short s3 s1 s2当结果超过 32767 时就会在开发者不知情的情况下截断这是设计上想避免的。跟这个规则配套的是复合赋值运算符的“豁免”short s 100; s 1; // 合法编译器隐式强转这种写法等价于s (short) (s 1)编译器帮你插入了强转所以能过。但它也意味着可能悄悄溢出而不报错使用时要清楚这个行为。3.4 字面量的严格边界L、F 后缀和范围检查写数字字面量时默认整数是int默认浮点数是double。三个容易踩的点long l1 3000000000L; // 不加 L 编译报错因为 30 亿超过 int 范围 float f1 3.14f; // 不加 f 编译报错因为 3.14 默认是 double byte b1 100; // 合法100 在 byte 范围内 byte b2 128; // 编译报错128 超出 byte 范围最后一个有点特殊Java 允许给byte直接赋一个字面量条件是它落在byte的范围内。编译器会在常量层面做检查本质上仍是一次隐式的窄化转换只不过因为常量值确定编译期就能判断安全。4. 包装类与缓存池从128 不等于 128看自动装箱的真相基本类型有对应的包装类Byte、Short、Integer、Long、Float、Double、Character、Boolean。泛型不支持基本类型集合类只能存引用类型所以包装类在 Java 里是绕不开的。4.1 自动装箱的本质编译器调用了 valueOf看这行代码Integer num 100;它等价于Integer num Integer.valueOf(100)。valueOf方法内部会判断如果数值在某个范围内直接返回缓存池里的同一个对象否则new一个全新的Integer。4.2 Integer 缓存池的范围-128 到 127Integer的缓存池默认覆盖 -128 到 127。在这个范围内valueOf返回的是同一个对象超出这个范围每次都会新建对象。所以Integer a 127; Integer b 127; System.out.println(a b); // true同一个缓存对象 Integer c 128; Integer d 128; System.out.println(c d); // false两个不同的对象这就是网上流传的那道经典面试题“为什么 128 不等于 128”的由来。实际上还有一层通过字面量赋值触发的是valueOf缓存逻辑但如果直接用new Integer(127)创建那不管是不是在缓存范围内它都是一个新的对象比较永远为 false。4.3 比较的正确姿势数值用 equals绝不裸用 包装类的比较的是引用地址不是数值。两个包装对象比较数值应该用equals()Integer m 200; Integer n 200; System.out.println(m.equals(n)); // true System.out.println(m n); // false但这里还有一个微妙之处如果其中一个操作数是基本类型会自动拆箱变成真正的数值比较。比如Integer m 200; int n 200; System.out.println(m n); // true因为 n 是 int触发拆箱很多线上 bug 就藏在这种“有时候比地址、有时候比值”的混乱里。我的建议是引用类型之间一律用equals不要依赖任何“恰好拆箱”的行为。4.4 自动装箱拆箱会带来隐藏的 GC 压力自动装箱看起来很美好但在循环里会产生大量临时对象// 在 1000 万次循环里每次都要装箱产生大量临时对象 Integer sum 0; for (int i 0; i 10_000_000; i) { sum i; // sum 先拆箱、再相加、再装箱 }这段代码等价于sum Integer.valueOf(sum.intValue() i)每次循环都在堆上分配新对象。大量短暂存在的包装对象会显著增加垃圾回收的压力。跑性能敏感的代码时能用基本类型就用基本类型别让包装类替你“低调地买单”。5. 我在项目里总结的基本类型使用守则最后这部分不是教科书内容是我实打实踩过坑之后形成的习惯分享出来供你参考。5.1 金额计算float、double 一律不碰只用 BigDecimal你可能会说金额不就是小数吗用double也不是不行。但等你看到 0.1 加 0.2 算出的那一长串小数再往数据库里存就知道问题有多尴尬了。金额必须用BigDecimal而且构造时要用字符串BigDecimal a new BigDecimal(0.10); BigDecimal b new BigDecimal(0.20); System.out.println(a.add(b)); // 0.30千万不能new BigDecimal(0.1)因为 0.1 这个double本身就不精确传进去只会把不精确的值“放大”得更彻底。纯粹的加减乘除可以用BigDecimal但如果涉及非常复杂的数学运算那需要重新评估性能BigDecimal比double慢一到两个数量级。5.2 时间戳和自增 ID直接上 long别犹豫当前毫秒时间戳已经接近 1.8 万亿远大于int上限。凡是时间戳、自增主键、全局唯一序号这类“只增不减”的字段我现在的习惯是一律long。不是为了炫技是为了不让未来的维护者在一个深夜因为溢出事故被叫醒。Java 里System.currentTimeMillis()返回的就是long数据库里对应BIGINT链路全程一致。5.3 byte 和 short 的适用场景节约空间不是主要目的很多人觉得现代机器内存大byte和short没用了。实际上它们在网络报文解析、文件格式读取、二进制协议处理里是刚需。比如解析一个 TCP 自定义协议头版本号占 1 个字节消息类型占 1 个字节长度占 2 个字节这时候byte和short就是直接对应协议字段的类型不是因为想省内存而是因为协议就是这么定义的。5.4 switch 支持的类型由 int 决定的“可映射性”switch语句能接受的类型从 Java 5 的枚举到 Java 7 的String本质上都要求能映射成int。枚举在编译时映射到ordinal()String通过hashCode()后走 int 逻辑。long、float、double不能直接在switch上使用因为它们无法无损或合理地映射成int。知道这个原理你就不会去写switch (longValue)然后对着编译错误发愣。5.5 排查溢出问题的一套通用流程如果你怀疑某个数字异常是基本类型溢出导致的可以按这个思路排查看字段类型确认是int、short还是其他类型。算边界当前业务数值理论上是否会超过该类型上限。看日志如果数值从正数突然跳到很大的负数大概率是溢出了——因为溢出会绕回该类型的最小值继续往上加。修类型把int改成long后同步检查数据库字段、接口入参、JSON 序列化这些链路避免只改了 Java 端传输端还是 32 位。验数据对已经溢出的历史数据做订正通常需要按业务规则重新生成。我开头说的那个同事后来就是把int改成了long。但除了类型变更他还得写一个数据订正脚本把已经变成负数的签到天数重新算回来。这中间还涉及跨年、跨月的统计逻辑前前后后折腾了一天。如果当初写接口的时候就先用long这些事根本不会发生。最后一个我自己的习惯在定义实体类的数字字段时我会多问自己一句——“这个字段会一直增大吗会超过 21 亿吗需要精确到小数吗”只要有一个答案是肯定的就优先选更宽或更精确的类型。基本类型的选择是写代码时最便宜、也最容易忽略的决策但它往往是线上事故最隐蔽的源头。