There are zero guarantees that such a simple program will show a perceivable result. I mean, it's not even guaranteed which thread will start first, at least.
But in general, the visibility effects are only guaranteed by the java language specification, that carefully builds a so-called "happens-before relationship". This is the only guarantee that you have, and that exactly says:
A write to a volatile field happens-before every subsequent read of that field.
without volatile, the safe-net is gone. And you might say - "but I can't reproduce". The answer to that would be:
... on this run
... on this platform
... with this compiler
... on this CPU
and so on.
The fact that you add a System.out.println in there (which internally will have a synchronized part), only aggravates things; in the sense that it takes away more chances to have that one thread run forever.
It took me a while, but I think I can come up with an example on how to prove that this can break. For that, you need a proper tool: designed for these kind of things
@JCStressTest
@State
@Outcome(id = "0", expect = Expect.ACCEPTABLE)
@Outcome(id = "3", expect = Expect.ACCEPTABLE_INTERESTING, desc = "racy read!!!")
@Outcome(id = "4", expect = Expect.ACCEPTABLE, desc = "reader thread sees everything that writer did")
public class NoVolatile {
private int y = 1;
private int x = 1;
@Actor
public void writerThread() {
y = 2;
x = 2;
}
@Actor
public void readerThread(I_Result result) {
if(x == 2) {
int local = y;
result.r1 = x + local;
}
}
}
You do not need to understand the code (though this would help), but overall it builds two "actors" or two threads that change two independent values : x and y. The interesting part is this:
if(x == 2) {
int local = y;
result.r1 = x + local;
}
if x == 2, we enter that if branch and result.r1 should always 4, right? What if result.r1 is 3, what does this mean?
This would mean that x == 2 for sure (otherwise there would be no write to r1 at all and as such result.r1 would be zero) and it would mean that y == 1.
That would mean that ThreadA (or writerThread) has performed a write (we know for sure that x == 2 and as such y should also be 2), but ThreadB (readerThread) did not observe that y is 2; it has still seen y as being 1.
And these are the cases defined by that @Outcome(....), obviously the one I care about is that 3. If I run this (up to you to figure out how), I will see that ACCEPTABLE_INTERESTING case is indeed present in the output.
If I make a single change:
private volatile int x = 1;
by adding volatile, I start to follow the JLS specification. Specifically 3 points from that link:
If x and y are actions of the same thread and x comes before y in program order, then hb(x, y).
A write to a volatile field happens-before every subsequent read of that field.
If hb(x, y) and hb(y, z), then hb(x, z).
This would mean that if I see that x == 2, I must also see that y == 2 (unlike without volatile). If I run now the example, 3 is not going to be part of the result.
This should prove that a non-volatile read can be racy and as such lost, while a volatile one - can't be lost.