Snapshots: How V8 Freezes a Heap into a File
A V8 snapshot is not a memory dump. It is a bytecode stream for a tiny interpreter, plus a relocation table, plus a version string that refuses to load against the wrong binary.
TL;DR: A V8 snapshot is a byte stream of opcodes for a miniature interpreter, not a memory image.
src/snapshot/serializer-deserializer.hdefines the whole instruction set:kNewObject,kBackref,kRootArray,kExternalReference. The deserializer allocates a real object for everykNewObject, fills in the fields, and patches pointers as it goes. Raw addresses cannot survive the trip, so every external reference is stored as an index into the isolate’sExternalReferenceTableand looked up again at load time. The blob is versioned: a mismatch between the snapshot’s 64-byte version string and the binary’s is a fatal error, not a warning.
Starting a JavaScript engine from scratch is expensive. V8 has to compile the interpreter’s dispatch table, build the maps for every object shape it knows about, create the strings, and set up the built-in functions. Do all of that on every process start and you are paying a fixed tax before the first line of user code runs.
V8’s answer is to do the work once, at build time, and write the result to a file. The interesting part is how it writes it, because the naive approach - dump the heap pages and mmap them back - does not work. A process’s heap contains absolute pointers: every map points at its prototype, every function points at its feedback vector, and every piece of generated code contains relocations against other pieces of generated code. Dump those bytes and the file is only valid at the address it was taken from.
V8 therefore serializes the heap as a program for a tiny interpreter, and the deserializer re-executes that program to rebuild the object graph. The interpreter’s instruction set is small enough to read in one sitting.
The instruction set
The whole format lives in one enum:
// src/snapshot/serializer-deserializer.h:71
enum Bytecode : uint8_t {
//
// ---------- byte code range 0x00..0x25 ----------
//
// 0x00..0x03 Allocate new object, in specified space.
kNewObject = 0x00,
// Reference to previously allocated object.
kBackref = 0x04,
// Reference to an object in the read only heap.
kReadOnlyHeapRef,
// Object in the startup object cache.
kStartupObjectCache,
// Root array item.
kRootArray,
// Object provided in the attached list.
kAttachedReference,
// Object in the shared heap object cache.
kSharedHeapObjectCache,
// Do nothing, used for padding.
kNop,The opcodes are grouped into ranges, and the value of the opcode within
its range is the payload. kNewObject occupies 0x00-0x03, one value per
heap space, and the space is a SnapshotSpace:
// src/snapshot/references.h:18
enum class SnapshotSpace : uint8_t {
kReadOnlyHeap = 0,
kOld = 1,
kCode = 2,
kTrusted = 3,
};
static constexpr int kNumberOfSnapshotSpaces = 4;Four spaces, because the snapshot has to place objects into the right one, and the mapping from heap space to snapshot space is not the identity:
// src/snapshot/serializer.cc:878
case OLD_SPACE:
// Young generation objects are tenured, as objects that have survived
// until snapshot building probably deserve to be considered 'old'.
case NEW_SPACE:
// Large objects (young and old) are encoded as simply 'old' snapshot
// objects, as "normal" objects vs large objects is a heap implementation
// detail and isn't relevant to the snapshot.
case NEW_LO_SPACE:
case LO_SPACE:
// Shared objects are currently encoded as 'old' snapshot objects. This
// basically duplicates shared heap objects for each isolate again.
case SHARED_SPACE:
case SHARED_LO_SPACE:
return SnapshotSpace::kOld;
case CODE_SPACE:
return SnapshotSpace::kCode;
case TRUSTED_SPACE:
case TRUSTED_LO_SPACE:
return SnapshotSpace::kTrusted;
// Shared objects are currently encoded as 'trusteds' snapshot objects.
// This basically duplicates shared trusted heap objects for each isolate
// again.
case SHARED_TRUSTED_SPACE:Read-only space holds the immortal objects - the built-in maps, the
core strings, the undefined/null/true/false values - and they are
shared by every isolate in the process. Old space holds ordinary
long-lived objects, and it is also where the young-generation and
large-object spaces land, because “young” and “large” are heap
implementation details that a snapshot should not encode. Code space
holds instruction streams, and trusted space holds the objects the
sandbox forbids user code from touching directly.
Then there are the “compressed” opcode ranges, which exist purely to make the file smaller:
// src/snapshot/serializer-deserializer.h:170
//
// ---------- byte code range 0x40..0x7f ----------
//
// 0x40..0x5f
kRootArrayConstants = 0x40,
// 0x60..0x7f
kFixedRawData = 0x60,
//
// ---------- byte code range 0x80..0xcf ----------
//
// 0x80..0x8f
kFixedRepeatRoot = 0x80,
// 0x90..0xcf
kFixedBackref = 0x90,
};kFixedBackref is the important one. A back reference is normally a
one-byte opcode plus a Uint30 index - five bytes to point at an object
you have already seen. But the first 64 objects you see are almost always
the same few dozen objects (the roots, the empty arrays), so those get
their own opcode range where the opcode is the index. kRootArrayConstants
does the same for the first 32 entries of the root table, which the source
comment in the roots header explains:
// src/roots/roots.h:96
#define STRONG_READ_ONLY_ROOT_LIST(V) \
/* Cluster the most popular ones in a few cache lines here at the top. */ \
/* The first 32 entries are most often used in the startup snapshot and */ \
/* can use a shorter representation in the serialization format. */ \
V(Undefined, undefined_value, UndefinedValue) \
V(TheHole, the_hole_value, TheHoleValue) \
V(Null, null_value, NullValue) \
V(True, true_value, TrueValue) \
V(False, false_value, FalseValue) \The blob is a container of four snapshots
A single file holds more than one snapshot. The header comment in
src/snapshot/snapshot.cc is the whole layout:
// src/snapshot/snapshot.cc:74
// Snapshot blob layout:
// [0] number of contexts N
// [1] rehashability
// [2] checksum
// [3] read-only snapshot checksum
// [4] (64 bytes) version string
// [5] offset to startup
// [6] offset to shared heap
// [7] offset to context 0
// [8] offset to context 1
// ...
// ... offset to context N - 1
// ... read-only snapshot data (first, for alignment)
// ... startup snapshot data
// ... shared heap snapshot data
// ... context 0 snapshot data
// ... context 1 snapshot data
So the file is a header plus four payloads:
Read-only snapshot. The immortal objects: the core maps, the well-known symbols, and the
Codeobjects for the builtins. It goes first, after the header, and the reason is in the source:cpp// src/snapshot/snapshot.cc:600 // Read-only snapshot first, so it's close to the start of the blob. // When the blob is page-aligned in the executable, this allows the // RO space image within it to also be page-aligned.Note what is not in it: the builtins’ machine code. The
Codeobjects in the read-only space have no instruction stream, because the instructions live in the embedded blob in the binary.Startup snapshot. The isolate-level state: the roots, the startup object cache, the isolate’s own tables.
Shared heap snapshot. Objects that belong in the shared heap of a shared isolate, used by
--shared-string-table.Context snapshots. One per
NativeContext. A context is a set of intrinsics - theObjectprototype, theArrayprototype, the global object. V8 ships one by default; an embedder can add more.
The header is fixed-size and the offsets are little-endian uint32_ts:
// src/snapshot/snapshot.cc:92
static const uint32_t kNumberOfContextsOffset = 0;
// TODO(yangguo): generalize rehashing, and remove this flag.
static const uint32_t kRehashabilityOffset =
kNumberOfContextsOffset + kUInt32Size;
static const uint32_t kChecksumOffset = kRehashabilityOffset + kUInt32Size;
static const uint32_t kReadOnlySnapshotChecksumOffset =
kChecksumOffset + kUInt32Size;
static const uint32_t kVersionStringOffset =
kReadOnlySnapshotChecksumOffset + kUInt32Size;
static const uint32_t kVersionStringLength = 64;
static const uint32_t kStartupOffsetOffset =
kVersionStringOffset + kVersionStringLength;
static const uint32_t kSharedHeapOffsetOffset =
kStartupOffsetOffset + kUInt32Size;
static const uint32_t kFirstContextOffsetOffset =
kSharedHeapOffsetOffset + kUInt32Size;The rehashability bit is a small optimisation with a large caveat. V8
randomises its hash seed per isolate, so string hashes baked into a
snapshot would not match the new isolate’s seed. If the snapshot was taken
from an isolate that had not yet used its hash table, the flag says “you
may rehash”, and the deserializer walks the objects and recomputes. If the
flag is clear, the hash seed is assumed to be unchanged.
The 64-byte version string is a hard gate. Not a soft check, not a warning - a fatal error:
// src/snapshot/snapshot.cc:780
void SnapshotImpl::CheckVersion(const v8::StartupData* data) {
if (!Snapshot::VersionIsValid(data)) {
char version[kVersionStringLength];
memset(version, 0, kVersionStringLength);
CHECK_LT(kVersionStringOffset + kVersionStringLength,
static_cast<uint32_t>(data->raw_size));
Version::GetString(base::Vector<char>(version, kVersionStringLength));
FATAL(
"Version mismatch between V8 binary and snapshot.\n"
"# V8 binary version: %.*s\n"
"# Snapshot version: %.*s\n"
"# The snapshot consists of %d bytes and contains %d context(s).",
kVersionStringLength, version, kVersionStringLength,
data->data + SnapshotImpl::kVersionStringOffset, data->raw_size,
ExtractNumContexts(data));
}
}The message is the whole point: the snapshot format is tied to the exact
V8 build. Point a new binary at an old snapshot and V8 refuses to start
rather than corrupting the heap. This is why an external snapshot has to
be regenerated whenever V8 is upgraded, and why mksnapshot is run as
part of the V8 build rather than shipped as a prebuilt artifact.
Writing the heap out
The serializer walks the object graph and emits the opcodes. The interesting decisions are in how it handles pointers, and there are only three kinds.
Back references. If the serializer has already emitted an object, it writes a back reference to it instead of emitting it twice. The identity map that tracks this is per-serializer.
Root references. Objects that live in the isolate’s root table get a one-byte “root index” opcode instead of being duplicated. The root table is part of the snapshot’s contract: index n means the same thing in the binary that wrote the file and the binary reading it.
External references. These are the ones that break the “just dump the
bytes” model, because they are addresses outside the V8 heap. The C++
function Array.prototype.sort calls, an AccessorInfo getter, a stub
cache slot - these are all raw addresses baked into generated code and
into accessor infos. They change every time the binary is loaded (ASLR,
different link order), so they cannot be stored raw.
The fix is an indirection table. ExternalReferenceEncoder maps each
address to a dense index; the snapshot stores the index; the deserializer
looks the index up in the new isolate’s table:
// src/snapshot/deserializer.cc:1811
template <typename IsolateT>
Address Deserializer<IsolateT>::ReadExternalReferenceCase() {
DCHECK(InTrustedSection());
uint32_t reference_id = static_cast<uint32_t>(source_.GetUint30());
return main_thread_isolate()->external_reference_table()->address(
reference_id);
}The table itself is a fixed-size array of addresses built at isolate init, and it is split in two: an isolate-independent prefix (builtins, runtime functions, accessors) and an isolate-dependent suffix (the isolate’s own data fields, the stub cache slots):
// src/codegen/external-reference-table.h:49
static constexpr int kSizeIsolateIndependent =
kSpecialReferenceCount + kExternalReferenceCountIsolateIndependent +
kBuiltinsReferenceCount + kRuntimeReferenceCount +
kAccessorReferenceCount;
static constexpr int kSize =
kSizeIsolateIndependent + kExternalReferenceCountIsolateDependent +
kIsolateFieldReferenceCount + kStubCacheReferenceCount +
kStatsCountersReferenceCount;The problem with a deduplicated table is that the deduplication itself depends on the order in which references were first seen. If the writing binary and the reading binary have different orderings, index 17 in one means something different in the other. So the serializer records the mismatches explicitly and the deserializer verifies them:
// src/snapshot/startup-serializer.cc:73
// This serializes any external reference which don't encode to their own
// index. This is so that the deserializer can verify that any entries that
// were deduplicated during serialization are also deduplicated in the
// deserializing binary.
ExternalReferenceTable* table = isolate->external_reference_table();
for (uint32_t i = 0; i < ExternalReferenceTable::kSizeIsolateIndependent;
++i) {
ExternalReferenceEncoder::Value encoded_reference =
EncodeExternalReference(table->address(i));
if (encoded_reference.index() != i) {
sink_.PutUint30(i, "expected reference index");
sink_.PutUint30(encoded_reference.index(), "actual reference index");
}
}
sink_.PutUint30(ExternalReferenceTable::kSizeIsolateIndependent,
"end of deduplicated reference indices");and the check on the way in:
// src/snapshot/startup-deserializer.cc:105
void StartupDeserializer::DeserializeAndCheckExternalReferenceTable() {
// Verify that any external reference entries that were deduplicated in the
// serializer are also deduplicated in this isolate.
ExternalReferenceTable* table = isolate()->external_reference_table();
while (true) {
uint32_t index = source()->GetUint30();
if (index == ExternalReferenceTable::kSizeIsolateIndependent) break;
uint32_t encoded_index = source()->GetUint30();
CHECK_EQ(table->address(index), table->address(encoded_index));
}
}CHECK_EQ - a crash, not a fallback. If the two binaries disagree about
the reference table, the snapshot is unusable and there is no way to
recover, so V8 says so immediately.
Rebuilding the heap
The deserializer is the interpreter for the opcodes above. For each
kNewObject it reads the map, computes the size, allocates in the right
space, sets the map, and then fills in the fields. The order matters
because the GC can run at any allocation:
// src/snapshot/deserializer.cc:864
// Filling an object's fields can cause GCs and heap walks, so this object has
// to be in a 'sufficiently initialised' state by the time the next allocation
// can happen. For this to be the case, the object is carefully deserialized
// as follows:
// * The space for the object is allocated.
// * The map is set on the object so that the GC knows what type the object
// has.
// * The rest of the object is filled with a fixed Smi value
// - This is a Smi so that tagged fields become initialized to a valid
// tagged value.
// - It's a fixed value, "Smi::uninitialized_deserialization_value()", so
// that we can DCHECK for it when reading objects that are assumed to be
// partially initialized objects.
// * The fields of the object are deserialized in order, under the
// assumption that objects are laid out in such a way that any fields
// required for object iteration (e.g. length fields) are deserialized
// before fields with objects.
That “fixed Smi value” is a lovely detail. A half-built object must have
some value in every tagged slot, and it must be a value the GC can
safely walk past. A Smi is never a heap object, so the collector will not
chase it. Using undefined would be a heap object and would be chased,
and would be wrong if the GC decided to mark it.
The allocation itself is a plain heap allocation with an unusual alignment rule:
// src/snapshot/deserializer.cc:884
const InSharedSpace in_shared_space =
InSharedSpace{IsSharedAllocationType(allocation)};
Tagged<HeapObject> raw_obj =
Allocate(allocation, size_in_bytes,
HeapObject::RequiredAlignment(in_shared_space, *map));
raw_obj->set_map_after_allocation(isolate_, *map);
MemsetTagged(raw_obj->RawField(kTaggedSize),
Smi::uninitialized_deserialization_value(), size_in_tagged - 1);The memsets happen after the map is set, so the object is already correctly typed as far as the collector is concerned.
Cycles, forward references, and the meta-map problem
Object graphs have cycles. a.foo = b; b.foo = a; cannot be serialised
depth-first without either infinite recursion or a two-pass approach.
V8’s answer is the pending forward reference: the serializer registers a
slot as “pending”, emits the object it will point at later, and the
deserializer patches the slot when the target arrives.
// src/snapshot/deserializer.cc:1404
int Deserializer<IsolateT>::ReadResolvePendingForwardRef(
uint8_t data, SlotAccessor slot_accessor) {
// Pending forward refs can only be resolved after the heap object's map
// field is deserialized; currently they only appear immediately after the
// map field or after the 'self' indirect pointer for trusted objects.
DCHECK(slot_accessor.offset() == sizeof(HeapObject) ||
slot_accessor.offset() == ExposedTrustedObject::kHeaderSize);
DirectHandle<HeapObject> obj = slot_accessor.object();
uint32_t index = source_.GetUint30();
auto& forward_ref = unresolved_forward_refs_[index];The tricky case is a map. A map’s own map (its “meta map”) is what tells
the collector that a map is a map, and a contextful meta map points back
at the NativeContext, which points back at maps. The serializer
special-cases this with two dedicated opcodes:
// src/snapshot/serializer.cc:483
if (map == ReadOnlyRoots(isolate()).meta_map()) {
DCHECK_EQ(space, SnapshotSpace::kReadOnlyHeap);
sink_->Put(kNewContextlessMetaMap, "NewContextlessMetaMap");
sink_->PutUint30(map->instance_type(), "MetaMapInstanceType");
} else {
DCHECK_EQ(space, SnapshotSpace::kOld);
DCHECK(IsContext(map->native_context_or_null()));
sink_->Put(kNewContextfulMetaMap, "NewContextfulMetaMap");
sink_->PutUint30(map->instance_type(), "MetaMapInstanceType");
// Defer serialization of the native context in order to break
// a potential cycle through the map slot:
// MAP -> meta map -> NativeContext -> ... -> MAP
// Otherwise it'll be a "forward ref to a map" problem: deserializer
// will not be able to create {obj} because {MAP} is not deserialized yet.
The comment is explicit about why a forward reference is not enough: the deserializer needs the map to allocate the object at all, so it cannot allocate a “hole” and patch it later. It needs the map first, and the map needs the context, and the context needs the map. The two dedicated opcodes break the cycle by making the meta map’s identity implicit.
Where the code lives
Builtins are compiled, not interpreted, and their machine code is part of
the snapshot. But there is a wrinkle: the code for a builtin is usually
off-heap, in the instruction stream, and the Code object in the heap
is a small handle that points at it.
The serializer has to copy that code out and the deserializer has to put
it back at a possibly different address. The mechanism is the same
SnapshotSpace::kCode value on the kNewObject opcode, plus
post-processing for the Code objects themselves:
// src/snapshot/deserializer.cc:727
} else if (InstanceTypeChecker::IsCode(instance_type)) {
Tagged<Code> code = TrustedCast<Code>(raw_obj);
if (!code->has_instruction_stream()) {
code->SetInstructionStartForOffHeapBuiltin(
main_thread_isolate(), EmbeddedData::FromBlob(main_thread_isolate())
.InstructionStartOf(code->builtin_id()));
} else {
code->UpdateInstructionStart(main_thread_isolate(),
code->instruction_stream());
}The !code->has_instruction_stream() branch is the interesting one. When
Builtins::kCodeObjectsAreInROSpace is true - and it is, unless you are
on a short-builtin-calls configuration - the builtins’ code is not in the
heap at all. It lives in the embedded blob, a separate region of
machine code compiled into the binary itself:
// src/snapshot/embedded/embedded-data.h:60
// Returns the global embedded blob (usually physically located in .text and
// .rodata).
static EmbeddedData FromBlob() {
return EmbeddedData(Isolate::CurrentEmbeddedBlobCode(),
Isolate::CurrentEmbeddedBlobCodeSize(),
Isolate::CurrentEmbeddedBlobData(),
Isolate::CurrentEmbeddedBlobDataSize());
}So there are two blobs, and they are not the same thing:
- The snapshot blob is the heap: objects, maps, strings, roots. It is
what
mksnapshotwrites out and whatCreateParams::snapshot_blobpoints at. - The embedded blob is the machine code of the builtins, physically in
the binary’s
.textsection.mksnapshotwrites it out separately, as a set of assembly files, so that the compiler can put it in the right section with the right attributes.
That split is why the Code objects in the snapshot have no instruction
stream: their code is already in the binary, at a fixed offset from the
blob’s start, and the deserializer just points the Code object at it.
The code cache is a different beast
The snapshot is for the engine’s own objects. User code is handled by a
separate mechanism: ScriptCompiler::CachedData, the code cache. Same
serializer machinery, but a different set of constraints, and the
difference shows up in the trust model.
A code cache is loaded from disk, potentially by an untrusted party, so it is split into a trusted and an untrusted section. The untrusted section cannot contain any opcode that could corrupt memory outside the sandbox:
// src/snapshot/serializer-deserializer.h:191
// Bytecodes that could corrupt memory outside the sandbox, which the
// deserializer only accepts in trusted sections. There is no default case,
// so that every new bytecode has to be classified here.
static constexpr bool IsTrustedBytecode(Bytecode bytecode) {
switch (bytecode) {
case kApiReference:
case kExternalReference:
case kSandboxedApiReference:
case kSandboxedExternalReference:
case kSandboxedRawExternalReference:
case kInitializeSelfTrustedPointer:
case kAllocateJSDispatchEntry:
case kProtectedPointerPrefix:
return true;No default case. Add a new opcode and the compiler makes you decide which
bucket it goes in. External references are trusted because they encode
raw addresses, which a malicious cache could otherwise use to point a
Code object anywhere in the process.
The code cache also has to deal with bytecode flushing. When a function’s
bytecode is discarded by the GC, the SharedFunctionInfo keeps an
UncompiledData instead, and the function has to be re-parsed from source
on the next call. A code cache that contains bytecode for a function
whose bytecode was since flushed is still valid - the cache is keyed by a
SHA-256 of the source text, not by bytecode identity:
// src/snapshot/code-serializer.cc:907
SerializedCodeData::SourceHash::SourceHash(
DirectHandle<String> source, DirectHandle<FixedArray> wrapped_arguments,
ScriptOriginOptions origin_options, Isolate* isolate) {
if (v8_flags.code_cache_source_hash_sha256) {
LITE_SHA256_CTX ctx;
SHA256_init(&ctx);
// Hash compilation-affecting metadata bits into the SHA-256 context.
// Only IsModule() in ScriptOriginOptions affects how JS source text is
// parsed and compiled into bytecode.
uint8_t flags = 0;
if (!wrapped_arguments.is_null()) flags |= 1;
if (origin_options.IsModule()) flags |= 2;
SHA256_update(&ctx, &flags, sizeof(flags));The IsModule() bit is there because a module and a script with the same
text compile to different bytecode. A module has a module scope, a
module context, and a different set of top-level bindings. Sharing a code
cache between the two would be a correctness bug, so the flag is part of
the hash.
The other half of the code cache story is InterpreterData. When V8 runs
with --interpreted-frames-native-stack, a function’s InterpreterData
holds a pointer to a copy of the interpreter entry trampoline. That
pointer is meaningless in another process, so the serializer swaps the
whole InterpreterData for the BytecodeArray it points at:
// src/snapshot/code-serializer.cc:162
if (V8_UNLIKELY(isolate()->interpreted_frames_native_stack()) &&
IsInterpreterData(*obj)) {
SBXCHECK(OutsideSandbox(obj->address()));
obj =
handle(TrustedCast<InterpreterData>(*obj)->bytecode_array(), isolate());
}The comment above it is a warning from someone who learned this the hard way: “if we try to serialize an InterpreterData our process will crash since it stores a code object.”
Bytecode flushing, from the other side
There is a nice loop between the snapshot and the bytecode flusher. The
SharedFunctionInfo has a 16-bit age field that the marker bumps by
the number of seconds since the last full GC, and the flusher discards
bytecode once the age passes bytecode_old_time (180 seconds by
default). See
Flushing Code: How V8 Gives Memory Back When the Heap is Full
for that side of it.
Two places in the snapshot code care about the age field:
First, the serializer deliberately writes a zero age rather than the live one, so the snapshot is byte-for-byte reproducible:
// src/snapshot/serializer.cc:1423
if (IsSharedFunctionInfo(*object_)) {
// The bytecode age field can be changed by GC concurrently.
static_assert(SharedFunctionInfo::kAgeSize == kUInt16Size);
uint16_t field_value = 0;
OutputRawWithCustomField(sink_, object_start, base, bytes_to_output,
offsetof(SharedFunctionInfo, age_),
sizeof(field_value),
reinterpret_cast<uint8_t*>(&field_value));The comment says “can be changed by GC concurrently”. Without this, two
runs of mksnapshot on the same machine could produce different bytes,
and the snapshot checksum would be non-deterministic. That would be a
disaster for reproducible builds, which is why mksnapshot also sets
v8_flags.predictable = true.
Second, the deserializer resets the age to zero for every SharedFunctionInfo
it creates, because the marker’s age arithmetic assumes the object was
last executed before the last GC:
// src/snapshot/deserializer.cc:894
// Make sure BytecodeArrays have a valid age, so that the marker doesn't
// break when making them older.
if (IsSharedFunctionInfo(raw_obj)) {
Cast<SharedFunctionInfo>(raw_obj)->set_age(0);And a third connection: the read-only space is exempt from bytecode flushing entirely, which is what you want, because the builtins’ own bytecode lives there:
// src/heap/marking-visitor-inl.h:515
bool MarkingVisitorBase<ConcreteVisitor>::HasBytecodeArrayForFlushing(
Tagged<SharedFunctionInfo> sfi) const {
if (HeapLayout::InReadOnlySpace(sfi)) return false;
if (IsFlushingDisabled(code_flush_mode_)) return false;Making a snapshot of your own
V8 exposes the whole thing as a public API. v8::SnapshotCreator is the
embedder’s entry point:
// include/v8-snapshot.h:268
/**
* Created a snapshot data blob.
* This must not be called from within a handle scope.
* @param function_code_handling whether to include compiled function code
* in the snapshot.
* @returns { nullptr, 0 } on failure, and a startup snapshot on success. The
* caller acquires ownership of the data array in the return value.
*/
StartupData CreateBlob(FunctionCodeHandling function_code_handling);FunctionCodeHandling::kClear versus kKeep is the interesting knob.
kClear throws away everything recompilable, so the snapshot stays small
and the embedder’s functions are compiled from source on first call.
kKeep preserves the compiled code, which makes the snapshot much bigger
but means the embedder’s code is ready to run immediately.
On the way back in, the blob goes into CreateParams:
// include/v8-isolate.h:327
/**
* Explicitly specify a startup snapshot blob. The embedder owns the blob.
* The embedder *must* ensure that the snapshot is from a trusted source.
*/
const StartupData* snapshot_blob = nullptr;The warning about trusting the source is not decoration. A snapshot blob is a program for the deserializer, and the deserializer allocates heap objects and writes pointers. A malicious blob is arbitrary memory corruption. (The sandbox’s trusted/untrusted section split exists for the code cache, which is more likely to be attacker-supplied; the startup snapshot is assumed to come from your own build.)
Node.js builds on exactly this API: node --build-snapshot runs the
snapshot creator over a script and writes the blob to a file, and
node --snapshot-blob=... passes that file back in as
CreateParams::snapshot_blob. V8 itself has no such flag - the flags are
Node’s, and the machinery underneath is the SnapshotCreator above.
Trade-offs
What it buys. Startup time. An isolate created from a snapshot skips
building the builtins, interning the core strings, and constructing the
maps. The savings are largest for embedders with a big intrinsic
environment, because the context snapshot carries the whole NativeContext
- prototypes, intrinsics, the global object - ready to use.
What it costs.
- The format is frozen. The snapshot is tied to a specific V8 build. The version check and the external-reference-table check both turn a mismatched binary into a crash. This is a deliberate trade: correctness over convenience.
- Size. The blob contains the whole object graph in a verbose format.
V8_SNAPSHOT_COMPRESSIONexists to shrink it, and the read-only snapshot is placed first specifically so it can be page-aligned and mapped rather than copied. - The deserializer is real code that has to be correct. Every opcode in
the enum is a place where a bug means heap corruption, and the
IsTrustedBytecodeswitch with no default case is V8 admitting that. - Snapshots freeze what was true at build time. Anything that depends on the environment - the ICU data, the timezone database, the hash seed - has to be handled explicitly. The hash seed gets the rehashability bit; the rest is the embedder’s problem.
The bottom line
A V8 snapshot is a program, not a picture. The serializer walks the object graph and emits a stream of single-byte opcodes plus payloads; the deserializer is an interpreter that allocates real objects, fills their fields, and patches pointers as it goes. Every absolute address that cannot be reproduced is replaced by an index into a table that the reader rebuilds, and the two tables are checked against each other at load time. The version string at the front of the blob is the escape hatch: if anything at all about the two builds differs, V8 refuses to start rather than guess.
The mental model to keep is that the snapshot format is a serialisation protocol between two versions of the same program, and the protocol is strict on purpose. Everything that makes it work - the trusted/untrusted section split, the external-reference indirection, the version gate, the deterministic age field - is there because a snapshot is a stream of pointers that has to be re-pointed.
The thing people get wrong
The common belief is that a snapshot is a memory image, and that
“loading a snapshot” is like restoring a core dump. It is not. There is
no mmap of the heap, no fixup of pointers by the loader, and no
snapshot that can be loaded into a different binary.
The consequence people miss is that a snapshot is only as good as the
binary it was made for. If you ship an external snapshot file alongside
your application, you are shipping a file that becomes unloadable the
moment V8 is upgraded, and the failure mode is a fatal version-mismatch
message rather than a fallback. That is why the embedded snapshot - the
one baked into the binary by mksnapshot during the build - is the
normal case, and the external blob is the special case.