Skip to main content
Java · lesson 3 of 66
Open the Java page →
Java beginner Lesson 3 of 66

Data Types in Java

Master Java's 8 primitive types, their wrapper classes, type casting, String handling, and how Java stores values in memory.

By SkillByExample · Updated

java basics datatypes

Every example in this lesson is a single file run with the JDK 21 source launcher (java File.java). Java has two categories of types: primitives (8 built-in value types) and reference types (objects, including arrays, Strings, and every class you write). Understanding when to use each — and how Java stores them — is fundamental to writing correct Java code. Choosing the wrong type is a common source of subtle bugs, especially around overflow and floating-point precision.

The 8 Primitive Types

Primitives are the building blocks of all Java data. They are stored directly in memory (not as objects), which makes them fast and memory-efficient. Knowing each type’s size and range helps you pick the right one and avoid overflow surprises.

TypeSizeRangeDefaultUse for
byte8-bit-128 to 1270Raw binary data, file I/O
short16-bit-32,768 to 32,7670Rarely used directly
int32-bit-2.1B to 2.1B0Whole numbers (default integer type)
long64-bit±9.2 × 10¹⁸0LLarge whole numbers, timestamps
float32-bit~7 decimal digits0.0fRarely used; prefer double
double64-bit~15 decimal digits0.0Decimal numbers (default float type)
char16-bit'\u0000' to '\uffff''\u0000'Single UTF-16 code unit
boolean1-bittrue / falsefalseFlags and conditions
public class Primitives {
    public static void main(String[] args) {
        // Integer types
        byte  b  = 127;
        short s  = 30_000;          // underscores allowed for readability
        int   i  = 2_147_483_647;   // Integer.MAX_VALUE
        long  l  = 9_223_372_036_854_775_807L; // L suffix required for long literals

        // Floating-point
        float  f = 3.14f;    // f suffix required
        double d = 3.141592653589793; // default for decimal literals

        // Character
        char c = 'A';
        System.out.println((int) c); // 65 — char is really an unsigned 16-bit int

        // Boolean
        boolean isJavaFun = true;
        boolean isEmpty   = false;

        System.out.println(b);
        System.out.println(s);
        System.out.println(i);
        System.out.println(l);
        System.out.println(f);
        System.out.println(d);
        System.out.println(isJavaFun);
        System.out.println(isEmpty);
    }
}
$ java Primitives.java
65
127
30000
2147483647
9223372036854775807
3.14
3.141592653589793
true
false

65 prints first, not last — the (int) c cast and its println run immediately after char c is declared, before the later block of prints for b, s, i, and the rest. The cast itself reveals that char is stored as an unsigned 16-bit integer — 'A' is just the number 65 in disguise.

Integer Overflow

Integer overflow is one of the most dangerous silent bugs in Java — the runtime does not throw an exception, it just wraps around to a wrong value. Knowing this upfront will save you from hard-to-trace calculation errors.

public class OverflowCheck {
    public static void main(String[] args) {
        int max = Integer.MAX_VALUE; // 2147483647
        System.out.println(max + 1); // -2147483648 — overflows silently!

        // Safe check using Math:
        try {
            int result = Math.addExact(max, 1); // throws ArithmeticException on overflow
            System.out.println(result);
        } catch (ArithmeticException e) {
            System.out.println("Overflow detected!");
        }
    }
}
$ java OverflowCheck.java
-2147483648
Overflow detected!

Adding 1 to Integer.MAX_VALUE (2,147,483,647) silently wraps to Integer.MIN_VALUE (-2,147,483,648). The Math.addExact method throws ArithmeticException instead, letting you catch the overflow explicitly.

Floating-Point Precision

Floating-point types (double and float) use binary representation internally, which cannot exactly represent most decimal fractions. This is not a Java bug — it is how IEEE 754 works in every major language. The practical rule: never use double for money or any calculation where exact decimal values matter. Use BigDecimal instead.

import java.math.BigDecimal;

