Operators and expressions

Every operator in one table, then precedence, the integer rules that surprise people, short-circuiting, the ternary's typing, and the bitwise operators a backend engineer actually meets.

12 min read Java Fundamentals

Most Java surprises are not in the libraries. They are in expressions people read past because the syntax is familiar from another language, and Java's rules are almost the same — which is worse than being different, because the difference only shows on the input that matters. This lesson is precedence, the integer rules, short-circuiting, the ternary's typing, and the bitwise operators, each with the case where it bites.

This is a lesson of exercises rather than diagrams, deliberately: an operator is about evaluation, and the way to learn what an expression does is to say what you think it does and then watch it run.

The operators, in one table

Most operators need no explanation, and the rest of the lesson is about the ones that do. For reference:

FamilyOperatorsWhat it produces, and the note worth keeping
Arithmetic+ - * / %a number; / on two integers truncates, and % is a remainder
Unary+x -x ++x x++ --x x-- !b ~x++ and -- change the variable: the prefix form gives the new value, the postfix form the old one
Relational== != < > <= >=a boolean; == on objects compares identity, not contents, so strings and wrappers need equals
Logical&& || !a boolean; && and || skip the right side when the left side already decides
Boolean, both sides& | ^on booleans, both sides always run; ^ is exclusive or, true when exactly one side is
Bitwise and shift& | ^ ~ << >> >>>on integers, work bit by bit
Assignment= += -= *= /= %= &= |= ^= <<= >>= >>>=the value assigned; the compound forms carry a hidden cast
Conditionalc ? a : bone of two values, with a single type computed from both
Type testinstanceofa boolean, and false for null

Precedence, and the two that matter

The full table has fifteen levels and is not worth memorising. Four rules cover almost everything:

  1. Arithmetic before comparison before logical. a + 1 > b && c is ((a + 1) > b) && c.
  2. Assignment is last, and right-associative. a = b = 0 assigns 0 to b, then to a.
  3. && binds tighter than ||. a || b && c is a || (b && c) — the one that reads wrong.
  4. Bitwise binds looser than comparison, which is the trap: x & 1 == 0 is x & (1 == 0) and does not compile. Write (x & 1) == 0.

The rule for everything else is to add parentheses. They cost nothing and they are read by people.

Integer arithmetic is not arithmetic

java
System.out.println(7 / 2);
System.out.println(-7 / 2);
System.out.println(7 % 2);
System.out.println(-7 % 2);

Integer division truncates toward zero, so -7 / 2 is -3 rather than -4. And % takes the sign of the left operand, which means it is a remainder rather than a modulus: -7 % 2 is -1, not 1.

That second one is a real bug source. index = hash % size on a negative hash gives a negative index, which is why Math.floorMod(hash, size) exists and why HashMap masks with & instead.

Three more rules with no syntax to warn you:

  • Everything narrower than int is promoted to int before arithmetic. byte + byte is an int, which is why byte c = a + b; does not compile.
  • Overflow wraps, silently. Integer.MAX_VALUE + 1 is Integer.MIN_VALUE. Only Math.addExact and friends throw.
  • int * int is an int, even when assigned to a long. long ms = 24 * 60 * 60 * 1000 * 1000; overflows before the widening; 24L * 60 * ... does not.

The range is asymmetric, and that is the part people miss. An int holds 2³¹ negative values and 2³¹−1 positive ones, so Integer.MIN_VALUE has no positive counterpart — and three different operations quietly hand it back unchanged:

plaintext
Math.abs(Integer.MIN_VALUE)   ->  -2147483648      still negative
Integer.MIN_VALUE / -1        ->  -2147483648      overflows, no exception
-Integer.MIN_VALUE            ->  -2147483648      negation is a no-op

