在将内存中的对象转换为XML文档时,如果对象之间存在循环引用,序列化过程就可能陷入无限递归,最终抛出栈溢出异常,或者生成层级深不可测的畸形文档。这个问题在双向关联的实体类中尤其常见,例如部门与员工互相持有对方的引用、树形结构中父子节点互指等场景。本文将系统分析循环引用的形成原因,并给出多种切实可行的处理方案。

循环引用是怎么产生的
循环引用的本质是对象图中存在一条首尾相接的引用路径。当序列化器沿着引用逐层展开对象时,如果路径形成一个环,它就会不断深入,永远无法结束。理解这一机制,需要先区分几种典型的循环形态。
第一种是自引用,即对象直接持有自身的引用。例如一个分类节点包含子分类列表,同时又包含一个指向父分类的字段,而这个父分类恰好就是它自己。第二种是双向引用,常见于主从关系建模,比如员工对象持有部门对象的引用,而部门对象又持有员工列表的引用,形成A到B、B回A的闭环。第三种是间接环,引用路径经过三个甚至更多对象之后回到起点,这类问题在排查时更隐蔽。
下面的Java代码展示了一个最简单的双向引用例子,当直接对它做递归式XML序列化时,程序会因无限递归而崩溃。
public class Department {
private String name;
private List<Employee> employees = new ArrayList<>();
// 省略getter和setter
}
public class Employee {
private String name;
private Department department; // 反向引用,形成环
// 省略getter和setter
}
需要说明的是,并非所有序列化框架遇到循环引用都会直接报错。有些框架会检测到重复对象并抛出明确异常,有些会生成重复嵌套的内容,还有些干脆栈溢出。行为差异取决于框架是否实现了对象图遍历时的已访问检测机制。
方案一:使用ID引用替代对象嵌套
解决循环引用最经典的思路是打平对象图。第一次遇到某个对象时,正常输出它的完整内容并分配一个唯一ID;再次遇到同一对象时,不再展开内容,而是只输出一个指向该ID的引用标记。这样既保留了引用关系,又避免了无限递归。
这种方案在XStream、JAXB配合XmlAdapter等工具中都有对应实现。以下是用XStream处理循环引用的示例,它默认就支持引用模式,会将重复对象转换为reference属性:
XStream xstream = new XStream();
xstream.alias("department", Department.class);
xstream.alias("employee", Employee.class);
// XStream默认启用引用模式,重复对象输出为reference属性
String xml = xstream.toXML(department);
生成的XML大致形态如下,可以看到第二个出现的对象被替换成了引用节点:
<department>
<name>研发部</name>
<employees>
<employee>
<name>张三</name>
<department reference="../.."/>
</employee>
</employees>
</department>
这种做法的优点是信息完整、语义清晰,反序列化时能够还原原始对象图。缺点是XML结构中出现了非标准的引用语法,如果接收方不是使用同一套框架解析,可能无法理解这种结构。跨系统交换数据时需要谨慎评估兼容性。
方案二:通过注解或适配器切断反向引用
如果反向引用在业务上并非必须传输的内容,最简单的办法就是在序列化时忽略它。以JAXB为例,可以使用@XmlTransient注解标记不需要输出的字段,从而斩断闭环。
public class Employee {
private String name;
@XmlTransient // 告诉JAXB序列化时跳过该字段
private Department department;
// 省略getter和setter
}
Jackson的XML模块则提供了@JsonIgnore以及专门处理父子关系的@JsonManagedReference和@JsonBackReference组合注解。前者标注在前向引用上正常序列化,后者标注在反向引用上自动跳过,反序列化时又会自动恢复引用关系,兼顾了传输效率与对象图完整性。
public class Department {
private String name;
@JsonManagedReference
private List<Employee> employees;
}
public class Employee {
private String name;
@JsonBackReference
private Department department;
}
这种方案实现成本低、生成的XML干净标准,是最常用的做法。但要注意它的前提:反向引用的数据可以被丢弃或者在接收端重建。如果接收方必须依赖反向字段才能完成业务处理,就不能简单忽略,需要结合ID引用方案。
方案三:转换为无环的中间数据结构
当对象模型复杂且框架能力有限时,可以考虑在序列化之前先做一层模型转换。思路是定义一套专门的传输对象(DTO),把带有环的业务对象映射成树形或扁平结构的DTO,再对DTO做XML序列化。环上的引用字段在DTO中被替换为简单的外键,例如只保留部门ID而不是完整的部门对象。
这种方案的好处是把复杂性收敛在转换层,传输层拿到的一定是无环结构,序列化行为完全可控,同时还能顺便裁剪字段、控制输出体积。缺点是增加了代码量,业务对象与DTO之间的映射需要维护,字段变更时要同步修改两处。
还有一种工程化的兜底手段是自定义序列化逻辑,在遍历对象图时维护一个已访问对象集合,遇到重复对象就输出引用标记或直接终止该分支。这相当于手工实现了框架的引用检测机制,适合无法更换序列化框架的老项目。此外,如果数据允许冗余,也可以选择打平为列表的形式,把部门列表和员工列表分别输出,员工节点中只携带departmentId字段,接收方自行组装关联关系,这是许多REST接口和报表导出场景采用的方式。
方案选择建议
选择哪种方案,可以从三个维度衡量。第一看数据是否需要完整还原:需要完整还原对象图时优先选ID引用方案或ManagedReference组合;允许丢失反向引用时用注解忽略最省事。第二看解析方是否确定:内部系统之间可以约定私有引用语法,对外接口则应输出标准结构,采用打平列表或外键形式更稳妥。第三看维护成本:小项目用注解快速解决,大型多实体关联系统建议引入DTO转换层,把环状依赖隔离在业务域内部。
无论采用哪种方式,都建议在编码阶段就建立规范:实体类设计时尽量让引用方向单一化,从源头减少双向关联;确需双向引用时,明确标注哪些字段参与序列化。同时为序列化逻辑编写针对性单元测试,构造最小化的环状对象图验证输出结果,避免问题在联调甚至上线后才暴露。