Struct:零开销线上类型
SuperJ 的 struct 是一种固定布局的记录类型,其**内存布局就是它的线上格式** — 紧凑、小端、无对象头、无继承、无 GC。一个 struct 给你的,正是 Java 要用 ByteBuffer 外加一整套访问器类层级才能伪造的东西:字节上的类型化视图,其中 o.field 编译成一个在编译期常量偏移处的直接加载。它为 SEDA 事件消息、mmap journal、网络协议头,以及任何必须读或写另一个进程产生的字节的代码而存在。
本教程从一个简单的原生 struct 走到完整特性集 — 嵌套 struct、定长数组、enum 字段、方法、变长字段、flyweight view()/bindTo() 模式,以及合成的 API。下面每段代码都改编自 SDK 自带的 struct 示例,因此原样即可编译运行。
设计理由(为什么没有继承、为什么没有类引用)和正式规格见本指南其余部分。本页是操作指南。
1. 第一个 struct
struct 用 struct 关键字声明。字段按声明顺序放在连续偏移处,无填充。
public struct EventHeader {
long seq;
long timestamp;
short sourceAppId;
short cmdType;
}
EventHeader.SIZE 是编译器合成的一个 static final int — 这里是 20(8 + 8 + 2 + 2)。用 new 分配一个,直接读写字段:
EventHeader h = new EventHeader();
h.seq = 42L;
h.timestamp = 1700000000L;
h.sourceAppId = (short) 7;
h.cmdType = (short) 100;
System.out.println("seq=" + h.seq);
System.out.println("SIZE=" + EventHeader.SIZE);
这就是原生的全部故事。h.seq = 42L 下放为 putLong(base + 0, 42);h.seq 下放为 getLong(base + 0)。没有访问器对象,运行时没有字段元数据 — 优化器看到的是与手写 buf.getLong(off) 相同的内存访问模式。
2. 嵌套 struct
struct 字段本身可以是 struct。嵌套 struct 被对其声明的偏移处内联 — 没有指针、没有独立分配。通过点号字段名链式访问:
public struct OrderEnvelope {
EventHeader header; // 在偏移 0..20 处内联
long orderId; // 20
long quantity; // 28
int side; // 36
}
OrderEnvelope env = new OrderEnvelope();
env.header.seq = 99L;
env.header.timestamp = 1234567890L;
env.orderId = 555L;
env.side = 1;
System.out.println("nested.seq=" + env.header.seq);
System.out.println("SIZE=" + OrderEnvelope.SIZE); // 40
OrderEnvelope.SIZE 是 40 — EventHeader.SIZE(20)加上外层字段。这就是你不用继承组合线上头的方式(见 §10)。
3. 定长数组与 enum 字段
定长数组
定长数组字段内联 N * sizeof(elem) 字节。语法是 elemType[N] fieldName — 不同于 Java 的动态 byte[](struct 中不允许):
public struct WithUuid {
long id;
byte[16] uuid; // 内联 16 字节
int trailer;
}
WithUuid w = new WithUuid();
w.id = 100L;
w.trailer = 999;
System.out.println("SIZE=" + WithUuid.SIZE); // 28
Enum 字段
enum 字段存储 enum 的 ordinal(以声明的整数宽度,默认 int)。赋一个 enum 常量,读回 enum,在 switch 中使用:
enum Color { RED, GREEN, BLUE }
public struct ColorEvent {
long id;
Color c;
}
ColorEvent e = new ColorEvent();
e.id = 123L;
e.c = Color.GREEN;
System.out.println("ord=" + e.c.ordinal());
System.out.println("name=" + e.c.name());
e.c = Color.BLUE;
System.out.println("switch=" + (e.c == Color.BLUE ? "yes" : "no"));
4. 方法、构造器与 static final 常量
struct 可以声明构造器、实例方法、静态方法和 static final 常量 — 就像一个类,只是每个方法都是非虚的(静态分派、完全可内联)。
struct Order {
long orderId;
long quantity;
int side;
static final int SIDE_BUY = 1;
static final int SIDE_SELL = 2;
public Order(long id, long qty, int s) {
this.orderId = id;
this.quantity = qty;
this.side = s;
}
public boolean isBuy() { return side == SIDE_BUY; }
public long notional(long price) { return quantity * price; }
public static int buyCode() { return SIDE_BUY; }
}
Order o = new Order(42L, 100L, Order.SIDE_BUY);
System.out.println(o.notional(5L)); // 500
System.out.println(o.isBuy()); // true
System.out.println(Order.buyCode()); // 1
无参构造器总是被合成(即使你声明了自己的重载),因此 §8 的 flyweight 模式可用 — new Order() 永远有效。
5. 变长字段:bytes 与 string
struct 可以在尾部数据区携带变长数据。变长字段在固定区占一个 8 字节槽(4 字节数据偏移 + 4 字节长度),指向尾部。两种形式:
bytes— 原始字节;读取返回byte[],写入接受byte[]。string— UTF-8 文本;可以直接赋一个String,读回也是String。
public struct LogLine {
long timestamp;
int level;
bytes payload; // 变长原始
}
LogLine line = new LogLine();
line.timestamp = 1700000000L;
line.level = 3;
byte[] data = new byte[5];
data[0] = (byte) 'h'; data[1] = (byte) 'e'; data[2] = (byte) 'l';
data[3] = (byte) 'l'; data[4] = (byte) 'o';
line.payload = data;
System.out.println("SIZE=" + LogLine.SIZE); // 固定区:20
System.out.println("totalSize=" + line.totalSize()); // 20 + 5 = 25
byte[] read = line.payload;
System.out.println("read.length=" + read.length); // 5
SIZE 只是固定区。totalSize() 返回 SIZE 加上所有已写变长数据之和 — 即要成帧/发布的字节数。
写入顺序与 reset()
变长写入严格有序,以避免隐式移位级联:
- 按声明顺序写变长字段。
- 每个变长字段每次构建序列最多写一次。
- 一旦后一个变长字段被写,前一个就被冻结 — 覆盖已冻结字段会抛
IllegalStateException。 reset()清除所有变长状态并开始一个全新的构建序列。
struct Msg { int id; bytes a; bytes b; }
Msg m = new Msg();
m.id = 7;
m.a = new byte[]{1, 2};
m.b = new byte[]{3, 4, 5};
// m.a = new byte[]{9}; // 会抛 IllegalStateException — 'a' 已冻结
m.reset(); // 全新构建序列
m.a = new byte[]{8};
m.b = new byte[]{8, 8};
这契合 SEDA 的"构建一次然后发布"模型。要"修改"一个已存在的消息,调用 reset() 重建 — 你不会就地修补字段。
6. 定容字段:string[N] / bytes[N]
定容字段是一个内联的 N 字节槽,带一个长度前缀(N <= 256 时 1 字节,更大 N 时 2 字节),然后是数据。它不同于变长 string/bytes(后者有尾部数据区):
struct Rec {
int id;
string[16] name; // 1 字节前缀,最多 15 数据字节
bytes[8] tag; // 1 字节前缀,最多 7 数据字节
string[300] note; // 2 字节前缀,最多 298 数据字节
}
Rec r = new Rec();
r.id = 5;
r.name = new byte[]{72, 73, 74}; // 3 字节
r.tag = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9}; // 9 -> 截断为 7
r.note = "a longer note here"; // String 赋值 OK
byte[] n = r.name;
System.out.println(n.length); // 3
System.out.println(new String(r.note)); // UTF-8 String 视图
UTF-8 String 访问器
string 或 string[N] 字段在写入时接受 String(以 UTF-8 字节存储)。一个变长 string字段读回时直接是 String:
struct Rec2 {
int id;
string[16] name; // 定容 UTF-8
string note; // 变长 UTF-8
bytes[4] raw; // 原始字节(仅 byte[])
}
Rec2 r = new Rec2();
r.id = 7;
r.name = "Bob";
r.note = "a longer note here";
r.raw = new byte[]{10, 20, 30};
System.out.println(new String(r.name)); // 定容 -> 通过 new String(...) 得 String
System.out.println(r.note); // 变长 -> 直接是 String
byte[] rw = r.raw; // bytes[4] -> byte[]
bytes / bytes[N] 字段保持纯 byte[] — 要把一个字段作为文本读,把它声明为 string。(new String(field) 在 string 字段上也可用,并产生一个独立的副本。)
7. Flyweight 模式:view() 与 bindTo()
热路径的构造模式是零分配:把一个 struct 引用绑定到已有字节 — 一个 byte[] 或一个 mmap 的 MemoryMapped 区域 — 并通过它读写字段。无拷贝、无 arena 分配。这就是 SEDA 遍历事件的方式,也是你就地解析网络帧的方式。
import sj.io.MemoryMapped;
import sj.io.File;
public struct MmFixed {
long id;
long qty;
int side;
}
view() — 绑定一次
MemoryMapped mm = new MemoryMapped(path, 64L);
MmFixed r = MmFixed.view(mm, 0L); // 在偏移 0 处把一个新视图绑定到 mm
r.id = 7L;
r.qty = 100L;
r.side = 1;
mm.close();
bindTo() — 重绑已有引用
MmFixed r = new MmFixed();
r.bindTo(mm, 0L);
r.id = 1L;
r.bindTo(mm, 32L); // 把同一个引用重新指向新偏移
r.id = 2L;
r.bindTo(mm, 0L); // 走回去
// 此时 r.id == 1L
预先一次分配,然后按记录重绑 — 这就是 LMAX/SEDA codec 的形状。
持久化往返
因为布局就是线上格式,把一个 struct 写入 mmap 文件再在新进程中读回是一个 no-op:
// 进程 1:写
MemoryMapped mm = new MemoryMapped(path, 64L);
MmFixed w = MmFixed.view(mm, 0L);
w.id = 12345L; w.qty = 67890L; w.side = 3;
mm.sync(); mm.close();
// 进程 2:读
MemoryMapped mm2 = new MemoryMapped(path, 64L);
MmFixed rd = MmFixed.view(mm2, 0L);
// rd.id == 12345L, rd.qty == 67890L, rd.side == 3
mm2.close();
没有序列化步骤。没有字节序转换。sync() 把 mmap 刷到磁盘;另一个进程映射同一个文件并读取相同字节。
变长 struct:3 参 view/bindTo
带任何 bytes/string 字段的 struct 具有动态总尺寸,因此绑定需要绑定长度(成帧记录大小减去其 4 字节长度前缀)。编译器为变长 struct 合成3 参重载(上面那些 2 参形式对它们不存在):
public struct MmVar {
long id;
bytes payload;
}
MemoryMapped mm = new MemoryMapped(path, 256L);
MmVar v = MmVar.view(mm, 0L); // 变长:也有 3 参形式可用
v.id = 42L;
v.payload = new byte[]{(byte)'a', (byte)'b', (byte)'c'};
byte[] r = v.payload; // {a, b, c}
8. 合成 API 参考
每个 struct 除你声明的内容外,还会得到这些合成成员:
| 成员 | 形状 | 说明 | |
|---|---|---|---|
SIZE | public static final int | 固定区大小(不含变长尾部)。 | |
Foo() | public Foo() | 无参构造器 — 总是被合成,即使你声明了其他构造器。零初始化。 | |
view(buf, off) | public static Foo view(byte[] buf, int off) | Flyweight:把一个新引用绑定到 buf 的 off 处,无分配。变长 struct 合成 3 参形式 view(buf, off, length)。 | |
view(mm, off) | public static Foo view(MemoryMapped mm, long off) | 对 mmap 的相同行为。变长:view(mm, off, length)。 | |
bindTo(buf, off) | public void bindTo(byte[] buf, int off) | 把已有引用重新指向新字节。变长:3 参形式。 | |
bindTo(mm, off) | public void bindTo(MemoryMapped mm, long off) | 对 mmap 的相同行为。变长:3 参形式。 | |
totalSize() | public int totalSize() | SIZE + 已写变长数据之和 — 即要成帧/发布的字节。 | |
reset() | public void reset() | 清除变长状态;把 (offset, length) 槽清零;回卷尾部数据游标。不清零固定区原语。 | |
eqBytes(other) | public boolean eqBytes(Foo other) | 字节范围相等(同类型,totalSize() 字节匹配)。== 仍是引用同一性。 | |
copyFrom(other) | public void copyFrom(Foo other) | 从 other 拷贝 totalSize() 字节到 this(内建 memmove)。返回后 this.eqBytes(other) == true。 | |
toString() | public String toString() | 调试打印:`Foo{f1=v1 | f2=v2}。原语与 enum ordinal 打印值;嵌套 struct/bytes/string 字段打印占位符。用户声明的 toString()` 优先。 |
一个变长 struct 经 flyweight 的写→读往返,每个字段读回为其声明类型:
struct Order {
long id;
int qty;
byte side;
string symbol; // 变长 UTF-8 -> 读回 String
bytes memo; // 变长原始 -> 读回 byte[]
}
byte[] buf = new byte[256];
Order w = Order.view(buf, 0);
w.id = 42; w.qty = 100; w.side = 1;
w.symbol = "GOOG";
w.memo = new byte[]{7, 8, 9};
int n = w.totalSize();
Order r = Order.view(buf, 0);
long id = r.id; // 原语 -> 值
int qty = r.qty;
String sym = r.symbol; // string -> String
byte[] memo = r.memo; // bytes -> byte[]
System.out.println("totalSize=" + n + " SIZE=" + Order.SIZE);
9. 一个真实的消息
把所有东西放一起 — 一个涵盖每种字段类型与一个带方法的嵌套 struct 的 struct:
enum Side { BUY, SELL }
struct Hash16 {
long hi;
long lo;
public void set(long h, long l) { this.hi = h; this.lo = l; }
public long mix() { return hi ^ lo; }
}
struct Transaction {
long txId;
long amount;
Side side; // enum 作为 ordinal
Hash16 hash; // 带方法的嵌套 struct(内联)
string[16] symbol; // 定容 UTF-8
string memo; // 变长 UTF-8
public boolean isBuy() { return side == Side.BUY; }
public long notional(long price) { return amount * price; }
}
Transaction t = new Transaction();
t.txId = 12345L;
t.amount = 50000L;
t.side = Side.SELL;
t.hash.set(12L, 5L); // 在父字节的该区域上运行
t.symbol = "AAPL";
t.memo = "first trade";
System.out.println(t.hash.mix()); // 9
System.out.println(new String(t.symbol)); // AAPL
System.out.println(t.toString()); // Transaction{txId=12345|...}
t.hash 是 base + hash_offset 处的一个视图 — 就地,无拷贝 — 因此 Hash16 上读 this.hi 的方法读到的是父缓冲区在该区域的字节。带方法的嵌套 struct 正是你在 Java 中要用对象和反射来模拟的类型化访问器。
10. struct 不能做什么
| 不能 | 为什么 | 替代 |
|---|---|---|
extends 另一个 struct/类 | 继承 + 冻结的线上布局很别扭;任何分派表都会污染线上格式。 | 通过嵌套 struct 组合(§2、§9)。 |
implements 一个接口 | struct 不属于接口分派系统。 | 基于注册表的分派 — 例如 SEDA 的 ctx.codecs().register(cmdType, flyweight)。 |
类类型字段(String、ArrayList、…) | 线上字节中的指针跨进程/重启毫无意义。 | string/bytes 用于文本/二进制(按值存储);嵌套 struct 用于结构化数据。 |
动态 T[] 数组字段 | 同样的指针问题 + 变长。 | 固定 T[N] 用于定长;bytes/string 用于变长。 |
| 非 final / 可覆盖方法 | 每个 struct 方法实际上都是 final(静态分派)。 | 如果你需要多态,声明一个类。 |
struct 是一个保留关键字 — 不能用作标识符。
11. 编译与链接
struct 代码通常使用 SDK 类型(MemoryMapped、File),并受益于全程序编译,使虚分派表能看到每个子类。在community 或 enterprise 安装上,用 --sdk-path … --enterprise(通过预构建 SDK 做全程序 LTO):
# 单文件(导入 SDK),community/enterprise 安装:
superj compile test_struct_mmap.sj --sdk-path $SJ_HOME/sdk --enterprise --link
./test_struct_mmap
在 VIP 安装上(SDK 源码可用),改用 --sdk-source:
# 单文件(导入 SDK),VIP 安装:
superj compile test_struct_mmap.sj --sdk-source $SJ_HOME/sdk/sj --link
./test_struct_mmap
或作为项目(推荐 — 构建工具会为已安装的变体选择正确的链接路径):
cd my-struct-project
superj run # 构建 + 运行;为你发出针对 OS 的链接行
对于项目,superj build / superj run 自动处理链接行 — 你永远不用手写 clang。完整的 new/build/run/test 工作流见 构建系统。
12. 小结
- 一个 struct 的布局就是它的线上格式 — 紧凑、小端、声明顺序 = 字节顺序。
o.field编译成一个在编译期常量偏移处的直接加载/存储。 - 两种构造模式:
new Foo()(arena、零初始化)与 flyweightFoo.view(buf, off)/bindTo(buf, off)(零分配、按记录重绑)。 - 变长
bytes/string字段使用带严格写入顺序的尾部数据区;totalSize()是要成帧的字节;reset()开始一次全新构建。 string字段读回为String;bytes字段读回为byte[]。没有对象被序列化 — 文本/字节按值内联存储。- 无继承、无接口、无类类型字段 — 改用嵌套 struct 与基于注册表的分派。
设计理由与完整规格见本指南其余部分。