Skip to Content
Course content

205: The Actor Model Concept in Java

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've spent any time with multi-threaded Java, you know the drill: you create a shared object, you slap synchronized on every method, and you pray you didn't create a deadlock that will freeze your production server at 3 AM. I've been there, and frankly, I'm tired of it. That's why I want to show you the Actor Model.

The core philosophy here is simple: don't share state. Instead, you treat "Actors" as little independent islands. If Actor A wants Actor B to do something, it doesn't call a method on Actor B. It sends a message to Actor B's mailbox. Actor B then processes those messages one by one, in its own time, on its own thread. No locks, no ConcurrentModificationException, no madness.

Defining our Message Protocol

To build this, we first need a way to represent "work." I like to use a sealed interface for this in modern Java; it keeps the message types strict so the Actor knows exactly what it's handling. Let's build a simple Bank Account system. In a traditional app, you'd just call account.deposit(100). Here, we'll send a Deposit message.

public sealed interface AccountMessage {
    record Deposit(double amount) implements AccountMessage {}
    record Withdraw(double amount) implements AccountMessage {}
    record GetBalance(java.util.function.Consumer<Double> callback) implements AccountMessage {}
}

Building the Actor Mailbox

Now we need the Actor itself. A real Actor needs two things: a private state and a queue (the mailbox). I'm going to use a ConcurrentLinkedQueue to hold the messages and a single-threaded ExecutorService to ensure that only one message is processed at a time. This is the "magic" part—because only one thread ever touches the account balance, we don't need a single synchronized keyword.

import java.util.concurrent.*;
import java.util.function.Consumer;

public class BankAccountActor {
    private double balance = 0; // Private state!
    private final Queue<AccountMessage> mailbox = new ConcurrentLinkedQueue<>();
    private final ExecutorService dispatcher = Executors.newSingleThreadExecutor();

    public void send(AccountMessage message) {
        mailbox.offer(message);
        dispatcher.submit(this::processNextMessage);
    }

    private void processNextMessage() {
        AccountMessage msg = mailbox.poll();
        if (msg == null) return;

        if (msg instanceof AccountMessage.Deposit d) {
            balance += d.amount();
            System.out.println("Deposited " + d.amount() + ". New balance: " + balance);
        } else if (msg instanceof AccountMessage.Withdraw w) {
            if (balance >= w.amount()) {
                balance -= w.amount();
                System.out.println("Withdrew " + w.amount() + ". New balance: " + balance);
            } else {
                System.out.println("Insufficient funds for withdrawal of " + w.amount());
            }
        } else if (msg instanceof AccountMessage.GetBalance g) {
            g.callback().accept(balance);
        }
    }
}

Oops, I almost cheated the model

When I first wrote a version of this, I made a classic mistake. I added a public double getBalance() method to the class because I thought, "It's just a getter, it's fine."

But that's a leak. If I call getBalance() from the main thread while the dispatcher thread is updating the balance, I'm back to square one: shared state and potential visibility issues. To stay true to the Actor Model, everything must be a message. That's why I added the GetBalance message with a callback. You don't "get" a value from an actor; you ask the actor to send the value back to you.

Putting the Actor to Work

Now we can throw a bunch of requests at this account from different threads, and we don't have to worry about race conditions. The mailbox handles the sequencing for us.

public class Main {
    public static void main(String[] args) {
        BankAccountActor account = new BankAccountActor();

        // Simulate multiple threads hitting the account
        Runnable task = () -> {
            account.send(new AccountMessage.Deposit(100));
            account.send(new AccountMessage.Withdraw(50));
        };

        for (int i = 0; i < 5; i++) {
            new Thread(task).start();
        }

        // Ask for the final balance via a callback
        account.send(new AccountMessage.GetBalance(bal > System.out.println("Final Balance: " + bal)));
    }
}

Notice how the send method returns immediately. The main thread isn't waiting for the deposit to happen; it's just dropping a note in the mailbox and moving on. This is how you build highly scalable systems—by decoupling the request from the execution.




📋 Practical Task

Build a Stock Price Ticker Actor

To practice this pattern, your task is to build a StockTickerActor. Instead of a bank account, this actor will manage the current price of a specific stock symbol.

Requirements:

  • Create a sealed interface TickerMessage with three record types: UpdatePrice(double newPrice), GetPrice(Consumer<Double> callback), and ResetPrice().
  • Implement the StockTickerActor using a ConcurrentLinkedQueue and a single-threaded ExecutorService.
  • Ensure that the price state is strictly private and can only be modified or read via the send() method.
  • Write a main method that simulates three different threads updating the price simultaneously, followed by a final request to print the price using the callback.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.