Java Data Types
Every variable in Java has a data type, and that type determines what kind of value the variable can hold, how much memory it occupies, and what operations you can perform on it. Java is a statically typed language, which means the compiler checks types at compile time rather than discovering mismatches while the program is running. Understanding data types deeply — not just their names, but how they’re stored and how they behave in edge cases — is one of the most important foundations for writing correct Java programs.
Overview: How Data Types Work in Java
Java has two broad categories of types: primitive types and reference types.
The eight primitive types are built directly into the language. They are not objects, they have no methods, and a variable of a primitive type stores the actual value directly. When the JVM runs a method, primitive local variables live on the stack — a fast, fixed-size region of memory tied to the current method call. When the method returns, that stack space is reclaimed instantly.
Reference types include classes, interfaces, arrays, and strings. A variable of a reference type does not store the object itself — it stores a reference (essentially a memory address) that points to an object living on the heap. The heap is a larger, garbage-collected memory region shared across the whole program. This is why assigning one object variable to another copies the reference, not the object: both variables end up pointing at the same object in memory.
The eight primitive types, their sizes, and their default values are:
| Type | Size | Range / Notes | Default |
|---|---|---|---|
byte |
8 bits | -128 to 127 | 0 |
short |
16 bits | -32,768 to 32,767 | 0 |
int |
32 bits | -2,147,483,648 to 2,147,483,647 | 0 |
long |
64 bits | Very large range; literals need an L suffix, e.g. 10000000000L |
0L |
float |
32 bits | Single-precision decimal; literals need an f suffix, e.g. 3.14f |
0.0f |
double |
64 bits | Double-precision decimal (the default for decimal literals) | 0.0d |
char |
16 bits | A single Unicode character, e.g. 'A' |
‘\u0000’ |
boolean |
JVM-dependent (conceptually 1 bit) | true or false only |
false |
Note that instance fields and array elements are automatically given these default values if you don’t initialize them, but local variables inside a method are not auto-initialized — the compiler forces you to assign a value before using them.
Alongside the primitives, Java provides a wrapper class for each one (Integer, Long, Double, Character, Boolean, and so on). Wrapper classes are ordinary reference types that hold a single primitive value plus useful methods (like Integer.parseInt). They exist because generic collections such as ArrayList can only store objects, not raw primitives, so Java needs an object form of int, double, etc.
Syntax
Declaring a variable follows this general form:
type variableName = value;
- type — a primitive keyword (
int,double, …) or a class/interface name (String,Integer, …). - variableName — a valid Java identifier, conventionally camelCase.
- value — a literal or expression compatible with the declared type. This part is optional for fields but usually required for local variables before first use.
You can declare multiple variables of the same type in one statement, and Java also supports var for local type inference (introduced in Java 10), where the compiler determines the type from the right-hand side:
int a = 1, b = 2, c = 3;
var message = "Hello"; // inferred as String
Examples
Example 1: Declaring and printing primitive types
public class Main {
public static void main(String[] args) {
byte level = 5;
short year = 2026;
int population = 8_000_000;
long distanceToSun = 149_600_000_000L;
float pi = 3.14159f;
double preciseValue = 3.14159265358979;
char grade = 'A';
boolean isJavaFun = true;
System.out.println("byte: " + level);
System.out.println("short: " + year);
System.out.println("int: " + population);
System.out.println("long: " + distanceToSun);
System.out.println("float: " + pi);
System.out.println("double: " + preciseValue);
System.out.println("char: " + grade);
System.out.println("boolean: " + isJavaFun);
}
}
Output:
byte: 5
short: 2026
int: 8000000
long: 149600000000
float: 3.14159
double: 3.14159265358979
char: A
boolean: true
This shows each primitive type in action. Notice the underscores in 8_000_000 and 149_600_000_000L — Java lets you insert underscores in numeric literals purely for readability; they’re ignored by the compiler. Also notice the required suffixes: L for a long literal larger than int range, and f for a float literal (without it, a decimal literal defaults to double, which would not fit in a float variable without a cast).
Example 2: Implicit widening vs. explicit narrowing casts
public class Main {
public static void main(String[] args) {
int wholeNumber = 100;
double widened = wholeNumber; // implicit widening, always safe
System.out.println("Widened int to double: " + widened);
double preciseValue = 9.78;
int narrowed = (int) preciseValue; // explicit narrowing, truncates
System.out.println("Narrowed double to int: " + narrowed);
int big = 130;
byte overflowed = (byte) big; // exceeds byte range (-128 to 127)
System.out.println("Overflowed byte: " + overflowed);
}
}
Output:
Widened int to double: 100.0
Narrowed double to int: 9
Overflowed byte: -126
Widening (small type to bigger type, like int to double) never loses information, so Java performs it automatically. Narrowing (big type to smaller type) can lose information, so it requires an explicit cast with (type). Casting a double to an int simply truncates the decimal part (9.78 becomes 9, not 10). Casting 130 to a byte overflows: 130 doesn’t fit in the -128..127 range, so the bits wrap around to -126. This wraparound is a common source of subtle bugs.
Example 3: Wrapper classes, autoboxing, and collections
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List scores = new ArrayList<>();
scores.add(95); // int autoboxed to Integer
scores.add(88);
scores.add(72);
int total = 0;
for (int score : scores) { // Integer unboxed to int automatically
total += score;
}
double average = (double) total / scores.size();
System.out.println("Total: " + total);
System.out.println("Average: " + average);
Integer a = 100;
Integer b = 100;
System.out.println("a == b for 100: " + (a == b));
Integer c = 200;
Integer d = 200;
System.out.println("c == d for 200: " + (c == d));
}
}
Output:
Total: 255
Average: 85.0
a == b for 100: true
c == d for 200: false
This example shows autoboxing (an int literal automatically converted to an Integer object when added to the list) and unboxing (the Integer automatically converted back to int in the for-each loop). It also reveals a classic gotcha: Java caches Integer objects for values -128 to 127, so two boxed Integers of 100 happen to be the exact same cached object (== returns true), but 200 falls outside the cache range, so two separately boxed 200s are different objects (== returns false). This inconsistency is exactly why you should never compare wrapper objects with ==.
Under the Hood: What the JVM Actually Does
When your program declares int x = 5; inside a method, the JVM reserves a fixed slot in that method’s stack frame and stores the raw bits of 5 there directly — no object, no header, no indirection. This is extremely fast and is why primitive arithmetic is cheap.
When your program declares Integer x = 5;, the compiler quietly rewrites this to Integer x = Integer.valueOf(5);. Integer.valueOf either returns a cached object (for -128 to 127) or allocates a new Integer object on the heap. The stack variable x then holds a reference (an address) pointing to that heap object, not the value itself. Every arithmetic operation on a boxed type first unboxes it back to a primitive, computes the result, and reboxes if needed — this constant boxing/unboxing is why wrapper types are noticeably slower than primitives in tight loops, and why autoboxing inside a loop (like summing a huge list of Integer) creates far more garbage for the collector to clean up than the equivalent primitive loop.
Casting works differently for primitives versus references. A primitive cast, like (int) 9.78, physically reinterprets or truncates the bits. A reference cast, like (String) someObject, does not change any bits at all — it only tells the compiler “trust me, this reference actually points to a String,” and the JVM verifies that at runtime, throwing a ClassCastException if you’re wrong.
Common Mistakes
Mistake 1: Losing precision with integer division.
int a = 7;
int b = 2;
double result = a / b; // WRONG: integer division happens before assignment
System.out.println(result); // prints 3.0, not 3.5
Because both a and b are int, Java performs integer division first (discarding the remainder) and only afterward converts the result to double. Fix it by casting one operand to double before the division happens:
double result = (double) a / b; // correct: 3.5
Mistake 2: Comparing wrapper objects with ==.
Integer x = 1000;
Integer y = 1000;
if (x == y) { // WRONG: compares references, not values
System.out.println("equal");
} else {
System.out.println("not equal"); // this prints, surprising many beginners
}
As shown earlier, values outside the -128..127 cache range are separate objects. Always compare wrapper values with .equals():
if (x.equals(y)) {
System.out.println("equal"); // correct and reliable
}
Best Practices
- Use the smallest type that comfortably fits your data’s range — but default to
intfor whole numbers anddoublefor decimals unless you have a specific memory or precision reason to do otherwise. - Never use
float/doublefor exact monetary values; useBigDecimalinstead, since binary floating point cannot represent many decimal fractions exactly. - Always compare wrapper objects (
Integer,Long, etc.) with.equals(), never==. - Add the
Lsuffix to long literals and thefsuffix to float literals so the compiler treats them as the type you intend. - Be explicit about narrowing casts, and double-check that the value actually fits in the target type to avoid silent overflow.
- Prefer primitives over wrapper types in performance-sensitive loops to avoid autoboxing overhead and extra garbage collection.
Practice Exercises
Exercise 1: Declare a short, an int, and a long variable representing a school’s student count, a city’s population, and a country’s national debt in cents. Print all three with descriptive labels.
Exercise 2: Write a program that divides two int variables holding 17 and 5, printing the result once as truncated integer division and once as an accurate decimal division. Expected output: 3 and 3.4.
Exercise 3: Create two Integer variables both set to 500, compare them with == and with .equals(), and print both results. Explain in a comment why the two comparisons differ.
Summary
- Java has 8 primitive types (
byte,short,int,long,float,double,char,boolean) stored directly on the stack. - Reference types (objects, arrays,
String) store a pointer to data on the heap, not the data itself. - Widening conversions happen automatically; narrowing conversions require an explicit cast and can overflow or truncate.
- Wrapper classes let primitives act as objects via autoboxing/unboxing, which is convenient but has a performance cost and a caching quirk for small
Integervalues. - Always use
.equals()to compare wrapper object values, and cast carefully to avoid the classic integer-division and overflow bugs.
