Why can a 10-character substring keep a 2 GB document alive in memory?
answer
- there are two ways to implement slicing
- what exactly does the small object point at
- reclamation is all-or-nothing per buffer
- compare retained size against shallow size
- which outlives which, slice or parent
basics
~20 sA substring can be a view — an offset and length over the parent's buffer — rather than a copy. Extraction is then O(1), but the view references the whole parent buffer, so all 2 GB stays reachable while the slice lives.
solid answer
~50 sThere are two ways to implement substring extraction, and immutability is what makes the cheap one legal. A **view** stores a reference to the parent's buffer plus an offset and length: O(1) time, O(1) extra space, and safe only because neither side can modify the shared characters. A **copy** allocates a fresh buffer of k characters: O(k) time and space, but the parent becomes unreachable as soon as you drop it. The view's hidden cost is retention — the slice's reference keeps the parent's entire buffer reachable, so a 10-character tag extracted from a 2 GB document pins 2 GB. The symptom in production is memory proportional to the largest inputs ever parsed rather than to live data. Neither is strictly better: views win for transient slices over a short-lived parent, copies win whenever slices outlive their parent.
code
pseudocode · 14 linestext = read_all_characters(source) // 2,000,000,000 characters
i = find(text, "ERROR")
tag = substring(text, i, i + 10) // 10 characters
text = none // the last direct reference is dropped
return tag // tag is stored in a long-lived index
// if substring returns a VIEW, tag holds:
// buffer -> the 2,000,000,000-character storage
// offset -> i
// length -> 10
// the whole buffer is still reachable through tag, so nothing is reclaimed
// if substring returns a COPY, tag holds its own 10-character buffer
// and the 2 GB storage becomes unreachable at the assignment abovego deeper
Be ready to say that extracting a substring either copies the characters or points into the parent's storage, and that the second option is why a small piece can keep a large original in memory.
Explain both cost profiles precisely — O(1) time and space for a view versus O(k) for a copy — and why memory reclamation is per-buffer, so a surviving view pins the whole parent regardless of its own length.
Demonstrate the diagnosis: retained size far exceeding shallow size, memory tracking the largest inputs ever parsed rather than live results, and a boundary fix that copies values on their way into long-lived storage.
Own the default for the codebase. Decide where slicing is allowed to share, make the copy point a reviewable boundary rather than a per-site judgement, and be able to say what you would give up in parsing throughput if you banned sharing outright.
## Two implementations of one operation Extracting characters `[i, i+k)` from a parent string of length n can be done two ways. **View (buffer sharing).** The new string stores three things: a reference to the parent's character buffer, an offset `i`, and a length `k`. No characters are copied. - Time: O(1). Space: O(1) beyond the small header. - Legal **only because strings are immutable**. If either side could write to the shared buffer, the other would see its contents change — precisely the aliasing hazard immutability exists to remove. This is a nice illustration that immutability is not only a safety property; it *enables* optimisations that are impossible for mutable buffers. - Hidden cost: the child references the **whole** parent buffer. Reachability is all-or-nothing — memory reclamation frees a buffer only when nothing at all references it, and it has no notion of "only ten of these two billion characters are still wanted". **Copy.** The new string allocates its own buffer of exactly k characters and copies them in. - Time: O(k). Space: O(k). - The parent becomes unreachable the moment the last direct reference to it is dropped. ## The postmortem shape A document processor reads a 2 GB file, finds a marker, extracts a 10-character tag, drops the document, and stores the tag in a long-lived index. With copying substrings, live memory after the loop is 10 characters per document. With view substrings, it is 2 GB per document, and the process dies after a handful of files. What makes this expensive to diagnose is how *innocent* the code looks. There is no cycle, no forgotten listener, no unclosed resource — the classic leak checklist finds nothing. The tells are: - Live memory scales with the **size of the largest inputs ever processed**, not with the number or size of the retained results. - A heap breakdown shows a small number of enormous character buffers, each reachable from a tiny string object, with no other referents. - Retained size per stored object is orders of magnitude larger than its shallow size — the single most diagnostic number here. - The fix that "shouldn't matter" — copying the extracted value — makes the problem vanish, which is the confirmation. ## The fix, and why it is a real tradeoff The fix is to force a copy of any slice that will outlive its parent: rebuild the extracted value into its own buffer before storing it. Structurally, do it at the boundary where parsed values become retained values, so the rule is "anything that goes into the index is copied" rather than a judgement call at each call site. But do not conclude that copying substrings is simply correct. Consider a tokenizer that slices a large input into millions of tokens, examines each, and keeps almost none. With views, tokenization allocates essentially nothing and the parent dies with the parse. With copies, you allocate and copy every token's characters — a large, entirely wasted cost, and the classic reason high-performance parsers slice rather than copy. The rule that captures both cases: - **Slices transient, parent short-lived → view.** O(1) extraction, no retention hazard, big win. - **Slices retained, parent large → copy.** Pay O(k) once to release O(n). The direction to keep straight is that the view is not "faster", it is *cheaper to create and more expensive to keep*. It moves cost from extraction time to retention. The history here is instructive because mainstream ecosystems have made both calls and changed their minds: Java shipped buffer-sharing substrings for years and switched to copying after exactly this retention bug proved too common in the field, while Go's slice-of-bytes and Rust's string slices deliberately share and put the retention discipline on the programmer. Same operation, same tradeoff, opposite defaults — which is why an interviewer asking this wants your reasoning, not a memorised answer about one environment. ## Related shapes The pattern generalises past strings: any small object that holds a reference into a large backing store — a sub-array view, a slice over a memory-mapped region, a parsed field pointing into a network buffer — has the same retention behaviour. Recognising the shape once is what lets you find it quickly in whatever form it next appears.
- If sharing causes this, why would any implementation choose it?Because for transient slices it is dramatically cheaper. A tokenizer that cuts millions of tokens out of one input, inspects each and keeps almost none allocates essentially nothing with views, whereas copying allocates and fills a buffer per token for values that are discarded microseconds later. The hazard only appears when slices outlive their parent.
- How would you confirm this diagnosis rather than guess at it?Compare retained size to shallow size for the stored objects: a 10-character string retaining hundreds of megabytes is conclusive. Supporting evidence is that live memory tracks the size of the largest inputs ever parsed rather than the count of stored results, and that forcing a copy of the extracted value makes the growth disappear.
- Where in the code would you put the forced copy, and why there?At the boundary where parsed values become retained values — the point where a field enters the index, cache or result object. Putting it there makes the rule mechanical and reviewable ("everything stored is copied") instead of a per-call-site judgement, and it leaves the transient slices in the parser untouched so you keep the extraction win.
- Does this failure shape occur outside strings?Yes, wherever a small object holds a reference into a large backing store: a sub-array view over a big array, a slice over a memory-mapped region, a parsed field pointing into a network read buffer. Reclamation frees whole buffers, so any surviving view pins the entire store regardless of how little of it is still wanted.
saying these in an interview costs you the question
- Assumes substring extraction is always a copy
- Calls the O(1) view strictly better than copying
- Thinks reclamation can free the unused part of a buffer
- Blames a cycle or an unclosed resource instead
- Concludes sharing slices is always wrong
- Ignores that immutability is what makes sharing safe