public class PrecisionDemo {
    public static void main(String[] args) {
        System.out.println(0.1 + 0.2);        // 0.30000000000000004 — NOT 0.3
        System.out.println(0.1 + 0.2 == 0.3); // false

        // For exact decimal arithmetic, use BigDecimal:
        BigDecimal a = new BigDecimal("0.1");
        BigDecimal b = new BigDecimal("0.2");
        System.out.println(a.add(b));          // 0.3 — exact

        // Always construct BigDecimal from String, not double:
        System.out.println(new BigDecimal(0.1));  // WRONG — 0.1000000000000000055511...
        System.out.println(new BigDecimal("0.1")); // CORRECT — exactly 0.1
    }
}
$ java PrecisionDemo.java
0.30000000000000004
false
0.3
0.1000000000000000055511151231257827021181583404541015625
0.1

0.1 + 0.2 does not equal 0.3 because neither 0.1 nor 0.2 can be represented exactly in binary floating-point. The BigDecimal constructed from a String gives the exact value, while the one built from a double inherits the imprecision.

Wrapper Classes

Every primitive has a corresponding wrapper class — an object representation that adds utility methods and enables use in generics. You cannot put an int into a List<Integer>, but you can put an Integer. Java’s autoboxing feature handles the conversion automatically in most cases.

PrimitiveWrapperUseful methods
intIntegerparseInt, valueOf, MAX_VALUE, toBinaryString
longLongparseLong, toBinaryString, bitCount
doubleDoubleparseDouble, isNaN, isInfinite
booleanBooleanparseBoolean, toString
charCharacterisDigit, isLetter, toUpperCase
public class CharDemo {
    public static void main(String[] args) {
        // Useful constants
        System.out.println(Integer.MAX_VALUE);   // 2147483647
        System.out.println(Integer.MIN_VALUE);   // -2147483648
        System.out.println(Double.MAX_VALUE);    // 1.7976931348623157E308
        System.out.println(Integer.toBinaryString(42)); // 101010

        // Wrapper utility methods
        System.out.println(Character.isDigit('7'));    // true
        System.out.println(Character.isLetter('A'));   // true
        System.out.println(Character.toUpperCase('a')); // A
    }
}
$ java CharDemo.java
2147483647
-2147483648
1.7976931348623157E308
101010
true
true
A

Wrapper classes provide static utility methods like parseInt for parsing strings, and constants like MAX_VALUE and MIN_VALUE for boundary checking. Character.isDigit and Character.isLetter are handy for input validation.

Autoboxing and Unboxing

Java automatically converts between primitives and their wrapper classes. This convenience feature is transparent in most code, but the null-unboxing pitfall it creates is a common source of NullPointerException bugs.

import java.util.ArrayList;
import java.util.List;

public class AutoboxDemo {
    public static void main(String[] args) {
        // Autoboxing — primitive to wrapper
        Integer boxed = 42;          // same as: Integer.valueOf(42)
        List<Integer> list = new ArrayList<>();
        list.add(99);                // int 99 autoboxed to Integer

        // Unboxing — wrapper to primitive
        int unboxed = boxed;         // same as: boxed.intValue()
        int total = list.get(0) + 1; // unboxing before arithmetic

        // Pitfall — null unboxing throws NullPointerException
        Integer nullValue = null;
        try {
            int x = nullValue;
        } catch (NullPointerException e) {
            System.out.println("NullPointerException caught!");
        }

        System.out.println(boxed);
        System.out.println(unboxed);
        System.out.println(total);
        System.out.println(list.get(0));
    }
}
$ java AutoboxDemo.java
NullPointerException caught!
42
42
100
99

The unboxing on line int x = nullValue throws a NullPointerException because Java tries to unbox null to an int. This can happen silently anywhere a wrapper is used in a context expecting a primitive.

Type Casting

Widening (automatic, no data loss)

Widening conversions are automatic because the destination type covers the source’s range. Integer-to-integer widening is exact, but int/long to float and long to double can round: (float) 123456789 is 1.23456792E8, printed as 1.2345679E8.

byte → short → int → long → float → double
public class CastingDemo {
    public static void main(String[] args) {
        int i = 100;
        long l = i;       // widening — automatic
        double d = i;     // widening — automatic
        System.out.println(l);
        System.out.println(d);
    }
}
$ java CastingDemo.java
100
100.0

The int value 100 widens to long (100) and then to double (100.0) with no data loss. Java inserts the conversion automatically — no cast needed.

Narrowing (explicit, may lose data)

