Flyweight Pattern: Shared Object Pool

The Flyweight pattern is a structural design pattern from the Gang of Four catalog that addresses a specific problem: how do you efficiently support large numbers of fine-grained objects without...

Key Insights

  • The Flyweight pattern separates intrinsic state (shared across objects) from extrinsic state (unique per context), enabling massive memory savings when dealing with thousands of similar objects.
  • A FlyweightFactory acts as the gatekeeper, ensuring object reuse through caching and preventing duplicate instances of shared state.
  • This pattern trades memory efficiency for CPU overhead—you’ll save RAM but pay with additional lookups and state management complexity.

Introduction to the Flyweight Pattern

The Flyweight pattern is a structural design pattern from the Gang of Four catalog that addresses a specific problem: how do you efficiently support large numbers of fine-grained objects without exhausting memory?

The answer is deceptively simple—share what you can, pass what you can’t.

Consider a text editor rendering a document with 100,000 characters. Creating a unique object for each character—complete with font family, size, color, and glyph data—would consume gigabytes of memory. But most characters share the same formatting. The Flyweight pattern exploits this redundancy by extracting common state into shared objects.

The pattern gets its name from boxing, where flyweight is the lightest weight class. These objects are stripped down to the essentials, carrying only what must be shared.

Understanding Intrinsic vs. Extrinsic State

The Flyweight pattern hinges on one critical distinction: separating what can be shared from what cannot.

Intrinsic state is context-independent data that remains constant across all uses. It lives inside the flyweight object and gets shared. Think of it as the object’s identity—the properties that define what it is.

Extrinsic state is context-dependent data that varies with each use. Clients must calculate or store this externally and pass it to the flyweight when needed. This is the object’s circumstance—where it is, how it’s being used right now.

Here’s a concrete example using character formatting in a text editor:

// Intrinsic state: shared across all instances of this character style
public class CharacterStyle {
    private final String fontFamily;
    private final int fontSize;
    private final Color color;
    private final boolean bold;
    private final boolean italic;
    
    public CharacterStyle(String fontFamily, int fontSize, Color color, 
                          boolean bold, boolean italic) {
        this.fontFamily = fontFamily;
        this.fontSize = fontSize;
        this.color = color;
        this.bold = bold;
        this.italic = italic;
    }
    
    // Extrinsic state (position, character) passed in at render time
    public void render(char character, int x, int y, Graphics g) {
        g.setFont(new Font(fontFamily, getFontStyle(), fontSize));
        g.setColor(color);
        g.drawString(String.valueOf(character), x, y);
    }
    
    private int getFontStyle() {
        int style = Font.PLAIN;
        if (bold) style |= Font.BOLD;
        if (italic) style |= Font.ITALIC;
        return style;
    }
}

The CharacterStyle object knows nothing about which character it formats or where that character appears. Those details arrive as parameters. A single CharacterStyle instance can format thousands of characters across the document.

Anatomy of the Flyweight Pattern

The pattern consists of four collaborating components:

// 1. Flyweight Interface: declares the contract for flyweight objects
public interface Flyweight {
    void operation(ExtrinsicState state);
}

// 2. ConcreteFlyweight: implements the interface, stores intrinsic state
public class ConcreteFlyweight implements Flyweight {
    private final String intrinsicState; // Shared, immutable
    
    public ConcreteFlyweight(String intrinsicState) {
        this.intrinsicState = intrinsicState;
    }
    
    @Override
    public void operation(ExtrinsicState state) {
        System.out.printf("Intrinsic: %s, Extrinsic: %s%n", 
                          intrinsicState, state);
    }
}

// 3. FlyweightFactory: creates and manages flyweight instances
public class FlyweightFactory {
    private final Map<String, Flyweight> cache = new HashMap<>();
    
    public Flyweight getFlyweight(String key) {
        return cache.computeIfAbsent(key, ConcreteFlyweight::new);
    }
    
    public int getCacheSize() {
        return cache.size();
    }
}

// 4. Client: maintains extrinsic state, requests flyweights from factory
public class Client {
    private final FlyweightFactory factory;
    private final List<ExtrinsicState> states = new ArrayList<>();
    
