Layers of Logic

2.2

How Numbers Live in Memory

Fixed-width two's complement represents signed integers. IEEE 754 represents binary floating point, including rounding and special values.

Going deeper26 min read4 exercises
01

Previously on

Section 2.1 introduced fixed-width integer types and IEEE 754 floating-point types.

This section gives the bits an interpretation. The width matters at every step. 1111_1111 can mean 255 as an unsigned pattern or -1 as an eight-bit signed pattern.

02

The problem

Run this complete program:

public class StoredValues {
    public static void main(String[] args) {
        byte negative = -42;
        float exact = 8.125f;
        float rounded = 0.7f;

        System.out.println(Integer.toBinaryString(negative & 0xFF));
        System.out.printf("%.20f%n", exact);
        System.out.printf("%.20f%n", rounded);
    }
}

Output:

11010110
8.12500000000000000000
0.69999998807907100000

Three questions now have evidence:

  1. How does 11010110 represent -42?
  2. Why does 8.125 survive exactly?
  3. Why is the stored float near 0.7 rather than equal to decimal 0.7?
03

The idea

Bits gain meaning from a representation rule and a width.

Signed integersBinary floating point
Java typesbyte, short, int, longfloat, double
Representationfixed-width two's complementIEEE 754 binary32 or binary64
Main limitfinite rangefinite range and finite precision
Past the limitordinary arithmetic wrapsrounding, infinity, or underflow

Positive binary values

Binary place values are powers of two.

7060514031201100
= 42
32 + 8 + 2 = 42. Leading zero bits do not change a positive magnitude.

The same eight positions can encode signed values because Java reads them using two’s complement.

04

Under the hood

Going deeper

Two’s complement at a fixed width

To form -42 in eight bits:

Step Bits
+42 0010_1010
invert every bit 1101_0101
add one within eight bits 1101_0110
11010110
-42
signed byte
The high bit is set. Read as unsigned this is 214; 214 - 256 = -42.

The subtraction rule is often faster than inverting:

if the top bit is 0: signed value = unsigned value
if the top bit is 1: signed value = unsigned value - 2^width

For 1111_0011, the unsigned value is 243. At eight bits, 243 - 256 = -13.

Why addition works without a separate negative-number circuit

Add 42 and -42 while keeping eight bits:

  0010_1010
+ 1101_0110
-----------
1_0000_0000

The ninth carry bit falls outside the width. The stored eight bits are 0000_0000, which is zero.

This is the central advantage of two’s complement. One fixed-width addition rule works for positive and negative operands. There is one zero pattern. Subtraction can use addition of the two’s complement.

The asymmetric minimum

An eight-bit signed range is -128 through 127.

0111_1111 = 127
1000_0000 = -128

There is no eight-bit +128. Negating the minimum within the same width returns the same pattern. This behavior generalizes to Short.MIN_VALUE, Integer.MIN_VALUE, and Long.MIN_VALUE.

Overflow is fixed-width wraparound

The maximum byte pattern plus one gives the minimum byte pattern:

0111_1111  127
+       1
----------
1000_0000 -128

Java promotes byte arithmetic to int, so this program uses a cast to return to eight bits:

byte maximum = Byte.MAX_VALUE;
byte wrapped = (byte) (maximum + 1);
System.out.println(wrapped); // -128

Ordinary int and long arithmetic also wraps at their own widths. Math.addExact, Math.subtractExact, and Math.multiplyExact provide checked alternatives.

IEEE 754 binary32

A normal finite float uses three fields:

0
sign
10000010
exponent
00000100000000000000000
fraction
= 8.125
One sign bit, eight exponent bits, and 23 stored fraction bits. Normal values also have an implied leading 1.

For normal finite values, the interpretation is:

(-1)^sign * 1.fraction * 2^(storedExponent - 127)

The implied leading 1 gives 24 bits of precision even though only 23 fraction bits are stored.

Build 8.125

8.125 is 1000.001 in binary. Normalize it:

1000.001 = 1.000001 * 2^3

The sign is 0. The stored exponent is 3 + 127 = 130, or 1000_0010. The fraction field holds the digits after the leading 1.: 000001, followed by zeros. Every required binary digit fits, so the result is exact.

Why 0.7 is rounded

Convert the fractional part by repeatedly multiplying by two:

Input Times 2 Next bit Remainder
0.7 1.4 1 0.4
0.4 0.8 0 0.8
0.8 1.6 1 0.6
0.6 1.2 1 0.2
0.2 0.4 0 0.4

The remainder 0.4 has returned, so the sequence repeats:

0.7 decimal = 0.10110011001100110011... binary

A float cannot store infinitely many fraction bits. IEEE 754 rounds to a nearby representable value. The exact decimal value of the resulting float is:

0.699999988079071044921875