Narrowing conversions require an explicit cast because you are telling the compiler “I know this might lose data — do it anyway.” Always check the range before narrowing to avoid silently corrupted values.

public class CastingDemo {
    public static void main(String[] args) {
        // Narrowing
        double d2 = 9.99;
        int i2 = (int) d2;   // explicit cast — truncates decimal
        System.out.println(i2); // 9 — NOT rounded, just truncated

        long big = 1_000_000_000_000L;
        int small = (int) big; // data loss — only keeps lower 32 bits
        System.out.println(small); // -727379968 — garbage

        // char ↔ int
        char c = 'A';
        int code = c;
        char back = (char)(code + 1);
        System.out.println(code);
        System.out.println(back);
    }
}
$ java CastingDemo.java
9
-727379968
65
B

The double 9.99 becomes 9 when cast to int — Java truncates, it does not round. Casting the long 1,000,000,000,000 to int keeps only the lower 32 bits, producing the garbage value -727379968. The char cast shows 'A' (65) and 'B' (66) as integers and characters.

Strings in Depth

String is immutable — once created, its value never changes. This is not just an implementation detail: it is what makes strings safe to share across threads, use as HashMap keys, and pass to methods without fear of the caller mutating them. Every “modification” creates a new object, so be aware of that cost when building strings in loops.

public class StringDemo {
    public static void main(String[] args) {
        String s = "Hello";
        s = s + " World"; // "Hello" is discarded; new String created in memory
        System.out.println(s);

        // String pool — literals are interned (shared)
        String a = "Java";
        String b = "Java";
        System.out.println(a == b);      // true — same object in pool
        System.out.println(a.equals(b)); // true

        // new String bypasses the pool
        String c = new String("Java");
        System.out.println(a == c);      // false — different objects
        System.out.println(a.equals(c)); // true — same content
    }
}
$ java StringDemo.java
Hello World
true
true
false
true

The == operator compares object identity, not content. Two string literals with the same text share the same object in the string pool, so == returns true. But new String("Java") creates a distinct object even with the same content — always use .equals() to compare string values.

Common String Methods

public class StringMethods {
    public static void main(String[] args) {
        String s = "  Hello, Java World!  ";
        System.out.println(s.trim());
        System.out.println(s.strip());
        System.out.println(s.toLowerCase());
        System.out.println(s.toUpperCase());
        System.out.println(s.contains("Java"));
        System.out.println(s.startsWith("  Hello"));
        System.out.println(s.endsWith("!"));
        System.out.println(s.endsWith("!  "));
        System.out.println(s.replace("Java", "Python"));
        String[] parts = s.split(", ");
        System.out.println(java.util.Arrays.toString(parts));
        System.out.println(s.indexOf("Java"));
        System.out.println(s.substring(8, 12));
        System.out.println(s.charAt(8));
        System.out.println(s.isEmpty());
        System.out.println(s.isBlank());
        System.out.println("".isEmpty());
        System.out.println("  ".isBlank());
    }
}
$ java StringMethods.java
Hello, Java World!
Hello, Java World!
  hello, java world!  
  HELLO, JAVA WORLD!  
true
true
false
true
  Hello, Python World!  
[  Hello, Java World!  ]
9
 Jav
 
false
false
true
true

trim() and strip() both remove whitespace, but strip() is Unicode-aware (Java 11+) — toLowerCase/toUpperCase/replace don’t touch whitespace at all, so their output keeps the original string’s leading and trailing spaces. endsWith("!") is false here because the string ends with spaces, while endsWith("! ") is true. The split uses regex, so the space in ", " is significant; the printed array is two elements, " Hello" and "Java World! ", joined by Arrays.toString’s own , . The line that looks blank right after Jav isn’t empty — it’s s.charAt(8), which lands on the single space between the comma and Java.

StringBuilder — Efficient String Building

When building strings in a loop, use StringBuilder to avoid creating many intermediate String objects. Each + concatenation in a loop allocates a new String, which becomes a garbage-collection burden. StringBuilder mutates a single internal buffer and produces one String at the end.

public class StringBuilderDemo {
    public static void main(String[] args) {
        // One mutable buffer instead of a new String per iteration
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 5; i++) {
            sb.append(i);
        }
        String result = sb.toString();
        System.out.println(result);