    public Client(FlyweightFactory factory) {
        this.factory = factory;
    }
    
    public void addObject(String flyweightKey, int x, int y) {
        states.add(new ExtrinsicState(flyweightKey, x, y));
    }
    
    public void render() {
        for (ExtrinsicState state : states) {
            Flyweight flyweight = factory.getFlyweight(state.getKey());
            flyweight.operation(state);
        }
    }
}

The client never instantiates flyweights directly. It always goes through the factory, which ensures sharing. The client maintains extrinsic state separately and passes it during operations.

Implementing a Flyweight Factory

The factory is the pattern’s backbone. It must guarantee that identical intrinsic state produces identical object references. Here’s a production-ready implementation:

public class FlyweightFactory<K, V extends Flyweight> {
    private final Map<K, V> cache;
    private final Function<K, V> creator;
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    
    public FlyweightFactory(Function<K, V> creator) {
        this.cache = new HashMap<>();
        this.creator = creator;
    }
    
    public V getFlyweight(K key) {
        // Fast path: check cache with read lock
        lock.readLock().lock();
        try {
            V cached = cache.get(key);
            if (cached != null) {
                return cached;
            }
        } finally {
            lock.readLock().unlock();
        }
        
        // Slow path: create with write lock
        lock.writeLock().lock();
        try {
            // Double-check after acquiring write lock
            return cache.computeIfAbsent(key, creator);
        } finally {
            lock.writeLock().unlock();
        }
    }
    
    public int size() {
        lock.readLock().lock();
        try {
            return cache.size();
        } finally {
            lock.readLock().unlock();
        }
    }
    
    public void clear() {
        lock.writeLock().lock();
        try {
            cache.clear();
        } finally {
            lock.writeLock().unlock();
        }
    }
}

Key implementation details:

  1. Lazy instantiation: Flyweights are created on first request, not upfront
  2. Thread safety: Read-write locks allow concurrent reads while serializing writes
  3. Double-checked locking: Prevents duplicate creation in race conditions
  4. Generic typing: The factory works with any flyweight type

Real-World Use Case: Game Particle System

Particle systems are the canonical Flyweight example. A single explosion might spawn 10,000 particles, but they all share the same texture, blend mode, and animation frames. Only position, velocity, and age differ.

// Flyweight: shared particle appearance
public class ParticleType {
    private final BufferedImage texture;
    private final BlendMode blendMode;
    private final float baseScale;
    private final int frameCount;
    
    public ParticleType(String texturePath, BlendMode blendMode, 
                        float baseScale, int frameCount) {
        this.texture = loadTexture(texturePath);
        this.blendMode = blendMode;
        this.baseScale = baseScale;
        this.frameCount = frameCount;
    }
    
    public void render(Graphics2D g, float x, float y, 
                       float scale, float rotation, int frame) {
        AffineTransform transform = new AffineTransform();
        transform.translate(x, y);
        transform.rotate(rotation);
        transform.scale(baseScale * scale, baseScale * scale);
        
        g.setComposite(blendMode.getComposite());
        g.drawImage(getFrame(frame), transform, null);
    }
    
    private BufferedImage getFrame(int frame) {
        int frameWidth = texture.getWidth() / frameCount;
        return texture.getSubimage(frame * frameWidth, 0, 
                                   frameWidth, texture.getHeight());
    }
    
    private BufferedImage loadTexture(String path) {
        // Load from resources
        try {
            return ImageIO.read(getClass().getResource(path));
        } catch (IOException e) {
            throw new RuntimeException("Failed to load texture: " + path, e);
        }
    }
}

// Extrinsic state: per-particle instance data
public class Particle {
    private final ParticleType type;
    private float x, y;
    private float vx, vy;
    private float rotation;
    private float scale;
    private float age;
    private final float maxAge;
    
    public Particle(ParticleType type, float x, float y, 
                    float vx, float vy, float maxAge) {
        this.type = type;
        this.x = x;
        this.y = y;
        this.vx = vx;
        this.vy = vy;
        this.maxAge = maxAge;
        this.age = 0;
        this.scale = 1.0f;
        this.rotation = 0;
    }
    
