🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
java.lang.IllegalStateException: Timer already cancelled.
at java.base/java.util.Timer.sched(Timer.java:408)
at java.base/java.util.Timer.schedule(Timer.java:204)
at com.devinhyderabad.heartbeat.Heartbeat.pulse(Heartbeat.java:29)⚡ Quick Fix Works 80% of the time
Guard the timer’s lifecycle with a flag, or better, move to ScheduledThreadPoolExecutor — Timer is one-shot by design.
private final AtomicBoolean cancelled = new AtomicBoolean();
public void stop() {
if (cancelled.compareAndSet(false, true)) {
timer.cancel(); // exactly one caller wins
}
}
public void pulse() {
if (!cancelled.get()) {
timer.schedule(task, 1_000); // never schedules post-cancel
}
}🧠 Why this Happens
Tap to expand the deep technical explanation
Timer keeps a queue guarded by a state field; cancel() transitions it to terminal, and sched() — reached by every schedule variant — throws this IllegalStateException for any subsequent submission. The deeper trap sits nearby: Timer runs all tasks on ONE thread, so any task that throws an unchecked exception kills that thread silently and every future task simply never runs, with no exception anywhere. Cancelled-or-dead, the class has no recovery story beyond constructing a new instance.
The HITEC City Parking Spot Analogy:
A club with one doorkeeper who quits the moment anything odd happens (task exception) or the manager closes the venue (cancel). New guest requests bounce off a padlocked door — the sign either says CLOSED forever or nothing says anything at all.
🔁 How to Reproduce Confirm this is your error
new Timer().cancel(), then schedule(anyTask, 1000): IllegalStateException straight out of Timer.sched. (Lab capture: OpenJDK Temurin 25.0.2.)
🛠️ Solutions (5 Ways to Fix)
Migrate to ScheduledThreadPoolExecutor
👉 Use this when/if you control the scheduling code and want exceptions isolated.
A scheduled pool runs tasks on multiple workers, survives individual task failures (with a wrapped handler to log them), exposes pool diagnostics like any executor, and supports fixed-rate and fixed-delay semantics identically to Timer. This migration removes both the cancelled-state trap and the silent-death trap at once.
ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(2);
scheduler.scheduleAtFixedRate(
() -> {
try {
heartbeat.beat();
} catch (RuntimeException e) {
log.warn("heartbeat failed; continuing", e);
}
},
1, 1, TimeUnit.SECONDS);Make cancellation single-shot and idempotent
👉 Use this when/if staying on Timer through a transition period.
An AtomicBoolean gate ensures cancel() executes once and every schedule() consults the same truth, converting the race between teardown and background producers into orderly refusal instead of an exception storm during shutdown.
if (live.compareAndSet(true, false)) {
timer.cancel(); // exactly one thread closes it
}
// producers check live.get() before every scheduleWrap TimerTasks so one failure cannot kill the thread
👉 Use this when/if legacy constraints force Timer to remain.
Since Timer dies silently on any uncaught RuntimeException, wrapping each task body in try/catch preserves the scheduler for all remaining tasks. Log loudly — the wrapper is the only thing standing between one bad task and total scheduling blackout.
timer.schedule(new TimerTask() {
public void run() {
try {
business.work();
} catch (Throwable t) {
log.error("timer task failed; timer kept alive", t);
}
}
}, delay);Treat Timer instances as disposable: recreate to restart
👉 Use this when/if restart-after-cancel genuinely reflects your lifecycle.
A cancelled Timer can never accept work again, so model restart as construction: a factory method returns a fresh Timer plus its guard state. Explicit recreation documents reality — versus callers hoping a dead instance revives.
Timer freshTimer() {
Timer t = new Timer("heartbeat", true);
live.set(true);
return t;
}DEV ONLY: share one static Timer across unrelated components
👉 Use this only to observe cross-component coupling explode — never in real code.
A global Timer means any component’s cancel() (or one component’s throwing task) terminates scheduling for every other feature sharing the instance, each discovering it through this very exception or through silent non-execution.
// ANTI-PATTERN — do not ship
static final Timer SHARED = new Timer();
// component A cancels; component B's schedule() now explodes📋 Version Notes
Identical terminal-state behavior; message unchanged since introduction.
No change; Timer remains legacy with silent-death semantics intact.
Prefer virtual-thread-friendly scheduling or delayedExecutor for lightweight deferred work.
🛡️ How to Prevent This Next Time
Ban raw Timer in favor of ScheduledThreadPoolExecutor via architecture tests, wrap every scheduled body defensively regardless of scheduler, and exercise shutdown ordering in CI so cancel-versus-schedule races fail tests instead of production heartbeats.