volatile гарантирует, что поток читает актуальное значение и публикует свою запись. Но counter++ не одна операция: нужно прочитать число, прибавить единицу и записать результат. Два потока могут прочитать одно и то же значение и оба записать одинаковый результат, потеряв один инкремент.
Для счётчика нужен механизм, который защищает всю read-modify-write последовательность: synchronized, Lock или подходящий атомарный тип, например AtomicInteger. volatile уместен для флага состояния, когда достаточно видимости одной записи.
Подробнее: Java Memory Model: visibility, ordering и happens-before.