初识Java 16-1 字符串

目录

字符串的常量和变量

不变的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字节码:

初识Java 16-1 字符串_第1张图片

注意:字符串常量一经确定,就不能更改。这里需要注意的是第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下的字节码:

初识Java 16-1 字符串_第2张图片

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

初识Java 16-1 字符串_第3张图片

        这里还有一个题外话,就是在上一个小节中出现的makeConcatWithConstants()方法。JDK 9最终决定使用invokedynamic命令调用makeConcatWithConstants()替代原本的StringBuilder,因此如果在JDK 8以上版本通过javap -c指令查看本例的字节码,会发现不同:

初识Java 16-1 字符串_第4张图片

这段字节码显得更加的简洁了,并且因为性能的优化全部交给了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 16-1 字符串_第5张图片

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

        然后是explicit()

初识Java 16-1 字符串_第6张图片

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

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

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

初识Java 16-1 字符串_第7张图片

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

【例子:有问题的thistoString()

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 16-1 字符串_第8张图片

        这是因为我们使用+操作符拼接了this,触发了字符串的自动类型转换:

"InfiniteRecursion对象的地址:" + this

+连接了String之外的对象时,编译器会试图寻找并使用这个对象的toString(),但thistoString()又是这个有thistoString(),然后编译器又进入下一层toString()。换言之,上述代码因为this的存在而在不断进行递归。

    若确实需要打印对象的地址,可以直接调用ObjecttoString()方法来实现。上述的例子可以这么做:

"地址:" + 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);
    }
}

        程序执行的结果是:

初识Java 16-1 字符串_第9张图片


格式说明符

        可以更详细地描述格式说明符,以达到对格式的精确控制。描述字符和数值的format格式基本如下:

%[argument_index$][flags][width][.precision]conversion // 可查看文档获取详细信息

这里介绍widthprecision

  • 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 16-1 字符串_第10张图片

    生成器模式:创建一个起始对象,然后向其中添加内容,最后通过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的行为产生的结果是对应的truefalse。但对任何其他类型而言,只要值不为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 16-1 字符串_第11张图片

        另外,为了支持文本块,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);
    }
}

        程序执行的结果是:

初识Java 16-1 字符串_第12张图片

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

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

你可能感兴趣的:(Java,java,开发语言,1024程序员节)