printf("%.20f", value) rounds that stored value to twenty digits after the point. Float.toString(value) uses the shortest decimal text that parses back to the same float, which is why println displays 0.7.

Exponent patterns also encode special values

The normal-value formula does not cover every exponent pattern.

Exponent bits Fraction bits Meaning
00000000 all zero positive or negative zero
00000000 nonzero subnormal finite value
00000001 to 11111110 any normal finite value
11111111 all zero positive or negative infinity
11111111 nonzero NaN, meaning not a number

This program exposes the arithmetic behavior:

public class SpecialFloats {
    public static void main(String[] args) {
        double positiveInfinity = 1.0 / 0.0;
        double notANumber = 0.0 / 0.0;

        System.out.println(positiveInfinity);
        System.out.println(notANumber);
        System.out.println(notANumber == notANumber);
        System.out.println(+0.0 == -0.0);
    }
}

Output:

Infinity
NaN
false
true

Integer division by zero is different: 1 / 0 throws ArithmeticException.

Equality needs a contract

0.1 + 0.2 == 0.3 is false because the two computations land on different representable doubles.

double sum = 0.1 + 0.2;
System.out.printf("%.17f%n", sum); // 0.30000000000000004

For measured or calculated quantities, compare against a tolerance chosen for the domain:

static boolean closeEnough(double a, double b, double tolerance) {
    return Math.abs(a - b) <= tolerance;
}

An absolute tolerance alone is not suitable across every magnitude. Production numeric code often combines absolute and relative tolerances and defines behavior for NaN and infinity.

Use BigDecimal when the domain requires exact decimal arithmetic, such as a currency amount with defined decimal rounding. Construct it from decimal text or BigDecimal.valueOf, not from an already rounded binary floating-point value.

05

What it costs

Two’s complement makes signed arithmetic efficient and predictable. Its fixed width also makes overflow silent unless code asks for checked operations.

IEEE 754 provides a large dynamic range, hardware support, infinities, NaN, and gradual underflow through subnormal values. It cannot represent every real or decimal value. Arithmetic must define acceptable error instead of assuming printed decimal text is exact.

Bit-level explanations depend on width and category. The normal float formula is not a formula for zero, subnormal values, infinity, or NaN.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. In eight-bit two's complement, what value does `1111_1111` represent?

    Show the answer

    The top bit is set, so read the unsigned pattern, 255, and subtract 2^8. The result is -1. You can also invert the bits and add one to recover magnitude 1.

  2. Why is `-Byte.MIN_VALUE` still -128 after conversion back to `byte`?

    Show the answer

    An eight-bit byte has a pattern for -128 but no pattern for +128. Negating 1000_0000 within eight bits produces 1000_0000 again. Java first promotes a byte operand to int, so a cast is needed to observe byte-width wrapping.

  3. Why is 8.125 exact as a float while 0.7 is not?

    Show the answer

    8.125 equals 8 + 1/8, so its binary form 1000.001 terminates. The binary expansion of 0.7 repeats. A float has finite precision, so Java stores the nearest representable value, which is approximately 0.699999988079071.

  4. Should every comparison between two `double` values use a tolerance?

    Show the answer

    No. A tolerance is useful when rounded measurements or calculations should be considered close within a domain-specific bound. Exact comparisons can be valid for sentinels, values copied without arithmetic, and some exactly representable results. Also handle NaN, infinities, and signed zero according to the program's contract. Exact decimal domains often need BigDecimal.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises90 pointsabout 100 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Two's Complement By Hand

Warm up·20 min·15 points

checkedex-2-2-a

Do the recipe by hand until it is automatic.

Work out the bit patterns for -7, -1 and -128 on paper first. Then run the program and see whether you were right.

Then go the other way. Here is a bit pattern: 11110011. Read it as a signed byte and tell me what number it is. Use the same flip-and-add-one steps, because they work in both directions.

Pay attention to -128. It is the one value in the byte range that has no positive twin, and working out its pattern will show you why.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Complement {
    static String bits(int value, int n)
    static int negate(int value, int n)
    static int signed(int rawBits, int n)
    static int fromBits(String bits)
}

bits returns exactly n binary digits, padded. negate applies flip-and-add-one within n bits. signed reads n raw bits as a signed number. fromBits takes a pattern and gives back the number, using its length as the width.

What your program must do

  • Do flip-and-add-one on paper for 42 and for -42 before writing any code
  • Implement all four methods so they work at 8 bits and at 4
  • Show that negating twice gives you back what you started with
  • Find the one value that is its own negative, and say why
Complement.java
public class Complement {

    // Exactly n binary digits, padded with leading zeros.
    static String bits(int value, int n) {
        return ""; // TODO
    }

    // Flip every bit, then add one. Stay within n bits.
    static int negate(int value, int n) {
        return 0; // TODO
    }

