> I wonder how did one go about starting to learn about these things (even though it's hard as you mentioned)?
Well, read a lot. And ask a lot of questions. And make lots of mistakes - those are the best ways for learning.
Here is a short reading list of useful terms to learn about. Google and read/understand, and follow what you find to other related stuff, and you'll probably learn a lot about what JITs can do to your code:
Inlining. Constant Propagation. Dead code elimination. Loop unrolling. Inline caching. Monomorphic and MegaMorphic. Uncommon Traps. Tiered compilation. Null check elimination. Bounds-check elimination. Lock coarsening. Escape analysis.
As a general starting point for guessing at what a JIT compiler could figure out and do to code, assume the compiler is really, really, really smart. Smarter than you. Smarter than you think is possible. I like to look at at the code and assume that whatever I can deduce myself and pre-compute (or eliminate) the compiler could also deduce and do the same with. If you can take a code sequence (across multiple methods and values), think about it hard, and simplify it to a short set of operations that will perform the same work, assume the compiler can do the same.
Then, look at the code the compiler actually generates, and try to figure out why it didn't do quite as well as you have hoped. It is far better to be disappointed by the compiler, and to address that disappointment by making it "easier" to detect efficiencies you want it to find, than to assume the compiler is stupid. Assuming the compiler is stupid wastes lots of time in "helping" it do stuff it can already do in it's sleep, and often makes ugly code ugly for no reason.
For an example of this sort of thinking in practice, I often use micro-benchmarking. Whenever I write a micro-benchmark loop and want it to make sure the compiler doesn't optimize the loop away, I make sure that:
1) The loop has side effects that cannot be eliminated (best way to do that is to output something based on an accumulated results of some sort).
2) That the side effects depend on the code I want to make sure run.
3) That the work is not being repeated on constant values.
4) That the side-effect behavior is complex enough to follow that it will not be practical to pre-derive.
And don't go thinking compilers don't try to figure out what you are doing. Compiler know how to compute series things, for example. Many compilers out there know are smart enough to replace the following code:
DoMyComputation(int i) { return i; }
...
sum = 0;
for (int i = 0; i < 10000; i++) {
sum += DoMyComputation(i);
}
System.out.println(sum);
with:
System.out.prinltn(49995000);
But most compilers today don't know how to do the same to:
sum = 0;
for (int i = 0; i < 10000; i++) {
sum += DoMyComputation(i % 79);
}
System.out.println(sum);
(Note that compilers could get smart enough to beat this too, they just aren't there yet in most cases. So this is "good enough" for current micro-benchmarking practices).