你了解过JDK8新出的处理字符串的类吗?

面试经历

面试官:你了解过 JDK8 新出的处理字符串的类吗?

:芭比芭比,芭比歪脖?阿巴阿巴阿巴...

在没有准备到这题的情况下,被突然的问起这样一个问题,还是挺郁闷的,一时不知道回答什么?

但是看完源码之后的我:就这?就这?

很多时候,及早的准备可以帮助你更快的理解面试官想问什么,帮助我们在面试时回答出面试官想要听到的答案。

审题

第一,JDK8新出的,那不就是 @since 1.8

since 1.8

第二,处理...的类,不就是工具类吗?工具类会在 JDK 包的什么位置呢?

这个关键词就暗示我们,这个类应该在 java.util 包或者其子包下面。

第三,字符串,不就是 String 吗?

所以,这个类名中应该包含 String 这个关键字。

---------- 华丽的分割线 ----------

如果碰巧你手头有个 Intellij IDEA, 碰巧又打开了一个 JDK8 的 Java 项目,那么你看到 Project 的 External Libraries 的第一个项展开,找到 rt.jar,点击展开。

再依次展开 javautil,翻一翻看一看,以 String 开头的类,不就是 StringJoiner 吗?

哦,面试官是想考我们 StringJoiner 啊!

1. StringJoiner 简介

StringJoiner 用于构造由分隔符分隔的字符序列,可以选择以提供的前缀开头,以提供的后缀结尾。

比如,"[George:Sally:Fred]" 这个字符串的分隔符是 :,前缀是 [,后缀是 ]。构造这个字符序列的代码如下:

StringJoiner sj = new StringJoiner(":", "[", "]");
sj.add("George").add("Sally").add("Fred");
String desiredString = sj.toString();

这一段简介是通过查阅 StringJoiner 类文件上的注释翻译得到的。

2. 读源码

接下来读一下核心源代码:

public final class StringJoiner {
    /* 前缀,以该前缀作为开头 */
    private final String prefix;
    /* 分隔符,多个元素之间用分隔符分隔 */
    private final String delimiter;
    /* 后缀,以该后缀作为结尾 */
    private final String suffix;

    /*
     * 情况一:等于 null,表示没有添加过元素
     * 情况二:等于 前缀+分隔字符序列,但是不含后缀
     * value 不含后缀的原因:不用每次添加元素前都修改后缀
     */
    private StringBuilder value;
    
    private String emptyValue;
    
    public StringJoiner(CharSequence delimiter,
                        CharSequence prefix,
                        CharSequence suffix) {
        Objects.requireNonNull(prefix, "The prefix must not be null");
        Objects.requireNonNull(delimiter, "The delimiter must not be null");
        Objects.requireNonNull(suffix, "The suffix must not be null");
        // make defensive copies of arguments
        // 从 CharSequence 实例对象复制生成不可变对象 String
        this.prefix = prefix.toString();
        this.delimiter = delimiter.toString();
        this.suffix = suffix.toString();
        // 初始化 emptyValue 为 前缀 + 后缀
        this.emptyValue = this.prefix + this.suffix;
    }

    public StringJoiner add(CharSequence newElement) {
        // 添加一个新的元素
        prepareBuilder().append(newElement);
        return this;
    }

    private StringBuilder prepareBuilder() {
        /*
         * 条件成立,表示至少已经包含一个元素
         * 比如此时已经调用过一次 add,现在是 value.toString 等于 [George
         *
         * 条件不成立:value == null,此次是第一次准备添加元素
         */
        if (value != null) {
            // 追加一个分隔符,为继续添加元素做“准备”
            value.append(delimiter);
        } else {
             /*
             * 首次添加元素时,需要创建 StringBuilder 对象,并且先追加前缀 prefix
             * 比如,当前缀是 [ ,执行完成下面这条语句后,value.toString 等于 [
             */
            value = new StringBuilder().append(prefix);
        }
        return value;
    }

