Ok, allora ecco svelato il codice!
Innanzitutto la seguente è la versione 
senza wait/notify. Ho solo preso il tuo codice nel post iniziale, l'ho scritto/pulito meglio ed ho aggiunto uno sleep di durata "casuale" prima del call().
public class Synch {
    public static void main(String[] args) {
        CallMe target = new CallMe();
        new Caller(target, "Hello");
        new Caller(target, "Synchronized");
        new Caller(target, "World");
    }
}
class Caller implements Runnable {
    private CallMe target;
    private String msg;
    public Caller(CallMe target, String msg) {
        this.target = target;
        this.msg = msg;
        new Thread(this).start();
    }
    public void run() {
        try {
            Thread.sleep((long) (40 * Math.random()));
        } catch (Exception e) {}
        target.call(msg);
    }
}
class CallMe {
    public synchronized void call(String msg) {
        System.out.print("[" + msg);
        try {
            Thread.sleep(1000);
        } catch (Exception e) {}
        System.out.println("]");
    }
}
Ho aggiunto lo sleep casuale perché senza di questo, e dipendentemente dal S.O./piattaforma e dalle logiche di scheduling dei thread, è possibile (e nemmeno troppo difficile) che l'output Hello Synchronized World sia "quasi" sempre questo.
Con lo sleep casuale invece è altamente più probabile che l'output vari anche da una esecuzione all'altra. Prova ad avviarlo un po' di volte (es. una decina) e lo vedrai tu stesso.
Ora aggiungo al codice sopra solo alcune cose:
- un "index" in Caller (quindi variabile di istanza, modifica al costruttore, ecc..)
- un "currentIndex" nel CallMe con i wait/notifyAll nel call()
public class Synch {
    public static void main(String[] args) {
        CallMe target = new CallMe();
        new Caller(target, 0, "Hello");
        new Caller(target, 1, "Synchronized");
        new Caller(target, 2, "World");
    }
}
class Caller implements Runnable {
    private CallMe target;
    private int index;
    private String msg;
    public Caller(CallMe target, int index, String msg) {
        this.target = target;
        this.index = index;
        this.msg = msg;
        new Thread(this).start();
    }
    public void run() {
        try {
            Thread.sleep((long) (40 * Math.random()));
        } catch (Exception e) {}
        target.call(index, msg);
    }
}
class CallMe {
    private int currentIndex = 0;
    public synchronized void call(int index, String msg) {
        try {
            while (index != currentIndex) {
                wait();
            }
        } catch (InterruptedException e) {
            System.err.println(e);
            return;
        }
        System.out.print("[" + msg);
        try {
            Thread.sleep(1000);
        } catch (Exception e) {}
        System.out.println("]");
        currentIndex++;
        notifyAll();
    }
}
Ora l'output è sempre quello dettato dalla sequenza 0 1 2. Puoi anche provare a costruire i Caller passando i numeri con un'altra sequenza e vedrai che l'output seguirà quella!
Il catch di InterruptedException è solo un pro-forma che nel caso della applicazione non dovrebbe mai capitare.
Se non è chiaro ... spiego!