This is the overflow that actually ships. Math.abs(key.hashCode()) % buckets looks defensive and produces a negative index on exactly one input in four billion, and ArrayIndexOutOfBoundsException: -2147483648 is a bug report people stare at for an afternoon. Use key.hashCode() & (buckets - 1) for a power-of-two table, or Math.floorMod, and reach for Math.negateExact and Math.absExact when you want the overflow to be an error.

Dividing by zero is two completely different things, depending on the type:

plaintext
int     1 / 0      ->  ArithmeticException: / by zero
double  1.0 / 0.0  ->  Infinity
double  0.0 / 0.0  ->  NaN
double -1.0 / 0.0  ->  -Infinity

Integer division throws and stops the program at the line that caused it. Floating-point division returns a value and carries on — and NaN then propagates through every arithmetic operation it touches, and compares false against everything including itself. A ratio that silently became NaN three methods ago is why a comparison starts returning false for reasons nobody can see.

Compound assignment hides a cast

java
byte b = 100;
b += 200;                 // no error, no warning
System.out.println(b);
 
byte c = 100;
c = (byte) (c + 200);     // the same thing, written out
System.out.println(c);

Both print 44, and that is the point: they are the same operation. b += 200 is not b = b + 200 — the specification defines it as b = (byte) (b + 200), so a compound assignment carries an implicit narrowing cast. That is why b += 200 compiles while b = b + 200 does not, and why it truncates 300 down to 44 without a word.

(300 does not fit in a byte, so the low eight bits survive: 300 − 256 = 44.)

The same rule makes int i = 5; i *= 1.5; legal and equal to 7, which looks like a rounding decision somebody made on purpose and is a silently inserted (int).

Short-circuiting is a guarantee, not an optimisation

&& and || evaluate the right side only when needed, and the language promises it:

java
if (user != null && user.isActive())        // safe
if (user != null & user.isActive())         // NullPointerException

& and | on booleans evaluate both sides. They exist because sometimes you want both side effects, and they are almost always a typo when you see them in a condition.

The ordering matters beyond null checks: put the cheap test first. if (cache.containsKey(k) && expensiveCheck(k)) does the expensive one only when it must.

Under the hood: the ternary has a type, and it is computed

cond ? a : b is an expression, so it has one type, and the compiler works it out from both branches before either runs. That inference is where it bites.

java
Object x = true ? 1 : 2.0;
System.out.println(x);

Both branches are numeric, so the compiler promotes to the wider type: the result is double, and the 1 becomes 1.0 even though the false branch never runs. The rules are the ones in the specification for binary numeric promotion, applied to an expression where only one side executes.

The dangerous version involves a wrapper:

java
Integer count = null;
int n = (flag) ? count : 0;      // NullPointerException when flag is true

One branch is Integer and the other is int, so the type of the whole expression is int — which means the Integer branch must be unboxed, and unboxing null throws. The NPE is on a line with no visible method call and no obvious dereference, which is why it takes so long to find.

The fix is to keep both branches the same type: Integer n = flag ? count : Integer.valueOf(0);, or better, handle the null before the expression.

Bitwise and shift

These come up in three places a backend engineer actually meets: flags, hashing, and reading someone else's performance-minded code.

& | ^and, or, exclusive or, bit by bit
~flip every bit
<<shift left, multiply by 2 per position
>>arithmetic shift right, keeps the sign
>>>logical shift right, fills with zero

Two facts worth carrying:

  • The shift distance is taken modulo 32 for int, and 64 for long. 1 << 32 is 1, not 0, and 1 << 33 is 2. Nothing warns you.
  • >> and >>> differ only for negative numbers. -8 >> 1 is -4; -8 >>> 1 is 2147483644. The second is what you want for hashing and the first for arithmetic, and mixing them up produces a number that looks plausible.
start>> 1>>> 1<< 32
bits1111...11000as int-8sign bit1

start. -8 as the JVM holds it: two's complement, so the top bit is the sign and the value is all ones except the last three. Every shift below acts on these 32 bits, not on the number -8.

