List.copyOf 固定了哪些内容:订单明细快照的三层边界
把一份订单明细保存为 List.copyOf(lines),随后原订单调了数量,历史明细里的数量也变了。列表没有增加或删除元素,却仍然算不上业务快照:它固定了元素引用的序列,没有复制这些引用指向的对象状态。
因此“列表不能修改”至少要拆开看:结构是否可以改变,列表是否跟随原容器变化,元素自己的状态是否还会变化。它们不是同一条保证。下面用同一份源数据,比较只读视图、浅复制和面向展示的值投影。
import java.util.*;
public class SnapshotBoundary {
static final class Line {
final String sku;
int quantity;
Line(String sku, int quantity) { this.sku = sku; this.quantity = quantity; }
@Override public String toString() { return sku + ":" + quantity; }
}
record LineView(String sku, int quantity) {}
public static void main(String[] args) {
Line first = new Line("A", 1);
List<Line> source = new ArrayList<>();
source.add(first);
List<Line> view = Collections.unmodifiableList(source);
List<Line> shallow = List.copyOf(source);
List<LineView> values = source.stream()
.map(x -> new LineView(x.sku, x.quantity)).toList();
source.add(new Line("B", 2));
System.out.println("sizes=" + view.size() + "," + shallow.size() + "," + values.size());
first.quantity = 7;
System.out.println("view=" + view);
System.out.println("shallow=" + shallow);
System.out.println("values=" + values);
try { shallow.add(new Line("C", 3)); }
catch (UnsupportedOperationException e) { System.out.println("structure=locked"); }
try { List.copyOf(Arrays.asList(first, null)); }
catch (NullPointerException e) { System.out.println("null=rejected"); }
if (values.get(0).quantity() != 1 || shallow.get(0).quantity != 7)
throw new AssertionError("snapshot boundary changed");
}
}
把代码保存为 SnapshotBoundary.java,Java 17 下运行 java SnapshotBoundary.java,得到:
sizes=2,1,1
view=[A:7, B:2]
shallow=[A:7]
values=[LineView[sku=A, quantity=1]]
structure=locked
null=rejected
三个 sizes 分别对应只读视图、List.copyOf 和值投影。源列表加上 B 后,只读视图能看到它;两个新列表的结构保持原样。接下来修改 A 的 quantity,浅复制列表仍持有同一个 Line,于是也看到 7;LineView 保存了投影时的字符串和整数值,保持 1。
这里的 record 有意只使用不可变字符串和基本类型。record 并不会自动递归冻结成员;若把可变 Line 或数组塞进 record,只是把同一问题移到另一层。快照边界要逐字段说明,不能靠类名里带 Snapshot 或使用某个语法形式就结束判断。
先确定快照是给谁看的
展示订单只需要 SKU 和数量,值投影足够,也避免把领域对象里的其他可变字段泄露给调用方。若快照还要用于结算或审计,就应额外保存对应业务需要的价格、版本和规则标识;单纯复制容器不能替代这些数据。字段选择取决于快照的用途,而不是“所有属性都深拷贝一遍”这个机械目标。
对于对象图,盲目递归复制也有成本和语义问题。两个明细可能引用同一个商品对象,图里还可能有循环;到底保留共享身份还是拆成独立值,必须先决定。这里用有限字段的不可变值作为出口,使合同可以直接用断言表达,避免为一个读取接口引入通用对象图克隆工具。
程序还验证 List.copyOf 拒绝 null 元素。这与有些只读包装的接收范围不同,迁移时不能把它当作完全等价替换。若历史数据允许 null,应先决定 null 表示缺失、占位还是坏数据,再在边界处理;随手过滤可能改变明细位置和数量。
从草稿导出一份能保存的版本
接到订单确认页时,可以让可变Draft只在自己的同步边界内修改,再导出一份带版本的值列表。下面给明细增加备注列表,故意把嵌套复制问题放回来:LineValue构造时复制notes,Snapshot构造时复制明细序列,所有留下的元素都是字符串、整数和不可修改的值列表。
保存 OrderSnapshots.java 后在 Java 17 运行。先保存版本一,再修改草稿数量与备注,检查旧快照有没有漂移:
import java.util.*;
public class OrderSnapshots {
static final class Line {
final String sku;
int quantity;
final List<String> notes;
Line(String sku,int quantity,List<String> notes) {
this.sku=sku;this.quantity=quantity;this.notes=new ArrayList<>(notes);
}
}
record LineValue(String sku,int quantity,List<String> notes) {
LineValue { notes=List.copyOf(notes); }
}
record Snapshot(long version,List<LineValue> lines) {
Snapshot { lines=List.copyOf(lines); }
}
static final class Draft {
private final List<Line> lines=new ArrayList<>();
private long version;
private Line find(String sku) { return lines.stream().filter(x->x.sku.equals(sku)).findFirst().orElseThrow(); }
synchronized void add(String sku,int quantity,List<String> notes) {
if (sku==null || sku.isBlank() || quantity<1 || quantity>999) throw new IllegalArgumentException("invalid line");
Line candidate=new Line(sku,quantity,List.copyOf(notes));
long next=Math.incrementExact(version);
lines.add(candidate);version=next;
}
synchronized void quantity(String sku,int quantity) {
if (quantity<1 || quantity>999) throw new IllegalArgumentException("invalid quantity");
Line line=find(sku);long next=Math.incrementExact(version);
line.quantity=quantity;version=next;
}
synchronized void note(String sku,String text) {
Objects.requireNonNull(text);Line line=find(sku);long next=Math.incrementExact(version);
line.notes.add(text);version=next;
}
synchronized Snapshot snapshot() {
List<LineValue> values=lines.stream().map(x->new LineValue(x.sku,x.quantity,x.notes)).toList();
return new Snapshot(version,values);
}
}
public static void main(String[] args) {
Draft draft=new Draft();draft.add("A",2,List.of("fragile"));
Snapshot saved=draft.snapshot();
draft.quantity("A",9);draft.note("A","priority");
Snapshot current=draft.snapshot();
System.out.println("saved version="+saved.version()+", quantity="+saved.lines().get(0).quantity()+", notes="+saved.lines().get(0).notes());
System.out.println("current version="+current.version()+", quantity="+current.lines().get(0).quantity()+", notes="+current.lines().get(0).notes());
try { saved.lines().get(0).notes().add("changed"); }
catch(UnsupportedOperationException e) { System.out.println("nested mutation=rejected"); }
long before=draft.snapshot().version();
try { draft.quantity("A",0); }
catch(IllegalArgumentException e) { System.out.println("invalid change kept version="+(before==draft.snapshot().version())); }
if(saved.lines().get(0).quantity()!=2 || saved.lines().get(0).notes().size()!=1) throw new AssertionError("snapshot drift");
}
}
saved version=1, quantity=2, notes=[fragile]
current version=3, quantity=9, notes=[fragile, priority]
nested mutation=rejected
invalid change kept version=true
原草稿达到版本三,旧快照仍保留数量二和原备注。尝试修改旧快照的备注会被拒绝,无效数量也不会消耗版本。与最开始仅做List.copyOf的浅复制相比,这里明确复制了业务实际需要的第二层可变列表,而不是宣称已经递归克隆所有未来字段。
snapshot与所有草稿修改都通过同一个对象监视器,读取数量、备注和版本发生在一个一致边界内。草稿的明细容器和查找方法保持private,也没有向调用方暴露内部Line,所以调用方不能经由返回值绕过同步直接修改草稿。若从已有项目接入,应复用它所属聚合或服务的并发边界;只给snapshot加synchronized,却让其他代码无锁改字段,不会得到这份保证。
版本号用来解释这份值来自哪个草稿状态,不自动防止旧页面覆盖新数据。提交确认时仍要把期望版本与当前版本核对,并由实际持久化机制原子执行更新。这里只完成内存快照导出,没有伪装成数据库乐观锁或完整结算事务。
新增字段时也要重新审视复制范围。BigDecimal这类不可变数值可以共享,byte数组、Date或其他可变明细则需要专门投影或防御复制;带文件句柄、连接或锁的资源,更不应该因为“整单快照”就被复制到页面模型中。把值出口限制在能解释的数据上,新增需求时更容易发现遗漏。
本例仍保留原始SKU字符串,而不是在快照过程中做大小写或编码规范化。若订单身份规则要求规范化,应在草稿接收边界完成并保存明确结果;导出快照最好只描述当时已确定的业务值。一个读取动作顺手改写身份,会让旧版本与当前版本之间的差异更难追踪。
复制动作发生在哪个时刻
即使返回的值最终不可变,读取源对象的过程也可能与写线程交错。先读 SKU 再读数量,不会自动获得一个跨字段的一致版本;遍历源 ArrayList 同时发生结构写入,也不属于安全快照协议。需要在所属聚合的同步边界内构建值对象,或从已有不可变版本投影,而不能把 copyOf 当作并发控制。
发布这份值列表同样需要正确的线程间可见性机制。单线程示例只验证结构和引用关系,不能证明任意共享变量赋值都建立了安全发布。服务可通过受控缓存、volatile 引用或同步边界发布完整不可变版本,具体选择取决于它已有的并发设计。
List.copyOf 也不承诺总是分配一个新列表对象;输入满足条件时可以复用现有不可变容器。代码因此只检查数据合同,不比较返回值是否与源对象同一个引用。需要可独立修改的新容器时,应该明确创建可变副本;需要稳定历史值时,应该保存值本身。一个“copy”字样覆盖不了这两种不同用途。