【Java踩坑笔记】26_toString里打印对象?小心无限递归栈溢出
26 | toString 里打印对象?小心无限递归栈溢出
摘要:
toString()里直接打印成员变量(对象类型),会触发该对象的toString(),如果互相引用就会无限递归,最终StackOverflowError。Lombok 的@ToString也会踩这个坑。
一、问题现象
publicclassToStringTest{classDepartment{privateStringname;privateCompanycompany;// 反向引用公司@OverridepublicStringtoString(){return"Department{name='"+name+"', company="+company+"}";// ↑// 触发 company.toString()}}classCompany{privateStringname;privateList<Department>depts;@OverridepublicStringtoString(){return"Company{name='"+name+"', depts="+depts+"}";// ↑// 触发 department.toString()}}publicstaticvoidmain(String[]args){Companyc=newCompany("ACME");Departmentd=newDepartment("IT",c);c.setDepts(List.of(d));System.out.println(c);// ❌ StackOverflowError}}运行结果:
Exception in thread "main" java.lang.StackOverflowError at java.base/java.lang.String.valueOf(String.java:3244) at java.base/java.lang.StringBuilder.append(StringBuilder.java:173) at Company.toString(ToStringTest.java:xx) at Department.toString(ToStringTest.java:xx) at Company.toString(ToStringTest.java:xx) ...二、踩坑现场
场景 1:Lombok @ToString 默认包含所有字段
// ❌ 危险:@ToString 默认打印所有字段,包括对象类型@ToStringclassOrder{privateLongid;privateUseruser;// User 对象,也会调用 user.toString()privateList<OrderItem>items;}@ToStringclassUser{privateLongid;privateList<Order>orders;// 反向引用,无限递归!}现象:打印order.toString()时,触发user.toString(),再触发orders.toString(),再触发order.toString()… 无限递归。
场景 2:日志打印对象
// ❌ 错误:日志直接打印对象,触发 toString()log.info("处理订单:{}",order);// 触发 order.toString()// 如果 order 的 toString() 里有循环引用 → StackOverflowError// 更严重的是:这个错误可能在生产环境才触发(数据特定时)三、原理解析
3.1 无限递归的形成
System.out.println(company) → company.toString() → 拼接 depts(List) → 遍历 List,对每个元素调用 toString() → d1.toString() → 拼接 company → company.toString() ← 又回来了! → 拼接 depts...递归深度每两轮 +2,直到超过 JVM 栈深度限制(通常 1024~15000,取决于-Xss设置),抛出StackOverflowError。
3.2 Lombok @ToString 的坑
@ToString// 默认:includeFieldNames = true,打印所有字段classOrder{privateUseruser;// 会调用 user.toString()}@ToString有一个配置:callSuper = false(默认),且不排除任何字段。如果字段是对象类型,就会触发该对象的toString()。
3.3 哪些场景会触发 toString()?
| 触发方式 | 说明 |
|---|---|
System.out.println(obj) | 自动调用obj.toString() |
字符串拼接+" obj | 自动调用obj.toString() |
日志框架log.info("{}", obj) | 大部分日志框架会调用obj.toString() |
String.valueOf(obj) | 内部调用obj.toString() |
四、正确写法
4.1 @ToString 排除导致循环的字段
// ✅ 正确:排除导致循环的字段@ToString(exclude="user")// Lombok 旧版本写法classOrder{privateLongid;privateUseruser;// 不打印 user}// ✅ Lombok 新版本:用 @ToString.ExcludeclassOrder{privateLongid;@ToString.Exclude// 排除单个字段privateUseruser;}4.2 手动重写 toString(),避免打印整个对象
// ✅ 正确:toString() 只打印基本类型字段@OverridepublicStringtoString(){return"Order{id="+id+", userId="+userId+"}";// 只打印 userId,不打印 user 对象}4.3 用 IDEA 生成 toString() 时,排除对象字段
IDEA →右键 → Generate → toString()→ 在弹窗里取消勾选对象类型的字段。
4.4 Jackson 序列化同理:用 @JsonIgnore
// ✅ Jackson 序列化时也会踩同样的坑classOrder{privateLongid;@JsonIgnore// 序列化时排除 user(防止无限递归)privateUseruser;}五、最佳实践
✅ toString() 的 4 条铁律
toString()里只打印基本类型字段,不要打印对象类型字段- 用 Lombok
@ToString,记得排除循环引用字段 - 日志打印时,必要时手动拼接关键字段,不要直接打印整个对象
- Jackson 序列化用
@JsonIgnore或@JsonManagedReference/@JsonBackReference处理双向引用
🔍 如何快速发现循环引用?
# 用 jcmd 或 jmap 导出堆快照jmap -dump:live,format=b,file=heap.hrof<pid># 用 Eclipse MAT 分析# 搜索:java.lang.StackOverflowError# 查看堆栈,找到递归调用的 toString()🛠️ IDEA 的 toString() 生成技巧
右键 → Generate → toString()→ 在对话框里:
- 勾选
Use field name(字段名 = 值,清晰) - 取消勾选对象类型字段(防止递归)
- 点击
Setting→ 勾选Skip null values(忽略 null 字段,输出更简洁)
六、小结
toString()里打印对象类型字段,会触发该对象的toString(),双向引用时无限递归- Lombok
@ToString默认包含所有字段,要注意循环引用 toString()里只打印基本类型,对象类型用exclude排除- 日志打印对象前,确认
toString()不会递归 - Jackson 序列化也有同样问题,用
@JsonIgnore处理
下一篇预告:大量使用反射,性能到底差多少?—— 反射调用比直接调用的性能差距,以及Method缓存的最佳实践。
