你了解过JDK8新出的处理字符串的类吗?
面试经历
面试官:你了解过 JDK8 新出的处理字符串的类吗?
我:芭比芭比,芭比歪脖?阿巴阿巴阿巴...
在没有准备到这题的情况下,被突然的问起这样一个问题,还是挺郁闷的,一时不知道回答什么?
但是看完源码之后的我:就这?就这?
很多时候,及早的准备可以帮助你更快的理解面试官想问什么,帮助我们在面试时回答出面试官想要听到的答案。
审题
第一,JDK8新出的,那不就是 @since 1.8
第二,处理...的类,不就是工具类吗?工具类会在 JDK 包的什么位置呢?
这个关键词就暗示我们,这个类应该在 java.util
包或者其子包下面。
第三,字符串,不就是 String 吗?
所以,这个类名中应该包含 String 这个关键字。
---------- 华丽的分割线 ----------
如果碰巧你手头有个 Intellij IDEA, 碰巧又打开了一个 JDK8 的 Java 项目,那么你看到 Project 的 External Libraries 的第一个项展开,找到 rt.jar,点击展开。
再依次展开 java 和 util,翻一翻看一看,以 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-0 和 Thread-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,建议一起准备。
喜欢这篇文章,不妨动动小手点点赞,你的点赞是我继续创作的动力!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开发者必知的日志记录最佳实践
· 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