文本块 Text Blocks(JDK 13 预览 / JDK 15 正式)
周五下午,黄俪从前端组冲进了后端办公区,手里举着一张打印出来的 JSON 字符串。
"你们后端返回的这个错误响应,我在 Chrome DevTools 里看到是正常的,但为什么你们的单元测试里写成了这副模样?"她把纸拍在桌上——上面是一行超过 200 个字符的 Java 字符串拼接,
\"和\n交织在一起,比天书还难懂。小崔尴尬地挠了挠头:"那是老代码……每次改 SQL 或者 JSON 模板我都要数转义符。"
白歌不紧不慢地打开 IDEA,敲下三个双引号:"JDK 15 的文本块,一个特性消灭所有字符串转义地狱。黄俪你看着——"
定义表
| 概念 | 描述 |
|---|---|
| 文本块(Text Block) | JDK 15 正式特性,使用 """...""" 三引号语法定义多行字符串,自动处理缩进和转义 |
| 公共前导空格(Incidental White Space) | 编译器自动去除每行共有的前导空格,以内容行的最小缩进量为基准 |
续行符 \ | 行尾的反斜杠,阻止该行的换行符被包含到字符串中,将多行拼接为一行 |
强制空格 \s | 在行尾强制保留一个空格字符,防止编译器自动修剪尾随空格 |
| String.formatted() | JDK 15 新增实例方法,等价于 String.format(format, args),支持文本块的参数化 |
传统多行字符串的痛点
基础用法:三引号定义多行字符串
public class TextBlockBasic {
public static void main(String[] args) {
// === 传统写法:拼接 + 转义地狱 ===
String oldJson = "{\n" +
" \"company\": \"飞翔科技\",\n" +
" \"founded\": 2020,\n" +
" \"employees\": [\"大翔\", \"白歌\", \"小崔\"]\n" +
"}";
System.out.println("=== 传统写法 ===");
System.out.println(oldJson);
// === JDK 15 文本块:所见即所得 ===
String newJson = """
{
"company": "飞翔科技",
"founded": 2020,
"employees": ["大翔", "白歌", "小崔"]
}""";
System.out.println("\n=== 文本块写法 ===");
System.out.println(newJson);
}
}
输出:
=== 传统写法 ===
{
"company": "飞翔科技",
"founded": 2020,
"employees": ["大翔", "白歌", "小崔"]
}
=== 文本块写法 ===
{
"company": "飞翔科技",
"founded": 2020,
"employees": ["大翔", "白歌", "小崔"]
}
进阶用法:缩进控制与特殊字符
自动去除公共前导空格
public class TextBlockIndent {
public static void main(String[] args) {
// 尽管文本块在代码中缩进了 3 层(类 → 方法 → 代码块),
// 编译器自动去除公共前导空格,输出格式保持整洁
String html = """
<html>
<body>
<h1>飞翔科技 - 员工管理系统</h1>
<p>当前在线:12人</p>
</body>
</html>""";
System.out.println(html);
System.out.println("---分隔线---");
// === 控制最终缩进:关闭引号的位置决定基准线 ===
String htmlIndented = """
<html>
<body>
<h1>飞翔科技</h1>
</body>
</html>
"""; // 注意:关闭 """ 左移了一级缩进
// 此时基准线是关闭 """ 的位置,输出每行前面会多一级缩进
System.out.println(htmlIndented);
}
}
输出:
<html>
<body>
<h1>飞翔科技 - 员工管理系统</h1>
<p>当前在线:12人</p>
</body>
</html>
---分隔线---
<html>
<body>
<h1>飞翔科技</h1>
</body>
</html>
续行符 \ 与强制空格 \s
public class TextBlockLineControl {
public static void main(String[] args) {
// === 续行符 \:多行源码拼成一行实际字符串 ===
String singleLine = """
飞翔科技是一家成立于2020年的\
互联网科技公司,致力于企业级\
应用开发与数字化转型服务。""";
System.out.println("续行符效果(实际是一行):");
System.out.println(singleLine);
// === 强制空格 \s:保留行尾空格 ===
// 通常编译器会删除行尾空格,但用 \s 可强制保留
String withTrailingSpace = """
员工姓名:大翔 \s
员工姓名:白歌 \s
员工姓名:小崔 \s""";
System.out.println("\n强制保留行尾空格:");
System.out.println(withTrailingSpace);
System.out.println("(注意上面每行末尾的空格被保留了)");
}
}
输出:
续行符效果(实际是一行):
飞翔科技是一家成立于2020年的互联网科技公司,致力于企业级应用开发与数字化转型服务。
强制保留行尾空格:
员工姓名:大翔
员工姓名:白歌
员工姓名:小崔
(注意上面每行末尾的空格被保留了)
实战场景:SQL / JSON / HTML 模板
飞翔科技项目中的 SQL 模板
public class TextBlockSQL {
public static void main(String[] args) {
String department = "技术部";
int minSalary = 10000;
// === JDK 15:文本块 + formatted() ===
String sql = """
SELECT e.id, e.name, e.salary, d.name AS dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.id
WHERE d.name = '%s'
AND e.salary >= %d
ORDER BY e.salary DESC
LIMIT 20""".formatted(department, minSalary);
System.out.println("生成的 SQL:");
System.out.println(sql);
// === 传统写法对比 ===
String oldSql = "SELECT e.id, e.name, e.salary, d.name AS dept_name\n" +
"FROM employees e\n" +
"JOIN departments d ON e.dept_id = d.id\n" +
"WHERE d.name = '" + department + "'\n" +
" AND e.salary >= " + minSalary + "\n" +
"ORDER BY e.salary DESC\n" +
"LIMIT 20";
System.out.println("\n=== 传统写法(同样功能,三倍的维护成本)===");
System.out.println(oldSql);
}
}
输出:
生成的 SQL:
SELECT e.id, e.name, e.salary, d.name AS dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.id
WHERE d.name = '技术部'
AND e.salary >= 10000
ORDER BY e.salary DESC
LIMIT 20
=== 传统写法(同样功能,三倍的维护成本)===
SELECT e.id, e.name, e.salary, d.name AS dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.id
WHERE d.name = '技术部'
AND e.salary >= 10000
ORDER BY e.salary DESC
LIMIT 20
文本块与 String 方法的协同
文本块返回的仍然是普通的 String 对象,可以无缝使用 JDK 11 引入的 lines()、strip() 等方法以及 JDK 12/15 新增的方法。
import java.util.stream.Collectors;
public class TextBlockStringMethods {
public static void main(String[] args) {
String employeeData = """
大翔,CTO,35000
白歌,架构师,30000
小崔,后端开发,12000
孔蓝,后端开发,15000
黄俪,前端开发,14000
李眉,运维工程师,13000""";
// === lines() 按行分割 + Stream 处理 ===
System.out.println("=== 薪资超过 15000 的员工 ===");
employeeData.lines()
.filter(line -> {
String[] parts = line.split(",");
return Integer.parseInt(parts[2]) > 15000;
})
.forEach(System.out::println);
// === stripIndent() 手动去除缩进 ===
String indented = " 飞翔科技\n 技术部\n 2020年成立";
System.out.println("\n=== stripIndent() 效果 ===");
System.out.println(indented.stripIndent());
// === translateEscapes() 处理转义序列 ===
String escaped = "飞翔\\n科技\\t技术部";
System.out.println("\n=== translateEscapes() 效果 ===");
System.out.println("原文: " + escaped);
System.out.println("翻译后: " + escaped.translateEscapes());
}
}
输出:
=== 薪资超过 15000 的员工 ===
大翔,CTO,35000
白歌,架构师,30000
=== stripIndent() 效果 ===
飞翔科技
技术部
2020年成立
=== translateEscapes() 效果 ===
原文: 飞翔\n科技\t技术部
翻译后: 飞翔
科技 技术部
易错场景
文本块开头不能紧跟内容
public class TextBlockStartPitfall {
public static void main(String[] args) {
// ❌ 编译错误:文本块内容必须从下一行开始
// String bad = """Hello""";
// ✅ 正确:第一行留空,从下一行开始
String good = """
Hello""";
System.out.println(good);
}
}
输出:
Hello
三引号内部的引号不需要转义
public class TextBlockQuotePitfall {
public static void main(String[] args) {
// === 文本块内包含双引号,无需转义 ===
String json = """
{
"message": "白歌说:"文本块消灭了转义。""
}""";
System.out.println(json);
// 但连续三个双引号仍需要至少转义第一个
String withTripleQuote = """
白歌在代码注释中写道:\"""文本块是 JDK 15 最好的特性。\""" """;
System.out.println(withTripleQuote);
}
}
输出:
{
"message": "白歌说:"文本块消灭了转义。""
}
白歌在代码注释中写道:"""文本块是 JDK 15 最好的特性。"""
行尾空格默认被删除
public class TextBlockTrailingSpacePitfall {
public static void main(String[] args) {
// === 行尾空格默认被修剪 ===
String withoutSlashS = """
姓名:大翔
姓名:白歌 """;
// 每行末尾的空格被自动去除了
// === 加 \s 强制保留 ===
String withSlashS = """
姓名:大翔 \s
姓名:白歌 \s""";
System.out.println("不加 \\s 的长度:" + withoutSlashS.length());
System.out.println("加了 \\s 的长度:" + withSlashS.length());
}
}
输出:
不加 \s 的长度:12
加了 \s 的长度:21
面试考点
面试官常问的三个问题:
问题一:"文本块和普通字符串有本质区别吗?"
答案:没有本质区别。文本块编译后就是一个普通的
String对象,存放在常量池中,遵循相同的equals()和hashCode()规则。"""..."""是编译期的语法糖——编译器负责去除公共前导空格、处理续行符\和强制空格\s,最终生成与手写拼接完全等价的字符串实例。用==比较两个内容相同的文本块,如果 JVM 将它们放入常量池则返回true。
问题二:"String.formatted() 和 String.format() 有什么区别?"
答案:功能等价,调用方式不同。
String.format(format, args...)是静态方法,format.formatted(args...)是 JDK 15 新增的实例方法。formatted()更适合文本块的链式风格:"""...""".formatted(args),读起来更自然。底层实现上formatted()内部直接调用String.format(this, args)。
问题三:"stripIndent() 和文本块的自动缩进去除有什么区别?"
答案:文本块的缩进去除是编译期完成的——编译器在编译时计算公共前导空格并去除。而
stripIndent()是运行时方法,可以手动处理任意字符串的缩进。stripIndent() 使用与文本块相同的算法(以内容行的最小缩进量为基准),常用于处理从文件或网络读取的多行字符串。JDK 15 引入这个方法正是为了文本块生态的完整性。
黄俪在团队 Wiki 上更新了前端后端的接口文档模板:"现在所有 REST API 的请求/响应示例都用文本块写,新人再也不用来问我'这段 JSON 的对齐怎么调'了。白歌说得对——格式自由,才是真正的开发自由。"