    // Read n raw bits as a signed number. Top bit set means negative.
    static int signed(int rawBits, int n) {
        return 0; // TODO
    }

    // A pattern like "11010110" back to the number. Its length is the width.
    static int fromBits(String bits) {
        return 0; // TODO
    }

    public static void main(String[] args) {
        // TODO: print the 8 bit pattern for 42, -42, 7, -7, 1, -1, 127, -128 and 0
        // TODO: negate a few by hand first, then check against your method
    }
}
Hint 1
Masking is the trick for staying inside n bits. (1 << n) - 1 is n ones, so value & mask keeps only the bits you care about.
Hint 2
To read raw bits as signed: if the top bit is set, subtract 2 to the power of n. That single line is what makes -1 come out as -1 rather than 255.
Hint 3almost the answer
-128 is its own negative in eight bits. Flip and add one on -128 gives -128 back, because +128 does not fit. That asymmetry is why the range runs -128 to 127 and not -127 to 128.
What this is really testing

Whether you can run the flip-and-add-one recipe without help, in both directions. This is a skill you either have or you do not, and ten minutes of paper fixes it permanently.

B

Inspect Exact Float Values

Real work·20 min·20 points

checkedex-2-2-b

Java usually prints a compact round-trip representation of a float. Compare that text with the exact decimal expansion of the stored binary value.

Print each value with Float.toString, with printf at twenty decimal places, and with the BigDecimal(float) technique from the hint. The first form is shortest-round-trip text. The second is rounded to twenty places. The third exposes the exact decimal expansion. Predict which values are exact before running.

Classify each value before running. Binary fractions built from powers of two terminate; values such as 0.7 require rounding to a representable float.

Once you can look at a decimal and say “that one will be exact” or “that one will not”, you have understood this section.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Hidden {
    static boolean isExact(float f)
    static String exactValue(float f)
    static boolean printsTheSame(float f)
}

isExact answers whether the float equals the intended decimal value. exactValue returns the exact decimal expansion of the stored binary float. printsTheSame compares that expansion with Float.toString.

What your program must do

  • Predict which of the nine values are stored exactly before running anything
  • Print the exact decimal expansion of each stored value and compare it with Float.toString
  • Say what the exact ones have in common
  • Explain why Float.toString showing 0.7 is a round-trip representation, not a claim that the stored value equals decimal 0.7
Hidden.java
import java.math.BigDecimal;

public class Hidden {

    // Is this float storing the decimal you wrote, or something near it?
    static boolean isExact(float f) {
        return true; // TODO
    }

    // The exact decimal expansion of the stored binary value.
    static String exactValue(float f) {
        return ""; // TODO
    }

    // Does Float.toString show the full exact decimal expansion?
    static boolean printsTheSame(float f) {
        return true; // TODO
    }

    public static void main(String[] args) {
        // Predict which of these are exact BEFORE running:
        //   0.5f  0.25f  0.125f  8.125f  1.0f  0.7f  0.1f  0.2f  0.3f
        // TODO: print each one's exact stored value and whether it is exact
    }
}
Hint 1
new BigDecimal(float) takes the bits exactly as stored. new BigDecimal(Float.toString(f)) takes the decimal you wrote. Comparing the two answers the question.
Hint 2
The exact ones are all sums of halves: 0.5, 0.25, 0.125, and 8.125 which is 8 + 1/8. Anything built from powers of two lands exactly on a binary fraction.
Hint 3almost the answer
Float.toString, which println uses here, prints the shortest decimal that parses back to the same float. The exact decimal expansion can be longer.
What this is really testing

Whether you can distinguish a float's exact stored value from its shortest round-trip decimal text. The short text is useful, but it does not expose every decimal digit of the binary value.

C

Take a Float Apart

Hard·35 min·30 points

checkedex-2-2-c

Go all the way down. Take a float apart into its three fields and put one back together yourself.

Do -2.5f on paper first, following all five steps from the section: convert, normalise, add the bias, take the mantissa, set the sign. Write down all 32 bits. Then run the program and see whether you got it.

Then look hard at 0.7f’s mantissa. The repeating group is visible. Find it, name it, and connect it back to the multiply-by-two loop that produced it.

Finish by decoding 1.0f. Apply the reverse formula to the printed fields and verify that the result is 1.0.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Dissect {
    static String allBits(float f)
    static int signBit(float f)
    static String exponentBits(float f)
    static String mantissaBits(float f)
    static int rawExponent(float f)
    static int realExponent(float f)
    static float rebuild(int sign, int rawExponent, String mantissa)
}

allBits returns all 32 padded. The three pieces are 1, 8 and 23 bits. rawExponent is what is stored, realExponent has the 127 bias removed. rebuild puts the pieces back and must give the original float.

