Java四种进制详解:从底层原理到避坑实战
发布时间:2026/10/6 4:50:07 作者:尧图编辑部 阅读量:1,286

这一期我们来聊聊Java里的四种进制。很多同学学到进制的时候第一反应是“这东西平时写业务用不到啊”然后草草跳过等到面试被问到Integer.toHexString为什么返回了一串ffffffff或者接手一段解析文件头、处理二进制协议的老代码时才发现欠下的债全得还。这篇博文会把进制从底层原理到实际使用完整过一遍不绕弯子把该讲的为什么讲透把该避的坑全部列清楚。适合刚学Java基础的同学也适合准备面试的人快速回炉。1. 四种进制的底层面貌与Java表示方式1.1 为什么计算机偏爱二进制要说清楚进制就得先从CPU最基本的物理单元说起。芯片内部有数以亿计的晶体管它们就像一个个微型开关只有两种稳定状态导通和截止。对应到电平上就是高电平和低电平。我们用数字来表达这两种状态就是 1 和 0。这就是二进制的基础。你可能会问为什么不直接用十进制理论上可以但代价极其高昂——要设计出能稳定区分十种电平状态的电路抗干扰难度会上升好几个数量级而且逻辑电路的核心基础是布尔代数它的运算天然就是二进制的0/1形式与、或、非、异或都是建立在比特位之上的。计算机选择二进制不是因为它喜欢二进制而是因为从物理学、数学两个角度看它都是最省事、最可靠的方案。八进制和十六进制本质上不是计算机“认识”的语言而是给人类看的。二进制写起来太长了一个int是32位全写出来是11111111111111111111111111111111这样一长串人眼很难辨识。而十六进制一位刚好对应二进制的4位八进制一位对应二进制的3位压缩率高、转换直观于是它们就成了人和机器之间沟通时常用的“简写符号”。这一点是我们理解全部进制转换的基石八进制和十六进制从来不是独立存在的某种神秘数字系统它们只是二进制的分组缩写。1.2 Java中四种进制字面量的正确写法在Java源代码里四种进制各有各的“身份证”写法进制前缀示例对应的十进制值十进制无前缀int a 100;100二进制0b或0Bint a 0b1100100;100八进制0int a 0144;100十六进制0x或0Xint a 0x64;100这里有个历史包袱必须讲清楚八进制的前缀是数字 0不是字母 O而且不允许写成0oPython里可以Java里不行。这个设计是从C语言时代继承下来的当时为了省字符就用数字 0 打头表示八进制。它非常容易让人栽跟头写int x 010;你以为值是10实际是8。JDK 7 之后还引入了两个很实用的语法糖二进制字面量的0b前缀以及数字分隔符_下划线。长数字可以按位分组可读性提高不少int mask 0b1010_1100_0011_0101; int big 0xFFFF_FFFF; // 十六进制分组照样能用 long uid 1_000_000_000L;有一点需要注意下划线只能放在数字之间不能放在前缀后面、不能放在小数点旁边、不能放在末尾。0x_FF是编译不过的编译器会直接报错。1.3 位宽与补码进制之上的一层规则光会写字面量还不够要真正读懂0xFFFFFFFF在Java代码里到底是多少必须理解补码。Java的基本整数类型都有固定位宽byte8位、short16位、int32位、long64位。计算机内部整数一律以补码形式存储这套编码规则让加减法统一走加法电路符号位不用单独处理。补码的规则用一句话概括正数的补码就是它本身的二进制负数的补码是它绝对值的二进制逐位取反再加1。比如-1在32位int里就是0xFFFFFFFF二进制32位全是1。因为这种编码规律同一个十六进制串在不同位宽下代表的数值完全不同十六进制串作为 byte (8位)作为 int (32位)0xFF-12550x80-1281280xFFFF无法装入-132位截断后是65535这里要特别强调一个高频坑byte b 0xFF;会编译报错因为0xFF默认是int类型的255超出了byte的范围。正确写法是byte b (byte) 0xFF;但强制转换后 b 的值不是255而是-1。后面讲Integer.toHexString负数时还会再次碰到这条规则它是进制阅读的核心逻辑。2. Java进制转换API全解析2.1 Integer/Long中的静态转换方法Java基础类库提供了完整的进制转换工具核心入口都在java.lang.Integer和java.lang.Long里方法作用返回类型Integer.toBinaryString(int i)转二进制字符串StringInteger.toOctalString(int i)转八进制字符串StringInteger.toHexString(int i)转十六进制字符串StringInteger.toString(int i, int radix)按指定进制转换radix范围2~36StringInteger.parseInt(String s, int radix)按指定进制解析字符串为intintInteger.valueOf(String s, int radix)作用同上返回包装对象Integer这几个方法用起来很直白int n 255; System.out.println(Integer.toBinaryString(n)); // 11111111 System.out.println(Integer.toOctalString(n)); // 377 System.out.println(Integer.toHexString(n)); // ff String s FF; int v Integer.parseInt(s, 16); // 255parseInt和valueOf的区别只有一个前者返回基本类型int后者返回Integer包装类型。推荐在用不到包装类型时都写parseInt能省下装箱开销。toString(int, radix)的radix参数非常灵活你甚至可以Integer.toString(10, 2)拿二进制字符串也可以Integer.toString(255, 36)拿到73这个特性在处理短网址、邀请码场景时很有用。2.2 格式化输出与填充对齐Integer.toHexString返回的字符串不补前导零。Integer.toHexString(15)返回f而不是0f。这在处理颜色值、哈希值、CRC校验码时非常致命——你以为拼好了两位十六进制结果短了一位整个协议包就坏了。解决办法是用String.format或StringBuilder手动补零int r 0xA, g 0xB, b 0xC; String color String.format(#%02X%02X%02X, r, g, b); // 输出 #0A0B0C%02X的意思是输出十六进制大写字符至少占2位不足用0补。这里引出了第二坑Integer.toHexString默认输出小写字母a-f而很多协议、颜色、日志规范要求大写。正则表达式大小写不敏感是好习惯但十六进制解析如果自己实现了映射表请务必统一大小写。如果你在拼接二进制位串做调试可以考虑这样写String bits String.format(%8s, Integer.toBinaryString(0x0F)) .replace( , 0); // 输出 00001111这是一个老程序员常用的手法先用空白填充再整体替换为0比循环拼接干净得多。2.3 任意进制输入的解析与校验parseInt在解析非法字符时会抛出NumberFormatException这是受检异常其实是运行时异常但很多新手没做任何保护线上数据一旦出现脏值整个调用链直接炸掉。建议在解析外部输入时统一处理String input zz; try { int val Integer.parseInt(input, 16); } catch (NumberFormatException e) { // 记录日志、回退默认值或抛出业务异常 }另外parseInt是允许带正负号的Integer.parseInt(-ff, 16)能正常解析为-255。所以当你需要解析无符号十六进制字符串时要特别小心Integer.parseInt(80000000, 16)会得到-2147483648而不是你以为的2147483648因为后者超出了int正数的上限。对这种场景要么用Long.parseLong(80000000, 16)先膨胀到位要么用Integer.parseUnsignedInt(80000000, 16)这个无符号解析方法JDK 8。很多人在解析IP地址、权限掩码时在这个地方踩过坑。3. 手工进制转换原理与速算3.1 二进制到十进制的展开进制转换的核心概念是“权位”。十进制里数字123的意思是1×10² 2×10¹ 3×10⁰。二进制同理从右到左每一位的权位分别是2⁰, 2¹, 2², 2³...。把二进制转十进制就是用每一位的值乘以对应的权位再求和二进制 1101 1×2³ 1×2² 0×2¹ 1×2⁰ 8 4 0 1 13想提速度把常见的2的幂背下来2⁰1、2¹2、2²4、2³8、2⁴16、2⁵32、2⁶64、2⁷128、2⁸256、2⁹512、2¹⁰1024。看到二进制数脑子里按位找对应的幂加法口算就行。这个方法在排查位掩码时尤其好用。3.2 十进制转二进制除2取余法反过来十进制转二进制用的是除2取余法规则就是不断除以2把余数从下往上倒着写出来十进制 13 13 ÷ 2 6 余 1 6 ÷ 2 3 余 0 3 ÷ 2 1 余 1 1 ÷ 2 0 余 1 倒序排列1101为什么余数要倒序因为第一次除得到的余数是二进制的最低位2⁰位最后一次除得到的余数是最高位。这个道理想通了转换就不会记反方向。八进制、十六进制的转法同理只是除数分别换成8和16。有一个常被忽略的细节十进制转十六进制时余数超过9要用字母代替。13 写作 D14 写作 E15 写作 F。丢了这个规则手工转换就会出错。3.3 二进制与十六进制/八进制互转的三分法、四分法既然我们说十六进制是二进制的分组缩写那互转就完全不用走十进制绕一圈。转换规则非常简单二进制转十六进制从右往左每4位分为一组每组替换成对应的十六进制字符左侧不足4位补0。二进制十六进制000000001100102001130100401015011060111710008100191010A1011B1100C1101D1110E1111F比如1101001011这串二进制从右往左分组001101001011最左侧补了两个0查表得到34B。二进制转八进制同理只是分组改为3位一组。1101001011从右往左分为001101001011得到八进制1513。这个速查表做一次终身受益不管是手工调试还是看日志都能一眼出结果。至于十六进制转二进制则是把每位十六进制字符展开成4位二进制例如0x3F展开为0011 1111去掉前导零就是111111。3.4 小数的进制转换乘基取整法整数转换讲完小数部分说说。十进制小数转二进制使用“乘2取整法”每次乘以2取走整数部分直到小数部分为0或达到精度要求。比如0.6250.625 × 2 1.25 取整数1剩0.25 0.25 × 2 0.5 取整数0剩0.5 0.5 × 2 1.0 取整数1剩0 结果0.101这句转换一旦碰到0.1就不灵了——0.1的二进制表示是无限循环小数0.00011001100110011...。这就是为什么0.1 0.2 ! 0.3的根源浮点数是有限的二进制位近似存储精度损失是天生注定的。我们用Float.toHexString看0.1f得到的十六进制是0x1.99999ap-4IEEE 754格式这个形态恰恰是底层二进制存储的直接翻译。理解了小数转换浮点数精度问题就不再神秘了。4. 实操避坑实录那些年踩过的进制坑4.1 八进制的“幽灵前缀”数字前多写一个0这绝对是Java里最经典的进制坑之一。有次同事手滑在配置类里写了个int timeout 0100;结果发现超时时间变成了64秒而不是100秒。排查时一度以为是线程池参数配置错误最后定位到是这个0前缀在作怪。必须牢记十进制整数绝对不能以0开头。写代码时输入法带着泰文或习惯性对齐补零都容易带出这个坑。更隐蔽的是有些新手以为Integer.parseInt(0100)会得到100实际也是100——注意parseInt的默认解析只在radix10下工作它不会因为字符串以0开头就按八进制解析只有字面量才遵循0前缀八进制规则。这俩语义完全不同面试也常在这里下套。Java里如果你确实想写八进制官方推荐你用0前缀但尽量少用可读性极差。代码审查时看到八进制字面量建议直接改成十六进制或十进制加注释。4.2 Integer.toHexString返回负数问题这是实战中出现频率最高的困惑。不少人写byte b -1; System.out.println(Integer.toHexString(b)); // ffffffff明明只想要ff结果出来8个f。原因是byte在参与运算时会自动类型提升为int而-1的int表示正是0xFFFFFFFF。toHexString对负数直接输出该int值的十六进制形式而不会帮你“去掉高位的f”。解决办法是先用位与操作屏蔽高字节byte b -1; String hex Integer.toHexString(b 0xFF); System.out.println(hex); // ffb 0xFF的原理b提升为int后是0xFFFFFFFF与0xFF逐位与高24位全部清零只保留低8位原始字节结果变成255。这个操作在解析二进制协议时简直是家常便饭每读一个字节都得 0xFF不然负数字节就会污染后续拼接。同理短整型转十六进制用short 0xFFFF也可以Long.toHexString配合无符号思路处理更大范围的场景。4.3 进制转换的溢出与截断有次朋友做STM32串口协议的上位机调试需要把设备上报的温度值无符号16位解析出来。直接Integer.parseInt(hexStr, 16)当int用没问题但一旦超过0x7FFF就变成了负数计算逻辑全乱。这里的核心教训是十六进制字符串本身是无符号语义但Java的int是有符号的解析前要先想清楚范围。遇到这种无符号需求优先用Long.parseLong(s, 16)或者Integer.parseUnsignedInt(s, 16)。如果数据更长比如64位JDK 8之后提供了Long.parseUnsignedLong(s, 16)来应对。直接对超范围的字符串调用Integer.parseInt只会收到一个刺眼的NumberFormatException: For input string: FFFFFFFF。还有截断问题把一个long转成int或者把一个int写进byte高位直接丢掉。比如(byte) 0x1234结果是0x34。写底层通信时一定要用位运算显式取出各字节而不是依赖强转截断。4.4 浮点数的十六进制转换后台开发按十六进制解析浮点数的需求不算多但嵌入式联调、通信协议对接时确实会出现。Java里浮点转十六进制有原生方法float f 10.5f; System.out.println(Float.toHexString(f)); // 0x1.5p3 System.out.println(Double.toHexString(10.5)); // 0x1.5p3这里的p表示乘上2的幂次。对应的逆向操作是Float.parseFloat(0x1.5p3)可以正确得到10.5。但注意如果是有人给你发来一个“十六进制字符串表示的浮点内存镜像”例如41280000这不是标准十六进制浮点字面量而是一段IEEE 754的原始4字节二进制内容。解析方式是把字符串先按十六进制解析成int再通过Float.intBitsToFloat还原int bits Integer.parseUnsignedInt(41280000, 16); float f Float.intBitsToFloat(bits); // 10.5反向操作也经常用到Float.floatToIntBits(f)拿到位模式后Integer.toHexString输出。这套 API 在字节序处理、日志打印浮点内部表示时都非常顺手。有一点提醒0x41280000如果按普通int解析是1091567616超过int上限所以必须走parseUnsignedInt否则又是正负号问题——你发现了进制坑是连环的。4.5 大小写与序列化一致性十六进制字母大小写虽然含义相同但对字符串比较、签名计算、数据库唯一索引来说是两码事。举个真实场景订单号用MD5摘要做串接有人在大写环境生成一遍、小写环境生成一遍两笔订单对不上了。不要指望业务系统对大小写做归一化统一规范最重要。我自己的习惯是对外输出全部用大写%02X日志内部看情况用小写方便人眼区分字母和数字但一旦定下来就不要混着用。如果从文件、网络流读取断言内容要记得先trim()去掉空格和换行否则解析时会带上不可见字符。十六进制字符串在传输中偶尔会夹杂空格、冒号(:)或横杠(-)别直接在源头改协议写个过滤器把分隔符剥掉再丢给解析函数这个通用做法更省事String cleaned raw.replaceAll([\\s:,-], );5. 经典面试题与解题实战5.1 判断二进制表示是否为回文面试有种题不直接考进制转换而是考概念活用给一个整数判断它的二进制表示是否是对称的。比如5的二进制是101就是回文。拿到这种题常规思路是转成字符串再双指针比较但更漂亮的做法是位运算逐位收尾比对。boolean isBinaryPalindrome(int n) { int left 31; while (left 0 ((n left) 1) 0) left--; // 找到最高位 int right 0; while (right left) { int a (n left) 1; int b (n right) 1; if (a ! b) return false; left--; right; } return true; }注意这里用了无符号右移而不是因为负数在Java里是补码带符号右移会在高位补1破坏判断。用才能裸眼看二进制位。这个细节本身就是面试官想测试的点。5.2 统计二进制中1的个数位运算技巧这题有经典的低效写法不断右移并检查末位。面试官下一秒就会问“有没有更快的方法”标准答案是n (n - 1)每执行一次就消掉最右边的一个1int countOnes(int n) { int count 0; while (n ! 0) { n n (n - 1); count; } return count; }为什么n - 1能消掉最右边的1因为减1操作会把最右侧的1变成0并把其右侧所有0变成1再与原数相与原来那个1及其右侧全部归零。这题考察的就是二进制运算的直觉进制不熟很难快速想到。5.3 蓝桥杯常见的进制转换综合题算法竞赛和面试笔试里进制题老面孔极多给定十进制数转成任意进制、给定任意进制字符串转十进制、两个不同进制数相加。核心是写一个通用转换器顺便解决大整数场景。// 十进制转任意进制2~36支持long static String toBase(long num, int radix) { if (num 0) return 0; boolean negative num 0; long n negative ? -num : num; StringBuilder sb new StringBuilder(); while (n 0) { long d n % radix; char c (char) (d 10 ? 0 d : A d - 10); sb.append(c); n / radix; } if (negative) sb.append(-); return sb.reverse().toString(); }反过来// 任意进制字符串转long注意溢出保护 static long fromBase(String s, int radix) { long res 0; boolean negative s.startsWith(-); for (int i negative ? 1 : 0; i s.length(); i) { char c Character.toUpperCase(s.charAt(i)); int digit Character.isDigit(c) ? c - 0 : c - A 10; res res * radix digit; } return negative ? -res : res; }这里的多处细节本身就是考点负数处理、字母转数字的边界A~F、结果逆序拼接。蓝桥杯的进制题大多是基础素材但年年有人因为字母大小写或者前导零丢分。还有一个常被忽视的点大数进制转换不能用int存中间结果n * radix digit很容易溢出建议直接上BigInteger或者一开始就用long并做好范围判断。5.4 面试官会追问的底层实现问题面试官如果继续深挖会问“Integer.toHexString的底层是怎么算的”。翻开OpenJDK源码核心逻辑是先判断正负数负数会先变成无符号的长整型值然后不断右移4位等价于除以16取出每一位再用查表法映射成字符。// OpenJDK Integer.toUnsignedString内部思路简化版 final char[] digits 0123456789abcdef.toCharArray(); int shift 4; // 十六进制每次移4位 int mask 0x0F;这里的关键点是源码用移位和掩码运算代替除法和取模。因为CPU的移位比除法快编译器也倾向于把除以2的幂优化成移位。了解了这个原理你写高性能代码时就会主动用位运算替代乘除。另一个常问的点是Integer.parseInt如何做合法性校验它按位累加每次先multiply再累加digit同时通过一个负累积值来统一处理正负号这样可以在一个循环里同时完成符号和溢出判断溢出时抛NumberFormatException。这个负累积技巧值得反向学习面试答出来很加分。6. 应用场景从位运算到协议解析6.1 状态压缩用bit位存储多个开关后端业务里经常会遇到“一个用户的状态包含多个开关”的需求比如通知开启、邮箱已验证、手机已验证、黑名单标记。建四列数据库字段当然可以但用状态位的话一个int就搞定而且查询和更新都能走位运算性能和代码量都更优int USER_NOTIFY 1 0; // 0001 第0位 int MAIL_VERIFY 1 1; // 0010 第1位 int PHONE_VERIFY 1 2; // 0100 第2位 int IS_BLACKLIST 1 3; // 1000 第3位 int status 0; status | USER_NOTIFY | PHONE_VERIFY; // 开启对应位 boolean hasNotify (status USER_NOTIFY) ! 0; int closed status ~USER_NOTIFY; // 关闭某位这个场景下你需要时刻知道自己操作的“位”在哪而十六进制是描述位掩码最自然的方式0x0001、0x0002、0x0004、0x0008一眼就能看出是哪些bit。写权限系统、功能开关、任务进度标记时这个模式非常常见。6.2 网络协议与文件头解析二进制协议比如Modbus、TCP头、自定义消息结构时一个常用的手段是把收到的字节流打印成十六进制日志private static final char[] HEX 0123456789ABCDEF.toCharArray(); static String bytesToHex(byte[] bytes) { char[] hexChars new char[bytes.length * 2]; for (int i 0; i bytes.length; i) { int v bytes[i] 0xFF; hexChars[i * 2] HEX[v 4]; hexChars[i * 2 1] HEX[v 0x0F]; } return new String(hexChars); }注意v 4取高4位v 0x0F取低4位标准的逐字节进制展开。反过来解析文件头时也常用0x89 0x50 0x4E 0x47判断PNG图片格式用0xCAFEBABE判断class文件。理解十六进制不是炫技排查乱码、逆向二进制、阅读协议文档时它就是你唯一的工具。6.3 哈希、颜色、UUID与字符编码MD5、SHA-256的摘要结果Java标准库默认返回的也是十六进制字符串。因为二进制摘要没法直接在日志里展示十六进制是人可读的最短形式。WEB开发里的色值就是十六进制#FF8800UUID的字符串表示本质也是十六进制加上横杠字符编码如UTF-8调试中文乱码时用bytesToHex打印字节序列马上能定位是哪段编码出问题。6.4 内存地址与调试输出JVM参数-Xmx日志、崩溃日志里的内存地址、ThreadMXBean线程转储中的栈指针统统以十六进制呈现。如果不懂0x7f243a00000这种地址的含义读纷繁的at dump日志时就是无字天书。另外用jstack排查死锁、用jmap看对象地址时地址高位的数值其实能反映出堆内存的位置范围——虽然日常用不到这个深度但至少要知道那串十六进制代表“进程内存中的坐标”。7. 几段个人心得与工具习惯做Java这些年我自己总结了几条关于进制和位运算的使用习惯算不上标准答案但确实让开发过程顺畅不少。一是在设计布尔型配置项时优先考虑位标志而不是多个boolean字段。单个int不但减少序列化体积也方便后续对历史数据做二进制兼容。只是在命名上不能偷懒每个掩码常量都要写清楚注释否则三个月后连自己都看不懂status 0x40是在查什么。二是调试二进制相关代码时尽量把日志里的进制统一成十六进制。二进制虽然最原始但位数一多就没有人眼辨识度了十六进制4位一组和现代int/long的结构天然对齐一眼能看到边界。真要逐位调试再用String.format(%08d, ...)格式化成二进制字符串输出。三是遇到长整型里的高低位拼接写一个专门的小工具类封装不直接在业务代码里 16 | ...散装操作。拼接容易拆解的掩码和移位方向全是细节每写一次就是一次出错机会封装起来只用写一次// 高低位拼接为长整型 static long compose(int high, int low) { return ((long) high 32) | (low 0xFFFFFFFFL); }这类工具代码一旦经过测试后续业务接入时就不用一遍遍重复排查位偏移方向。四是要学会利用jshell快速验证进制问题。随手打开终端敲一句jshell输入Integer.toHexString(-2)比开IDE建类快得多。进制的边界情况多验证成本又低千万别靠脑子里推演。进制这个东西表面上看就是“数字怎么表示”的小规则实际上它贯穿了内存布局、编码、协议、加密、网络通信的方方面面。真正花一下午把它从底层到应用串一遍后面写二进制协议、读底层日志、处理跨语言数据交换时的效率提升是立竿见影的。坑我已经帮你踩过并标注出来了剩下的就靠你在实际代码里多写、多试把这些规则消化成肌肉记忆。