本篇是 2017 年 Java 学习笔记系列的第十二天,主角是开发中使用频率最高的类——String。先建立”不可变性 + 字符串常量池”的入门认知,再把几十个常用方法按判断、获取、转换、替换四大类整理清楚,顺带聊聊字符串拼接的性能意识和 Scanner 键盘录入的换行陷阱。

本篇目标

  • 理解 String 不可变的含义与字符串常量池的作用
  • 按功能分类掌握 String 的常用方法
  • 知道为什么循环里不该用 + 拼字符串
  • 会用 Scanner 接收键盘输入,并避开 nextInt 与 nextLine 混用的坑

String 的不可变性:从常量池说起

String s = "abc"; 这行代码里,字面量 "abc" 存放在字符串常量池中,s 只是一个指向它的引用。再写 String s2 = "abc";,JVM 发现池中已有相同内容,直接复用同一个对象——所以 s == s2 是 true。常量池本质上是一块”字符串缓存区”,目的是让内容相同的字符串在内存里只存一份,避免浪费。

除了字面量赋值,String 也提供了构造方法:new String() 创建空串、new String(byte[]) 把字节数组按平台默认编码转成字符串、new String(char[]) 把字符数组合成字符串、new String("abc") 基于已有字符串再建一个新对象。日常开发几乎只用字面量,构造方法主要在处理字节、字符数组的场景登场。

所谓不可变,是指字符串对象一旦创建,其内容就不能再改。所有看似”修改”的方法(toUpperCase、replace、substring……)都是返回一个全新的 String 对象,原对象纹丝不动。这样设计至少带来三个好处:常量池可以安全地被多处共享,而不用担心被某个引用改掉;hashCode 只需计算一次并可以缓存,这也是 String 能成为 HashMap 最常用的 key 类型的原因之一;对象天然线程安全,多线程共享无需加锁。至于 intern()、JDK 9 之后底层改用 byte 数组压缩存储等进阶话题,先知道有这回事即可。

新手最常犯的错误是忘记接收返回值:

String s = "abc";
s.toUpperCase(); // 新对象被创建后没人接住,直接丢弃
System.out.println(s); // 还是 abc
String t = s.toUpperCase(); // 正确:接住返回的新对象

由此引出几个经典面试辨析:

String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2); // true:常量池复用,指向同一对象

String s3 = new String("abc"); // 若池中无 "abc" 则创建两个对象(池中+堆中),有则只创建堆中一个
System.out.println(s1 == s3); // false:一个在池里,一个在堆里
System.out.println(s1.equals(s3)); // true:内容相同

String a = "a" + "b" + "c"; // 全是常量,编译期折叠成 "abc"
System.out.println(a == s1); // true

String x = "ab";
String y = x + "c"; // 有变量参与,运行期在堆中拼出新对象
System.out.println(y == s1); // false
System.out.println(y.equals(s1)); // true

分水岭就一句话:常量拼接发生在编译期,变量拼接发生在运行期;== 比地址,equals 比内容。

常用方法分类速查

String 的方法很多,按用途记四类就不会乱。

判断类

equals(区分大小写)、equalsIgnoreCase(忽略大小写)、contains、startsWith、endsWith、isEmpty。注意 equals 是实例方法,调用者为 null 会直接空指针,所以推荐 "admin".equals(input) 这种常量在前、变量在后的写法。

获取类

length() 返回字符个数(注意数组是 length 属性,String 是 length() 方法,别混);charAt(index) 取指定位置字符;indexOf / lastIndexOf 查找字符或子串位置,找不到返回 -1;substring(start, end) 截取子串,包左不包右。这里有两个高频错误:一是把 indexOf 的返回值直接当布尔用,正确姿势是判断 != -1;二是 charAt 和 substring 的索引越界会抛 StringIndexOutOfBoundsException,访问前先确认 length。

转换类

toCharArray() 转字符数组、getBytes() 转字节数组、静态方法 String.valueOf(...) 把任意类型转字符串、toUpperCase() / toLowerCase() 大小写转换、concat 拼接。这些方法都返回新对象,因此可以链式调用。比如把首字母转大写、其余转小写:

String s = "hELLO";
String result = s.substring(0, 1).toUpperCase() + s.substring(1).toLowerCase();
System.out.println(result); // Hello

替换与去除类

