Java 中字符串的进化之路
字符串作为 Java 中使用最为广泛的,自诞生之初就经历了不断地演进和优化,本篇文章跟着大明哥一起来探索 Java 字符串的进化之路,一起来领略字符串是如何茁壮成长的。
Java 1.0:诞生之初
在 Java 诞生之初,字符串是通过 char
类型的数组实现的,char
类型在当时是固定为 16 位的,用于表示 UTF-16
编码的字符。
字符串从一开始就设计为不可变的,这意味着一旦一个字符串对象被创建,那么它的内容就不能更改。如果需要修改字符串,实际上是创建了一个新的字符串对象。这种设计有几个优点:
- 简化的内存管理
- 提高字符串操作的效率
- 允许字符串常量池的存在
字符串常量池,是一个特殊内存区域,用于存储字符串字面量。它允许相同的字符串字面量共享同一个存储区域。当编译器遇到字符串字面量时,它会首先检查池中是否已存在相同的字符串。如果存在,就重用该实例;如果不存在,则创建一个新的字符串实例。这种方式减少了内存的占用,因为相同内容的字符串不会被多次创建。
Java 1.0,属于字符串的诞生,功能还比较简单。
刚刚开始,字符串拼接还只能使用 +
来实现,虽然这种方式简单直观且易用,但是性能不是很好,尤其是在需要拼接大量字符串时。所以,Java 引入 StringBuffer
来处理可变字符串。
StringBuffer
允许在现有字符串上进行修改,而无需创建新的字符串对象,这在处理需要频繁变更的字符串数据时是非常有用的。但由于要考虑线程安全问题,StringBuffer
的所有公共方法都是同步的,导致在性能上有点儿缺陷。
Java 5:引入 StringBuilder
Java 1.0 引入 StringBuffer
,虽然它解决了字符串修改的性能问题,但由于它的每个公开方法都是同步的,导致它的性能欠佳。为了解决性能问题,Java 5 引入 StringBuilder
。
StringBuilder
与 StringBuffer
类似,都允许在现有字符串上修改,而无需创建新的字符串对象,但是 StringBuilder
不是线程安全的,在单线程环境下 StringBuilder
比 StringBuffer
更加高效。
Java 7:改进字符串常量池
为了避免同一个字符串被多次创建,Java 1.0 引入字符串常量池,旨在减少字符串对内存的占用。在 Java 7 之前,它位于Java堆中的“永久代”(PermGen),但是永久代的空间是有限的(永久代默认大小随平台不同在32M和96M间变动,我们也可以使用-XX:MaxPermSize=N
来增加其大小),这意味着字符串常量池的大小也是有限的,当应用程序使用大量字符串时,永久代可能会被填满,从而导致OutOfMemoryError
。
为了解决这个问题,Java 7 将字符串常量池移到 Java 堆中,这样做的目的有如下几个:
- 扩大字符串常量池的大小:将字符串常量池移动到堆内存中,意味着它的大小不再受限于永久代的大小,而是由整个堆的大小决定。这允许更多的字符串被缓存,从而减少内存的使用。
- 优化垃圾回收:将字符串池放入堆内存中,意味着字符串池中的字符串可以通过常规的垃圾回收机制来管理和清理,而不需要特殊的处理。
Java 7 的改进提高了字符串的处理效率,也使得内存管理变得更加灵活和高效。
Java 9:底层存储结构变更
从 Java 诞生之初,一直到 Java 9 ,String
的底层一直都是用 char[]
来存储的:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
//The value is used for character storage.
private final char value[];
}
每个 char
都以 2 个字节存储在内存中,这本身其实是没什么问题的。但是 Oracle 的 JDK 开发人员调研了成千上万个应用程序的 heap dump
信息,他们注意到大多数字符串都是以 Latin-1
字符编码表示的,然而 Latin-1
只需要一个字节存储就够了,两个字节完全是浪费,这比 char
数据类型存储少 50%(1 个字节)了。有这么大的提升。所以,在 Java 9 中,他们将 String
的底层存储结构调整为 byte[]
:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence,
Constable, ConstantDesc {
@Stable
private final byte[] value;
private final byte coder;
}
Java 9 这样调整的目的是减小字符串的内存占用,这样带来了两个好处:
- 节省内存:对于包含大量
ASCII
字符的字符串,内存占用大幅减少,因为每个字符只占用一个字节而不是两个字节。 - 提高性能:由于字符串的存储结构与编码方式更加紧凑,字符串操作的性能也有所提高。
需要注意的是,这仅仅只是底层数据结构的变化,对于我们上层调用者完全是透明的,不会有任何影响,String
的方法以前怎么使用,现在还是怎么使用,例如:
public class StringTest {
public static void main(String[] args) {
String skString1 = "skjava";
String skString2 = "死磕Java";
System.out.println(skString1.charAt(0));
System.out.println(skString2.charAt(0));
}
}
// 结果......
s
死
charAt()
源码如下:
public char charAt(int index) {
if (isLatin1()) {
return StringLatin1.charAt(value, index);
} else {
return StringUTF16.charAt(value, index);
}
}
isLatin1()
用于判断编码格式是否为 Latin-1
字符编码,如果是则调用 StringLatin1.charAt()
,否则调用 StringUTF16.charAt()
。
这里为什么要判断字符编码呢?因为 Latin-1
字符编码也称 ISO 8859-1
,它包括了拉丁字母(包括西欧、北欧和南欧语言的字母)以及一些常见的符号和特殊字符,但是它并不支持其他非拉丁字母的语言,例如希腊语、俄语或中文,对于这些我们只能使用其他字符编码了。
在 Java 9 中,String
支持的字符编码格式有两种:
Latin-1
:Latin-1
编码用于存储只包含拉丁字符的字符串。它采用了一字节编码,每个字符占用一个字节(8位)。UTF-16
:UTF-16
编码用于存储包含非拉丁字符的字符串,以及当字符串包含不适合Latin-1
编码的字符时。
在 Java 9 中,String
多了一个成员变量 coder
,它代表编码的格式,0 表示 Latin-1
,1 表示 UTF-16
,我们在看 skString1
和 skString2
:
从这张图可以清晰地看到 “skjava”
的字符编码是 Latin-1
,而 “死磕Java”
的字符编码则是 UTF-16
。不同的字符编码选择不同的方法来获取。其实你看下 String 里面的方法都是这种模式。
所以,Java 9 中的 String 使用
Latin-1
和UTF-16
两种字符编码方式,根据字符串的内容来选择合适的编码格式,以便在内部存储时提高效率。
但是,有小伙伴就喜欢硬扛,我就不喜欢 Latin-1
,可以完全用UTF-16
么 ?可以。Java 满足你的一切不合理的要求。
-XX:-CompactStrings
:禁用精简字符串特性。
- 如果启用
Compact Strings
(默认情况),JVM 会根据字符串的内容来选择Latin-1
还是UTF-16
,以在内存中有效地存储字符串,减小内存占用。 - 如果禁用
Compact Strings
(使用-XX:-CompactStrings
),JVM 将始终使用UTF-16
编码来存储字符串。
一般来说,我们是不需要显式设置 -XX:-CompactStrings
,开启 Compact Strings
能够帮组我们节约内存和提高性能。
Java 13:引入文本块
对于类似于 HTML、XML 格式的字符串,在 Java 13 之前我们一般都是采用这种方式来拼接:
String html = "<html>" +
"<body>" +
"<p>skjava.com</p>" +
"</body>" +
"</html>";
当然你说你会采用 StringBuilder 之类的,但是本质还是一样的。这种方式的缺点是不仅使难以阅读,也增加了维护的难度。而且如果字符串中包括了特殊字符,还需要转义,处理起来非常麻烦。
所以为了解决这种多行字符串处理的复杂性,提高代码的清晰度和开发效率,Java 13 引入文本块。
文本块是Java中的一个新的字符串字面量,它通过使用三个双引号(
"""
)来标记字符串的开始和结束,允许字符串跨越多行而无需显式的换行符或字符串连接。
例如上面的例子使用文本块可以写成:
String html = """
<html>
<body>
<p>skjava.com</p>
</body>
</html>
""";
这样写就比上面的那种拼接的方式更加直观和简洁了。
使用文本块具备如下几个优势:
- 多行字符串的简化:在以往,编写多行字符串时,需要通过使用
\n
来实现换行,或者通过+
来连接多个字符串。而使用文本块则让他们变得非常简单。 - 自动格式化和缩进处理:采用自动拼接的方式,是无法格式化和缩进处理的,而使用文本块则会自动处理字符串的格式化和缩进,它是基于字符串的起始位置来确定缩进级别。这就意味着代码的可读性得到了极大的提升,同时也保持了字符串内容的预期格式。
- 方便的处理特殊字符:在以前对于特殊字符我们是需要进行转义处理的,但使用文本块后,就不再需要对字符串中的特殊字符进行转义了。
所以,文本块特别适合处理多行文本数据,极大地简化了多行字符串的处理。
同时,在 Java 14 中得到增强,新加入了两个转义符,分别是:\
和 \s
:
\
** (行尾转义符)**: 这个转义符用于去除行尾换行符,使得代码中的文本块和实际字符串之间可以有更好的对齐,同时不在字符串的内容中包含不必要的换行符。\s
** (空格转义序列)**: 这个转义符用于在需要的地方显式添加尾随空格,这在某些格式化文本中是必要的。
文本块在 Java 15 中成为正式特性。
Java 21:引入字符串模板
有这么一个字符串 “1 + 2 = 3
”,这个怎么实现?对 Java 很熟练的小伙伴可能会有如下四种方法:
- 使用
+
进行字符串拼接
String s = x + " + " + y + " = " + (x + y);
- **使用 **
StringBuilder
String s = new StringBuilder()
.append(x)
.append(" + ")
.append(y)
.append(" = ")
.append(x + y)
.toString()
String::format
** 和String::formatted
将格式字符串从参数中分离出来**
String s = String.format("%2$d + %1$d = %3$d", x, y, x + y);
or
String s = "%2$d + %1$d = %3$d".formatted(x, y, x + y);
java.text.MessageFormat
String s = MessageFormat.format("{0} + {1} = {2}", x,y, x + y);
这四种方案虽然都可以解决,但很遗憾的是他们或多或少都有点儿缺陷,尤其是面对 Java 13 引入的文本块(Java 13 新特性—文本块)更是束手无措。
那怎么解决呢?Java 21 引入字符串模版来解决这种字符串格式化的问题。
字符串模板
为了简化字符串的构造和格式化,Java 21 引入字符串模板功能,该特性主要目的是为了提高在处理包含多个变量和复杂格式化要求的字符串时的可读性和编写效率。
它的设计目标是:
- 通过简单的方式表达混合变量的字符串,简化 Java 程序的编写。
- 提高混合文本和表达式的可读性,无论文本是在单行源代码中(如字符串字面量)还是跨越多行源代码(如文本块)。
- 通过支持对模板及其嵌入式表达式的值进行验证和转换,提高根据用户提供的值组成字符串并将其传递给其他系统(如构建数据库查询)的 Java 程序的安全性。
- 允许 Java 库定义字符串模板中使用的格式化语法(java.util.Formatter ),从而保持灵活性。
- 简化接受以非 Java 语言编写的字符串(如 SQL、XML 和 JSON)的 API 的使用。
- 支持创建由字面文本和嵌入式表达式计算得出的非字符串值,而无需通过中间字符串表示。
该特性处理字符串的新方法称为:Template Expressions,即:模版表达式。它是 Java 中的一种新型表达式,不仅可以执行字符串插值,还可以编程,从而帮助开发人员安全高效地组成字符串。此外,模板表达式并不局限于组成字符串——它们可以根据特定领域的规则将结构化文本转化为任何类型的对象。
STR 模板处理器
STR
是 Java 平台定义的一种模板处理器。它通过用表达式的值替换模板中的每个嵌入表达式来执行字符串插值。使用 STR 的模板表达式的求值结果是一个字符串。
STR
是一个公共静态 final 字段,会自动导入到每个 Java 源文件中。
我们先看一个简单的例子:
@Test
public void STRTest() {
String sk = "死磕 Java 新特性";
String str1 = STR."\{sk},就是牛";
System.out.println(str1);
}
// 结果.....
死磕 Java 新特性,就是牛
上面的 STR."\{sk},就是牛"
就是一个模板表达式,它主要包含了三个部分:
- 模版处理器:
STR
- 包含内嵌表达式(
\{blog}
)的模版 - 通过
.
把前面两部分组合起来,形式如同方法调用
当模版表达式运行的时候,模版处理器会将模版内容与内嵌表达式的值组合起来,生成结果。
这个例子只是 STR模版处理器一个很简单的功能,它可以做的事情有很多。
- 数学运算
比如上面的 x + y = ?
:
@Test
public void STRTest() {
int x = 1,y =2;
String str = STR."\{x} + \{y} = \{x + y}";
System.out.println(str);
}
这种写法是不是简单明了了很多?
- 调用方法
STR模版处理器还可以调用方法,比如:
String str = STR."今天是:\{ LocalDate.now()} ";
当然也可以调用我们自定义的方法:
@Test
public void STRTest() {
String str = STR."\{getSkStr()},就是牛";
System.out.println(str);
}
public String getSkStr() {
return "死磕 Java 新特性";
}
- 访问成员变量
STR模版处理器还可以访问成员变量,比如:
public record User(String name,Integer age) {
}
@Test
public void STRTest() {
User user = new User("大明哥",18);
String str = STR."\{user.name()}今年\{user.age()}";
System.out.println(str);
}
需要注意的是,字符串模板表达式中的嵌入表达式数量没有限制,它从左到右依次求值,就像方法调用表达式中的参数一样。例如:
@Test
public void STRTest() {
int i = 0;
String str = STR."\{i++},\{i++},\{i++},\{i++},\{i++}";
System.out.println(str);
}
// 结果......
0,1,2,3,4
同时,表达式中也可以嵌入表达式:
@Test
public void STRTest() {
String name = "大明哥";
String sk = "死磕 Java 新特性";
String str = STR."\{name}的\{STR."\{sk},就是牛..."}";
System.out.println(str);
}
// 结果......
大明哥的死磕 Java 新特性,就是牛...
但是这种嵌套的方式会比较复杂,容易搞混,一般不推荐。
多行模板表达式
为了解决多行字符串处理的复杂性,Java 13 引入文本块(Java 13 新特性—文本块),它是使用三个双引号("""
)来标记字符串的开始和结束,允许字符串跨越多行而无需显式的换行符或字符串连接。如下:
String html = """
<html>
<body>
<h2>skjava.com</h2>
<ul>
<li>死磕 Java 新特性</li>
<li>死磕 Java 并发</li>
<li>死磕 Netty</li>
<li>死磕 Redis</li>
</ul>
</body>
</html>
""";
如果字符串模板表达式,我们就只能拼接这串字符串了,这显得有点儿繁琐和麻烦。而字符串模版表达式也支持多行字符串处理,我们可以利用它来方便的组织html、json、xml等字符串内容,比如这样:
@Test
public void STRTest() {
String title = "skjava.com";
String sk1 = "死磕 Java 新特性";
String sk2 = "死磕 Java 并发";
String sk3 = "死磕 Netty";
String sk4 = "死磕 Redis";
String html = STR."""
<html>
<body>
<h2>\{title}</h2>
<ul>
<li>\{sk1}</li>
<li>\{sk2}</li>
<li>\{sk3}</li>
<li>\{sk4}</li>
</ul>
</body>
</html>
""";
System.out.println(html);
}
如果决定定义四个 sk
变量麻烦,可以整理为一个集合,然后调用方法生成 <li>
标签。
FMT 模板处理器
FMT 是 Java 定义的另一种模板处理器。它除了与STR模版处理器一样提供插值能力之外,还提供了左侧的格式化处理。下面我们来看看他的功能。比如我们要整理模式匹配的 Switch 表达在 Java 版本中的迭代,也就是下面这个表格
Java 版本 | 更新类型 | JEP | 更新内容 |
---|---|---|---|
Java 17 | 第一次预览 | JEP 406 | 引入模式匹配的 Swith 表达式作为预览特性。 |
Java 18 | 第二次预览 | JEP 420 | 对其做了改进和细微调整 |
Java 19 | 第三次预览 | JEP 427 | 进一步优化模式匹配的 Swith 表达式 |
Java 20 | 第四次预览 | JEP 433 | |
Java 21 | 正式特性 | JEP 441 | 成为正式特性 |
如果使用 STR 模板处理器,代码如下:
@Test
public void STRTest() {
SwitchHistory[] switchHistories = new SwitchHistory[]{
new SwitchHistory("Java 17","第一次预览","JEP 406","引入模式匹配的 Swith 表达式作为预览特性。"),
new SwitchHistory("Java 18","第二次预览","JEP 420","对其做了改进和细微调整"),
new SwitchHistory("Java 19","第三次预览","JEP 427","进一步优化模式匹配的 Swith 表达式"),
new SwitchHistory("Java 20","第四次预览","JEP 433",""),
new SwitchHistory("Java 21","正式特性","JEP 441","成为正式特性"),
};
String history = STR."""
Java 版本 更新类型 JEP 更新内容
\{switchHistories[0].javaVersion()} \{switchHistories[0].updateType()} \{switchHistories[0].jep()} \{switchHistories[0].content()}
\{switchHistories[1].javaVersion()} \{switchHistories[1].updateType()} \{switchHistories[1].jep()} \{switchHistories[1].content()}
\{switchHistories[2].javaVersion()} \{switchHistories[2].updateType()} \{switchHistories[2].jep()} \{switchHistories[2].content()}
\{switchHistories[3].javaVersion()} \{switchHistories[3].updateType()} \{switchHistories[3].jep()} \{switchHistories[3].content()}
\{switchHistories[4].javaVersion()} \{switchHistories[4].updateType()} \{switchHistories[4].jep()} \{switchHistories[4].content()}
""";
System.out.println(history);
}
得到的效果是这样的:
Java 版本 更新类型 JEP 更新内容
Java 17 第一次预览 JEP 406 引入模式匹配的 Swith 表达式作为预览特性。
Java 18 第二次预览 JEP 420 对其做了改进和细微调整
Java 19 第三次预览 JEP 427 进一步优化模式匹配的 Swith 表达式
Java 20 第四次预览 JEP 433
Java 21 正式特性 JEP 441 成为正式特性
是不是很丑?完全对不齐,没法看。为了解决这个问题,就可以采用FMT模版处理器,在每一列左侧定义格式:
@Test
public void STRTest() {
SwitchHistory[] switchHistories = new SwitchHistory[]{
new SwitchHistory("Java 17","第一次预览","JEP 406","引入模式匹配的 Swith 表达式作为预览特性。"),
new SwitchHistory("Java 18","第二次预览","JEP 420","对其做了改进和细微调整"),
new SwitchHistory("Java 19","第三次预览","JEP 427","进一步优化模式匹配的 Swith 表达式"),
new SwitchHistory("Java 20","第四次预览","JEP 433",""),
new SwitchHistory("Java 21","正式特性","JEP 441","成为正式特性"),
};
String history = FMT."""
Java 版本 更新类型 JEP 更新内容
%-10s\{switchHistories[0].javaVersion()} %-9s\{switchHistories[0].updateType()} %-10s\{switchHistories[0].jep()} %-20s\{switchHistories[0].content()}
%-10s\{switchHistories[1].javaVersion()} %-9s\{switchHistories[1].updateType()} %-10s\{switchHistories[1].jep()} %-20s\{switchHistories[1].content()}
%-10s\{switchHistories[2].javaVersion()} %-9s\{switchHistories[2].updateType()} %-10s\{switchHistories[2].jep()} %-20s\{switchHistories[2].content()}
%-10s\{switchHistories[3].javaVersion()} %-9s\{switchHistories[3].updateType()} %-10s\{switchHistories[3].jep()} %-20s\{switchHistories[3].content()}
%-10s\{switchHistories[4].javaVersion()} %-9s\{switchHistories[4].updateType()} %-10s\{switchHistories[4].jep()} %-20s\{switchHistories[4].content()}
""";
System.out.println(history);
}
输出如下:
Java 版本 更新类型 JEP 更新内容
Java 17 第一次预览 JEP 406 引入模式匹配的 Swith 表达式作为预览特性。
Java 18 第二次预览 JEP 420 对其做了改进和细微调整
Java 19 第三次预览 JEP 427 进一步优化模式匹配的 Swith 表达式
Java 20 第四次预览 JEP 433
Java 21 正式特性 JEP 441 成为正式特性
目前该特性还处于预览阶段,我们静候佳音。
- 点赞
- 收藏
- 关注作者
评论(0)