Java · lesson 3 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Data 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
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.
| Type | Size | Range | Default | Use for |
|---|---|---|---|---|
byte | 8-bit | -128 to 127 | 0 | Raw binary data, file I/O |
short | 16-bit | -32,768 to 32,767 | 0 | Rarely used directly |
int | 32-bit | -2.1B to 2.1B | 0 | Whole numbers (default integer type) |
long | 64-bit | ±9.2 × 10¹⁸ | 0L | Large whole numbers, timestamps |
float | 32-bit | ~7 decimal digits | 0.0f | Rarely used; prefer double |
double | 64-bit | ~15 decimal digits | 0.0 | Decimal numbers (default float type) |
char | 16-bit | '\u0000' to '\uffff' | '\u0000' | Single UTF-16 code unit |
boolean | 1-bit | true / false | false | Flags 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.
| Primitive | Wrapper | Useful methods |
|---|---|---|
int | Integer | parseInt, valueOf, MAX_VALUE, toBinaryString |
long | Long | parseLong, toBinaryString, bitCount |
double | Double | parseDouble, isNaN, isInfinite |
boolean | Boolean | parseBoolean, toString |
char | Character | isDigit, 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
| Category | Types | Notes |
|---|---|---|
| Integer | byte short int long | Default to int; use long for large values |
| Floating-point | float double | Default to double; use BigDecimal for exact math |
| Character | char | 16-bit Unicode; rarely used directly |
| Boolean | boolean | true or false only |
| Text | String | Immutable object; use StringBuilder for building |
| Wrappers | Integer Double etc. | Object form of primitives; needed for generics |