目录
字符串的常量和变量
不变的String常量
String变量
重载的+和更快的StringBuilder
容易忽略的递归现象
对字符串的操作
格式化输出
System.out.format()
Formatter类
格式说明符
Formatter转换
String.format()
新特性:文本块
本笔记参考自: 《On Java 中文版》
Java通常被用于Web开发等工作,String可以说是最常用的一个类了。
字符串的常量和变量
String对象往往是不可变的,Java为String常量的存储设置了特殊的位置。报告除此之外,也存在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变量
官方文档建议我们通过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。
重载的+和更快的StringBuilder
String的不变性会带来一些效率上的问题。上面的例子语句已经展示了一些:
- 每一次进行String修改的操作都可能需要创建新的String对象,内存的分配和回收会影响效率。
这里有一个典型案例:+操作符。这是Java中唯一一个进行了重载的操作符。其目的就是为了配合String对象。
除此之外,还有:
- 使用new String()或者+创建新的String对象时,会绕过字符串常量池,导致性能下降。
- 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<Nature> 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开发者能够方便地进行输出格式对齐等操作。
System.out.format()
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(),该方法会返回一个格式化字符串。
Formatter类
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:
- width:用于控制一个字段的最小长度,长度不足时用空格填充。
- precision:用于指定字段长度的最大值,这一标识对不同类型有不同含义:
- 对字符串:限制字符串的最大输出字符数。
- 对浮点数:指定要显示的小数位数。
- 不允许对整数使用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对象中。
Formatter转换
简单介绍一下常用的转换字符。
字符 | 效果 |
---|---|
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。
String.format()
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()也可以用于普通字符串,它更清晰。
文本块的结果就是一个普通字符串,因此任何对普通字符串有用的方法都对它有效。