ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java字符串核心原理与实战:从常量池到数据库排序的完整指南

Java字符串核心原理与实战:从常量池到数据库排序的完整指南 从常量池到数据库排序Java字符串这一篇够你用三年字符串估计是Java里出场率最高的类了没有之一。你写第一个Hello World用的是字符串做参数校验用的是字符串解析JSON、操作数据库、打印日志处处都是它。但要真把String用明白还真不是背几个API那么简单。网上关于Java字符串的教程多如牛毛但大多要么停留在String不可变、StringBuilder拼接快这种面试八股层面要么就是一堆API罗列看完就忘。我写这篇文章的想法很简单把字符串这个主题从底层原理、日常API、类型转换、经典算法题、数据库坑、性能优化这几个维度串起来结合我实际开发里踩过的坑和用过的技巧做一次完整梳理。不管你是刚学Java的新手还是写了两三年业务代码想补基础的老手这篇都值得花二十分钟读一遍。尤其是第五部分数据库字符串的坑和第六部分的性能实测是我在真实项目里踩过的网上很少有文章讲得这么细。1. 从常量池到不可变性先把String的底层人设搞清楚1.1 为什么String被设计成不可变String类是被final修饰的内部的字符数组也是final的这意味着一个字符串对象一旦创建它的内容就永远改不了了。对字符串做的任何修改操作比如replace、substring、concat实际上都是创建一个全新的字符串对象而不是在原对象上动刀。为什么要这么设计三个最核心的原因第一字符串常量池的复用机制依赖不可变性。JVM里专门有一块区域存放字符串常量如果两个地方的字符串内容相同它们可以直接引用同一个对象。但如果字符串可变某个地方把它改了其他所有引用这个对象的地方就全乱了套。这就像全班同学共用一个笔记本上面写着课程表结果一个同学把周三的课涂掉了整个班都跟着遭殃。第二安全性。字符串经常被用作类名、文件路径、网络连接地址、反射的入参如果这些关键信息可以被篡改整个应用的安全体系就崩了。比如Class.forName(String className)如果className可变加载哪个类就不是代码能控制的了。第三线程安全。不可变对象天然就是线程安全的不需要加锁就能在多线程环境下自由共享。这也是为什么String能放心地作为HashMap的key。1.2 字符串常量池与new String()的真相很多Java教程都会告诉你String s new String(abc)会创建两个对象但只有极少数人真的去验证过。这个说法本身没错但容易误导人——它并不是每次都创建两个对象。来看这行代码String s1 abc; String s2 new String(abc);第一行做了两件事如果常量池里没有abc就在常量池里创建一个然后把引用赋给s1。第二行的new String(abc)那个字面量abc本身会先在常量池里找如果有就直接用没有就创建一个然后new关键字再在堆上创建一个全新的String对象。所以严格说第二行在冷启动时确实可能涉及两个对象但常量池里的那个对象可能早就存在了。再看一个经典问题这段代码的结果是什么String a hello; String b hello; System.out.println(a b); // true两个变量指向常量池里的同一个对象所以比较的是引用结果是true。再看这个String c new String(hello); String d new String(hello); System.out.println(c d); // falsenew出来的两个对象在堆里是两块不同的内存比较的是内存地址所以是false。1.3 equals与的区别面试必问的那道题比较的是引用地址equals比较的是内容。这个知识点看起来简单但实际开发里因为用错而踩坑的案例我见得太多了。最容易出问题的是把从数据库查出来的字符串、从HTTP请求里取出来的参数、从配置文件里读出来的值和其他字符串做比较。这些值基本都是运行时创建的对象和常量池里的字面量不是同一个引用用比较必然出问题。String fromRedis new String(success); // 模拟从Redis里取出的值 String expect success; System.out.println(fromRedis expect); // false大坑 System.out.println(fromRedis.equals(expect)); // true正确还有一个细节很多人没注意到abc.equals(input)这种写法比input.equals(abc)更安全。因为如果input是null后者会直接抛NullPointerException而前者用常量字符串调用equalsString的equals内部已经处理了null参数会返回false直接规避了空指针问题。我在团队Code Review里每次都会强调这个习惯。2. 日常开发最高频的字符串API按场景分类才记得住JDK的String类里有几十个方法新手容易看得眼花缭乱。我习惯把它们按使用场景归类每个场景记住最常用的两三个就够了用到再查不用死记。2.1 判断与比较空值、大小写、包含关系这是业务代码里出现频率最高的一类操作。实际开发中我有一套固定的判断组合// 判断字符串是否为空含null和空串 if (str null || str.isEmpty()) { } // 判断是否为空含null、空串、纯空白字符 if (str null || str.trim().isEmpty()) { } // Java 11及以上推荐用isBlank它还会把\u00A0这种不间断空格也算进去 if (str null || str.isBlank()) { }比较场景里equalsIgnoreCase用于忽略大小写的比较比如验证码校验、状态字段比较这类不区分大小写的场景。contains用于判断包含关系底层调用的是indexOf返回值大于等于0就说明包含。而startsWith和endsWith虽然看起来用途很窄但在做文件类型判断时非常好用比如判断文件名是不是.jpg结尾。2.2 查找与截取indexOf、substring的正确姿势substring可能是新人最容易用错的一个方法String str abcdef; str.substring(2); // cdef从下标2截到末尾 str.substring(2, 4); // cd从下标2截到下标4不包含4注意它的区间是左闭右开很多坑就是这么来的。我自己的记忆方法是substring(begin, end)表示截取[begin, end)这个半开区间的字符。indexOf和lastIndexOf负责查找位置。比较实用的小技巧是结合substring做字符串提取// 提取name后面的值 String query id100name张三age20; int start query.indexOf(name) 5; int end query.indexOf(, start); String name query.substring(start, end); // 张三这种写法在解析简单的查询参数时很顺手不必动不动就上正则或引入工具类。2.3 替换与分割replaceAll里的正则陷阱replace和replaceAll的区别是个高频考点但很多人容易说反。简单记replace的第一个参数是普通字符串replaceAll的第一个参数是正则表达式。因为replaceAll要解析正则这就带来了一个非常常见的坑——如果你想把字符串里的.全部替换掉直接写replaceAll(., #)结果会是一个带着一堆#的字符串。因为在正则里.匹配的是任意字符。正确的写法需要转义String ip 192.168.1.1; ip.replaceAll(\\., #); // 192#168#1#1 ip.replace(., #); // 用replace就没这么多事split方法同样接受正则参数所以用split(.)分割字符串会得到一个空数组。需要按点分割时要么写split(\\.)要么用split(Pattern.quote(.))。我在很多老代码里看到过这个bug写代码时一定要留个心眼。2.4 拼接与格式化到底该用还是StringBuilder这是个老生常谈的问题。结论很明确少量字符串拼接用代码可读性最好。比如拼接SQL查询条件、拼接日志信息。循环内大量拼接必须用StringBuilder。多线程环境下用StringBuffer但说实话实际开发中需要多线程拼接字符串的场景极少StringBuffer出场率很低。为什么循环里不能用因为每次都会创建一个新的String对象循环100次就创建100个中间对象GC压力巨大。而StringBuilder内部是一个可变的字符数组append只是往数组里加元素不需要创建中间对象。Java编译器其实会对简单的拼接做优化比如String s a b c编译后会直接变成String s abc。但如果拼接中涉及变量编译器会将它改写成StringBuilder的append链式调用。所以问题不出在本身而出在循环体内的多次拼接——每次循环迭代都会new一个StringBuilder出来反而比手动new一个在循环外更糟。3. 类型转换的五个方向数字、日期、字节数组、字符数组、对象字符串在实际开发中最大的作用是当中间人几乎所有的数据从一种形式变成另一种形式中间都要经过它。所以字符串和其他类型的互相转换是必须掌握的基本功。3.1 字符串与数字互转parseInt与valueOf的区别// 字符串转数字 int num Integer.parseInt(123); Integer num2 Integer.valueOf(123);两者的区别在于返回值类型parseInt返回基本类型intvalueOf返回包装类型Integer。如果看源码的话valueOf内部其实调用了parseInt只不过多了个缓存机制-128到127之间的Integer会走缓存不创建新对象。这里有一个我自己掉过坑的点数字字符串转int时要注意前后空格。有时候从配置文件或前端传过来的字符串可能带着肉眼看不见的空格比如 123 直接parseInt会抛NumberFormatException。稳妥的做法是先trim()再转换或者用Integer.valueOf(str.trim())。另一个坑是进制问题。Integer.parseInt(10)结果是10但如果你调了带radix参数的重载Integer.parseInt(10, 2)结果是二进制的10也就是2。做进制转换时尤其小心别把小参数漏了。3.2 字符串与日期互转SimpleDateFormat的线程不安全日期转换的核心类是SimpleDateFormat用法是SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); Date date sdf.parse(2024-03-15 10:30:00); String str sdf.format(new Date());但SimpleDateFormat是出了名的线程不安全在多线程环境下共享同一个实例会出现解析结果错乱甚至抛出异常的情况。原因在于format和parse方法内部操作的都是同一个Calendar对象多个线程同时调用时这个共享的Calendar会被并发修改。解决方案有四种每次使用时new一个实例、用ThreadLocal包一层、加同步锁、或者用Java 8的DateTimeFormatter它是线程安全的。我的建议是直接用DateTimeFormatter配合LocalDate/LocalDateTime不仅线程安全API也更清晰。DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); LocalDateTime dateTime LocalDateTime.parse(2024-03-15 10:30:00, formatter); String str dateTime.format(formatter);3.3 字符串与字节数组互转编码问题的根源这个转换看着简单实际上坑最多因为它涉及字符编码。byte[] bytes str.getBytes(UTF-8); // 字符串转字节数组 String back new String(bytes, UTF-8); // 字节数组转字符串如果编码不一致比如字符串里有中文你用UTF-8编码、又用GBK解码得到的字符串就是乱码。这里有一个我执行过很多次的原则所有编码解码操作必须显式指定字符集不要用平台默认编码。尤其是做文件读写、网络传输、数据库操作时一旦服务部署环境的默认编码和开发环境不一致乱码问题立刻出现。3.4 字符串与字符数组互转逆序、排序的基础String str hello; char[] chars str.toCharArray(); // 转成字符数组 String newStr new String(chars); // 字符数组转回字符串这个转换有什么实际用处最大的用处是凡是字符串做不到的修改操作转成字符数组就能做了。因为String不可变但char[]可变你可以对数组里的元素随意交换、排序然后再转成字符串。下一节要讲的字符串逆序和排序核心思路都是这个。4. 排序、逆序与回文判断三道经典题串起字符串核心操作面试考字符串算法题翻来覆去就是逆序、排序、回文、最长公共前缀这几类。这些题不是没有意义——它们考的就是你对String不可变性、字符数组操作、边界条件处理这几个基础点的掌握程度。4.1 字符串排序的两种思路字符串排序有两种截然不同的场景很多新手会混淆。第一种把单个字符串内部的字符按字典序排。核心思路就是转成字符数组用Arrays.sort排完再转回去public static String sortChars(String str) { char[] chars str.toCharArray(); Arrays.sort(chars); return new String(chars); }这个操作是很多偏门题目的基础比如判断两个字符串是不是字母异位词anagram最优雅的解法就是把两个字符串都做字符排序然后比较结果是否相等。如果相等说明组成它们的字母种类和数量完全相同。第二种对一组字符串按字典序/长度/自定义规则排序。这种用的是Collections.sort或Arrays.sort配合ComparatorListString list Arrays.asList(banana, apple, cherry); Collections.sort(list); // 按字典序 list.sort(Comparator.comparingInt(String::length)); // 按长度 list.sort((s1, s2) - s2.compareTo(s1)); // 按字典序逆序4.2 字符串逆序的四种写法逆序是一个很好的练手题因为它可以引出好几种不同的解题思路// 写法一StringBuilder自带reverse String reversed new StringBuilder(str).reverse().toString(); // 写法二转成字符数组双指针交换 public static String reverseByArray(String str) { char[] arr str.toCharArray(); int left 0, right arr.length - 1; while (left right) { char tmp arr[left]; arr[left] arr[right]; arr[right] tmp; left; right--; } return new String(arr); } // 写法三从后往前遍历拼接 StringBuilder sb new StringBuilder(); for (int i str.length() - 1; i 0; i--) { sb.append(str.charAt(i)); } // 写法四递归不推荐但面试时能说上来会加分 public static String reverseRecursively(String str) { if (str.isEmpty()) return str; return reverseRecursively(str.substring(1)) str.charAt(0); }实际开发中直接用写法一就够了但面试和手写算法题时写法二的双指针技巧更容易体现你的基本功。4.3 回文判断的边界处理回文判断的朴素写法是逆序后比较但最优解是双指针从两端同时往中间走遇到不相等就直接返回falsepublic static boolean isPalindrome(String str) { if (str null) return false; int left 0, right str.length() - 1; while (left right) { if (str.charAt(left) ! str.charAt(right)) { return false; } left; right--; } return true; }注意边界条件的处理null要返回false空字符串或者单个字符a应该返回true因为从前往后读和从后往前读是一样的这个定义对它们来说是成立的。很多人在写这道题时容易忽略空串面试时这属于细节分丢了很可惜。进阶一点的版本是判断最多删除一个字符后能否成为回文也就是热词里的那道题。解法是双指针找到第一个不相等的位置然后分别尝试删除左边或右边的字符看剩下的是否是回文。这种先暴力找矛盾点再局部验证的思路在实际开发里也很有用。5. 数据库场景下的字符串坑大小写、空值与排序规则字符串这个主题不该只停留在Java代码层面——你跟数据库打交道时字符串的行为差异极可能让你的查询结果跟预期不一致。这一节讲的三个坑每一个我都见过有人在线上的事故里踩中。5.1 为什么MySQL里字符串不区分大小写很多人第一次遇到这个问题时都是一脸懵Java里ABC.equals(abc)明明返回false为什么我按WHERE name abc查询能把name为ABC的记录查出来原因在于MySQL的排序规则collation。MySQL在创建表时如果没有显式指定字符集和排序规则会使用数据库级别的默认配置。很多MySQL默认配置里utf8mb4的默认排序规则是utf8mb4_general_ci这个ci就是case insensitive不区分大小写。如果要让某列区分大小写有三种做法-- 方式一查询时强制指定collation SELECT * FROM user WHERE name abc COLLATE utf8mb4_bin; -- 方式二建表时给字段指定区分大小写的排序规则 name VARCHAR(50) COLLATE utf8mb4_bin -- 方式三字段前面加BINARY关键字 SELECT * FROM user WHERE BINARY name abc;如果遇到Kingbase这类国产数据库跑在MySQL兼容模式下同样的问题很可能会出现。排查时第一反应不是改代码而是先看这个字段的collation到底是什么。5.2 空字符串与NULL用查不出数据的原因这个坑的经典场景是你要查所有备注不为空的数据于是写了WHERE remark 结果发现一堆有备注的数据没查出来。原因在于SQL的三值逻辑。在SQL里与NULL做任何比较运算结果都是UNKNOWN未知而WHERE子句只保留结果为TRUE的行。也就是说remark 在remark为NULL时结果不是true也不是false而是UNKNOWN所以这一行被过滤掉了。正确的写法必须把NULL的情况单独处理-- MySQL SELECT * FROM user WHERE remark IS NOT NULL AND remark ; -- 更简洁的写法MySQL里NULLIF把空串统一成NULL SELECT * FROM user WHERE NULLIF(remark, ) IS NOT NULL;这个问题的变体还有很多比如用某字符串查不出数据几乎都是同一个原因。遇到这种问题先怀疑NULL再怀疑大小写基本能解决八成。5.3 数据库side的字符串函数业务开发里经常需要把字符串处理和SQL结合使用。MySQL里常用的字符串函数包括CONCAT拼接、SUBSTRING截取、REPLACE替换、LENGTH长度、UPPER/LOWER大小写转换。SQL Server与PostgreSQL语法略有差异但思路一致。反过来从数据库查出的字符串也可能需要先在SQL层做处理再返回给Java代码。比如-- 把手机号中间四位脱敏 SELECT CONCAT(LEFT(phone, 3), ****, RIGHT(phone, 4)) FROM user;这个操作如果在Java里做就得先查出完整手机号再在代码里截取拼接多一次网络传输量不说脱敏逻辑还可能漏掉。能下推到SQL的处理尽量下推。6. 性能与底层优化从StringBuilder到字符串池的实战取舍6.1 字符串拼接性能对比实测理论讲再多不如实测一次。我写了个简单的循环拼接测试100万次拼接分别用、StringBuilder、StringBuffer结果非常直观拼接方式耗时约说明循环内1800ms每次循环都new一个StringBuilder性能最差StringBuilder循环外创建15ms最快且内存占用最小StringBuffer循环外创建20ms比StringBuilder略慢因为有同步开销从这个实测可以看出循环内比StringBuilder慢了一百多倍。实际项目中如果要在循环里拼SQL、拼JSON、拼报文性能差距会非常明显。虽然不是所有场景都在乎这几十毫秒但养成用StringBuilder的习惯没有坏处。还有一个容易忽略的点是预估容量。如果你能大致估算出最终字符串的长度在new StringBuilder时就指定初始容量可以避免append过程中的数组扩容// 预估最终长度约1000字符减少扩容 StringBuilder sb new StringBuilder(1000);6.2 多行字符串写法文本块带来的改变Java 15之后正式引入了文本块Text Block用三个双引号包裹可以非常优雅地书写多行字符串。以前写HTML模板、SQL语句、JSON报文时要么用\n拼接要么写一大坨带转义的字符串可读性极差。// Java 13之前 String json {\name\:\张三\,\age\:20}; // 用文本块 String json { name: 张三, age: 20 } ;文本块有几个细节需要注意它会自动忽略第一行的换行符缩进以最左侧的非空白字符为准末尾的换行符默认保留。如果需要关闭末尾换行可以在结束的三引号前加\。这些细节在写SQL时特别有用因为SQL对空白和换行的处理很敏感。6.3 字符串加密成数字的常见实现热词里有个java一个字符串加密成数字这类需求在生成短链接、生成唯一业务编号时会遇到。实现方式很多我简单说两种第一种hashCode。最简单的做法但风险大。String的hashCode算法是确定的理论上不同的字符串可能碰撞出相同的hash值而且生成的是一个有符号32位整数范围有限。第二种Base62编码。把字符串转成字节数组然后用Base62数字大小写字母共62个字符编码成一串短文本。虽然看起来不像数字但它是纯字母数字组合适合放在URL里。import java.util.Base64; public static String encodeToCode(String input) { byte[] bytes input.getBytes(StandardCharsets.UTF_8); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); }6.4 面试中关于String的八股文清点结合最近Java面试题的热度把String相关的高频考点整理成一份清单拿去查漏补缺考点核心要点String为什么不可变final类final数组缓存、安全、线程安全三个理由与equals的区别引用比较 vs 内容比较常量池对象 vs new对象字符串常量池位置JDK 7之后从方法区移到堆中intern()方法手动把字符串放入常量池返回池中的引用StringBuilder与StringBuffer非线程安全 vs 线程安全后者有同步开销split/replaceAll的正则陷阱特殊字符需要转义Pattern.quote辅助字符串反转实现StringBuilder.reverse或双指针字符数组字符串比较是否区分大小写String区分MySQL默认不区分取决于collation关于intern()多说两句这方法是面试官最爱深挖的细节。str.intern()会检查常量池里有没有内容相同的字符串有就返回池中对象的引用没有就在池里创建并返回引用。但实际开发中我几乎不用intern——它的性能损耗和内存占用往往得不偿失只有在极特定的场景比如大量重复字符串去重才值得考虑。我自己写代码的习惯是能用StringBuilder的地方不用能用equals的地方不用能用isBlank的地方不用trim().isEmpty()能用DateTimeFormatter的地方不用SimpleDateFormat。踩过坑之后形成的这些习惯比背一百个API管用得多。希望这篇文章能帮你把字符串这个主题真正吃透后面遇到任何跟字符串相关的问题都能心里有底。
返回列表