        // StringBuilder chaining
        String csv = new StringBuilder()
            .append("Alice")
            .append(",")
            .append(25)
            .append(",")
            .append("Engineer")
            .toString();
        System.out.println(csv);
    }
}
$ java StringBuilderDemo.java
01234
Alice,25,Engineer

StringBuilder.append returns this, enabling method chaining — each .append() call returns the same builder instance. The final .toString() freezes the mutable buffer into an immutable String.

var — Local Type Inference (Java 10+)

var reduces boilerplate in local variable declarations by letting the compiler infer the type from the initialiser. The type is still fixed at compile time — var is not dynamic typing, just a way to avoid writing the type name twice when it is already obvious from the right-hand side.

import java.util.ArrayList;

public class VarDemo {
    public static void main(String[] args) {
        var count  = 0;               // int
        var name   = "Alice";         // String
        var items  = new ArrayList<String>(); // ArrayList<String>
        var scores = new int[]{1, 2, 3};      // int[]

        System.out.println(count);
        System.out.println(name);
        System.out.println(items);
        System.out.println(java.util.Arrays.toString(scores));
    }
}
$ java VarDemo.java
0
Alice
[]
[1, 2, 3]

var only works for local variables with an initialiser — the compiler needs the initializer to infer the type. It cannot be used for fields, method parameters, or variables without initialization.

Practice

1. Primitive ranges

Create variables of each primitive type (byte, short, int, long, float, double, char, boolean). Print each one. What happens if you try to assign 130 to a byte? How about true to an int?

Solution
public class PrimitiveRanges {
    public static void main(String[] args) {
        byte b = 127;      // OK — within range
        // byte b2 = 130;  // error: incompatible types: possible lossy conversion from int to byte

        short s = 32767;
        int i = 2147483647;
        long l = 9223372036854775807L;
        float f = 3.14f;
        double d = 3.141592653589793;
        char c = 'A';
        boolean flag = true;

        System.out.println(b);
        System.out.println(s);
        System.out.println(i);
        System.out.println(l);
        System.out.println(f);
        System.out.println(d);
        System.out.println(c);
        System.out.println(flag);
    }
}
$ java PrimitiveRanges.java
127
32767
2147483647
9223372036854775807
3.14
3.141592653589793
A
true

A byte is 8-bit signed: it can hold -128 to 127. Assigning 130 produces a compile error. boolean and int are completely incompatible — int x = true will not compile.

2. Fix the floating-point trap

Check whether 0.1 + 0.2 equals 0.3 using double, then do the same sum with BigDecimal and compare it to 0.3 with compareTo.

Solution
import java.math.BigDecimal;

public class FixFloatingPoint {
    public static void main(String[] args) {
        // Floating-point is imprecise
        double d1 = 0.1;
        double d2 = 0.2;
        double dSum = d1 + d2;
        System.out.println("double: " + (dSum == 0.3));  // false

        // BigDecimal is exact
        BigDecimal bd1 = new BigDecimal("0.1");
        BigDecimal bd2 = new BigDecimal("0.2");
        BigDecimal bdSum = bd1.add(bd2);
        System.out.println("BigDecimal: " + (bdSum.compareTo(new BigDecimal("0.3")) == 0)); // true
    }
}
$ java FixFloatingPoint.java
double: false
BigDecimal: true

The double comparison fails because 0.1 + 0.2 produces 0.30000000000000004. BigDecimal constructed from String gives exact decimal values, so 0.1 + 0.2 truly equals 0.3.

3. Safe narrowing

Write a method safeNarrow(long value) that returns the int value if it fits in an int, or throws IllegalArgumentException with a descriptive message if it doesn’t. Test it with 1_000_000_000L, 3_000_000_000L, and -2_147_483_649L.

Solution
public class SafeNarrow {
    public static int safeNarrow(long value) {
        if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
            throw new IllegalArgumentException(
                "Value " + value + " is out of int range [" +
                Integer.MIN_VALUE + ", " + Integer.MAX_VALUE + "]"
            );
        }
        return (int) value;
    }

    public static void main(String[] args) {
        try { safeNarrow(1_000_000_000L); } catch (Exception e) { System.out.println(e.getMessage()); }
        try { safeNarrow(3_000_000_000L); } catch (Exception e) { System.out.println(e.getMessage()); }
        try { safeNarrow(-2_147_483_649L); } catch (Exception e) { System.out.println(e.getMessage()); }
    }
}
$ java SafeNarrow.java
Value 3000000000 is out of int range [-2147483648, 2147483647]
Value -2147483649 is out of int range [-2147483648, 2147483647]

