目录
本笔记参考自: 《On Java 中文版》
Java通常被用于Web开发等工作,String可以说是最常用的一个类了。
String对象往往是不可变的,Java为String常量的存储设置了特殊的位置。报告除此之外,也存在String变量。下文先讨论String常量。
String类的对象是不可变的。正因如此,相同的String常量可以共享同一个对象(我们可以为一个String对象设置多个别名)。而在官方文档中,任何看似修改String对象的方法实际上都返回了一个新的String对象。
【例子:新的String】
- public class Immutable {
- public static String upcase(String s) {
- return s.toUpperCase();
- }
-
- public static void main(String[] args) {
- String s1 = "A string";
- String s2 = upcase(s1);
- System.out.println("s1: " + s1);
- System.out.println("s2: " + s2);
- }
- }
程序执行的结果是:
String对象在传递变量时只会传递引用,其中的内容是不会被复制的。
通过参数创建新的对象,保留原本对象,这种行为就涉及到代码的不变性。当我们使用参数时,我们需要的是获取其中的信息,而不是修改传入的对象。这种不变性能够防止副作用,并且降低代码的读者的理解成本。
官方文档建议我们通过String buffers创建字符串变量。这些变量被开辟在堆上,也因此不会共享同一个对象。
如上所述,String类的对象是特殊的。在程序运行时,若代码中出现了字符串常量,这些常量(变量则放到堆上)就会被JVM收集到一个字符串池中。代码中存储字符串常量的引用都会指向处于内存池中的同一对象:
- public class StringQuote {
- public static void main(String[] args) {
- String s1 = "字符串常量";
- String s2 = "字符串常量";
- System.out.println("s1 == s2: " + (s1 == s2));
-
- // 通常,可以通过StringBuffer类创建字符串变量
- StringBuffer buffer = new StringBuffer("这是一个字符串变量");
- String s3 = "这是一个字符串变量";
- System.out.println("buffer是否存在于字符串池中:"
- + (buffer.equals(s3))); // 这个equals()方法会比较地址位置
-
- // 除此之外,还有一点值得提及:
- String s4 = "Hello";
- String s5 = "He";
- System.out.println("s4 == s5 + \"llo\": "
- + (s4 == s5 + "llo")); // 编译器会对这行语句进行判断,并优化
- }
- }
程序执行的结果是:
通常,我们会把一个字符串常量赋给String类对象,而把字符串变量赋给StringBuffer类对象(官方文档对StringBuffer类的描述是:线程安全、可变的字符序列)。此外,StringBuffer类也包含了一些用于操作字符串的方法,例如insert()、append()等(此处不详细介绍)。
在语句s4 == s5 + "llo"中,s5+"llo"发生了字符串拼接。通过JDK自带的javap反编译工具,可以查看这段代码对应的字节码,通过控制台输入命令:
javap -c StringQuote
-c表示生成JVM字节码。
在控制台上输出StringQuote对应的JVM字节码:
注意:字符串常量一经确定,就不能更改。这里需要注意的是第73行调用的makeConcatWithConstants()方法
,该方法被用于字符串拼接,它会(在堆上)创建一个新的String对象来存储更改后的字符串。这也是为什么最后一次比较会返回false。
String的不变性会带来一些效率上的问题。上面的例子语句已经展示了一些:
这里有一个典型案例:+操作符。这是Java中唯一一个进行了重载的操作符。其目的就是为了配合String对象。
除此之外,还有:
或许Java的设计者也注意到这些问题,因此编译器会对一些情况进行优化。接下来的例子承接上述的StringQuote,但这次需要更深入一些:
【例子:使用+拼接字符串】
- public class Concatenation {
- public static void main(String[] args) {
- String other = "拼接";
- String s = other + "字符串" + "常量" + 12;
- System.out.println(s);
- }
- }
程序执行的结果是:
若想要理清这段代码的工作原理,我们还需要观察它的JVM字节码。实际上,JDK 9为了提高性能,对字符串的拼接操作进行了更改。因此我们先观察JDK 8下的字节码:
注意其中的StringBuilder操作。程序中并没有调用这个类,但是编译器还是使用了它,因为它具有更高的效率。我们可以在文档中找到它:
这里还有一个题外话,就是在上一个小节中出现的makeConcatWithConstants()方法。JDK 9最终决定使用invokedynamic命令调用makeConcatWithConstants()替代原本的StringBuilder,因此如果在JDK 8以上版本通过javap -c指令查看本例的字节码,会发现不同:
这段字节码显得更加的简洁了,并且因为性能的优化全部交给了makeConcatWithConstants()方法,因此即使编译器升级,也不用为了更好的性能重新编译代码。
但是,官方文档中依旧推荐使用StringBuilder,因为在包括循环中的字符串拼接等情况中,StringBuilder可能可以提供更好的性能。
------
理所当然的,编译器对String操作的优化是有限的。下面的例子会通过两种不同的方式生成String对象的实例:
【例子:不同方式生成String】
- public class WiththeStringBuilder {
- public String implicit(String[] fields) { // 使用+操作符
- String result = "";
- for (String field : fields)
- result += field;
-
- return result;
- }
-
- public String explicit(String[] fields) { // 使用StringBuilder
- StringBuilder result = new StringBuilder();
- for (String field : fields)
- result.append(field);
-
- return result.toString();
- }
- }
同样,通过javap -c查看JVM字节码(Java 8版本)。这里对输出结果进行了处理,首先是implicit():
可以看到,StringBuilder对象的构建发生在循环内部,因此每次循环进行,我们都会得到一个新的StringBuilder对象。
然后是explicit():
循环的代码变得更短了,并且该方法只创建了一个StringBuilder对象。因为StringBuilder对象是显式调用的,因此我们可以使用其自带的构造器进行大小指定:
这样也能避免不断重新分配缓冲区。
在JDK 9后,implicit()的字节码明显简化了:
因此在实际使用的过程中,我们应该权衡好不同方法之间的利弊。而若涉及循环,并且想要追求更高的性能,那么使用StringBuilder或许是个更好的选择。
【例子:StringBuilder的一个例子】
- import java.util.Random;
- import java.util.stream.Collectors;
-
- public class UsingStringBuilder {
- public static String string1() {
- Random rand = new Random(47);
- StringBuilder result = new StringBuilder("[");
- for (int i = 0; i < 25; i++) {
- result.append(rand.nextInt(100));
- result.append(", ");
- }
- result.delete(result.length() - 2, result.length());
- result.append("]");
- return result.toString();
- }
-
- public static String string2() {
- String result = new Random(47)
- .ints(25, 0, 100)
- .mapToObj(Integer::toString) // 将Integer对象转换为String对象
- .collect(Collectors.joining(", "));
-
- return "[" + result + "]";
- }
-
- public static void main(String[] args) {
- System.out.println(string1());
- System.out.println(string2());
- }
- }
程序执行的结果是:
笔者通过jmh简单测试过上述两种方法,结果如下:
在这里,string1()效率更高。
在方法string1()中,我们使用append()一个一个将字符进行拼接。如果在这里使用+操作符,例如append(a + ": " + b),那么编译器就会进行介入,并创建更多的StringBuilder对象。若不确定想要比较不同方法的优缺点,可以使用javap。
string2()使用了Stream,代码的可读性更高。另外,在Java 8及以下版本中,Collectors.joining()内部使用也会使用StringBuilder,但新版本则会直接使用Stream的方法。
StringBuilder是Java 5引入的。在此之前Java就是使用StringBuffer进行操作,这个方法是线程安全的,同时成本更高。根据文档的描述,StringBuilder在大部分情况下会更快。
Java为所有的标准集合重写了toString()方法,这样它们就能正确地表示内部存储的信息。例如:
【例子:集合中重写的toString】
- import java.util.List;
- import java.util.Random;
- import java.util.stream.Collectors;
- import java.util.stream.Stream;
-
- class Nature {
- private String[] strings =
- {"Tree", "River",
- "Mountain", "Flower",
- "Bird", "Cloud",
- "Sunset", "Forest",
- "Ocean", "Rainbow"};
-
- private static int count = 0;
- private static Random random = new Random(47);
-
- public static Nature getNature() {
- return new Nature();
- }
-
- @Override
- public String toString() {
- return (count++) +
- ": " + strings[random.nextInt(10)];
- }
- }
-
- public class ArrayListDisplay {
- public static void main(String[] args) {
- List
list = Stream.generate(Nature::getNature) - .limit(10)
- .collect(Collectors.toList());
-
- System.out.println(list);
- }
- }
程序执行的结果是:
因为toString()被重写了,所以List中的所有元素的值都能被打印出来。
但toString()并非完全没有问题(即使它被进行了重写)。假若我们想要打印某一数据的地址,并为此使用了this。这种做法看起来合理,因为this是一个引用:
【例子:有问题的this与toString()】
- import java.util.stream.Stream;
-
- public class InfiniteRecursion {
- @Override
- public String toString() {
- return
- "InfiniteRecursion对象的地址:"
- + this + "\n";
- }
-
- public static void main(String[] args) {
- Stream.generate(InfiniteRecursion::new)
- .limit(10)
- .forEach(System.out::println);
- }
- }
若我们尝试执行这段代码,就会出现一个很长的异常串:
这是因为我们使用+操作符拼接了this,触发了字符串的自动类型转换:
"InfiniteRecursion对象的地址:" + this
当+连接了String之外的对象时,编译器会试图寻找并使用这个对象的toString(),但this的toString()又是这个有this的toString(),然后编译器又进入下一层toString()。换言之,上述代码因为this的存在而在不断进行递归。
若确实需要打印对象的地址,可以直接调用Object的toString()方法来实现。上述的例子可以这么做:
"地址:" + super.toString()
对String对象的操作大部分如下:
方法 | 参数 / 重载版本 | 用途 |
---|---|---|
构造器 | 重载版本 | 构建String对象 |
默认构造器 | ||
含有String的构造器 | ||
含有StringBuilder的构造器 | ||
含有StringBuffer的构造器 | ||
含有char[]等的构造器 | ||
含有byte[]等的构造器 | ||
length() | - | 计算String中的Unicode代码单元的个数 |
charAt() | 索引(int) | 根据索引(index)返回指定的char |
getChars() getBytes() | 所复制字符串的开始索引 (int) | 将String中的字符复制到目标数组中 |
所复制字符串的结束索引 (int) | ||
要复制到的目标数组(char[] / byte[]) | ||
目标数组的起始偏移量(int) | ||
toCharArray() | - | 将String类型的字符串转变为char[] |
equals() equalsIngnoreCase() | 比较的对象 (equals() - Object) (equalsIngnoreCase() - String) | 将两个对象的内容进行相等性检查,若相等,则返回true |
compareTo() compareToIgnoreCase() | 要比较的字符串(String) | 按字典顺序比较String的内容(字母大小写不相等) |
contains() | 要搜索的序列(CharSequence) | 若序列存在于String中,则返回true |
contentEquals() | 用于比较的序列 (StringBuffer / CharSequence) | 若该String与序列的内容完全一致,则返回true |
isEmpty() | - | 若String长度为0,返回true;否则返回false |
regionMatches() | 字符串索引的起始偏移量(int) | 判断该字符串的指定区域是否与参数的匹配 (该方法还有一个重载,提供了“忽略大小写”的功能) |
字符串参数(String) | ||
上述字符串参数索引的起始偏移量(int) | ||
要比较的长度(int) | ||
startsWith() | 指定前缀(String) | 判断字符串是否以指定前缀开头 (该方法的重载提供了偏移量设定) |
endsWith() | 指定后缀(String) | 判断字符串是否以指定后缀结尾 |
indexOf() lastIndexOf() | 重载版本 | 若存在于字符(或子字符串)匹配的匹配项,则返回匹配项开始的索引。否则返回-1。 (indexOf()与lastIndexOf()不同之处在于,后者是从后往前搜索的) |
字符(char - Unicode) | ||
字符和起始索引 | ||
字符、起始索引和结束索引 | ||
要搜索的子字符串(String) | ||
子字符串和起始索引 | ||
子字符串、起始索引和结束索引 | ||
matches() | 正则表达式(String) | 若String与正则表达式匹配,返回true |
split() | 用于分隔的正则表达式(String) | 根据正则表达式拆分String,返回结果数组(String[]) |
(可选)最大分割数(int) | ||
join()(Java 8引进) | 分隔符(CharSequence) | 将元素合并成由分隔符分隔的新的String |
要合并的元素 (CharSequence... / Interable) | ||
substring() (subSequence()类似) | 重载版本 | 返回一个String对象,包含指定的字符集合 (subSequence()则返回CharSequence) |
起始索引 | ||
起始索引和结束索引 | ||
concat() | 用于拼接的字符串(String) | 返回一个新的String对象,该对象拼接了原始字符串和参数的String |
replace() | 旧字符(String / CharSequence) | 返回替换后的新的String对象。若没有匹配目标,则返回旧的String |
新字符(String / CharSequence) | ||
replaceFirst() | 用于匹配的正则表达式(String) | 返回新的String对象,该对象中与正则表达式匹配的第一个匹配项被替换成参数指定的String |
用于替换的字符串(String) | ||
replaceAll() | 用于匹配的正则表达式(String) | 返回新的String对象,该对象中的所有匹配项均被替换 |
用于替换的字符串(String) | ||
toLowerCase() toUpperCase() | - | 返回一个新的String对象,所有字母的大小写均发生了对应的变化。若无更改,返回旧的String |
trim() | - | 返回一个新的String对象,删除了两端所有的空白字符。若无更改,返回旧的String |
valueOf() (静态方法) | 重载版本 | 返回一个String,其中包含的是输入参数的字符显示 |
Object | ||
char[] | ||
char[]、偏移量和计数 | ||
boolean | ||
char | ||
int | ||
long | ||
float | ||
double | ||
intern() | - | 为每一个唯一的字符序列生成一个独一无二的String引用 |
format() (静态方法) | 格式化字符串(String) (包括会被替换的格式说明符) | 生成格式化后的String |
参数(Object...) | ||
(可选)区域设置(Locale) |
从上述方法可以发现,当一个String的方法会更改String的内容时,这些方法都会返回一个新的String对象。而若不需要修改,方法就会返回原始String的引用,节省存储和开销。
Java 5提供了类似于C语言中printf()的格式化输出,这使得Java开发者能够方便地进行输出格式对齐等操作。
Java提供了printf()和format()方法。例如:
- public class SimpleFormat {
- public static void main(String[] args) {
- int x = 5;
- double y = 1.14514;
-
- // 旧的方法:
- System.out.println("打印数据中... [" + x + " " + y + "]");
- // 新的方法:
- System.out.format("打印数据中... [%d %.5f]%n", x, y);
- // 或者:
- System.out.printf("打印数据中... [%d %.5f]%n", x, y);
- }
- }
程序执行的结果是:
format()和printf()是等价的。另外,String类也有一个静态的format(),该方法会返回一个格式化字符串。
Java中的所有格式化功能最终都由java.util包中的Formatter类处理。我们输入格式化字符串,然后Formatter将其转换为我们需要的。例如:
【例子:通过Formatter转换为我们需要的结果】
- import java.io.PrintStream;
- import java.util.Formatter;
-
- public class Turtle {
- private String name;
- private Formatter f;
-
- public Turtle(String name, Formatter f) {
- this.name = name;
- this.f = f;
- }
-
- public void move(int x, int y) {
- f.format("箭头【%s】现在位于(%d, %d)%n",
- name, x, y);
- }
-
- public static void main(String[] args) {
- PrintStream outprint = System.out;
-
- // Formatter类的构造器允许我们指定信息输出
- Turtle t1 = new Turtle("壹",
- new Formatter(System.out));
- Turtle t2 = new Turtle("贰",
- new Formatter(outprint));
-
- t1.move(0, 0);
- t2.move(2, 2);
- t1.move(2, 6);
- t2.move(1, 7);
- t1.move(4, 0);
- t2.move(3, 3);
- }
- }
程序执行的结果是:
可以更详细地描述格式说明符,以达到对格式的精确控制。描述字符和数值的format格式基本如下:
%[argument_index$][flags][width][.precision]conversion // 可查看文档获取详细信息
这里介绍width和precision:
【例子:打印购物收据】
- import java.util.Formatter;
-
- // 使用生成器模式构建程序
- public class ReceiptBuilder {
- private double total = 0;
- private Formatter f =
- new Formatter(new StringBuilder());
-
- public ReceiptBuilder() {
- f.format(
- "%-15s %4s %9s%n", "物品", "数量", "价格");
- f.format(
- "%-15s %6s %10s%n", "----", "---", "-----");
- }
-
- public void add(String name, int qty, double price) {
- f.format("%-15.15s %5d %10.2f%n", name, qty, price);
- total += price + qty;
- }
-
- public String build() {
- f.format("%-15.15s %5s %10.2f%n", "税款", "", total * 0.06);
- f.format("%-15s %6s %10s%n", "", "", "-----");
- f.format("%-15.15s %5s %10.2f%n", "总额", "", total * 1.06);
- return f.toString();
- }
-
- public static void main(String[] args) {
- ReceiptBuilder receiptBuilder =
- new ReceiptBuilder();
-
- receiptBuilder.add("衬衫", 4, 15.9);
- receiptBuilder.add("棉袄", 2, 24.5);
- receiptBuilder.add("风帽", 1, 6.89);
-
- System.out.printf(receiptBuilder.build());
- }
- }
程序执行的结果是:
生成器模式:创建一个起始对象,然后向其中添加内容,最后通过build()生成结果。
将一个StringBuilder传递给Formatter构造器,这样就初始化了一个Formatter对象。之后添加的内容都会被储存在这个StringBuilder对象中。
简单介绍一下常用的转换字符。
字符 | 效果 |
---|---|
d | 整数类型(十进制表示) |
c | Unicode字符 |
b | Boolean值 |
s | 字符串 |
f | 浮点数(十进制表示) |
e | 浮点数(科学记数法表示) |
x | 整数类型(十六进制表示) |
h | 哈希码(十六进制表示) |
% | 字面量“%” |
其中,转换字符b可以适用于任何类型的变量。尽管如此,但其的行为会因为对应参数类型的不同而发生变化:
【例子:转换字符b的使用例】
- public class Conversion {
- public static void main(String[] args) {
- boolean b = false;
- System.out.printf("b = %b%n", b);
-
- int i = 0;
- System.out.printf("i = %b%n", i); // 注意,此处的i是0。但打印结果依旧为true
- char[] c = null;
- System.out.printf("c = %b%n", c); // 只有当参数值为null时,才会打印false
- }
- }
程序执行的结果是:
对于除boolean基本类型或Boolean对象而言,b的行为产生的结果是对应的true和false。但对任何其他类型而言,只要值不为null,结果总会是true,即使是数值0。
Java 5提供了一个用来创建字符串的方法:String.format()。它是一个静态方法,参数与Formatter中的format()方法完全相同,但返回一个String。
【例子1:使用String.format()】
- public class DatabaseException extends Exception {
- public DatabaseException(int transactionID,
- int queryID, String message) {
- super(String.format("(t%d, q%d) %s",
- transactionID, queryID, message));
- }
-
- public static void main(String[] args) {
- try {
- throw new DatabaseException(3, 7, "一个错误发生了");
- } catch (Exception e) {
- System.out.println(e);
- }
- }
- }
程序执行的结果是:
事实上,String.format()的实现方式就是实例化一个Formatter,并传入参数。
---
【例子2:转储为十六进制】
这个例子会将二进制文件中的字节格式化为十六进制,并进行输出。
- import java.nio.file.Files;
- import java.nio.file.Paths;
-
- public class Hex {
- public static String format(byte[] data) {
- StringBuilder result = new StringBuilder();
- int n = 0;
-
- for (byte b : data) {
- if (n % 16 == 0)
- result.append(String.format("%05X: ", n));
- result.append(String.format("%02X ", b));
- n++;
- if (n % 16 == 0)
- result.append("\n");
- }
- result.append("\n");
- return result.toString();
- }
-
- public static void main(String[] args)
- throws Exception {
- if (args.length == 0) // 若没有外来输入,则将本文件作为测试数据
- System.out.println(
- format(Files // readAllBytes():以byte数组的形式返回整个文件
- .readAllBytes(Paths.get("Hex.java")))
- );
- else
- System.out.println(
- format(Files.readAllBytes(Paths.get(args[0])))
- );
- }
- }
程序执行的结果是(截取前三行):
JDK 15添加了文本块,这一特性通过使用三对双引号(""" """)来表示包含换行符的文本块。
【例子:使用文本块】
- public class TextBlocks {
- public static final String OLD =
- "好运来 祝你好运来\n" +
- "好运带来了喜和爱\n" +
- "好运来 我们好运来\n" +
- "迎着好运兴旺发达通四海\n"; // 节选自《好运来》
-
- public static final String NEW = """
- 好运来 祝你好运来
- 好运带来了喜和爱
- 好运来 我们好运来
- 迎着好运兴旺发达通四海
- """;
-
- public static void main(String[] args) {
- System.out.println(OLD.equals(NEW));
- }
- }
程序执行的结果是:
这种新的文本块方便我们创建大型的文本,其格式更加易读。
注意:开头的"""后面的换行符会被自动去掉,块中的公用缩进也会被去掉。若想要保留缩减,可以通过移动末尾的"""来达成这一效果:
【例子:文本块中的缩减】
- public class Indentation {
- public static final String NONE = """
- XXX
- XXX
- XXX
- """; // 没有缩进
-
- public static final String TWO = """
- XXX
- XXX
- XXX
- """; // 两个空格的缩进
-
- public static final String EIGHT = """
- XXX
- XXX
- XXX
- """; // 八个空格的缩进
-
- public static void main(String[] args) {
- System.out.println(NONE);
- System.out.println(TWO);
- System.out.println(EIGHT);
- }
- }
程序执行的结果是:
另外,为了支持文本块,Java向String类中添加了一个新的formatted()方法:
- public class DataPoint {
- private String location;
- private Double temperature;
-
- public DataPoint(String loc, Double temp) {
- location = loc;
- temperature = temp;
- }
-
- @Override
- public String toString() {
- return """
- Location: %s
- Temperature: %.2f
- """.formatted(location, temperature);
- }
-
- public static void main(String[] args) {
- var D1 = new DataPoint("D1", 11.4);
- var D2 = new DataPoint("D2", 5.14);
-
- System.out.println(D1);
- System.out.println(D2);
- }
- }
程序执行的结果是:
formatted()方法是一个成员方法,它并不像String.format()一样是静态的。formatted()也可以用于普通字符串,它更清晰。
文本块的结果就是一个普通字符串,因此任何对普通字符串有用的方法都对它有效。