ในโลก JVM (Kotlin/Java) เมื่อคุณสร้างคลาสสำหรับจัดเก็บธุรกรรม เช่น:
data class LedgerEntry(
val status: Byte, // 1 Byte
val amount: Long, // 8 Bytes
val isSettled: Boolean // 1 Byte
)
คุณไม่จำเป็นต้องสนใจลำดับการประกาศตัวแปร เพราะ HotSpot JVM มีระบบ Field Reordering (เช่น flag -XX:FieldsAllocationStyle) ช่วยสลับตำแหน่งในระดับคลาสให้อัตโนมัติเบื้องหลัง แต่ในภาษา Go ซึ่งเป็น Bare-Metal Compiled Language คอมไพเลอร์จะไม่สลับบรรทัดโค้ดให้คุณเด็ดขาด เพราะ Go ถือว่านักพัฒนาต้องเป็นผู้ควบคุม Memory Layout เองอย่างโปร่งใส
ก่อนจะคำนวณไบต์ มาดูวิธีเขียนไวยากรณ์พื้นฐานของ Go ที่จำเป็นต้องใช้ในเรื่องนี้กันอย่างละเอียด:
// Kotlin: data class Account(val id: Long, val balance: Int)
// Go: ใช้คีย์เวิร์ด type ตามด้วยชื่อ และ struct
type Account struct {
ID int64 // ชนิดข้อมูลอยู่ข้างหลังชื่อตัวแปร
Balance int32
}
// แบบที่ 1: ใช้ Struct Literal (กำหนดค่าทันที)
acc1 := Account{
ID: 1001,
Balance: 50000,
}
// แบบที่ 2: ประกาศตัวแปรเปล่า (จะได้ Zero Value: ID=0, Balance=0)
var acc2 Account
// แบบที่ 3: ใช้คำสั่ง Print ด้วย package fmt
// %v พิมพ์ค่าทั่วไป, %+v พิมพ์ชื่อฟิลด์พร้อมค่า, %#v พิมพ์โครงสร้าง Go syntax
fmt.Printf("%+v\n", acc1) // Output: {ID:1001 Balance:50000}
Go มีแพ็กเกจ unsafe ในตัวสำหรับตรวจสอบโครงสร้าง RAM ของ Struct:
import (
"fmt"
"unsafe"
)
func InspectMemory() {
var acc Account
// 1. unsafe.Sizeof: ขนาดไบต์รวมทั้งหมดของ Struct (รวม Padding)
fmt.Printf("Total Size: %d Bytes\n", unsafe.Sizeof(acc))
// 2. unsafe.Alignof: ค่าตัวหาร Alignment ที่ Struct ต้องการ (1, 2, 4 หรือ 8)
fmt.Printf("Alignment: %d\n", unsafe.Alignof(acc))
// 3. unsafe.Offsetof: ตำแหน่งไบต์สัมพัทธ์ของฟิลด์จากจุดเริ่มต้นของ Struct
fmt.Printf("Offset of ID: %d\n", unsafe.Offsetof(acc.ID))
fmt.Printf("Offset of Balance: %d\n", unsafe.Offsetof(acc.Balance))
}
| ชนิดข้อมูล (Go Type) | ขนาดจริง (Size in Bytes) | Alignment Requirement (Offset ต้องหารลงตัวด้วย) |
|---|---|---|
bool, int8, uint8, byte |
1 Byte | 1 Byte (วางตรงตำแหน่งใดก็ได้) |
int16, uint16 |
2 Bytes | 2 Bytes (Offset 0, 2, 4, 6, ...) |
int32, uint32, float32 |
4 Bytes | 4 Bytes (Offset 0, 4, 8, 12, ...) |
int64, uint64, float64, uintptr |
8 Bytes | 8 Bytes (Offset 0, 8, 16, 24, ...) |
Pointer (*T), unsafe.Pointer |
8 Bytes | 8 Bytes (Offset 0, 8, 16, 24, ...) |
string (String Header: Data Pointer + Len) |
16 Bytes | 8 Bytes (ยึดตามขนาด Pointer ภายใน) |
slice (Slice Header: Data Pointer + Len + Cap) |
24 Bytes | 8 Bytes (ยึดตามขนาด Pointer ภายใน) |
unsafe.Alignof(field) ลงตัวเสมอ หากไม่ลงตัวคอมไพเลอร์จะเติม Internal Padding ทันทีพิจารณาระบบบันทึกรายการโอนเงิน Core Banking Transaction Entry:
type BadTransaction struct {
Flag bool // 1 Byte
AccountID int64 // 8 Bytes (ต้องการ Offset หาร 8 ลงตัว!)
IsPending bool // 1 Byte
AmountCent int64 // 8 Bytes (ต้องการ Offset หาร 8 ลงตัว!)
CurrencyID uint16 // 2 Bytes
}
จัดเรียงฟิลด์จากขนาด ใหญ่ที่สุด (8 Bytes) $\to$ ปานกลาง (4B, 2B) $\to$ เล็กที่สุด (1B):
type GoodTransaction struct {
AccountID int64 // 8 Bytes (Offset 0)
AmountCent int64 // 8 Bytes (Offset 8)
CurrencyID uint16 // 2 Bytes (Offset 16)
Flag bool // 1 Byte (Offset 18)
IsPending bool // 1 Byte (Offset 19)
// Trailing Padding อีก 4 Bytes เพื่อปัดเศษรวมเป็น 24 ซึ่งหารด้วย 8 ลงตัวพอดี
}
ในภาษา Go ชนิดข้อมูล struct{} (Empty Struct) มีขนาด 0 Bytes หากวางไว้ต้นหรือกลาง Struct จะไม่กินเนื้อที่เลย แต่ถ้ามันถูกวางไว้ที่ บรรทัดสุดท้าย (Final Field) ของ Struct:
type FinalZeroTrap struct {
ID int64
Flag struct{} // ฟิลด์ขนาด 0 ไบต์อยู่ท้ายสุด!
}
&s.Flag) จะชี้ไปยังแอดเดรสที่หลุดขอบเขตของตัวแปรในหน่วยความจำ ซึ่งอาจไปทับแอดเดรสของอ็อบเจกต์ถัดไปบน Heap ส่งผลให้ Garbage Collector เกิดความสับสนว่าอ็อบเจกต์ถัดไปกำลังถูกชี้อยู่หรือไม่ เพื่อป้องกันปัญหานี้ Go Compiler จึงสั่ง เติม Trailing Padding เข้าไปเต็มขนาด Word (8 Bytes) ทันที ทำให้ขนาดกลายเป็น 16 Bytes แทนที่จะเป็น 8 Bytes!
ในระบบ Concurrency หากมี Struct สองตัวที่มีการเขียนข้อมูลบ่อยๆ โดยกอร์ลูทีนคนละตัว บังเอิญถูกวางอยู่ใน **CPU Cache Line เดียวกัน (64 Bytes)** เมื่อกอร์ลูทีนแรกแก้ไขค่า CPU Core ตัวแรกจะสั่ง Invalidate แคชของ CPU Core ตัวอื่นทันที ทำให้ Core อื่นต้องไปดึงข้อมูลจาก RAM ใหม่ แม้ว่าจะไม่ได้แตะตัวแปรเดียวกันเลยก็ตาม ปรากฏการณ์นี้เรียกว่า False Sharing ทางแก้คือการใส่ Padding คั่นระหว่างฟิลด์ด้วย [8]uint64 (64 Bytes) เพื่อดันข้อมูลให้แยก Cache Line กันเด็ดขาด
LedgerEntry ด้านล่างถูกวางฟิลด์แบบไม่เป็นระเบียบ ทำให้กินพื้นที่บวมถึง 32 BytesAccountMarker วางฟิลด์ struct{} ไว้บรรทัดสุดท้าย ทำให้เกิด Trailing Padding บวม 8 ไบต์Marker struct{} ไปอยู่ในตำแหน่งที่ไม่ทำให้เกิด Memory Padding เพิ่มเติมAccountID (8 Bytes) พอดีRFIDPacket ด้านล่างมีขนาดบวมถึง 40 BytesTimestamp int64, ReaderID int32, BatteryLevel uint8, AntennaID uint8, SignalQuality uint16, IsDuplicate bool