首页 · ← SuperJ 手册 中文|EN

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.SIZE40EventHeader.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. 变长字段:bytesstring

struct 可以在尾部数据区携带变长数据。变长字段在固定区占一个 8 字节槽(4 字节数据偏移 + 4 字节长度),指向尾部。两种形式:

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()

变长写入严格有序,以避免隐式移位级联:

  1. 声明顺序写变长字段。
  2. 每个变长字段每次构建序列最多写一次
  3. 一旦后一个变长字段被写,前一个就被冻结 — 覆盖已冻结字段会抛 IllegalStateException
  4. 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 访问器

stringstring[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 除你声明的内容外,还会得到这些合成成员:

成员形状说明
SIZEpublic static final int固定区大小(不含变长尾部)。
Foo()public Foo()无参构造器 — 总是被合成,即使你声明了其他构造器。零初始化。
view(buf, off)public static Foo view(byte[] buf, int off)Flyweight:把一个新引用绑定到 bufoff 处,无分配。变长 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=v1f2=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.hashbase + hash_offset 处的一个视图 — 就地,无拷贝 — 因此 Hash16 上读 this.hi 的方法读到的是父缓冲区在该区域的字节。带方法的嵌套 struct 正是你在 Java 中要用对象和反射来模拟的类型化访问器。

10. struct 不能做什么

不能为什么替代
extends 另一个 struct/类继承 + 冻结的线上布局很别扭;任何分派表都会污染线上格式。通过嵌套 struct 组合(§2、§9)。
implements 一个接口struct 不属于接口分派系统。基于注册表的分派 — 例如 SEDA 的 ctx.codecs().register(cmdType, flyweight)
类类型字段(StringArrayList、…)线上字节中的指针跨进程/重启毫无意义。string/bytes 用于文本/二进制(按值存储);嵌套 struct 用于结构化数据。
动态 T[] 数组字段同样的指针问题 + 变长。固定 T[N] 用于定长;bytes/string 用于变长。
非 final / 可覆盖方法每个 struct 方法实际上都是 final(静态分派)。如果你需要多态,声明一个类。

struct 是一个保留关键字 — 不能用作标识符。

11. 编译与链接

struct 代码通常使用 SDK 类型(MemoryMappedFile),并受益于全程序编译,使虚分派表能看到每个子类。在communityenterprise 安装上,用 --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. 小结

设计理由与完整规格见本指南其余部分。

SuperJ — manual · generated from struct.md at pack time · Powered by superJ — this site is served by superj_web 中文|EN