    public void update(float deltaTime) {
        x += vx * deltaTime;
        y += vy * deltaTime;
        age += deltaTime;
        scale = 1.0f - (age / maxAge); // Shrink over lifetime
        rotation += deltaTime * 2; // Spin
    }
    
    public boolean isAlive() {
        return age < maxAge;
    }
    
    public void render(Graphics2D g, int frameCount) {
        int frame = (int) ((age / maxAge) * frameCount) % frameCount;
        type.render(g, x, y, scale, rotation, frame);
    }
}

// Factory managing particle types
public class ParticleSystem {
    private final FlyweightFactory<String, ParticleType> typeFactory;
    private final List<Particle> particles = new ArrayList<>();
    
    public ParticleSystem() {
        this.typeFactory = new FlyweightFactory<>(key -> {
            return switch (key) {
                case "fire" -> new ParticleType("/fire.png", 
                    BlendMode.ADDITIVE, 0.5f, 8);
                case "smoke" -> new ParticleType("/smoke.png", 
                    BlendMode.ALPHA, 1.0f, 4);
                case "spark" -> new ParticleType("/spark.png", 
                    BlendMode.ADDITIVE, 0.2f, 1);
                default -> throw new IllegalArgumentException("Unknown: " + key);
            };
        });
    }
    
    public void spawnExplosion(float x, float y) {
        ParticleType fire = typeFactory.getFlyweight("fire");
        ParticleType smoke = typeFactory.getFlyweight("smoke");
        ParticleType spark = typeFactory.getFlyweight("spark");
        
        Random rand = new Random();
        
        // 100 fire particles, 50 smoke, 200 sparks - all sharing 3 types
        for (int i = 0; i < 100; i++) {
            particles.add(new Particle(fire, x, y, 
                randVelocity(rand, 50), randVelocity(rand, 50), 0.5f));
        }
        for (int i = 0; i < 50; i++) {
            particles.add(new Particle(smoke, x, y,
                randVelocity(rand, 20), -30 + rand.nextFloat() * 10, 2.0f));
        }
        for (int i = 0; i < 200; i++) {
            particles.add(new Particle(spark, x, y,
                randVelocity(rand, 200), randVelocity(rand, 200), 0.3f));
        }
    }
    
    private float randVelocity(Random rand, float magnitude) {
        return (rand.nextFloat() - 0.5f) * 2 * magnitude;
    }
}

Memory savings are dramatic. Without Flyweight, 350 particles each holding a texture copy might consume 350MB. With Flyweight, three shared textures consume 3MB while 350 lightweight Particle objects add perhaps 28KB.

Trade-offs and When to Apply

Benefits:

  • Dramatic memory reduction when many objects share common state
  • Reduced garbage collection pressure from fewer allocations
  • Improved cache locality when flyweights fit in CPU cache

Costs:

  • Increased code complexity from state separation
  • Runtime overhead from factory lookups and extrinsic state management
  • Debugging difficulty when shared state causes unexpected side effects

Apply the pattern when:

  • Your application creates thousands of similar objects
  • Objects can be decomposed into shared and unique parts
  • Memory profiling confirms object allocation is a bottleneck
  • The intrinsic state is immutable (mutable shared state is dangerous)

Skip it when:

  • You have dozens of objects, not thousands
  • Objects have little shared state
  • The complexity cost outweighs memory savings

Flyweight in Modern Frameworks

You use flyweights daily without realizing it. Java’s String pool is the classic example:

String a = "hello";
String b = "hello";
String c = new String("hello").intern();

System.out.println(a == b);  // true - same pooled instance
System.out.println(a == c);  // true - intern() returns pooled instance

// The JVM maintains a flyweight factory for string literals

Other examples include:

  • Integer caching: Java caches Integer objects for values -128 to 127
  • Connection pools: Database connections share underlying socket resources
  • React’s reconciliation: Virtual DOM nodes share component definitions
  • Font registries: Operating systems share loaded font data across applications

The pattern is so fundamental that modern runtimes bake it in. Understanding Flyweight helps you recognize these optimizations and apply similar thinking to your domain objects.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.