Natural alignment, padding and why misaligned access is slow or non-atomic, how the CLR lays out classes (auto) and structs (sequential by default, auto when they contain references), object headers and minimum object size, measuring sizes, and using StructLayout, FieldOffset, Pack and InlineArray for interop and performance.
CPUs read memory most efficiently when a value sits at an address that is a multiple of its size, which is called natural alignment: a 4-byte int at a multiple of 4, an 8-byte long at a multiple of 8. Compilers and the CLR add padding between fields to keep that alignment, so struct { byte; long; byte; } takes 24 bytes, not 10. Misaligned access can be slower, can span two cache lines, and isn't guaranteed atomic. In .NET, classes use auto layout (the runtime reorders fields to minimise padding), structs default to sequential layout (but get auto layout if they contain references), and every object carries a 16-byte overhead on 64-bit. I use StructLayout, FieldOffset and Pack when matching a native or binary layout, and otherwise let the runtime decide.
Interlocked) require alignment.long (possible only with explicit layouts or unsafe code) can tear.struct Bad { byte A; long B; byte C; } struct Good { long B; byte A; byte C; }
offset: 0 A | 1-7 pad | 8-15 B | 16 C | 17-23 pad 0-7 B | 8 A | 9 C | 10-15 pad
size: 24 bytes size: 16 bytes
The size of a struct is rounded up to a multiple of its largest field's alignment so that arrays of it stay aligned.
| Type | Default layout | Notes |
|---|---|---|
| Class | Auto | Fields reordered (references first, then larger to smaller primitives) to reduce padding; you can't rely on order |
| Struct with only unmanaged fields | Sequential | C# compiler emits LayoutKind.Sequential; fields stay in declaration order with padding |
| Struct containing references | Auto in managed memory | The runtime reorders for the GC; sequential only applies when marshalled |
[StructLayout(LayoutKind.Explicit)] |
You choose each FieldOffset |
Used for interop and unions; overlapping references with values is not allowed |
Object overhead on 64-bit: an object header (8 bytes, used for locking and hashing) plus a method table pointer (8 bytes), and a minimum object size of 24 bytes. A class with one int field uses 24 bytes; the same data in a struct array uses 4 bytes per element.
Unsafe.SizeOf<T>(): managed size of a value type (including padding).Marshal.SizeOf<T>(): size when marshalled to native code, which can differ (for example bool is 4 bytes by default when marshalled, 1 byte managed).sizeof(T) for unmanaged types (in an unsafe context for user-defined structs).GC.GetAllocatedBytesForCurrentThread() around an allocation, or a memory profiler, for class instances.LibraryImport), using Sequential with the right Pack, or Explicit offsets.MemoryMarshal.Read<T> (mind endianness too).[InlineArray(N)] (.NET 8+) gives a struct with N inline elements, safer than fixed buffers.A market-data service kept 50 million quote structs in memory. The struct was declared as bool IsBid; double Price; int Size; bool IsFirm;, which padded to 24 bytes. Reordering to double Price; int Size; bool IsBid; bool IsFirm; made it 16 bytes, saving about 400 MB and improving scan speed because more quotes fitted per cache line.
Checks low-level memory knowledge relevant to interop and high-scale data handling.
Green flags: explains natural alignment and padding with an example, knows auto versus sequential layout and object overhead, distinguishes Unsafe.SizeOf from Marshal.SizeOf, uses StructLayout appropriately, mentions atomicity.
Red flags: thinks size equals the sum of fields; uses Pack = 1 as a general optimisation; doesn't know classes have per-object overhead.
| Type | Size | Natural alignment |
|---|---|---|
byte, bool |
1 | 1 |
short, char |
2 | 2 |
int, float |
4 | 4 |
long, double, references, nint |
8 | 8 |
decimal |
16 | 8 |
Vector128<T> |
16 | 16 (best performance; unaligned loads allowed) |
Struct alignment = largest field alignment; struct size = padded to a multiple of that.
reference points here
|
v
[ object header (8) ][ MethodTable* (8) ][ fields ... (auto layout) ][pad]
sync block index, type info, references grouped first
hash code, lock bits vtable, GC info then 8, 4, 2, 1-byte fields
int[] array: [header][MethodTable*][length (8 on 64-bit, incl. padding)][elements...]
[StructLayout(LayoutKind.Sequential, Pack = 1)] // no padding: matches a packed C struct
public struct PackedHeader { public byte Version; public uint Length; public ushort Flags; } // 7 bytes
[StructLayout(LayoutKind.Explicit)]
public struct Int32Bytes // a union: same 4 bytes viewed two ways
{
[FieldOffset(0)] public int Value;
[FieldOffset(0)] public byte B0;
[FieldOffset(1)] public byte B1;
[FieldOffset(2)] public byte B2;
[FieldOffset(3)] public byte B3;
}
Pack = 1 makes Length misaligned at offset 1, which is fine for marshalling but slower to access and not atomic.
| Field | Managed | Default marshalled |
|---|---|---|
bool |
1 byte | 4 bytes (Win32 BOOL) unless [MarshalAs(UnmanagedType.U1)] |
char |
2 bytes | 1 or 2 depending on CharSet |
string |
reference | pointer to a native string, copied |
With LibraryImport (source-generated interop), prefer blittable types (only primitives and blittable structs) so data passes without copying. DisableRuntimeMarshalling makes that explicit.
NativeMemory.AlignedAlloc(size, alignment) and NativeMemory.AlignedFree (.NET 6+) allocate memory aligned to, for example, 64 bytes for SIMD or cache-line-sized buffers.
Unsafe.SizeOf<T>() and field offsets via a quick unsafe helper or ObjectLayoutInspector (a community NuGet package that prints field offsets and padding).Bad and Good from the answer and print Unsafe.SizeOf for each; then allocate 10 million of each in arrays and compare GC.GetTotalMemory.string field to Bad and check whether the size and order change.Unsafe.SizeOf<T>() and Marshal.SizeOf<T>() for a struct with a bool field.[InlineArray(8)] struct of int and use it as a fixed-size buffer with Span<int>.namespace Alignment;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
public struct Bad { public bool IsBid; public double Price; public int Size; public bool IsFirm; }
public struct Good { public double Price; public int Size; public bool IsBid; public bool IsFirm; }
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct PackedHeader { public byte Version; public uint Length; public ushort Flags; }
public struct WithBool { public bool Flag; public int Value; }
[InlineArray(8)]
public struct Buffer8 { private int _element0; }
public static class Program
{
public static void Main()
{
Console.WriteLine($"Bad: {Unsafe.SizeOf<Bad>()} bytes"); // 24
Console.WriteLine($"Good: {Unsafe.SizeOf<Good>()} bytes"); // 16
Console.WriteLine($"PackedHeader: {Unsafe.SizeOf<PackedHeader>()} bytes"); // 7
Console.WriteLine($"WithBool managed {Unsafe.SizeOf<WithBool>()}, marshalled {Marshal.SizeOf<WithBool>()}"); // 8 vs 8 (bool marshals as 4 bytes, then int)
long before = GC.GetAllocatedBytesForCurrentThread();
var bad = new Bad[1_000_000];
long badBytes = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
var good = new Good[1_000_000];
long goodBytes = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"1M Bad: {badBytes / 1_000_000.0:F1} MB, 1M Good: {goodBytes / 1_000_000.0:F1} MB");
var buffer = new Buffer8();
Span<int> span = buffer; // InlineArray converts to Span<T>
span.Fill(7);
Console.WriteLine(span.Length); // 8
GC.KeepAlive(bad); GC.KeepAlive(good);
}
}
Mistake: assuming a struct's size is the sum of its fields.
Padding for alignment can add a lot: bool, double, int, bool is 24 bytes, not 14.
Instead: measure with Unsafe.SizeOf<T>() and order fields from largest to smallest when size matters.
Mistake: relying on class field order. The CLR uses auto layout for classes and may reorder fields, so code that assumes offsets (unsafe reads, serialisers that copy memory) breaks. Instead: use explicit, sequential structs for anything that needs a fixed layout.
Mistake: using Pack = 1 everywhere "to save memory".
Misaligned fields are slower to access, can straddle cache lines, and lose atomicity guarantees.
Instead: use packing only to match external layouts, and reorder fields for compactness otherwise.
Mistake: confusing managed size with marshalled size.
bool is 1 byte managed but 4 bytes when marshalled by default, so native code reads the wrong offsets.
Instead: check Marshal.SizeOf, use blittable types with LibraryImport, and add [MarshalAs] where needed.
Mistake: forgetting object overhead when sizing caches.
A class with one int costs 24 bytes plus the reference that points to it, so millions of tiny objects use far more memory than expected.
Instead: estimate per-object overhead in capacity planning, and use struct arrays for very large collections of small records.
Mistake: overlapping references with values in an explicit layout. The runtime rejects types that overlay an object reference with other fields, because the GC couldn't tell what's a reference. Instead: keep references out of explicit unions, or use separate fields.
Alignment is a niche topic that pays off in three places: interop, very large in-memory datasets, and concurrency.
| Layout choice | Use it when | Risk if misused |
|---|---|---|
| Default (auto for classes, sequential for unmanaged structs) | Normal code | None; do not depend on class field order |
| Reorder struct fields largest to smallest | Millions of structs in arrays | None; pin the size with a unit test |
Sequential, Pack = 1 |
Matching a packed native or wire format | Slower misaligned access, non-atomic reads |
Explicit + FieldOffset |
Native unions, exact offsets | Runtime rejects overlapping references; easy to get offsets wrong |
[InlineArray(N)] |
Fixed-size buffers inside structs | Size fixed at compile time |
| Padding to 64/128 bytes | Hot per-thread data | Wasted memory if applied broadly |
Seniors know the CLR's defaults and why they exist, measure sizes instead of guessing, and keep explicit layout confined to boundary types (interop and serialisation) so the rest of the codebase stays idiomatic. They also connect it to capacity planning: memory per item, items per cache line and the 16-byte object overhead are real numbers when sizing caches and container memory limits. Security note: reading untrusted bytes straight into a struct with MemoryMarshal.Read<T> skips validation, so lengths and enum values from that struct must be checked before use.
Cores keep their caches coherent: when one core writes to a cache line, copies of that whole 64-byte line in other cores are invalidated. If thread A updates counters[0] and thread B updates counters[1], they're logically independent but sit on the same line, so the line ping-pongs between cores on every write. That's false sharing: more threads, more invalidations, and the loop gets slower. Symptoms are high CPU with poor scaling and no locks in sight. The best fix is to accumulate in a local variable per thread and combine once at the end (Parallel.For with localInit/localFinally). Where per-thread state must live in shared memory, pad each slot to its own cache line (128 bytes is safe across x64 and Apple Arm64).
Endianness is the order in which the bytes of a multi-byte value are stored. Little-endian puts the least significant byte first, so 0x12345678 is stored as 78 56 34 12. Big-endian puts the most significant byte first: 12 34 56 78. x64 and Arm64 machines running .NET are little-endian, but network protocols ("network byte order") and many file formats are big-endian. In .NET I use BinaryPrimitives.ReadInt32BigEndian and friends so the byte order is explicit and portable. BitConverter follows the machine, and BinaryReader/BinaryWriter are always little-endian.
How to tell it (STAR)
**Situation:** At [company], our market-data pods were running close to their [8 GB] memory limit and getting OOM-killed at market open. **Task:** I needed to reduce memory without dropping data. **Action:** The heap profile showed the quote arrays were the biggest consumer. Measuring the struct showed [24] bytes for [14] bytes of fields because of padding. I reordered the fields from largest to smallest, getting it to [16] bytes, and added a test that pins the size. **Result:** Each pod used about [400 MB] less, the OOM restarts stopped, and scans got faster because more quotes fitted in each cache line.
Last updated 23 Oct 2026.