1 / 4
java
System.out.println(1 << 32);
System.out.println(-8 >> 1);
System.out.println(-8 >>> 1);

The idiom (x & 1) == 0 for "is even" is faster than x % 2 == 0 only in the sense that it is what the JIT compiles the second into anyway — and it is correct for negative numbers, where x % 2 == 1 is not. That is the real reason to prefer it.

Walkthrough: the average that went negative

A metrics aggregator computed (low + high) / 2 over two int timestamps to find a midpoint. It ran correctly for four years.

The day it broke, low and high were both above 1.2 billion. Their sum overflowed to a negative number, the division halved the negative, and the midpoint landed before both inputs. The binary search around it then walked off the front of the array.

This is the same overflow that sat in Arrays.binarySearch in the JDK itself until 2006, and the fix is the same: low + (high - low) / 2, which cannot overflow because the difference is smaller than either operand.

No exception, no warning, four years of correct answers, then an ArrayIndexOutOfBoundsException a long way from the arithmetic.

Try it yourself

What is printed?

java
int i = 5;
System.out.println(i++ + ++i);
System.out.println(i);
Answer

12 and 7. i++ evaluates to 5 and leaves i at 6; ++i makes it 7 and evaluates to 7; 5 + 7 is 12. Java fixes the evaluation order left to right, so this is defined behaviour rather than the undefined behaviour the same line has in C. Defined and still not worth writing.

Why does this not compile?

java
short a = 1, b = 2;
short c = a + b;
Answer

a + b is promoted to int, and an int does not fit in a short without a cast. Write short c = (short) (a + b); — or use int, which is what the JVM works in anyway. The same promotion is why char + char is an int and 'a' + 'b' prints 195.

What is i afterwards?

java
int i = 5;
i = i++;
System.out.println("i = " + i);
 
int j = 0;
int r = j++ + j++ + j++;
System.out.println("j = " + j + ", r = " + r);
Answer

i is 5 — unchanged. i++ evaluates to the old value 5 and leaves i at 6; then the assignment writes that 5 back over it. The increment happened and was overwritten.

The second line is the rule behind it, made visible: operands are evaluated strictly left to right, each fully, before the operator runs. j++ yields 0, then 1, then 2 — summing to 3 — and leaves j at 3.

This is not a puzzle about ++. It is the evaluation-order rule, and it is the same rule that explains why arr[i] = i++ is a trap and why f() + g() calls f first even when g is cheaper.

Which of these is not zero?

java
System.out.println(0.1 + 0.2 - 0.3);
System.out.println(1e16 + 1 - 1e16);
System.out.println(Integer.MIN_VALUE + Integer.MIN_VALUE);
Answer

The first. 0.1 + 0.2 is 0.30000000000000004, so the subtraction leaves about 5.5e-17. The second is 0 because 1e16 + 1 cannot be represented and rounds back to 1e16 — the addition is simply lost. The third is 0 too, by wrapping: MIN_VALUE + MIN_VALUE overflows exactly to zero. Three different mechanisms, two of which give the "right" answer for the wrong reason.

Misconceptions

  • "% is modulus." It is a remainder and takes the sign of the left operand. Math.floorMod is the modulus.
  • "+= is shorthand." It carries an implicit narrowing cast, which is why it compiles where the long form does not.
  • "Overflow is an error." It wraps. Only Math.*Exact throws.
  • "The ternary just picks a branch." It has one type computed from both branches, and that type can force an unboxing that throws.
  • "x << 32 clears the value." The shift distance is taken modulo 32, so it is x unchanged.

Going deeper

  • The Java Language Specification chapter 15, Expressions — 15.25 on the conditional operator is the ternary typing rule written out, and it is worth reading once.
  • Hacker's Delight, for what the bitwise operators are actually for.
  • Math.addExact, multiplyExact, floorMod, floorDiv — the methods that exist because the operators do the other thing.
Progress is saved on this device and to your account when signed in.