Course contents

Operators and precedence

How Java groups and evaluates expressions, from the precedence ladder to i = i++, shifts, short-circuiting and the hidden cast in compound assignment.

FreeLesson10 min readWarm-upReviewed Oct 2026 · Java 21
On this page
  1. Picture the precedence ladder first
  2. Under the hood: arithmetic, increments and the operand stack
  3. Under the hood: evaluation order, logic and bits
  4. Real world: where these bite in production
  5. Advanced: the ternary and compound assignment traps
  6. How to say it in the interview

➗ What are operators and precedence?

Operators are the symbols that turn values into new values: +, %, ++, &&, >>>, ?: and friends. Precedence decides how an expression without parentheses is grouped, so 2 + 3 * 4 is 2 + (3 * 4) and gives 14, not 20. Precedence answers "which operator grabs which operand". A separate rule, evaluation order, answers "which operand runs first", and Java fixes that one too: left to right.

For this lesson, we'll go operator family by operator family, and at each stop we'll look at the one behaviour that ends up in interview puzzles. Interviewers love this topic because a three-line snippet like i = i++ exposes whether you know what the compiler and the JVM really do, or whether you're guessing from habit.

Below the line (out of scope)

The 30-second answer

Java groups operands by a fixed precedence ladder (postfix and unary bind tightest, assignment and lambda loosest) and then evaluates operands strictly left to right (JLS 15.7). Arithmetic on byte, short and char is promoted to int. && and || short-circuit; & and | always evaluate both sides. >> keeps the sign, >>> fills with zeros. Compound assignment like s += 1 hides a cast. And i = i++ leaves i unchanged, because the old value is saved on the operand stack before the increment, then written back.

What you’ll learn

  • Separate precedence (grouping) from evaluation order (always left to right, JLS 15.7)
  • Explain why i = i++ leaves i unchanged using the operand stack and bytecode
  • Choose between && and &, and between >> and >>>, and predict their results
  • Spot the ternary promotion and unboxing traps, and the implicit cast in s += 1
  • Use instanceof pattern matching (Java 16) with correct flow scoping

01Picture the precedence ladder first

Think of precedence as a ladder. Operators near the top grab their operands first, the way multiplication grabs its neighbours before addition gets a look. Associativity breaks ties on the same rung: most binary operators group left to right, so 10 - 2 - 3 is (10 - 2) - 3 = 5, while assignment and the ternary group right to left, so a = b = 5 assigns b first.

Here's the whole ladder on one card, with the four rungs that cause the most bugs called out on the right.

Fig. 1

The Java precedence ladder from postfix (binds tightest) down to assignment and lambda (binds loosest), with common traps annotated

The Java precedence ladder from postfix (binds tightest) down to assignment and lambda (binds loosest), with common traps annotated

Two rungs deserve a second look. Equality (==) sits above bitwise &, so x & 1 == 0 means x & (1 == 0) and does not compile for an int. And shift sits below addition, so 2 << 1 + 1 is 2 << 2 = 8. When in doubt, add parentheses: they cost nothing at runtime and save the next reader a trip to the spec.

02Under the hood: arithmetic, increments and the operand stack

Start with the arithmetic operators, because they carry three rules people forget:

  • Promotion. byte, short and char operands become int before any arithmetic. long wins over int, float over long, double over everything.
  • Overflow wraps silently. Integer.MAX_VALUE + 1 is -2147483648, and even Math.abs(Integer.MIN_VALUE) stays negative.
  • % takes the sign of the left operand. -7 % 3 is -1 and 7 % -3 is 1. If you want a non-negative bucket index, use Math.floorMod(-7, 3), which returns 2. % also works on doubles: 5.5 % 2 is 1.5.

Division follows the same split. Integer / 0 throws ArithmeticException, but 1.0 / 0 is Infinity and 0.0 / 0 is NaN.

Now the famous one.

Try it yourself first

Before reading on, predict what each line prints. Then explain why in terms of what the JVM does, not "post-increment happens later".