replace(old, new) 替换所有匹配内容;replaceAll 支持正则表达式,后面正则篇再展开;trim() 只去除首尾的空白,中间的空格不动;compareTo 按字典序比较大小,返回值是两者的差值。

拼接的性能意识

String 不可变意味着每次 + 拼接都要创建新对象并把旧内容复制过去。写一次无所谓,放进循环里就是灾难:

String s = "";
for (int i = 0; i < 10000; i++) {
s += i; // 每轮都新建对象并整体复制,累计拷贝量是平方级
}

一万次循环背后是上亿次字符拷贝,时间和内存都在被白白消耗——这就是典型的平方级开销。结论:循环中或大量拼接,一律使用 StringBuilder——它内部维护可变的字符数组,追加只是往数组里写,均摊下来每次拼接接近常数时间。编译器虽然能把单行 + 优化成 StringBuilder,但跨循环的拼接它救不了你:每一轮 s += i 都会被翻译成一次”新建 StringBuilder、append、toString”,对象照样满天飞。StringBuilder 是下一篇的主角,这里先立个意识。

Scanner:键盘录入与换行陷阱

Scanner 是对输入流做解析的工具类,new Scanner(System.in) 接收键盘录入。常用两类方法:hasNextXxx() 判断是否还有下一个某种类型的输入项,nextXxx() 真正取走它——nextInt() 读整数,nextLine() 读一整行。

经典陷阱:先 nextInt() 再 nextLine(),第二个方法会”秒过”不等你输入。原因是 nextInt 只读到数字为止,你敲的回车符还留在缓冲区,紧随其后的 nextLine 立刻把这个残留的换行读走,拿到一个空串。

Scanner sc = new Scanner(System.in);
int age = sc.nextInt(); // 输入 18 回车
String name = sc.nextLine(); // 读到的是残留的空行,程序没等你输入名字!

最稳的解决方案是全部用 nextLine 接收,再按需转换类型:

Scanner sc = new Scanner(System.in);
int age = Integer.parseInt(sc.nextLine());
String name = sc.nextLine();

经典练习

模拟登录,给三次机会

Scanner sc = new Scanner(System.in);
for (int i = 3; i > 0; i--) {
System.out.println("请输入用户名:");
String user = sc.nextLine();
System.out.println("请输入密码:");
String pwd = sc.nextLine();
if ("admin".equals(user) && "admin".equals(pwd)) {
System.out.println("登录成功");
break;
}
System.out.println("用户名或密码错误,还剩 " + (i - 1) + " 次机会");
}

注意 "admin".equals(user) 的写法:常量在前,即使 user 是 null 也不会抛异常,这是值得养成的习惯。

统计字符串中各类字符的个数

String s = "ABCDEabcd123456!@#$%^";
int upper = 0, lower = 0, digit = 0, other = 0;
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
if (ch >= 'A' && ch <= 'Z') upper++;
else if (ch >= 'a' && ch <= 'z') lower++;
else if (ch >= '0' && ch <= '9') digit++;
else other++;
}
System.out.println("大写:" + upper + " 小写:" + lower + " 数字:" + digit + " 其他:" + other);

char 可以直接和字符字面量比大小,因为 char 本质上是数字(Unicode 码点)——这是遍历字符串最常用的套路。

统计大串中小串出现的次数

思路:用 indexOf(小串, 起始位置) 找一次、计数加一,然后把起始位置推到”本次命中位置 + 小串长度”,继续找,直到返回 -1:

String big = "woaijavawozhenaijavawodeaijava";
String small = "java";
int count = 0, index = 0;
while ((index = big.indexOf(small, index)) != -1) {
count++;
index += small.length();
}
System.out.println("java 出现了 " + count + " 次"); // 3

这个练习把 indexOf 的重载形式、循环条件和”指针推进”三件事串在了一起,值得动手写一遍。

小结

  • String 不可变:字面量驻留常量池,任何”修改”都产生新对象;== 比地址,equals 比内容。
  • 常量拼接编译期折叠进常量池,变量拼接运行期在堆里生成新对象——这是面试题的分水岭。
  • 常用方法按判断、获取、转换、替换四类记忆;链式调用利用的正是”返回新对象”。
  • 循环拼接用 StringBuilder,别用 +;Scanner 混用 nextInt/nextLine 会吞换行,统一用 nextLine 再转换最稳。