ExecutorService + SynchronousQueue/LinkedBlockingQueue + Callables without mutable state = basically a functional coroutine model of programming in Java with non-lispy syntax.
If you're doing it right, you're not allocating new threads for every task (and neither is Erlang, they're "cheating" in roughly the same way I'm describing - their "threads" are really "tasks"). You push tasks onto your work queue, taking up very little memory per task, and your ExecutorService takes care of them. The only difference is you have to set up your ExecutorService yourself, which is a plus and a minus compared to the runtime implicitly managing one for you.. sometimes it'd be convenient to have a default coroutine manager, I guess.
It's the same concept as giving a copying garbage collector really large subspaces - GC only happens when one fills. Giving each process its own heap means that the GC pauses will still be brief.
Erlang's scheduler also takes dataflow (e.g. which processes have new messages to process?) into account, so some kinds of overhead are O(active processes) rather than O(processes).
That isn't entirely true. There are ThreadLocal variables in Java. However, these have some performance ramifications and have to be carefully managed so they don't leak, basically because the system wasn't designed with this kind of storage in mind.
For example, correct me if I'm wrong, but in Java if you do something like:
MyAwesomeClass myObject = new MyAwesomeClass();
myThreadLocal.set(myObject);
myOtherObject.someField = myObject;
Then the thread local and the field value are references to the same object, in the global heap. Any thread can get to "myOtherObject.someField" and change my thread's local copy.This specific example is probably bad design, but the underlying difference is between a shared memory design and a message passing share-nothing design, where the latter doesn't let you make that kind of mistake.
In Erlang the paradigm is different enough that my bad example doesn't really translate[1], but even if two similar references were made equivalent, they're to _immutable_ objects not references - so from the programmer's point of view they're never shared at all.
In addition, if you want to share something then you send a message so the other process (aka thread) always has their own distinct copy.
[1] The process dictionary is kind of the same as thread local storage, but the kind of state you'd assign via a reference on a mutable field is dealt with differently.