public class Increments {
    public static void main(String[] args) {
        int i = 0;
        i = i++;
        System.out.println(i);        // ?

        int j = 0;
        j = ++j;
        System.out.println(j);        // ?

        int m = 5;
        m = m++ + ++m;
        System.out.println(m);        // ?
    }
}

The answers are 0, 1 and 12. Here's the bytecode javac emits for i = i++ (from javap -c), with i in local slot 1 because slot 0 holds args:

iconst_0        // push 0
istore_1        // i = 0
iload_1         // push a COPY of i (0) onto the operand stack
iinc 1, 1       // bump the local slot: i is now 1
istore_1        // pop the saved 0 and store it into i: i is 0 again

The JVM is a stack machine. Every expression is computed on a small per-frame operand stack, while variables live in local variable slots. Post-increment means "the value of this expression is the old value". The compiler honours that by loading the old value onto the stack before incrementing the slot. Then the assignment stores the stack value, wiping out the increment.

Fig. 2

Step by step through iload_1, iinc and istore_1: the increment lands in the local slot but the saved old value on the operand stack overwrites it

Step by step through iload_1, iinc and istore_1: the increment lands in the local slot but the saved old value on the operand stack overwrites it

Notice three things:

  • ++j compiles to iinc 1, 1 then iload_1, so the stack holds the new value and j ends as 1.
  • In m = m++ + ++m, the left operand runs first: m++ yields 5 and sets m to 6; then ++m sets m to 7 and yields 7. 5 + 7 = 12 is stored into m.
  • None of this is undefined behaviour. In C, i = i++ is undefined. In Java, the JLS pins it down, so the answer is always 0.

03Under the hood: evaluation order, logic and bits

JLS 15.7 gives you three guarantees: the left operand of a binary operator is fully evaluated before any part of the right operand; every operand is evaluated before the operation runs; and method arguments are evaluated left to right. Precedence only decides grouping. Order is always left to right.

public class Order {
    static int log(String name, int v) {
        System.out.print(name);
        return v;
    }

    public static void main(String[] args) {
        System.out.println(" = " + (log("a", 1) + log("b", 2) * log("c", 3)));
        // prints "abc = 7": * groups first, but a still runs first

        int k = 1;
        int r = k + (k = 5) * k;      // 1 + 5 * 5
        System.out.println(r);        // 26

        int[] arr = {0, 0};
        int idx = 0;
        arr[idx] = idx = 1;           // array ref and index are evaluated first
        System.out.println(arr[0] + "," + arr[1]);   // 1,0

        Object o = "hello";
        if (o instanceof String s && s.length() > 3) {   // Java 16 pattern
            System.out.println(s.toUpperCase());          // HELLO
        }
    }
}

Logical operators. && and || short-circuit: if the left side decides the answer, the right side never runs. That's why s != null && s.length() > 0 is safe. On booleans, & and | are the non-short-circuit versions: s != null & s.length() > 0 evaluates both sides and throws NullPointerException when s is null. You'd only pick & deliberately when the right side has a side effect that must always happen, which is usually a design smell.

