• 初识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

    1. public class Immutable {
    2. public static String upcase(String s) {
    3. return s.toUpperCase();
    4. }
    5. public static void main(String[] args) {
    6. String s1 = "A string";
    7. String s2 = upcase(s1);
    8. System.out.println("s1: " + s1);
    9. System.out.println("s2: " + s2);
    10. }
    11. }

            程序执行的结果是:

            String对象在传递变量时只会传递引用,其中的内容是不会被复制的

            通过参数创建新的对象,保留原本对象,这种行为就涉及到代码的不变性。当我们使用参数时,我们需要的是获取其中的信息,而不是修改传入的对象。这种不变性能够防止副作用,并且降低代码的读者的理解成本。


    String变量

            官方文档建议我们通过String buffers创建字符串变量。这些变量被开辟在堆上,也因此不会共享同一个对象。

            如上所述,String类的对象是特殊的。在程序运行时,若代码中出现了字符串常量,这些常量(变量则放到堆上)就会被JVM收集到一个字符串池中。代码中存储字符串常量的引用都会指向处于内存池中的同一对象:

    1. public class StringQuote {
    2. public static void main(String[] args) {
    3. String s1 = "字符串常量";
    4. String s2 = "字符串常量";
    5. System.out.println("s1 == s2: " + (s1 == s2));
    6. // 通常,可以通过StringBuffer类创建字符串变量
    7. StringBuffer buffer = new StringBuffer("这是一个字符串变量");
    8. String s3 = "这是一个字符串变量";
    9. System.out.println("buffer是否存在于字符串池中:"
    10. + (buffer.equals(s3))); // 这个equals()方法会比较地址位置
    11. // 除此之外,还有一点值得提及:
    12. String s4 = "Hello";
    13. String s5 = "He";
    14. System.out.println("s4 == s5 + \"llo\": "
    15. + (s4 == s5 + "llo")); // 编译器会对这行语句进行判断,并优化
    16. }
    17. }

            程序执行的结果是:

            通常,我们会把一个字符串常量赋给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,但这次需要更深入一些:

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

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

            程序执行的结果是:

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

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

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

    这段字节码显得更加的简洁了,并且因为性能的优化全部交给了makeConcatWithConstants()方法,因此即使编译器升级,也不用为了更好的性能重新编译代码。

            但是,官方文档中依旧推荐使用StringBuilder,因为在包括循环中的字符串拼接等情况中,StringBuilder可能可以提供更好的性能。

    ------

            理所当然的,编译器对String操作的优化是有限的。下面的例子会通过两种不同的方式生成String对象的实例:

    【例子:不同方式生成String

    1. public class WiththeStringBuilder {
    2. public String implicit(String[] fields) { // 使用+操作符
    3. String result = "";
    4. for (String field : fields)
    5. result += field;
    6. return result;
    7. }
    8. public String explicit(String[] fields) { // 使用StringBuilder
    9. StringBuilder result = new StringBuilder();
    10. for (String field : fields)
    11. result.append(field);
    12. return result.toString();
    13. }
    14. }

            同样,通过javap -c查看JVM字节码(Java 8版本)。这里对输出结果进行了处理,首先是implicit()

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

            然后是explicit()

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

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

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

    因此在实际使用的过程中,我们应该权衡好不同方法之间的利弊。而若涉及循环,并且想要追求更高的性能,那么使用StringBuilder或许是个更好的选择。

     【例子:StringBuilder的一个例子】

    1. import java.util.Random;
    2. import java.util.stream.Collectors;
    3. public class UsingStringBuilder {
    4. public static String string1() {
    5. Random rand = new Random(47);
    6. StringBuilder result = new StringBuilder("[");
    7. for (int i = 0; i < 25; i++) {
    8. result.append(rand.nextInt(100));
    9. result.append(", ");
    10. }
    11. result.delete(result.length() - 2, result.length());
    12. result.append("]");
    13. return result.toString();
    14. }
    15. public static String string2() {
    16. String result = new Random(47)
    17. .ints(25, 0, 100)
    18. .mapToObj(Integer::toString) // 将Integer对象转换为String对象
    19. .collect(Collectors.joining(", "));
    20. return "[" + result + "]";
    21. }
    22. public static void main(String[] args) {
    23. System.out.println(string1());
    24. System.out.println(string2());
    25. }
    26. }

            程序执行的结果是:

            笔者通过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】

    1. import java.util.List;
    2. import java.util.Random;
    3. import java.util.stream.Collectors;
    4. import java.util.stream.Stream;
    5. class Nature {
    6. private String[] strings =
    7. {"Tree", "River",
    8. "Mountain", "Flower",
    9. "Bird", "Cloud",
    10. "Sunset", "Forest",
    11. "Ocean", "Rainbow"};
    12. private static int count = 0;
    13. private static Random random = new Random(47);
    14. public static Nature getNature() {
    15. return new Nature();
    16. }
    17. @Override
    18. public String toString() {
    19. return (count++) +
    20. ": " + strings[random.nextInt(10)];
    21. }
    22. }
    23. public class ArrayListDisplay {
    24. public static void main(String[] args) {
    25. List list = Stream.generate(Nature::getNature)
    26. .limit(10)
    27. .collect(Collectors.toList());
    28. System.out.println(list);
    29. }
    30. }

            程序执行的结果是:

            因为toString()被重写了,所以List中的所有元素的值都能被打印出来。

            但toString()并非完全没有问题(即使它被进行了重写)。假若我们想要打印某一数据的地址,并为此使用了this。这种做法看起来合理,因为this是一个引用:

    【例子:有问题的thistoString()

    1. import java.util.stream.Stream;
    2. public class InfiniteRecursion {
    3. @Override
    4. public String toString() {
    5. return
    6. "InfiniteRecursion对象的地址:"
    7. + this + "\n";
    8. }
    9. public static void main(String[] args) {
    10. Stream.generate(InfiniteRecursion::new)
    11. .limit(10)
    12. .forEach(System.out::println);
    13. }
    14. }

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

            这是因为我们使用+操作符拼接了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()

    所复制字符串的开始索引intString中的字符复制到目标数组中
    所复制字符串的结束索引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()正则表达式(StringString与正则表达式匹配,返回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()方法。例如:

    1. public class SimpleFormat {
    2. public static void main(String[] args) {
    3. int x = 5;
    4. double y = 1.14514;
    5. // 旧的方法:
    6. System.out.println("打印数据中... [" + x + " " + y + "]");
    7. // 新的方法:
    8. System.out.format("打印数据中... [%d %.5f]%n", x, y);
    9. // 或者:
    10. System.out.printf("打印数据中... [%d %.5f]%n", x, y);
    11. }
    12. }

            程序执行的结果是:

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


    Formatter

            Java中的所有格式化功能最终都由java.util包中的Formatter类处理。我们输入格式化字符串,然后Formatter将其转换为我们需要的。例如:

    【例子:通过Formatter转换为我们需要的结果】

    1. import java.io.PrintStream;
    2. import java.util.Formatter;
    3. public class Turtle {
    4. private String name;
    5. private Formatter f;
    6. public Turtle(String name, Formatter f) {
    7. this.name = name;
    8. this.f = f;
    9. }
    10. public void move(int x, int y) {
    11. f.format("箭头【%s】现在位于(%d, %d)%n",
    12. name, x, y);
    13. }
    14. public static void main(String[] args) {
    15. PrintStream outprint = System.out;
    16. // Formatter类的构造器允许我们指定信息输出
    17. Turtle t1 = new Turtle("壹",
    18. new Formatter(System.out));
    19. Turtle t2 = new Turtle("贰",
    20. new Formatter(outprint));
    21. t1.move(0, 0);
    22. t2.move(2, 2);
    23. t1.move(2, 6);
    24. t2.move(1, 7);
    25. t1.move(4, 0);
    26. t2.move(3, 3);
    27. }
    28. }

            程序执行的结果是:


    格式说明符

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

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

    这里介绍widthprecision

    • width:用于控制一个字段的最小长度,长度不足时用空格填充。
    • precision:用于指定字段长度的最大值,这一标识对不同类型有不同含义:
      • 对字符串:限制字符串的最大输出字符数。
      • 对浮点数:指定要显示的小数位数。
      • 不允许对整数使用precision(否则会抛出异常)。

    【例子:打印购物收据】

    1. import java.util.Formatter;
    2. // 使用生成器模式构建程序
    3. public class ReceiptBuilder {
    4. private double total = 0;
    5. private Formatter f =
    6. new Formatter(new StringBuilder());
    7. public ReceiptBuilder() {
    8. f.format(
    9. "%-15s %4s %9s%n", "物品", "数量", "价格");
    10. f.format(
    11. "%-15s %6s %10s%n", "----", "---", "-----");
    12. }
    13. public void add(String name, int qty, double price) {
    14. f.format("%-15.15s %5d %10.2f%n", name, qty, price);
    15. total += price + qty;
    16. }
    17. public String build() {
    18. f.format("%-15.15s %5s %10.2f%n", "税款", "", total * 0.06);
    19. f.format("%-15s %6s %10s%n", "", "", "-----");
    20. f.format("%-15.15s %5s %10.2f%n", "总额", "", total * 1.06);
    21. return f.toString();
    22. }
    23. public static void main(String[] args) {
    24. ReceiptBuilder receiptBuilder =
    25. new ReceiptBuilder();
    26. receiptBuilder.add("衬衫", 4, 15.9);
    27. receiptBuilder.add("棉袄", 2, 24.5);
    28. receiptBuilder.add("风帽", 1, 6.89);
    29. System.out.printf(receiptBuilder.build());
    30. }
    31. }

            程序执行的结果是:

        生成器模式:创建一个起始对象,然后向其中添加内容,最后通过build()生成结果。

            将一个StringBuilder传递给Formatter构造器,这样就初始化了一个Formatter对象。之后添加的内容都会被储存在这个StringBuilder对象中。


    Formatter转换

            简单介绍一下常用的转换字符。

    字符效果
    d整数类型(十进制表示)
    cUnicode字符
    bBoolean值
    s字符串
    f浮点数(十进制表示)
    e浮点数(科学记数法表示)
    x整数类型(十六进制表示)
    h哈希码(十六进制表示)
    %字面量“%

            其中,转换字符b可以适用于任何类型的变量。尽管如此,但其的行为会因为对应参数类型的不同而发生变化:

    【例子:转换字符b的使用例】

    1. public class Conversion {
    2. public static void main(String[] args) {
    3. boolean b = false;
    4. System.out.printf("b = %b%n", b);
    5. int i = 0;
    6. System.out.printf("i = %b%n", i); // 注意,此处的i是0。但打印结果依旧为true
    7. char[] c = null;
    8. System.out.printf("c = %b%n", c); // 只有当参数值为null时,才会打印false
    9. }
    10. }

            程序执行的结果是:

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


    String.format()

            Java 5提供了一个用来创建字符串的方法:String.format()。它是一个静态方法,参数与Formatter中的format()方法完全相同,但返回一个String。

    【例子1:使用String.format()

    1. public class DatabaseException extends Exception {
    2. public DatabaseException(int transactionID,
    3. int queryID, String message) {
    4. super(String.format("(t%d, q%d) %s",
    5. transactionID, queryID, message));
    6. }
    7. public static void main(String[] args) {
    8. try {
    9. throw new DatabaseException(3, 7, "一个错误发生了");
    10. } catch (Exception e) {
    11. System.out.println(e);
    12. }
    13. }
    14. }

            程序执行的结果是:

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

    ---

    【例子2:转储为十六进制】

            这个例子会将二进制文件中的字节格式化为十六进制,并进行输出。

    1. import java.nio.file.Files;
    2. import java.nio.file.Paths;
    3. public class Hex {
    4. public static String format(byte[] data) {
    5. StringBuilder result = new StringBuilder();
    6. int n = 0;
    7. for (byte b : data) {
    8. if (n % 16 == 0)
    9. result.append(String.format("%05X: ", n));
    10. result.append(String.format("%02X ", b));
    11. n++;
    12. if (n % 16 == 0)
    13. result.append("\n");
    14. }
    15. result.append("\n");
    16. return result.toString();
    17. }
    18. public static void main(String[] args)
    19. throws Exception {
    20. if (args.length == 0) // 若没有外来输入,则将本文件作为测试数据
    21. System.out.println(
    22. format(Files // readAllBytes():以byte数组的形式返回整个文件
    23. .readAllBytes(Paths.get("Hex.java")))
    24. );
    25. else
    26. System.out.println(
    27. format(Files.readAllBytes(Paths.get(args[0])))
    28. );
    29. }
    30. }

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

     新特性:文本块

            JDK 15添加了文本块,这一特性通过使用三对双引号("""  """)来表示包含换行符的文本块

    【例子:使用文本块】

    1. public class TextBlocks {
    2. public static final String OLD =
    3. "好运来 祝你好运来\n" +
    4. "好运带来了喜和爱\n" +
    5. "好运来 我们好运来\n" +
    6. "迎着好运兴旺发达通四海\n"; // 节选自《好运来》
    7. public static final String NEW = """
    8. 好运来 祝你好运来
    9. 好运带来了喜和爱
    10. 好运来 我们好运来
    11. 迎着好运兴旺发达通四海
    12. """;
    13. public static void main(String[] args) {
    14. System.out.println(OLD.equals(NEW));
    15. }
    16. }

            程序执行的结果是:

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

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

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

    1. public class Indentation {
    2. public static final String NONE = """
    3. XXX
    4. XXX
    5. XXX
    6. """; // 没有缩进
    7. public static final String TWO = """
    8. XXX
    9. XXX
    10. XXX
    11. """; // 两个空格的缩进
    12. public static final String EIGHT = """
    13. XXX
    14. XXX
    15. XXX
    16. """; // 八个空格的缩进
    17. public static void main(String[] args) {
    18. System.out.println(NONE);
    19. System.out.println(TWO);
    20. System.out.println(EIGHT);
    21. }
    22. }

            程序执行的结果是:

            另外,为了支持文本块,Java向String类中添加了一个新的formatted()方法:

    1. public class DataPoint {
    2. private String location;
    3. private Double temperature;
    4. public DataPoint(String loc, Double temp) {
    5. location = loc;
    6. temperature = temp;
    7. }
    8. @Override
    9. public String toString() {
    10. return """
    11. Location: %s
    12. Temperature: %.2f
    13. """.formatted(location, temperature);
    14. }
    15. public static void main(String[] args) {
    16. var D1 = new DataPoint("D1", 11.4);
    17. var D2 = new DataPoint("D2", 5.14);
    18. System.out.println(D1);
    19. System.out.println(D2);
    20. }
    21. }

            程序执行的结果是:

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

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

  • 相关阅读:
    FastDFS分布式文件系统搭配nginx的安装、配置与使用
    [AGC058D]Yet Another ABC String
    专业/户籍不限!腾讯/华为招聘提到的PMP证书!多行业适用
    【string题解 C++】字符串相乘 | 翻转字符串III:翻转单词
    2023年【氧化工艺】考试内容及氧化工艺操作证考试
    洛谷刷题C语言:梦中的统计、统计天数、语句解析、爱与愁的心痛、西游记公司
    BeanFactory、FactoryBean和ObjectFactory、ApplicationContext的入门理解
    idea打开hierarchy面板
    文档管理系统对会计部门的重要性
    Java--集合框架详解
  • 原文地址:https://blog.csdn.net/w_pab/article/details/134015444