The first call succeeds (1 billion fits in int). The second and third throw because they exceed Integer.MAX_VALUE (2,147,483,647) or fall below Integer.MIN_VALUE (-2,147,483,648).

4. String comparison

Create two strings: a using a literal "Hello" and b using new String("Hello"). Predict and then verify the output of a == b and a.equals(b). Then call b.intern() and compare a == b.intern().

Solution
public class StringComparison {
    public static void main(String[] args) {
        String a = "Hello";               // literal — in string pool
        String b = new String("Hello");   // new object — NOT in pool

        System.out.println(a == b);       // false — different objects
        System.out.println(a.equals(b));  // true — same content

        System.out.println(a == b.intern()); // true — intern() returns the pool instance
    }
}
$ java StringComparison.java
false
true
true

The literal "Hello" is interned into the string pool. new String("Hello") creates a separate object on the heap. b.intern() looks up (or creates) the canonical pool instance — it returns the same object as a, so a == b.intern() is true.

5. StringBuilder vs String concatenation

Write the same string-building loop two ways: once with String result += i inside a loop of 1000 iterations, and once with StringBuilder. Time both with System.nanoTime(). Which is faster and why?

Solution
public class StringPerformance {
    public static void main(String[] args) {
        // Using String concatenation (slow — creates 1000 intermediate objects)
        long start1 = System.nanoTime();
        String result1 = "";
        for (int i = 0; i < 1000; i++) {
            result1 += i;
        }
        long end1 = System.nanoTime();
        System.out.println("String time: " + (end1 - start1) + " ns (varies)");

        // Using StringBuilder (fast — single buffer)
        long start2 = System.nanoTime();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 1000; i++) {
            sb.append(i);
        }
        String result2 = sb.toString();
        long end2 = System.nanoTime();
        System.out.println("StringBuilder time: " + (end2 - start2) + " ns (varies)");
    }
}
$ java StringPerformance.java
String time: 4343357 ns (varies)
StringBuilder time: 69635 ns (varies)

Your numbers will differ, and a single cold run like this mostly measures JIT warm-up, but the gap is consistently large. StringBuilder is faster because it mutates a single internal buffer, while String concatenation creates a new String object on every += — for 1000 iterations, that is 1000 objects discarded to the garbage collector.

Summary

CategoryTypesNotes
Integerbyte short int longDefault to int; use long for large values
Floating-pointfloat doubleDefault to double; use BigDecimal for exact math
Characterchar16-bit Unicode; rarely used directly
Booleanbooleantrue or false only
TextStringImmutable object; use StringBuilder for building
WrappersInteger Double etc.Object form of primitives; needed for generics

Frequently Asked Questions

What is the difference between int and Integer in Java?
int is a primitive — a raw value stored directly in memory, fast and efficient. Integer is a wrapper class — an object that wraps an int. You need Integer (or other wrappers) when working with generics (List<Integer>), nullability, or methods from the Collections API. Java auto-converts between them via autoboxing.
Why does 0.1 + 0.2 not equal 0.3 in Java?
Because double and float use IEEE 754 binary floating-point representation, which cannot exactly represent most decimal fractions. Use BigDecimal for financial or exact decimal arithmetic.
Is String a primitive in Java?
No. String is a class (java.lang.String). However, it has special treatment in Java — string literals are interned and Java provides the + operator for concatenation, which makes it feel built-in.
When should I use var instead of writing the type explicitly?
Use var when the type is obvious from the right-hand side — local variables with initializers like collections or streams. It reduces ceremony without sacrificing type safety since var is resolved at compile time, not runtime.
What happens during integer overflow in Java?
Java silently wraps around on overflow — there is no exception thrown. Integer.MAX_VALUE + 1 becomes Integer.MIN_VALUE and vice versa. Use Math.addExact, Math.multiplyExact, etc. to detect overflow explicitly.