接触Java一段时间之后,你会发现String是绕不开的。不管是写登录校验、处理接口返回值,还是将来做算法题,字符串操作几乎每天都在碰。很多Java初学者学到变量、数组之后,就开始用String拼业务,却搞不清它和基本类型有什么区别,甚至会在比较字符串时用==踩坑。这篇文章我想把你领到String的门口,从原理讲到常用方法,再给你我实际写代码和带新人时总结出来的坑和习惯。适合已经会基本语法、想系统过一遍String用法的同学参考。
1. String是什么——先从内存视角认识它
1.1 字符串不是基本类型,而是一个类
Java里的基本类型只有8种:byte、short、int、long、float、double、char、boolean。String从头到尾都不在这个清单里。它是一个普通的类,全限定名是java.lang.String,只是因为它太常用了,编译器给了它一些特殊待遇,比如不用import、可以直接用双引号写字面量、可以配合+号做拼接。
很多初学者会把String当成基本类型来用,一旦深入就会碰壁。为什么?因为基本类型变量存的是值本身,而引用类型变量存的是对象的地址。String s = "hello";这个s,其实存的是一个指向字符串对象的引用,而不是把"hello"这个值直接装进s里。这个区别直接影响了后续的==比较结果,也影响了我们在方法间传递String时对“修改”的理解。
举个实际例子:你写一个方法,传入一个String参数,在方法内部给这个参数重新赋值,方法结束后你会发现外部的变量完全没变。这经常让初学者困惑,以为是Java的传值问题。其实本质是:你传进去的是引用的副本,方法里改的是副本指向的新对象,原来的变量还指着老对象。把这个想清楚,很多后续问题都能解开。
1.2 不可变性:为什么String设计成这样
String对象一旦创建,里面的字符序列就不能再改。你写s = s + "!";,表面上看着像是在改s,实际上是把s指向了一个新的String对象,原来的"hello"还待在原地不动。
不可变性带来几个直接的好处。第一,String对象可以被安全缓存和共享,JVM才能用字符串常量池做复用,不然对象内容变来变去,共享就没法谈。第二,String很适合做HashMap的键,因为hashCode不会因为内容变化而失效,你朝Map里存了一个键,不用担心这个键的hashCode偷偷变了导致取不出来。第三,多线程环境下String天然线程安全,毕竟它肚子里的数据根本没有修改入口。
代价也很明显:频繁修改字符串会不断产生新对象,内存和GC的压力都会变大。这也是为什么后文要专门讲StringBuilder。它就像一个可以随意改写的字符序列容器,不满足不可变的要求,但正因为可变,才高效。
1.3 底层存储:char[]还是byte[]?
源码不是必须背的,但了解底层存储有助理解为什么有些操作耗内存。早期JDK里,String内部维护的是一个final char[] value,也就是一个不可变字符数组。到了较新的JDK,为了节省内存引入了byte[]加编码标记的做法,英文和纯数字字符串在Latin-1编码下每个字符只占1个字节,比原来char数组的2个字节省一半。
这个细节跟初学者关系不大,但它解释了一件事:为什么String的很多方法返回新字符串而不是原地修改。因为底层是final数组,任何“修改”都得创建新数组、拷贝内容、生成新对象。你看到substring截取、replace替换,看起来轻巧,背后其实都有数组拷贝的过程。
2. 创建字符串的两种方式,差别比想象中大
2.1 字面量 vs new String()
String s1 = "javaguide";和String s2 = new String("javaguide");表面看都是创建了一个内容相同的字符串,但内存行为完全不同。
字面量创建时,JVM会先去字符串常量池里找有没有内容相同的字符串。如果有就直接复用,没有就新建一个放入池中。用构造方法创建时,不管池里有没有,都直接new一个全新的String对象出来,这个对象在堆内存里;为了构造它,字面量"javaguide"本身也会有一个对象存在池里。也就是说,new String("javaguide")这一行代码,最坏情况下会产生两个字符串对象。
所以我在带新人时一直强调:能写字面量就写字面量,不要没事new String(...)。现代Java代码里,new String("xxx")基本属于错误写法,除了极少数需要强制创建新对象的场景,没有任何必要。它的存在更多是为了让你理解引用比较的原理。
2.2 用代码实测地址与内容
光说不练假把式,看个最简单的例子:
String a = "hello"; String b = "hello"; String c = new String("hello"); System.out.println(a == b); // true 还是 false? System.out.println(a == c); // true 还是 false? System.out.println(a.equals(c)); // true 还是 false?我上课时喜欢让学生先猜再跑。结果是:a == b为true,因为两个字面量内容相同,s1和s2指向同一个常量池对象;a == c为false,因为c是堆上独立创建的新对象;a.equals(c)为true,因为equals比较的是内容顺序和内容是否一致。
很多人第一次跑出这个结果时会愣住,然后开始怀疑==。不是==坏了,是==对引用类型本来比较的就是“是不是同一个对象”。String是类,所以遵循引用比较规则。 只要把这个底层逻辑焊死在脑子里,后面所有字符串面试题都难不倒你。
3. 日常使用频率最高的String方法,逐个拆讲
3.1 比较:equals、equalsIgnoreCase和==到底怎么选
规则很简单:比较内容用equals,忽略大小写用equalsIgnoreCase,只有判断“是不是同一个对象”时才用==。实际业务代码里,99%的场景是在比较内容,所以默认写成equals就好。
null处理也常出问题。如果你直接写str.equals("abc"),而str是null,直接空指针。反过来写"abc".equals(str)就好很多,因为字符串常量永远不会为null。这个习惯看起来很小,但能帮你少踩很多坑。我见过因为变量写前面导致半夜查NPE的,你细品。
String status = null; // 不推荐:可能NPE // if (status.equals("success")) { ... } // 推荐:常量放前面 if ("success".equals(status)) { ... }3.2 查找定位:indexOf、lastIndexOf、contains
indexOf是查找某个字符或子串第一次出现的位置,没找到返回-1;lastIndexOf是最后一次出现的位置;contains内部实际上也调用了indexOf,只是把返回值转成了boolean。判断“是否包含”的时候,优先用contains,代码读起来最自然。
查找所有出现位置时,可以配合循环用重载版本indexOf(String str, int fromIndex),从指定位置继续找:
String text = "hello world, hello java"; int idx = text.indexOf("hello"); while (idx != -1) { System.out.println("找到位置: " + idx); idx = text.indexOf("hello", idx + 1); }这段代码的输出是0和13。这个技巧在做敏感词定位、关键字高亮、文本统计时非常实用。
3.3 截取与清理:substring、trim、strip
substring有两个版本:substring(int beginIndex)和substring(int beginIndex, int endIndex)。记住一个口诀:开始下标含,结束下标不含。比如"abcdef".substring(1, 3)结果是"bc"。这个规则跟数学里的左闭右开区间一样,写之前先在脑子里过一遍。
trim()只能去掉字符串首尾的空格字符,并不能去掉全角空格等Unicode空白。较新的JDK里多了strip(),它能识别Unicode whitespace,去得更全面。如果你的项目已经用了较新的JDK,清理逻辑建议直接用strip()。两者的差别在处理全角空格时能看到明显区别。
3.4 替换与变形:replace、replaceAll、toUpperCase
replace方法只做普通字符替换,replaceAll接收的是正则表达式。你在replaceAll里写"."就是在写正则里的“匹配任意字符”,很多新手想替换小数点,结果发现所有字符都被替换了。正则写错是重灾区,我的建议是普通替换一律用replace,确实需要正则时才用replaceAll。
大小写转换用toUpperCase和toLowerCase。注意它们还有一个接收locale参数的重载,如果处理土耳其语等特殊locale的文本,直接用无参版本可能出意外。国内业务一般不需要纠结,但你知道这个坑,面试聊起来会显得很稳。
3.5 切分:split需要注意的两个细节
split在业务里出场率很高,用来按分隔符把字符串切分成数组。第一个容易踩的坑是:默认情况下,它会丢弃末尾的空字符串。比如"a,b,c,".split(",")的结果是["a", "b", "c"],最后一个空字符串没了。如果你需要保留,可以传负数限制条件,例如split(",", -1)。
第二个坑是split传的是正则,而不是普通字符。按.切分时必须写split("\\."),否则.会被当成“匹配任意字符”的正则,结果完全不对。这一点和replaceAll很像,归根结底是你得意识到:这些方法接受的是正则表达式。如果只是简单分隔符,也可以考虑用StringTokenizer,但它的行为细节和split不太一样,主要还是看团队约定。
4. 字符串拼接:+号、concat和StringBuilder怎么选
4.1 三种方式各有什么优缺点
+号:最直观,适合少量拼接,编译器会自动优化成StringBuilder,但循环内优化效果有限,因为每次循环还是会产生新的拼接结果对象。concat:只能拼接String类型,内部会新建数组拷贝,写法不如+灵活,使用频率不高。StringBuilder:可变的字符序列,适合循环内频繁拼接,也是日常开发中最推荐的拼接方式。StringBuffer:和StringBuilder功能基本一致,但方法加了synchronized,线程安全,代价是性能略差。单线程环境没有理由选它。
4.2 循环里拼接的真实性能对比
有一年我在做代码评审时,看到一个人在循环里用+拼了一个近千条的报文,每趟循环都new出新字符串对象,内存波动非常明显。后来改成StringBuilder,耗时和内存都降下来了。
我做过一个简单直观的测试:循环10万次拼接,用+号、用StringBuilder、用StringBuffer各跑一遍。结果很稳定:StringBuilder最快,StringBuffer居中,+号最慢,而且慢得不是一点半点,往往是数量级的差距。原因也不复杂:+在每次循环里都会创建新的StringBuilder和新的String对象,GC压力全在这上面;StringBuilder只在初始容量不够时才扩容,中间不改内容就不新增对象。
写业务时,如果拼接次数在个位数,用+完全没问题,代码还好看。一旦放进循环,不管循环多少层,请老老实实改用StringBuilder。这个选择不是玄学,是性能和代码可维护性的综合考量。
4.3 StringBuilder的常用操作
StringBuilder最常用的方法无非是append、insert、replace、delete、reverse和toString。我尤其提醒一下insert,它在头部插入数据时需要移动后面所有字符,效率不如append,所以业务里如果只是不断往尾部追加,就一直用append就好。以append为例:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 100; i++) { sb.append("item-").append(i).append(";"); } String result = sb.toString();这段代码里我用链式调用把多个append串在一起,可读性很高。另外可以给StringBuilder指定初始容量,比如new StringBuilder(1024),如果预估字符串会比较长,提前给一个合适容量减少扩容次数,性能会更好一点。
4.4 格式化:String.format和模板拼接
String.format用起来像定制模板,数字保留两位小数、左对齐、补零都能搞定。举个常见写法:
String message = String.format("姓名:%s,年龄:%d,分数:%.2f", name, age, score);format的优势在可读性,尤其适合日志、报表、邮件模板这种固定结构的场景。它不一定比直接拼接快,所以不要用它做高频率循环里的拼接。遇到要拼大段SQL或JSON时,建议也别手写+,容易漏引号、漏逗号,最好用专门的模板引擎或工具类,让结构化内容保持清晰。
提示:构造日志消息时要注意日志框架的懒加载机制,能避免无谓的字符串拼接开销。能用占位符的地方就不要提前拼好,这也是老开发的一个习惯。
5. 常见问题与避坑指南
5.1 空指针、isEmpty与isBlank的区别
isEmpty()判断的是length == 0,也就是空字符串""。isBlank()在较新的JDK里引入,判断的是字符串是否只包含空白字符。比如" "这种由空格组成的内容,isEmpty返回false,isBlank返回true。如果你要校验用户输入是否为空或者全是空格,isBlank更符合直觉。
不管用哪个,调用前最好先判空。最稳妥的写法是:
if (str == null || str.isBlank()) { // 处理空值情况 }如果项目里已经有字符串工具类,直接用工具类的isNotBlank之类的方法更省心。自己写工具时要注意:很多现成工具类已经帮你处理过null,不要重复造轮子。
5.2 字符串反转与字符数组
String没有reverse方法,StringBuilder有。想反转字符串,可以new StringBuilder(原串).reverse().toString()。这个一行写法虽然简单,面试却经常让你不用现成方法手写一个。手写思路也不难:用toCharArray拿到字符数组,然后用双指针从两头交换字符:
String s = "abcdef"; char[] arr = s.toCharArray(); int left = 0, right = arr.length - 1; while (left < right) { char temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } String reversed = new String(arr);理解了字符数组和String的转换关系,这类问题就顺了。顺便说一句,字符串和字符数组之间用toCharArray和new String(char[])互转,几乎天天用到。
5.3 编码与乱码问题
字符串在JVM内部是Unicode编码,但和外部交互时经常要用UTF-8、GBK等编码。new String(bytes, charset)负责把字节数组按指定字符集解码成字符串;String.getBytes(charset)把字符串按字符集编码成字节数组。两边字符集不一致就会乱码。
我踩过的坑是:从外部接口收到字符串后,不要想当然地再转一次编码,先确认对方返回的Content-Type里charset是什么。很多乱码不是代码写错了,而是双方对字符集的处理方式不一致。排查思路是:先看原始字节长什么样,再确认源头到底用的哪种编码,然后再决定是否需要转码。不要盲猜,不要看到一个乱码就立刻“转成UTF-8”,有时候越转越乱。
5.4 用String处理业务时的几个习惯
最后聊几个我工作里积累的小习惯。
第一,字符串常量尽量定义成final常量,不要到处散写字面量。以后万一要统一调整规则,你只需要改一处,不用全文搜索替换。
第二,判断相等时把常量放前面,避免空指针,前面已经提过。
第三,能直接对String操作就别动不动转成char[]。有些同事一看要处理字符串就先把数组拆开,很多时候原生方法完全够用。
第四,注意String在方法间传递的行为。很多人以为它是引用传递,方法里改了外面能看到,其实传的还是值的副本,只是这个值恰好是引用的副本。方法内部重新赋值不会影响外部变量。这个知识点想明白之后,看很多代码就不会再犯迷糊。
第五,当你要在一个循环里判断大量字符串状态时,可以用switch表达式配合字符串,较新的JDK里支持对String做switch,比写一长串if-else清晰不少。
搞明白这些,你再看网上那些“String vs new String”、“==与equals区别”之类的题目,基本一眼就能看出答案。字符串的坑大多不在于API多复杂,而在于你忘了它是一个对象。只要你还记得它指向的是常量池还是堆上的对象,记得比较内容要用equals,记得它是不可变的,那String的大多数坑你都能轻松避开。