    @Override
    public String toString() {
        /*
         * 条件成立,表示至少已经包含一个元素
         * 比如此时已经调用过一次 add,现在是 value.toString 等于 [George
         *
         * 条件不成立:value == null,此次是第一次准备添加元素
         */
        if (value == null) {
            // 如果没有调用 setEmptyValue,此时 emptyValue 等于构造函数中的初始化结果,等于前缀 + 后缀
            // 比如,以本文的例子,返回的是 []
            return emptyValue;
        } else {
            // 后缀为空字符串
            if (suffix.equals("")) {
                // 此时不需要在 value 基础上添加后缀再输出,因此直接返回 value.toString
                return value.toString();
            } else {
                // 预先保存不含后缀时 value 的长度
                int initialLength = value.length();
                /*
                 * 先给 value 追加后缀 suffix,
                 * 再 toString 得到 以前缀开头,后缀结尾,且以分隔符分隔的字符串,
                 * 最后赋值给 result
                 */
                String result = value.append(suffix).toString();
                // reset value to pre-append initialLength
                /*
                 * 隐藏条件: initialLength < count
                 * 我们知道 StringBuilder 底层是 char[],此处减小 value 的实际长度
                 * 这样后续继续添加元素时,会覆盖刚刚追加的 suffix
                 */
                value.setLength(initialLength);
                return result;
            }
        }
    }
}

再看一下 StringJoiner 如何和另一个 StringJoiner 对象合并:

public StringJoiner merge(StringJoiner other) {
    Objects.requireNonNull(other);
    if (other.value != null) {
        final int length = other.value.length();
        // lock the length so that we can seize the data to be appended
        // before initiate copying to avoid interference, especially when
        // merge 'this'
        StringBuilder builder = prepareBuilder();
        // 合并另一个 StringJoiner 时,只截取它的由分隔符分隔的字符序列部分,舍弃它的前缀和后缀
        builder.append(other.value, other.prefix.length(), length);
    }
    return this;
}

3. StringJoiner 是非线程安全的类

在测试 Java 多线程时,最好加上日志框架,这样打印信息内容比较全面,包含线程信息。

3.1 加入日志框架

为了能够更好的看到输出结果,建议修改 Maven 项目,pom.xml 的 <project> 加入以下依赖:

<dependencies>
    <!-- slf4j: 提供日志的 API -->
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>${slf4j.version}</version>
    </dependency>
    <!-- logback 日志实现核心包 -->
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-core</artifactId>
        <version>${logback.version}</version>
    </dependency>
   <!-- slf4j 和 logback 的经典整合 -->
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>${logback.version}</version>
    </dependency>

</dependencies>

pom.xml 的 <project> 再加入以下属性:

<properties>
    <lombok.version>1.18.16</lombok.version>
    <logback.version>1.2.3</logback.version>
</properties>

新增 logback.xml 加入到 src/main/resources 中:

<configuration>
	<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
		<encoder>
			<Pattern>
				%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n
			</Pattern>
		</encoder>
	</appender>


	<!-- 注意,我此处设置的 name 为 "demo" -->
	<logger name="demo" level="debug" additivity="false">
		<appender-ref ref="STDOUT"/>
	</logger>

	<root level="error">
		<appender-ref ref="STDOUT"/>
	</root>

</configuration>

3.2 实验源代码
证明代码如下:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;

public class StringJoinerMain {

    private static Logger logger = LoggerFactory.getLogger("demo");

    public static void main(String[] args) throws InterruptedException {
        StringJoiner joiner = new StringJoiner(",", "[", "]");
        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            final int val = i;
            Thread thread = new Thread(() -> {
                joiner.add(val + "");
                logger.info(joiner.toString());
            });
            threads.add(thread);
        }

        threads.forEach(Thread::start);

        for (Thread thread : threads) {
            thread.join();
        }
    }
}

3.3 实验一

某一次实验的输出结果:

观察结果:
Thread-0 和 Thread-1 比较不符合我们的期望,其他的还算“可以理解”。

3.3.1 写写并发

add 方法是 StringJoiner 的写入方法,我们来看一下写写并发的情况:
我们看 Thread-0Thread-1 的输出结果都包含 [,01
add 方法先要调用 prepareBuilder 方法获得 StringBuilder,再调用 append 增加元素。
add 方法是非同步方法的,显然是可以被打断的。

add 方法不是线程安全的,也就是说 StringJoiner 并发写入时不是线程安全的。

3.3.2 读读并发

toString 方法是 StringJoiner 的读取方法(也不完全算,因为底层实际上是有写操作的,但是最后还是恢复了。这是光看名字感觉只是个读方法),我们来看一下读读并发的情况:
我们可以发现 Thread-1 的输出结果中结尾有 2 个 ]
StringJoiner 的 toString 方法不是同步方法,所以多线程并发执行时,以下两行代码是可能出现交替执行的情况的:

