目录

字符串的常量和变量

不变的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);
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_字符串

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")); // 编译器会对这行语句进行判断,并优化
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_java结尾包含字符串_02

String类对象,而把字符串变量赋给StringBuffer类对象(官方文档对StringBuffer类的描述是:线程安全、可变的字符序列)。此外,StringBuffer类也包含了一些用于操作字符串的方法,例如insert()、append()等(此处不详细介绍)。

s4 == s5 + "llo"中,s5+"llo"发生了字符串拼接。通过JDK自带的javap反编译工具,可以查看这段代码对应的字节码,通过控制台输入命令:

javap -c StringQuote

-c表示生成JVM字节码。

在控制台上输出StringQuote对应的JVM字节码:

java结尾包含字符串 java 包含字符串_java结尾包含字符串_03

注意:字符串常量一经确定,就不能更改。这里需要注意的是第73行调用的makeConcatWithConstants()方法,该方法被用于字符串拼接,它会(在堆上)创建一个新的String对象来存储更改后的字符串。这也是为什么最后一次比较会返回false。


重载的+和更快的StringBuilder

String的不变性会带来一些效率上的问题。上面的例子语句已经展示了一些:

  • 每一次进行String修改的操作都可能需要创建新的String对象,内存的分配和回收会影响效率。

+操作符。这是Java中唯一一个进行了重载的操作符。其目的就是为了配合String对象。

除此之外,还有:

  • 使用new String()或者+创建新的String对象时,会绕过字符串常量池,导致性能下降。
  • String类的一些操作也会需要额外的空间,这就会带来更大的开销。

StringQuote,但这次需要更深入一些:

【例子:使用+拼接字符串】

public class Concatenation {
    public static void main(String[] args) {
        String other = "拼接";
        String s = other + "字符串" + "常量" + 12;
        System.out.println(s);
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_开发语言_04

        若想要理清这段代码的工作原理,我们还需要观察它的JVM字节码。实际上,JDK 9为了提高性能,对字符串的拼接操作进行了更改。因此我们先观察JDK 8下的字节码:

java结尾包含字符串 java 包含字符串_开发语言_05

注意其中的StringBuilder操作。程序中并没有调用这个类,但是编译器还是使用了它,因为它具有更高的效率。我们可以在文档中找到它:

java结尾包含字符串 java 包含字符串_1024程序员节_06

makeConcatWithConstants()方法。JDK 9最终决定使用invokedynamic命令调用makeConcatWithConstants()替代原本的StringBuilder,因此如果在JDK 8以上版本通过javap -c指令查看本例的字节码,会发现不同:

java结尾包含字符串 java 包含字符串_1024程序员节_07

这段字节码显得更加的简洁了,并且因为性能的优化全部交给了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():

java结尾包含字符串 java 包含字符串_java_08

可以看到,StringBuilder对象的构建发生在循环内部,因此每次循环进行,我们都会得到一个新的StringBuilder对象

explicit():

java结尾包含字符串 java 包含字符串_开发语言_09

循环的代码变得更短了,并且该方法只创建了一个StringBuilder对象。因为StringBuilder对象是显式调用的,因此我们可以使用其自带的构造器进行大小指定:

java结尾包含字符串 java 包含字符串_字符串_10

这样也能避免不断重新分配缓冲区。

在JDK 9后,implicit()的字节码明显简化了:

java结尾包含字符串 java 包含字符串_java_11

因此在实际使用的过程中,我们应该权衡好不同方法之间的利弊。而若涉及循环,并且想要追求更高的性能,那么使用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());
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_1024程序员节_12

jmh简单测试过上述两种方法,结果如下:

java结尾包含字符串 java 包含字符串_java_13

在这里,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);
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_1024程序员节_14

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);
    }
}

        若我们尝试执行这段代码,就会出现一个很长的异常串:

java结尾包含字符串 java 包含字符串_java结尾包含字符串_15

+操作符拼接了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()

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);
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_java_16

format()和printf()是等价的。另外,String类也有一个静态的format(),该方法会返回一个格式化字符串。


Formatter类

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);
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_字符串_17


格式说明符

        可以更详细地描述格式说明符,以达到对格式的精确控制。描述字符和数值的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());
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_1024程序员节_18

    生成器模式:创建一个起始对象,然后向其中添加内容,最后通过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
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_java结尾包含字符串_19

boolean基本类型或Boolean对象而言,b的行为产生的结果是对应的true和false。但对任何其他类型而言,只要值不为null,结果总会是true,即使是数值0。


String.format()

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);
        }
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_java_20

String.format()的实现方式就是实例化一个Formatter,并传入参数。

java结尾包含字符串 java 包含字符串_java结尾包含字符串_21

---

【例子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])))
            );
    }
}

        程序执行的结果是(截取前三行):

java结尾包含字符串 java 包含字符串_开发语言_22

 新特性:文本块

"""  """)来表示包含换行符的文本块

【例子:使用文本块】

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));
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_字符串_23

        这种新的文本块方便我们创建大型的文本,其格式更加易读。

        注意:开头的"""后面的换行符会被自动去掉,块中的公用缩进也会被去掉。若想要保留缩减,可以通过移动末尾的"""来达成这一效果:

【例子:文本块中的缩减】

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结尾包含字符串 java 包含字符串_java结尾包含字符串_24

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);
    }
}

        程序执行的结果是:

java结尾包含字符串 java 包含字符串_字符串_25

formatted()方法是一个成员方法,它并不像String.format()一样是静态的。formatted()也可以用于普通字符串,它更清晰。

    文本块的结果就是一个普通字符串,因此任何对普通字符串有用的方法都对它有效。