Course outline

C# / Advanced: Lifetimes, Concurrency, and Runtime Design / Lesson 13

Value Semantics, References, and Garbage Collection

Reason from copying and reachability instead of simplistic stack-versus-heap slogans, and separate memory reclamation from resource release.

About 5 min readContent reviewed
On this page

Lesson goals

  • Trace struct values and class references through assignment.
  • Explain boxing and shallow reference-bearing copies.
  • Identify reachable retention and deterministic disposal responsibilities.

Copy semantics matter more than guessed placement

A value-type assignment copies the value, including any reference fields it contains. A reference-type assignment copies a reference to the same object. This language-level distinction does not mean every struct lives on a physical stack or every source variable maps to a separate allocation. Runtime placement and optimization depend on the surrounding program.

Choose a struct when its value semantics suit the model, not solely to avoid a presumed heap allocation. Large copies, mutable fields, interface boxing, and embedding can change costs. A class can represent identity and shared state deliberately. Document whether a method mutates a caller-visible object or only its local copy.

Compare independent values and shared objects

The first program copies a mutable struct and a class reference. Changing the struct copy leaves its original field at one. Changing the class through the alias changes the one shared object to nine. The output is justified by assignment semantics, without observing any address or forcing garbage collection.

A struct containing List<int> would copy that list reference, so its elements could still be shared. readonly struct restricts mutation of its own fields but does not recursively freeze mutable objects those fields reference. Avoid exporting mutable structs when callers can accidentally modify temporary copies or misunderstand identity.

csharp Value copying versus reference aliasing
using System;

var value = new ValuePoint { X = 1 };
var copiedValue = value;
copiedValue.X = 9;
var reference = new ReferencePoint { X = 1 };
var alias = reference;
alias.X = 9;
Console.WriteLine($"value={value.X} copied={copiedValue.X} reference={reference.X}");

internal struct ValuePoint { public int X; }
internal sealed class ReferencePoint { public int X; }
Expected output
value=1 copied=9 reference=9

The struct has two independent X fields; both class variables designate one X field.

Boxing creates a separate value copy

Boxing a non-null value type into object creates an object containing a copy of that value. Later changes to the original struct do not update the boxed copy. Unboxing requires the compatible boxed value type; it is not a general numeric conversion. Generic collections can avoid many routine object-based boxes, but interfaces and API shape still matter.

The second example shows the copied value directly. It does not infer exact allocation counts or timing from the syntax. Some compiler/runtime optimizations can remove work when behavior permits, so use profiling and carefully designed benchmarks before making a performance claim about a particular build.

csharp A boxed struct retains its copied value
using System;

var point = new Point { X = 1 };
object boxed = point;
point.X = 9;
Console.WriteLine($"original={point.X} boxed={((Point)boxed).X}");

internal struct Point { public int X; }
Expected output
original=9 boxed=1

Changing point after boxing leaves the boxed Point at one.

Reachable data remains the program’s responsibility

The garbage collector reclaims objects that are no longer reachable under runtime rules. A static cache, event subscription, active task, or retained collection can keep otherwise unwanted objects alive. This is a logical memory leak even though the collector is functioning correctly. Bound caches and queues and unsubscribe or release ownership when the component’s lifetime ends.

Do not promise immediate memory return after assigning null, clearing a list, or calling GC.Collect. Collection, allocator reuse, and returning memory to the operating system are different processes. Generations and the large object heap are implementation/runtime topics whose thresholds and behavior should be checked against official documentation for the actual runtime.

GC does not own every external resource deadline

An unreachable managed wrapper can still own a finite external handle until cleanup occurs. Dispose and await using express deterministic resource lifetimes; finalization is a fallback mechanism for suitable types, not a normal prompt release strategy. SafeHandle helps implement robust native-handle ownership without scattering raw-pointer cleanup.

Measure retained memory with representative workloads and matching diagnostic tools. A collection’s small Count does not prove that referenced object graphs are small. Tests should verify event unsubscription, task completion, and disposal contracts without asserting nondeterministic finalizer timing. Fix the reachable graph or ownership rule before forcing more collections.

Common errors and fixes

A struct copy unexpectedly shares a list.

Why it happens
Its reference field was copied, not recursively cloned.
How to fix it
Copy the nested data or document shared semantics.
Prevent it next time
Review every reference-bearing field.

A boxed value does not reflect later updates.

Why it happens
Boxing captured a value copy.
How to fix it
Use the intended reference or explicit replacement semantics.
Prevent it next time
Test boxing boundaries when exposing mutable structs.

Repeated GC.Collect does not shrink a growing cache.

Why it happens
The objects are still reachable.
How to fix it
Bound or release cache entries under a lifetime policy.
Prevent it next time
Profile retention paths rather than only allocation totals.

Check your understanding

1. Why can a readonly struct containing a Dictionary still expose mutable state?

Show worked solution

readonly restricts mutation of the struct’s own instance fields, but its dictionary field is a reference value. Other code can mutate that referenced dictionary unless access and ownership prevent it. Use an immutable representation or private copies with a clear exposure policy if the domain needs stable data. Do not equate readonly with recursive immutability.

2. A long-lived publisher references a subscriber through an event. What lifecycle question should be answered before blaming GC?

Show worked solution

Determine when the subscriber should stop receiving events and who removes the subscription. The publisher’s delegate can keep the subscriber reachable, so the collector cannot infer that the relationship is no longer useful. Unsubscribe or use a deliberately designed lifetime mechanism, and test that component shutdown releases the relationship without relying on finalizer timing.

Key takeaways

  • Value copying and reference aliasing are semantic rules, not placement slogans.
  • Boxing and shallow copies preserve their own distinct identities.
  • Bound reachability and dispose resources instead of treating GC as an ownership protocol.

Official references