What your program must do

  • Work out the 32 bits of 8.125f on paper before writing any code
  • Split a float into its three pieces and print them
  • Remove the bias and say what the real exponent means for each value
  • Rebuild the float from the pieces and confirm you get the original back
Dissect.java
public class Dissect {

    // All 32 bits, padded with leading zeros.
    static String allBits(float f) { return ""; }  // TODO

    // 1 bit, 8 bits, 23 bits.
    static int    signBit(float f)      { return 0;  }  // TODO
    static String exponentBits(float f) { return ""; }  // TODO
    static String mantissaBits(float f) { return ""; }  // TODO

    // What is stored, and what it means once the bias is removed.
    static int rawExponent(float f)  { return 0; }  // TODO
    static int realExponent(float f) { return 0; }  // TODO

    // Put the three pieces back together. This must give the original float.
    static float rebuild(int sign, int rawExponent, String mantissa) {
        return 0f; // TODO
    }

    public static void main(String[] args) {
        // TODO: take 8.125f, 0.7f, 1.0f, -2.5f and 0.5f apart and print the pieces
        // TODO: work 8.125f out on paper first and check your answer matches
    }
}
Hint 1
Float.floatToIntBits(f) gives you the raw 32 bits as an int, and Float.intBitsToFloat goes the other way.
Hint 2
The exponent is stored with 127 added, so a raw 127 means an exponent of 0. That is why 1.0f has raw 127 and 0.5f has raw 126.
Hint 3almost the answer
Rebuilding is (sign << 31) | (rawExponent << 23) | mantissaAsInt. Getting that round trip to work is what proves you understood the layout rather than just described it.
What this is really testing

Whether you can go from a decimal number to its exact 32 bits by hand, and back. This is the a direct way to connect the sign, exponent, and fraction fields to a numeric value.

D

The Money Bug

Real work·25 min·25 points·The Registry

checkedex-2-2-d

Ten items at 0.10 each. The total should be exactly 1.00. Run it and see what you get.

Then fix it properly with BigDecimal, and pay close attention to how you create the BigDecimal. There is a constructor that takes a double and it will quietly undo your entire fix, because by the time BigDecimal receives the value the error has already happened.

Finish by going back to The Registry. Your unit needs a running balance, or a supply budget, or anything measured in money. Add it, choose the type deliberately, and write a comment saying why you chose what you chose.

This is the first time The Registry gets a decision that has a wrong answer.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Money {
    static double tenDimesAsDouble()
    static BigDecimal tenDimesAsBigDecimal()
    static boolean doubleEqualsOne()
    static boolean bigDecimalEqualsOne()
    static boolean closeEnough(double a, double b, double tolerance)
}

Both totals add 0.10 ten times. The BigDecimal version must build its values from Strings, not from doubles, or it inherits the same problem it is supposed to fix.

What your program must do

  • Show the double total failing to equal 1.0
  • Show that the error is small enough to look right when printed
  • Do the same with BigDecimal and get it exactly right
  • Show what goes wrong if you build a BigDecimal from a double instead of a String
Money.java
import java.math.BigDecimal;

public class Money {

    // Add 0.10 ten times.
    static double tenDimesAsDouble() { return 0; }  // TODO

    // The same, with BigDecimal. Build the values from Strings.
    static BigDecimal tenDimesAsBigDecimal() { return null; }  // TODO

    static boolean doubleEqualsOne()     { return false; }  // TODO
    static boolean bigDecimalEqualsOne() { return false; }  // TODO

    // One comparison policy for approximate measurements: within a supplied tolerance.
    static boolean closeEnough(double a, double b, double tolerance) {
        return false; // TODO
    }

    public static void main(String[] args) {
        // TODO: print both totals and both comparisons
        // TODO: try new BigDecimal(0.1) instead of new BigDecimal("0.1") and see what happens
    }
}
Hint 1
BigDecimal compares with compareTo, not equals. equals also compares the scale, so 1.0 and 1.00 are not equal by it, which is Section 10.8's territory.
Hint 2
The double total prints as something like 0.9999999999999999. Every one of the ten additions was slightly off, and the errors added up instead of cancelling.
Hint 3almost the answer
new BigDecimal(0.1) preserves the exact binary double value. new BigDecimal("0.1") and BigDecimal.valueOf(0.1) represent the decimal text 0.1. Build monetary values from decimal inputs before binary floating-point arithmetic occurs.
What this is really testing

Whether you can recognise the situation where double is the wrong tool. This exact bug has cost real companies real money, and it looks completely innocent right up until an auditor finds it.

08

After the credits

Section 2.3 uses these representations to explain narrowing casts, precision loss during widening, and arithmetic promotion.

Two’s complement returns with bitwise and shift operators. IEEE 754 returns in statistics, streams, money, and every API that accepts floating-point measurements.

Threads you opened in this section

Two's complement will return in 3.1 - Operators