String result = value.append(suffix).toString();
// reset value to pre-append initialLength
value.setLength(initialLength);

add 方法先要调用 append 方法获得追加后缀,再调用 setLength 将 value 恢复到没有追加后缀前的状态。
toString 方法是非同步方法的,显然是可以会出现交替执行的情况。

3.4 实验二

3.4.1 读写并发

这个地方有个比较费解的地方就是为啥从 Thread-2 开始,value 值中就存在“空格”了?

public class WhyBlankExists {

    public static void main(String[] args) {
        StringBuilder value = new StringBuilder();
        // Thread-1
        value.append("[0,]1]");
        // Thread-0
        value.setLength(3);
        // Thread-1
        value.setLength(5);
        // Thread-2
        value.append(",");
        value.append("2");
        System.out.println(value);
    }
}

输出结果:

因为 setLength(3) 调用了 Arrays.fill(value, count, newLength, '\0'); 将 StringBuilder 底层的 char[] 数组中的 ']','1',']' 都改为了 '\0':这个字符在ASCii中表示空:

然后再调用 setLenght(5),表示接下来 append(",") 从 char[] 数组下标 5 开始。

感谢掘金网友 Rickyu 的指正,该小节已更新

4. 应用

4.1 和 Stream.collect 一起使用:

List<String> users = Arrays.asList("George", "Sally", "Fred");
String commaSeparatedUsers = users.stream()
        .collect(Collectors.joining(",", "[", "]"));
System.out.println(commaSeparatedUsers);

[George,Sally,Fred]

Collectors.joining 源码:

public static Collector<CharSequence, ?, String> joining(CharSequence delimiter,
                                                         CharSequence prefix,
                                                         CharSequence suffix) {
    // 参数一:创建新的结果容器 StringJoiner,容器用来存放 CharSequence 元素
    // 参数二:将新的 CharSequence 元素合并到结果容器
    // 参数三:将两个结果容器 StringJoiner 合并为一个
    // 参数四:在容器上执行可选的最终转换,将 StringJoiner 转换为 字符串 String
    // 参数五:表示此收集器 Collector 的特性。这个集合是不可变的。
    return new CollectorImpl<>(
            () -> new StringJoiner(delimiter, prefix, suffix),
            StringJoiner::add, StringJoiner::merge,
            StringJoiner::toString, CH_NOID);
}

4.2 String.join

List<String> users = Arrays.asList("George", "Sally", "Fred");
String commaSeparatedUsers = String.join(",", users);
System.out.println(commaSeparatedUsers);

George,Sally,Fred

5. 与面试官的千层博弈

我在第一层:聊概念

  • 工具类 StringJoiner 的作用是帮助我们返回一个由分隔符分隔的字符序列,可以选择以提供的前缀开头,以提供的后缀结尾。

第二层:聊 API

  • StringJoiner 可以在构造函数中指定分隔符,前缀和后缀。可以通过 add 方法添加序列中的元素,可以通过 toString 输出结果,还可以通过 merge 把另一个 StringJoiner 中的元素拼接过来。

第三层:聊底层

  • StringJoiner 的底层运用的是 StringBuilder 作为 value。它有两种情况,一种是 null,表示没有添加过元素;另一种是前缀 + 由分隔符分隔的字符序列。

第四层:聊应用
JDK8用到 StringJoiner 的地方

  • Stream#collect(Collector) 方法可以传入 Collectors#joining(delimiter, prefix, suffix)

  • String.join 方法可以输出由分隔符分隔的字符序列,用的也是 StringJoiner,其中前缀和后缀均为空字符串("")

第五层:预测面试提问走向

  • 面试官接下来可能问 StringBuilder 的问题,也可能问 Stream + Lambda,建议一起准备。

喜欢这篇文章,不妨动动小手点点赞,你的点赞是我继续创作的动力!

posted @   极客子羽  阅读(385)  评论(0编辑  收藏  举报
编辑推荐:
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
点击右上角即可分享
微信分享提示