ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java字符串反转与% / 运算符底层原理:String、StringBuilder、StringBuffer选型全解析

Java字符串反转与% / 运算符底层原理:String、StringBuilder、StringBuffer选型全解析 搞 Java 的应该都有过这种体会面试别人或者被面试的时候基础越简单的地方越容易翻车。就拿“字符反向遍历”这个小操作来说看起来不就是倒着取字符吗但一旦和%、/这两个运算符混在一起再搭配上 String、StringBuilder、StringBuffer 的选型问题很多写了两年 Java 的人也未必能一次答对。我面过不少人简历上写着“熟练掌握 Java 基础”结果让手写一个字符串反转有人用charAt能写出来但说不清索引边界有人直接调reverse()却讲不明白底层原理还有人把%和/的语义彻底搞反写出来的数字颠倒逻辑完全错误。今天这篇就把这三个点彻底撸一遍该讲的原理、该给的实际用法、该避的坑一次性说清楚。这其实是面试里非常典型的“复合型基础题”——一个看起来简单的需求实际在考察你对字符串底层结构、运算符语义和 Java 常用类的理解程度。我自己在实际项目里也确实被这些细节坑过比如写数字金额转大写、处理日期字符串逆序、解析日志行全都绕不开这几个基础点。这篇文章适合准备面试的 Java 开发、刚学完 Java 语法想巩固基础的同学也适合当了几年 CRUD 工程师想回头补地基的人。1. 三个主题为什么偏偏被凑到一起1.1 它们都在考“底层数据表示”这件事先说一个很多人没意识到的事实字符串反向遍历、% 和 /、字符串类选型这三个知识点表面上毫不相干但在计算机底层全都在和“数据的组成方式”打交道。字符串反向遍历考验的是你对字符串底层字符数组的索引理解。Java 里的String本质上是char[]JDK 9 之后优化为byte[]所以“反向遍历”本质上是“从数组尾部往头部遍历”。你只有真正理解了“字符串就是字符序列有索引、有长度”这个概念才能写出不出越界错误的代码。而%和/这两个运算符在字符串处理场景里最常见的用法就是“拆分数字的每一位”。任何十进制整数都可以用“除以 10 取余数”的方式逐位拆解。比如你拿到一个字符串“12345”想把它倒过来变成“54321”最直观的做法就是把它转成数字然后用% 10取出个位、用/ 10去掉个位。这个过程如果对运算符语义不清楚代码一跑就直接出 bug。再说字符串类选型它考验的是对 Java 对象模型和 JVM 内存分配的理解。为什么不建议在循环里用String做拼接为什么StringBuffer是线程安全但性能稍差这些问题的答案都指向String的不可变设计。这个知识点单独考还好一旦和循环反转结合就变成“让你用三种不同方式实现字符串反转并说明各自性能差异”——这一下就拉开了普通开发者和资深开发者的差距。1.2 这三者组合起来就是一道完整的基础能力试卷我后来面试别人的时候越来越喜欢出这类组合题。比如给定一个整数123456要求反转输出654321不能用现成的reverse()方法。这道题目看着简单实际上考察了至少六个点——你会不会用% 10取余数、会不会用/ 10去掉最低位、循环条件应该怎么写、结果会不会溢出 int 范围、字符串和数字之间怎么转换、以及中间用StringBuilder还是StringBuffer。如果你能把这道题答得滴水不漏说明你的 Java 基础是真正扎实的。反过来如果这三块知识是割裂的面试官稍微追问一下“你 StringBuilder 和 StringBuffer 到底选哪个为什么”你就会开始支支吾吾。接下来的内容就是把这三大块逐一拆开揉碎最后再串成一道完整题目来分析。2. 字符串反向遍历两种思路三种写法一个核心2.1 手动遍历charAt for 循环是最稳的底座先上最基础也最推荐掌握的写法。字符串反向遍历核心不外乎“从最后一个字符开始依次往前取”。这里最直接的方法就是charAt(int index)配合for循环String str Hello Java; for (int i str.length() - 1; i 0; i--) { char c str.charAt(i); System.out.print(c); }运行结果就是 “avaJ olleH”。这段代码里的边界条件值得展开说一下length()返回的是字符串长度而索引从 0 开始所以最后一个字符的索引是length() - 1。循环的终止条件是i 0一旦i变成-1就退出循环。这两个细节就是“索引边界”的核心考点。注意这里最容易犯的错就是把起始索引写成str.length()然后代码直接抛StringIndexOutOfBoundsException。我在实际开发中见过不止一次这种错误而且写错的往往是有两三年经验的人——因为他复制了正序遍历的代码只改了循环方向忘了把起始索引同步减一。如果你还需要把反转后的字符串存起来自然想到用另一个变量拼接。但这里就出现了我们后面要大讲特讲的字符串类选型问题。最常规的做法是声明一个StringBuilder循环内append最后toStringString str Hello Java; StringBuilder sb new StringBuilder(); for (int i str.length() - 1; i 0; i--) { sb.append(str.charAt(i)); } String reversed sb.toString();这段代码看起来没问题但有个性能细节值得注意StringBuilder是通过一个可变的char[]来存储字符的。初始容量默认是 16当你 append 的字符数超过当前容量时它会自动扩容。扩容机制在 JDK 8 里是(oldCapacity 1) 2也就是说每次扩容大约变成原来的两倍。如果你提前知道字符串长度最好在创建StringBuilder时直接指定容量new StringBuilder(str.length())。这样能减少扩容次数虽然反转变换的长度一般不大但养成习惯对性能敏感型代码有意义。2.2 API 一行流StringBuilder 的 reverse() 并非“银弹”很多人拿到“字符串反转”的需求第一反应是String str Hello Java; String reversed new StringBuilder(str).reverse().toString();这段代码确实能跑而且简洁漂亮。但关键问题是你能不能讲清楚reverse()底层做了什么如果面试官追问一句“这个方法的实现原理是什么”你能答上来吗我直接说结论reverse()的实现本质上就是双指针交换字符。以 AbstractStringBuilder 的实现为例它从数组的两端向中间遍历将对应位置的字符对调。具体的核心逻辑是用两个游标left和rightleft从 0 开始right从count-1开始每次交换value[left]和value[right]后left、right--直到两者相遇。// 伪代码帮助理解 for (int left 0, right count - 1; left right; left, right--) { char temp value[left]; value[left] value[right]; value[right] temp; }所以reverse()并不是什么黑魔法它只是把“反向遍历 重新组装”封了一层。你在用 API 的时候能秒懂底层原理才算真正掌握了这个知识点。但这里也要泼一盆冷水StringBuilder.reverse()有一个容易忽略的细节它在反转包含 Unicode 代理对的字符时会出现问题。举个具体的例子有些生僻字或 emoji 在 Java 的char存储中占两个char位置比如‍这种。直接用reverse()反转代理对会被拆开出现乱码。如果在国内业务里你只需要处理常用汉字和英文字母基本不会碰到这个问题但如果你的服务要处理表情符号或者小众语言字符就要留个心眼。2.3 反向遍历的真实用途除了反转还能做回文判断反向遍历有什么用很多初学者觉得这就是面试题实际开发用不上。其实不然我举几个真实场景。第一个是判断回文。比如你想判断一个字符串上海自来水来自海上是不是回文正序遍历和反向遍历同时进行逐一比较字符即可。也可以直接反转后与原字符串比较String str 上海自来水来自海上; String reversed new StringBuilder(str).reverse().toString(); boolean isPalindrome str.equals(reversed);不过这个写法有个小坑它不算最高效的因为创建了一个完整的反转字符串。如果字符串非常大内存开销会翻倍。更好的方式是双指针从两端向中间移动但那个就不是“反向遍历”这个基础考点的核心范畴了。第二个是配合正则或拆分处理日志。比如你拿到一行日志2024-01-15 23:59:58 ERROR ...想要提取最后的异常信息可以从字符串末尾反向遍历找第一个换行符的位置。这种场景下lastIndexOf可能更合适但原理依然是“从尾部查找”。第三个也是最经典的就是配合%和/做数字反转这个在下一节展开。3. % 和 /基础运算符细节多到让人怀疑人生3.1 取余和取整两兄弟的语义必须刻进潜意识%是取余运算符/是除法运算符。如果操作数都是整数那么a / b的结果是整数商直接截断小数部分a % b的结果是余数。让我用一个生活化的例子解释你有 7 个苹果分给 3 个人每人分 2 个剩下 1 个。那么7 / 3 2就是每人分到的数量商7 % 3 1就是最后剩下来的那个余数。这个逻辑用在数字拆分上就太经典了。任意一个十进制正整数x % 10都能取出它的个位数字而x / 10能去掉它的个位数字。只要不断重复这两个操作你就能把一个整数的每一位都拆出来int num 12345; while (num 0) { int digit num % 10; // 取出当前最低位 System.out.print(digit); // 从个位开始输出天然就是逆序 num num / 10; // 去掉最低位 }这段代码的运行结果是54321。看看这不就是数字版的“反向遍历”吗你不需要把数字转成字符串再反转直接用%和/循环就能解决。所以标题里把“字符反向遍历”和%、/放在一起一点都不突兀——它们在“逆序”这个需求上是同一类思路。3.2 负数参与运算时的符号陷阱这地方我敢说至少有一半的 Java 开发者在面试的时候会答错。问一个问题-7 % 3的结果是多少看到这个问题很多人第一反应是“好像是 -1不对也许是 1”答案在 Java 中是-7 % 3 -1。Java 的取余运算结果的符号与被除数第一个操作数的符号保持一致。也就是说-7 % 3的余数是-1而7 % -3的结果是1。这个规则和有些编程语言不一样比如 Python 里-7 % 3的结果是2Python 的取模运算总是返回非负结果。如果面试官抠细节这就是个很好的区分点。再补一个坑整数除法-7 / 2在 Java 里的结果是-3因为 Java 的整数除法是向零截断的也就是说它直接去掉小数部分而不是向下取整。这一点在处理负数时很容易出错尤其是做分页计算、循环分组的时候。实战提醒如果在业务代码里碰到负数取余建议先Math.abs()转成非负数操作或者用Math.floorMod()这种能保证结果符号的方法。否则写出来的分片逻辑一旦出现负数就会得到意想不到的桶编号。3.3 运算符的优先级短路符号这类细节别忽略还有一个比余数符号更隐蔽的坑是优先级问题。%、/、*的优先级是相同的它们从左往右结合都比、-高。这看起来很简单但一旦表达式复杂起来就容易冤枉出错。举个例子你要写一个判断闰年的表达式年份能被 4 整除但不能被 100 整除或者能被 400 整除。正确写法是(year % 4 0 year % 100 ! 0) || year % 400 0这题大家都熟但如果有人在里面不加括号写成year % 4 0 year % 100 ! 0 || year % 400 0也能跑因为优先级高于||结果还是对的。真正容易出错的是下面这种int result a % b c / d * e;你得清楚这个表达式等同于(a % b) ((c / d) * e)。如果自己写代码时不加括号别人读起来很吃力评审时大概率会被要求改掉。我的建议是凡是混用%、/、*和加减法的地方一律用括号显式分组。不是为了编译器是为了让人脑不误解。3.4 除数为 0 和浮点取余面试爱问的边界题整数除法中x / 0会抛出ArithmeticException这个大家都知道。但浮点数不一样1.0 / 0的结果是Infinity0.0 / 0的结果是NaN它们都不会抛异常。这个差别经常在面试题里出现。浮点数取余%也是成立的比如5.5 % 2.0 1.5。但它不是真正的数学取模而是基于浮点运算的余数。再加上浮点数本身的精度问题业务代码里基本不建议对浮点数取余。如果你真的需要判断一个浮点数是不是某个值的整数倍最好转成 BigDecimal 操作否则你会被0.1 0.2这类经典问题折磨到怀疑人生。4. 字符串类选型String、StringBuilder、StringBuffer 的门道4.1 先看一张对比表再逐个拆原理说到字符串类的选择很多人第一反应是背区别“String 不可变StringBuilder 线程不安全StringBuffer 线程安全。”但如果面试官让你展开说说你就得知道背后的设计逻辑了。特性StringStringBuilderStringBuffer可变性不可变可变可变线程安全安全final 不可变所以天然安全不安全安全方法加 synchronized性能拼接时最差最好略差于 StringBuilder适用场景字符串常量、少量拼接单线程环境大量拼接多线程环境共享可变字符串核心区别只有一句话String 是不可变的StringBuilder 和 StringBuffer 是可变的字符序列。但“不可变”三个字到底意味着什么它意味着任何对 String 的修改操作都会生成一个新的 String 对象。比如String s Hello; s s World;这行代码其实没有修改原来的Hello对象而是创建了一个内容为Hello World的新 String 对象然后让s指向这个新对象。原来的Hello对象还存在于内存中只是没有引用指向它等待被垃圾回收。为了支撑这个不可变设计Java 在实现 String 时做了很多手脚。最核心的是String 内部的字符数组是final修饰的JDK 8 及以前是private final char value[]JDK 9 及以后变成了private final byte[] value并引入了COMPACT_STRINGS机制。这个改动是为了内存优化——对于纯 Latin-1 字符的字符串一个字符只占一个字节而不是固定两个字节。这也是为什么我建议你用str.length()而不是getBytes().length来判断实际长度因为length()返回的是字符个数而后者的长度会随字符集变化。4.2 字面量与 new String字符串常量池的关键另一个高频考点是“直接赋值”和new String()的区别。直接赋值会去字符串常量池中查找如果池中有相同内容的字符串就直接复用引用如果没有就创建新字符串并放入池中。而new String(abc)无论如何都会在堆中创建一个新对象即使常量池里已经有abc了。String s1 abc; String s2 abc; String s3 new String(abc); System.out.println(s1 s2); // true引用同一个常量池对象 System.out.println(s1 s3); // false堆里的新对象 System.out.println(s1.equals(s3)); // true内容相同这个知识点表面上是考和equals的区别实际上考的是 JVM 内存模型的功底。说到这里我要严肃提醒一句如果这个点你只是背结论不理解为什么面试只要换个壳子你立刻露馅。比如面试官问“String 的 intern() 方法是干嘛的”你要是背不出那前面的“true/false”答对了也白搭。intern()的作用是如果常量池中有相同内容的字符串则返回常量池中的引用否则把当前字符串加入常量池并返回引用。有了它你可以在某些内存敏感场景中复用字符串对象。但注意JDK 7 之后常量池从永久代移到了堆中intern 的行为也相应发生了变化具体细节这里不展开但面试时提到这层深度的候选者通常能进入下一轮。4.3 字符串拼接的性能玄机编译器优化与循环陷阱字符串拼接是性能优化的重灾区。简单写两行s a编译器会优化成StringBuilder的 append 吗答案是在一条语句内会。来看这段代码String s Hello; s ; s World;在编译后的字节码中每一行s 都会大致等价于s new StringBuilder(s).append( ).toString();也就是说每执行一次就会 new 一个 StringBuilder再 new 一个 String。这段代码只有两次拼接影响不大。但如果放在循环里比如循环一万次拼接那就会创建一万个 StringBuilder 和一万个 String 对象GC 压力直接拉满。正确的做法很简单在循环外创建一个 StringBuilder循环内反复 appendStringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i); } String result sb.toString();这里又回到了第 2 节提到的知识点如果能在创建 StringBuilder 时预估最终长度最好直接指定容量避免扩容开销。我自己在写聚合 SQL、拼大批量消息体、生成导出文件内容时都习惯先算好大概长度再创建。4.4 StringBuilder 和 StringBuffer到底选哪个这个考点其实就两个字锁。StringBuffer 的关键方法加了synchronized所以在多线程环境下是安全的。StringBuilder 没有加锁所以性能稍高但不适合多线程共享。很多人在实际开发里会有一个困惑如果我是单线程操作为什么不能直接使用 StringBuffer 图个“安全”没问题功能上完全可行。但既然是单线程加锁带来的性能损耗完全是多余的尤其在并发量高的服务里方法级的synchronized会造成不必要的竞争开销。反过来如果你在多线程环境下共享同一个可变字符串对象比如多个线程往同一个日志缓冲区里写内容那么用 StringBuilder 就会出现数据错乱必须换成 StringBuffer或者自己加锁。我的建议是开发新代码时默认使用 StringBuilder只有在明确需要多线程共享可变字符序列时才考虑 StringBuffer。不过说句实在话现代业务系统里大多数字符串操作都是局部变量根本不会跨线程共享所以 StringBuffer 的使用频率其实很低但面试中它的存在感又极高。提示还有一个小点容易忽略StringBuilder和StringBuffer的 API 几乎完全一样所以你从 StringBuffer 切到 StringBuilder 只需改类名。但如果代码里依赖了 StringBuffer 的线程安全性直接替换就会有隐患重构要谨慎。5. 真题串讲反向遍历、%、/、字符串类的综合实战5.1 经典题型一整数反转核心考 % 和 /现在我们把三块知识串起来。要求是给定一个 32 位有符号整数x返回反转后的数字。比如输入123输出321输入-123输出-321。题目要求不能用字符串反转 API 的话就得纯用%和/。public int reverse(int x) { int result 0; while (x ! 0) { int digit x % 10; x x / 10; // 溢出判断 if (result Integer.MAX_VALUE / 10 || (result Integer.MAX_VALUE / 10 digit 7)) { return 0; } if (result Integer.MIN_VALUE / 10 || (result Integer.MIN_VALUE / 10 digit -8)) { return 0; } result result * 10 digit; } return result; }逐行解释一下x % 10取出低位数字x / 10去掉低位数字。注意这个算法天然支持负数比如x -123第一轮digit -3x -12第二轮digit -2第三轮digit -1。由于 Java 取余和整除对负数向零截断的特性整个反转过程不需要单独处理正负号这是这道题能正确跑通的前提。溢出判断是这个题目最有含金量的部分。result result * 10 digit有可能会超出 int 范围。因为 int 最大值是2147483647如果你当前的result已经大于2147483647 / 10那乘以 10 后必然溢出。等于2147483647 / 10时还要看最后一位 digit 是否超过 7。负数方向同理下限是-2147483648最后一位不能小于-8。这种细节只有在真正动手写过之后才会有感觉。我和不少候选人聊过这题有些人能写完主循环但溢出判断写得含糊甚至直接省略最后面试评价就卡在“基础不错但边界意识不足”。实际上只要把%和/的语义吃透了这道题的每一步推导都是水到渠成的不需要背答案。5.2 经典题型二判断回文字符串核心考反向遍历与字符串类选型再看一道综合题给定字符串判断忽略大小写且不区分非字母数字后是否为回文。比如A man, a plan, a canal: Panama是回文race a car不是。最直接的做法是过滤掉非字母数字字符然后逆序比较public boolean isPalindrome(String s) { StringBuilder filtered new StringBuilder(); for (char c : s.toCharArray()) { if (Character.isLetterOrDigit(c)) { filtered.append(Character.toLowerCase(c)); } } String origin filtered.toString(); String reversed filtered.reverse().toString(); return origin.equals(reversed); }这里用到StringBuilder.reverse()一行搞定反转。但如果你不想额外构造反转字符串也可以用双指针。双指针的本质其实就是两个方向的同时遍历——一个从左往右一个从右往左。虽然它快但对新手来说逻辑稍微绕一点。我建议面试时先给出上面的简单解法然后补一句“如果需要 O(1) 额外空间可以改成双指针”面试官会认为你思路是打开的。这里也引申出一个字符串类选型的细节filtered这个 StringBuilder 在循环中被反复 append。如果输入字符串很长初始容量最好按输入长度估算一下否则扩容次数会增多。平时做题感觉不到线上处理大文本时就明显了。我自己有一次处理一个几MB的日志字符串做敏感信息脱敏就是因为没有预估 StringBuilder 容量导致大量的数组复制处理耗时肉眼可见。后来改成new StringBuilder(input.length() 16)才舒适了。5.3 经典题型三逐字符处理大文本时用 charAt 还是 toCharArray还有一道高频题统计一个字符串中每个字符出现的次数。常规写法是MapCharacter, Integer map new HashMap(); for (char c : str.toCharArray()) { map.merge(c, 1, Integer::sum); }这里有个隐藏的优化点toCharArray()会复制一份底层的字符数组占用额外的内存。如果字符串很大这个复制成本不可忽略。更省内存的写法是用charAt配合 for 循环MapCharacter, Integer map new HashMap(); for (int i 0; i str.length(); i) { char c str.charAt(i); map.merge(c, 1, Integer::sum); }charAt直接通过底层数组的索引访问不会产生额外对象。从代码可读性来看两种差不多从内存分配角度看第二种更优。这个话题一旦展开又回到了第一节提到的“字符串反向遍历本质是数组遍历”——理解到这一层你才算真的摸到 Java 字符串的底。6. 避坑清单与面试加分细节6.1 六个高频翻车点速查表最近几年我前前后后看了几百份笔试代码和面试手写代码把最常出现的错误整理成了下面这张表建议收藏反复看。易错点错误示例正确认知反向遍历起始索引for (int i str.length(); i 0; i--)最后一个字符索引是length() - 1取余结果符号以为-7 % 3结果是 1Java 中结果是-1符号跟随被除数整数除法方向以为-7 / 2结果是-4Java 中结果是-3向零截断字符串拼接性能循环内部直接s i每轮都会创建新对象应用 StringBuilder 比较字符串s1 s2判断内容必须用equals比较的是引用除数为零浮点数1.0 / 0以为抛异常结果是Infinity不抛异常这张表里每一个坑我在 review 代码时都真实遇到过。尤其是“循环内直接拼接 String”这个几乎是中高级开发者偶尔也会犯的毛病。一旦数据量上来线上 GC 飙升基本就是这么来的。6.2 面试时怎么表达更容易加分如果你正在准备 Java 面试这几个话题的表达方式也可以打磨一下。比如问到字符串反转不要只甩一行new StringBuilder(str).reverse().toString()这会让人觉得你只会背 API。更优的表述是先手动写一版charAt反向遍历强调索引边界再提一句 StringBuilder 的reverse()底层是双指针交换最后补充 StringBuffer 和 StringBuilder 的线程安全差异及应用场景。这样一条线说下来面试官对你基础功底的评价会明显不一样。问到%和/除了答语义之外主动提到“负数场景下需要注意符号”、“可以做数字拆分和回文判断”都是加分项。不要把知识挤牙膏式地让面试官一点点追问能主动把关联讲出来才是真正的“理解”。6.3 一个可复用的综合练习题如果你看完这篇文章想动手验证一下自己的掌握程度我推荐做一个综合练习给定一个字符串把其中所有连续的数字子串提取出来反转后放回原位置。比如输入abc123def45输出abc321def54。这个练习会逼着你把今天讲的所有东西都用上拆数字时可能需要%和/如果先转成 int 处理定位子串时你需要反向遍历或使用charAt精确找边界保存结果时你需要考虑用 String 还是 StringBuilder循环拼接时你得注意性能和容量问题。能独立完成这个练习说明这三块知识你已经真正打通了。我在实际项目里做过类似的需求——把用户输入的富文本中的数字验证码全部倒序以规避某些转发链接被自动识别的问题。当时就是靠打磨这些基础 API 组合完成的没有引入任何额外工具类。Java 基础这层功夫从来都不是纸上谈兵而是每一个线上小需求背后的硬实力。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进