Bitwise and shifts. &, |, ^ and ~ work bit by bit on integers. Shifts have two surprises. First, the shift distance is masked: for int only the low 5 bits count, so 1 << 32 is 1, not 0 (for long it's the low 6 bits, so 1L << 32 is 4294967296). Second, the two right shifts differ on negatives:

Expression Result Why
-8 >> 1 -4 arithmetic shift copies the sign bit
-8 >>> 1 2147483644 logical shift fills with zeros
-1 >>> 28 15 the top four bits become the bottom four
x & 1 0 or 1 cheap odd/even test, also works for negatives

instanceof with a pattern (Java 16, JEP 394). o instanceof String s tests and binds in one step, and s is in scope only where the test is definitely true. That's why o instanceof String s && s.length() > 3 compiles but o instanceof String s || s.isEmpty() does not: on the right of ||, the test was false. Flow scoping also works in reverse: after if (!(o instanceof String s)) return;, s is usable for the rest of the method. Since Java 21 you can deconstruct records in the same place: o instanceof Point(int x, int y).

04Real world: where these bite in production

In the real world

Hash-based bucketing is the classic incident. Someone writes buckets[key.hashCode() % n], it passes tests, and then a key with a negative hash code arrives and throws ArrayIndexOutOfBoundsException in production. HashMap itself sidesteps this with (n - 1) & hash on a power-of-two table, which is always non-negative. The fix in your own code is Math.floorMod(hash, n).

The other repeat offender is the mid-point calculation (low + high) / 2 in binary search. With indexes above about 1.07 billion the sum overflows to a negative number. The overflow-safe version is low + (high - low) / 2, or (low + high) >>> 1, which treats the overflowed sum as unsigned. That second form is what Arrays.binarySearch uses, and it's a nice detail to drop in an interview.

05Advanced: the ternary and compound assignment traps

The conditional operator ?: has its own typing rules (JLS 15.25), and they surprise people because the result type is decided at compile time from both branches, whichever one runs.

public class TernaryTraps {
    public static void main(String[] args) {
        System.out.println(true ? 1 : 2.0);     // 1.0: int promoted to double

        char c = 'x';
        int zero = 0;
        System.out.println(true ? c : 0);       // x: constant 0 fits in char
        System.out.println(true ? c : zero);    // 120: char promoted to int

        short s = 1;
        s += 1;          // fine: means s = (short) (s + 1)
        // s = s + 1;    // compile error: int cannot go into short
        int x = 10;
        x += 3.7;        // compiles, x is 13: the .7 is silently cut off

        Integer boxed = null;
        boolean flag = false;
        int n = flag ? 1 : boxed;               // NullPointerException: unboxing
    }
}
Where candidates slip

Many candidates are quick to say s += 1 and s = s + 1 are the same thing. They aren't. JLS 15.26.2 defines E1 op= E2 as E1 = (T) ((E1) op (E2)) with E1 evaluated once. The hidden cast is why s += 1 compiles and why x += 3.7 quietly truncates. It's also why byte b = -8; b >>>= 1; gives -4: the shift happens on the promoted int (2147483644) and the cast back to byte keeps only the low 8 bits.

The ternary NullPointerException deserves a sentence of its own. When one branch is int and the other is Integer, the result type is int, so the Integer branch gets unboxed even if it's null. Make both branches the same boxed type, or check for null first. We go deeper into unboxing in Wrapper classes and autoboxing.

Performance angle

Bit tricks rarely buy anything in modern Java. The JIT already turns x * 8 into a shift and x % 16 on a known non-negative value into a mask. Write the readable version first. Reach for >>> and masks only when they express intent, such as hashing, flags or unsigned arithmetic.

06How to say it in the interview

When I ask an operator puzzle, I'm listening for three things: do you separate grouping (precedence) from order (left to right), can you explain i = i++ in terms of the operand stack instead of "it happens later", and do you know about the hidden cast in compound assignment. Bonus points if you name the JLS section without being asked.

In the interview

"Precedence decides how operands are grouped, but Java always evaluates operands left to right, so side effects are predictable. i = i++ leaves i unchanged: javac loads the old value onto the operand stack, increments the local, then stores the old value back. && and || short-circuit, & and | don't. >> is sign-preserving, >>> is unsigned. And compound assignment includes an implicit narrowing cast, which is why short s; s += 1 compiles but s = s + 1 doesn't."

Here's how this might look on the whiteboard:

On the whiteboard

Operators

  • precedence = grouping; order = always L to R (JLS 15.7)
  • byte/short/char promote to int; overflow wraps
  • % sign follows dividend; use floorMod for buckets
  • i = i++: iload, iinc, istore, so i unchanged
  • &&/|| short-circuit; &/| evaluate both
  • >> keeps sign; >>> zero-fills; shift distance masked
  • ?: type from both branches; watch unboxing NPE
  • a op= b means a = (T)(a op b)
Practise this topicOperators & control flow: interview questions & scenarios
Further reading (4)