The Java Memory Model is to blame.
The JVM has an evil coin.
It will flip this coin sometimes. Specifically, anytime you access any field, the JVM flips the evil coin.
Heads, and the JVM will give you the value contained in this field that it has cached from earlier. The value that it had in this thread, regardless of any updates written to it from other threads.
Tails, and it will give you the value as it was written to last, by any thread.
It's evil, in the sense that it is not a fair coin: Today, and during your unit tests, and as the debugger runs, and with this particular phase of the moon, it works every time. Same tomorrow and next week. But 2 weeks from now just as you are giving that demo to the important customer? Reliably failing.
The key point is: If you make the JVM ever flip the coin, you lost.
Do not make the JVM flip it.
To disble the JVM's evil coin, you must establish so-called happens-before/happens-after relationships.
An HB/HA relationship works as follows:
- For any 2 statements anywhere in your entire code base, the 2 statements either have HB/HA, or they do not.
- If they do have HB/HA, then the JVM guarantees you that the happens-after line cannot observe any state as it was before the happens-before line, other than timing. In other words, code executes as if the statement that has the 'happens before' relationship with the 'happens after' line actually happened before. It doesn't actually have to, but you cannot observe that it didn't, so that doesn't matter.
- However, if they do not have HB/HA, anything can happen. You may observe it, or not, even if you have absolute proof that line B ran after line A, then line B may or may not observe anything A changed, JVM's discretion: Evil coin flips occur.
You may find this all incredibly stupid design and obviously bizarre; why would the JVM have an evil coin and why is it playing these games?
The answer is efficiency. Inter-core communication is incredibly expensive. The JVM will often but not always attempt to run code efficiently and this usually involves a local cached copy. The JVM doesn't spend every moment checking if it has nothing to do and aggressively synchronizing all these caches together in some sensible fashion, that would be far too inefficient. CPUs have other stuff to do, and even if they don't, hey, laptops are a thing, as is the power bill, as is your Datacenter's provisioned CPU cycle counts, so spending CPU cycles that aren't neccessary is a waste that shouldn't be occurring.
So the JVM doesn't. It will happily leave things unsynced, or not, at is discretion, for days if it wants.
That is what you are observing. With the debugger fired up, all sorts of efficiencies aren't run because they would confuse the debugger, so you are observing from thread A that check = 0 many seconds after thread B set it to that value. But, without the debugger on, you may print something that tells you B has set check to 0, but then A sees that check isn't 0 for days on end.
In other words, in thread B, check seems to be 0. You can print the time and confirm that this happens at point in time X. And yet, even though it is clearly much, much later, A is still observing that check is still 3 or whatever it was before A set it to 0. The explanation is that B and A each have their own copy of check. The JVM isn't gonna sync em just cuz you want it to. And there is no simple syncItUp() method. And, yeah, there is no guarantee that these local copies are there either. The JVM is free to sync them up or not. With the debugger on, it syncs em. Without it, it doesn't. Weird, but the spec of the JVM gives the JVM the power to do it. Evil coin. The one, and only, way to guarantee things, is to establish HB/HA.
To establish HB/HA, well, you can search the web. But, the easy ways are:
- The
synchronized keyword: If thread A exits a block that synchronized on object ref to object X, that is guaranteed to 'happen before' any other thread that enters such a synchronized block later on.
- Thread starts. If thread A starts thread B, then the line right before
b.start(); is HB relative to the first line in thread B.
volatile. writes to volatile fields establish HB/HA vs. reads, though you get no guarantees if it actually runs earlier (these aren't locks). It would 'work' here in your case, though.
- Any java library tool that internally uses this stuff. For example, just about everything in the
java.util.concurrent package.
So, in short, make that volatile. More generally though, trying to read and write to the same field from different threads is Here Be Dragons and They WILL bite your head off! territory. It's hard to write the code properly and if you mess up, a unit test won't catch it. You'll have a flakey app that works great for you and fails on other machines or in bizarre conditions.
If you can, use proper tools (j.u.c classes are a good start), and try to just not write to fields from multiple threads, at all. For example, do all inter-thread comms via a database with proper transaction management.