Java cheat sheet

Table of contents

Variables Primitive types Non-Primitive types Numbers Strings Characters Arrays Collections ArrayLists LinkedLists HashSets TreeSets LinkedHashSets PriorityQueues HashMaps TreeMaps LinkedHashMaps Stacks Conditional statements Loops Classes and objects Access modifiers Getters and setters Inheritance super keyword Constructor overloading Method overloading Method overriding Abstract classes Interfaces Anonymous classes Sealed classes Records Inheritance vs Association vs Aggregation vs Composition final keyword Wrapper classes and autoboxing Expressions and operators User input/output Type casting (widening, narrowing) Exception handling Classic I/O Java NIO Serialization Printf Varargs Lambda expressions Functional interfaces Streams API Optional class Method references var (Java 10+) Concurrency & Multithreading Parallelism Generics Annotations Reflection Enums Inner/nested classes Packages and imports Java modules (JPMS) Unit testing Date & time Build tools Javadoc Timers Monitoring Shell commands

Variables

String name = "Hello World";

Creates variable that can be reassigned.

final String name = "Hello World";

Creates variable that cannot be reassigned.

Primitive types

Primitive data types are the most basic types of data that are not objects. They are stored directly in memory for efficiency.

byte smallNumber = 100;

Used to save memory in large arrays where values are small. Its range is from -128 to 127.

short mediumNumber = 30000;

Rarely used but useful for memory efficiency. Its range is from -32 768 to 32 767.

int age = 25;

The most commonly used integer type. Its range is from -2 147 483 648 to 2 147 483 647.

long bigNumber = 9223372036854775807L;

Used when int is not enough for large numbers. Needs an L suffix. Its range is from -9 223 372 036 854 775 808 to 9 223 372 036 854 775 807

float price = 10.99f;

Less precise, used for saving memory. Needs an f suffix. 6-7 decimal digits precision.

double pi = 3.141592653589793;

More precise, preferred for decimal calculations. 15-16 decimal digits precision.

char letter = 'A';
char unicodeChar = '\u00A9'; // '©'

Stores a single character using Unicode.

boolean isJavaFun = true;

Stores only true or false.

Non-Primitive types

Non-primitive types (also called reference types) are more complex than primitive types because they refer to objects rather than storing raw values. Unlike primitive types, non-primitive types store references (memory addresses) to objects. They can be null (meaning they don't reference any object). Non-primitive types are created using classes.

String name = "Hello"; // String literal
OR
String name = new String("Hello"); // Explicit object creation

Strings are sequences of characters and are immutable objects in Java.

int[] numbers = {1, 2, 3, 4}; // Array of integers
String[] names = new String[3]; // Array of Strings (default values: null)
String[] names = {"Alice", "Bob", "Charlie"}; // Predefined values

Arrays are fixed-size containers for elements of the same type. They can hold both primitive and non-primitive types.

class Car {   
  String model;   
  int year;   
}

public class Main {   
  public static void main(String[] args) {     
    Car myCar = new Car(); // Creating an object     
    myCar.model = "Tesla"; // Assigning values     
    myCar.year = 2024;     
  }   
}

A class is a blueprint for objects. An object is an instance of a class. Can have attributes (variables) and methods (functions). Objects are created using new.

int x = 10;
Integer y = Integer.valueOf(x); // Wrapping int into an object
int z = y.intValue(); // Unwrapping (getting primitive value back)

Java provides wrapper classes to treat primitive types as objects.

Common wrapper classes:
Integer (for int)
Double (for double)
Boolean (for boolean)

It's useful in collections (like ArrayList<Integer>).
It provides utility methods (Integer.parseInt("100")).

import java.util.ArrayList;
public class Main {   
  public static void main(String[] args) {     
    ArrayList<String> names = new ArrayList<>();     
    names.add("Alice");     
    names.add("Bob");     
    System.out.println(names.get(0)); // Alice     
  }   
}

Collections are dynamic data structures that store objects. Unlike arrays, collections can grow or shrink dynamically. Common types: ArrayList, LinkedList, HashSet, HashMap.

Important Notes on Reference Types:

Numbers

Math.abs(-10);

Returns 10

Math.round(4.7);

Returns 5

Math.floor(4.9);

Returns 4.0 (Rounds down)

Math.ceil(4.1);

Returns 5.0 (Rounds up)

Math.pow(2, 3);

Returns 8.0 (2^3)

Math.sqrt(16);

Returns 4.0

Math.max(10, 20);

Returns 20

Math.min(10, 20);

Returns 10

Math.random();

Returns a random value between 0.0 and 1.0

(int) (Math.random() * 10);

Returns a random integer (0-9)

int num = Integer.parseInt("123"); // 123
double d = Double.parseDouble("3.14"); // 3.14

Converts strings to numbers.

char x = '1';
int y = x - '0'; // 1

Converts char to int.

String str = Integer.toString(456); // "456"
String str2 = String.valueOf(3.14); // "3.14"

Converts numbers to strings.

import java.math.BigInteger;

BigInteger bigNum1 = new BigInteger("999999999999999999999");
BigInteger bigNum2 = new BigInteger("2");
BigInteger result = bigNum1.multiply(bigNum2);
System.out.println(result); // 1999999999999999999998

For extremely large values beyond long, use BigInteger or BigDecimal.

import java.math.BigDecimal;

BigDecimal num1 = new BigDecimal("0.1");
BigDecimal num2 = new BigDecimal("0.2");
System.out.println(num1.add(num2)); // 0.3

BigDecimal is useful because floating-point arithmetic has precision issues (0.1 + 0.2 != 0.3 exactly in double).

int num = 100;
double d = num; // 100.0 (No problem (int - double))

Implicit (Widening) Casting (Safe)

double pi = 3.14159;
int approx = (int) pi; // 3 (Loses decimal part)

Explicit (Narrowing) Casting (Risky)

int num = 25;
String binary = Integer.toBinaryString(num);
System.out.println(binary); // Output: 11001

Converts integer to its binary representation.

String binaryStr = "11001";
int num = Integer.parseInt(binaryStr, 2);
System.out.println(num); // Output: 25

Converts binary to integer.

Strings

String str1 = "Hello, World!";

String literals are stored in the String Pool, which optimizes memory usage by reusing existing string values.

String str2 = new String("Hello, World!");

Creates a new object in heap memory, even if an identical string exists in the pool. Not recommended unless needed.

String name = "Java";
name = name + " 17"; // Creates a new String object ("Java 17"), old one is discarded
System.out.println(name); // Output: Java 17

Strings are immutable (cannot be changed after creation). Any modification creates a new object, rather than modifying the original. Use StringBuilder for efficiency if modifying strings frequently.

String text = "Hello";
text.length();

Returns the length of the text, in this case 5

String text = "Hello";
text.charAt(1);

Returns character at a certain position, in this case 'e'

String s = "Java Programming";
s.substring(5); // Output: "Programming"
s.substring(0, 4); // Output: "Java" (exclusive end index)

Extracts substrings

String word = "Java";
word.toUpperCase(); // JAVA
word.toLowerCase(); // java

Changes case

String sentence = "I love Java!";
sentence.indexOf("Java"); // 7
sentence.contains("love"); // true

Searches in strings

String file = "document.pdf";
file.startsWith("doc"); // true
file.endsWith(".pdf"); // true

Checks start and end of string

String text2 = "I like Java";
text2.replace("Java", "Python"); // "I like Python"

Replaces parts of the string

String csv = "Apple,Banana,Cherry";
String[] fruits = csv.split(",");
fruits[1]; // "Banana"

Splits a string into an array

String[] words = {"Java", "Python", "C++"};
String result = String.join(", ", words);

System.out.println(result); // Output: Java, Python, C++

Joins a string array to a string. Only works with String[].

String a = "Java";
String b = "Java";
a.equals(b); // true

Checks if the two strings are equal. It's case-sensitive. Use .equalsIgnoreCase() for case-insensitive comparison.

String a = "Hello";
String b = new String("Hello");
a == b; // false (different objects in memory)

Using == is not always reliable as it compares memory references, not values.

String first = "Hello";
String second = "World";

// Method 1
String result = first + " " + second; // "Hello World"

// Method 2
String result = first.concat(" ").concat(second); // "Hello World"

// Method 3
StringBuilder sb = new StringBuilder("Hello");
sb.append(" World");
System.out.println(sb.toString()); // "Hello World"

Concatenation can be done using the + operator or the concat() method or StringBuilder. StringBuilder is faster than + for multiple concatenations.

int num = 100;
String str = String.valueOf(num); // "100"

Converts number to string

int number = Integer.parseInt("123"); // 123
double d = Double.parseDouble("3.14"); // 3.14

Converts string to number

String formatted = String.format("Hello %s, your score is %d", "Alice", 90);
System.out.println(formatted); // "Hello Alice, your score is 90"

Formats string.

String a = "Java";
String b = "Java";
System.out.println(a == b); // true (both refer to the same object in the String Pool)

String c = new String("Java").intern();
System.out.println(a == c); // true (intern() moves it to the pool)

The String Pool avoids duplicate objects, saving memory.

char letter = 'A';
String str = Character.toString(letter); // "A"

Converts char to string

char[] letters = {'J', 'a', 'v', 'a'};
String word = new String(letters); // "Java"

Converts char[] to string

String str = "Java";
char[] letters = str.toCharArray();

Converts string to char[].

String str1 = "Hello";
String str2 = "";

str1.isEmpty(); // false
str2.isEmpty(); // true

Checks if a string is empty.

String str1 = " ";
String str2 = "Hello, World!";

str1.isBlank(); // true
str2.isBlank(); // false

Checks if a string is empty or only has whitespace.

String str = " Java ";

str; // " Java "
str.trim(); // "Java"

Removes whitespace from both side of a string.

for (int i = 0; i < str.length(); i++) {   
  // Print current character   
  System.out.print(str.charAt(i) + " ");   
}

Iterates over the characters of a string.

String message = """   
  Hello,   
  This is a multi-line string.   
  It preserves line breaks and formatting!   
  """;

// Example with JSON
String json = """   
  {     
    "name": "Java",     
    "version": 17,     
    "features": ["Text Blocks", "Records", "Pattern Matching"]     
  }   
  """;

System.out.println(json);

A text block (Java 15+) is a multi-line string enclosed in triple double quotes: """. No need to escape newlines or double quotes. Indentation is handled smartly. Line breaks are preserved automatically. """ must be on its own line. The closing """ must also be on its own line, but can be indented. Content inside preserves whitespace and formatting, which is great for HTML, SQL, JSON, etc.

Characters

char ch = 'A';
Character chObj = ch; // autoboxing
char again = chObj; // unboxing

Character is a wrapper class for the primitive type char. It allows you to treat characters as objects, which is useful when working with collections (e.g. List<Character>) or using methods from the Character class.

Character.isLetter('A'); // true
Character.isDigit('3'); // true
Character.isLetterOrDigit('7'); // true
Character.isWhitespace(' '); // true
Character.isUpperCase('Z'); // true
Character.isLowerCase('z'); // true
Character.toUpperCase('a'); // 'A'
Character.toLowerCase('T'); // 't'
Character.getNumericValue('5'); // 5
Character.forDigit(7, 10); // '7'
Character.isDefined('©'); // true

The Character class provides many static utility methods to work with characters.

char c = 'a';

if (Character.isLetter(c)) {   
  System.out.println(c + " is a letter."); // Output: a is a letter.   
}

char upper = Character.toUpperCase(c);
System.out.println("Uppercase: " + upper); // Output: Uppercase: A

Example of using Character methods.

Arrays

An array in Java is a fixed-size data structure that stores multiple elements of the same type in a contiguous memory location.

int[] numbers; // Recommended
int numbers2[]; // Also valid, but less common

This declares an array, but it doesn't allocate memory yet (declaration without initialization).

int[] numbers = new int[5]; // Creates an array with 5 elements (default: 0)

Using this method an array is initialized with default values:

int[] numbers = {10, 20, 30, 40, 50};

This creates and initializes an array in one step.

int[] arr = {5, 10, 15};
System.out.println(arr[0]); // Output: 5
arr[1] = 20; // Modify element at index 1
System.out.println(arr[1]); // Output: 20

In this example we access and modify elements of an array. Arrays use zero-based indexing (0 is the first element). Accessing an out-of-bounds index causes ArrayIndexOutOfBoundsException.

int[] numbers = {10, 20, 30, 40};
System.out.println(numbers.length); // Output: 4

In this example we find the length of an array. .length gives the number of elements in the array (not the last index).

// Using a for loop
int[] numbers = {10, 20, 30};
for (int i = 0; i < numbers.length; i++) {   
  System.out.println(numbers[i]);   
}

// Using enhanced for loop (for-each)
for (int num : numbers) {   
  System.out.println(num);   
}

Loops through arrays. Using enhanced for loop is simpler and avoids index errors but cannot modify elements directly, unless the element is mutable.

// Declaring a 2D Array
int[][] matrix = new int[2][3]; // 2 rows, 3 columns

// Initializing a 2D Array
int[][] matrix = {   
  {1, 2, 3},   
  {4, 5, 6}   
};

// Accessing Elements in a 2D Array
System.out.println(matrix[1][2]); // Output: 6

// Looping Through a 2D Array
for (int i = 0; i < matrix.length; i++) {   
  for (int j = 0; j < matrix[i].length; j++) {     
    System.out.print(matrix[i][j] + " ");     
  }   
  System.out.println();   
}

Multidimensional arrays.

import java.util.Arrays;

int[] numbers = {5, 1, 4, 3, 2};
Arrays.sort(numbers);
System.out.println(Arrays.toString(numbers)); // Output: [1, 2, 3, 4, 5]

Sorts an array.

int[] original = {1, 2, 3};
int[] copy = Arrays.copyOf(original, original.length);
System.out.println(Arrays.toString(copy)); // Output: [1, 2, 3]

Copies an array.

int[] arr = new int[5];
Arrays.fill(arr, 100);
System.out.println(Arrays.toString(arr)); // Output: [100, 100, 100, 100, 100]

Fills an array with a value.

int[] arr = {10, 20, 30, 40, 50};
int index = Arrays.binarySearch(arr, 30);
System.out.println(index); // Output: 2

Searches in a sorted array (binarySearch).

import java.util.Arrays;

int[] numbers = {1, 2, 3};
System.out.println(Arrays.toString(numbers)); // Output: [1, 2, 3]

Converts arrays to strings. Use Arrays.deepToString() for multi-dimensional arrays.

import java.util.ArrayList;

ArrayList<Integer> list = new ArrayList<>();
list.add(10);
list.add(20);
list.add(30);
System.out.println(list); // Output: [10, 20, 30]

Resizes an array (using ArrayList). Since arrays have a fixed size, you can use ArrayLists for dynamic resizing. It's more flexible than arrays.

public static void printArray(int[] arr) {   
  for (int num : arr) {     
    System.out.print(num + " ");     
  }   
}

int[] numbers = {1, 2, 3};
printArray(numbers); // Output: 1 2 3

Passes an array as an argument.

public static int[] createArray() {   
  return new int[]{10, 20, 30};   
}

int[] newArr = createArray();
System.out.println(Arrays.toString(newArr)); // Output: [10, 20, 30]

Returns an array from a method.

import java.util.Arrays;

// Incorrect way of comparing arrays
public class ArrayComparisonIncorrect {   
  public static void main(String[] args) {     
    int[] arr1 = {1, 2, 3};     
    int[] arr2 = {1, 2, 3};     
    
    System.out.println(arr1 == arr2); // Output: false     
  }   
}

// Correct way of comparing arrays
public class ArrayComparisonCorrect {   
  public static void main(String[] args) {     
    int[] arr1 = {1, 2, 3};     
    int[] arr2 = {1, 2, 3};     
    
    System.out.println(Arrays.equals(arr1, arr2)); // Output: true     
  }   
}

Use Arrays.equals(arr1, arr2) instead of arr1 == arr2 to compare arrays. Arrays.equals(arr1, arr2) compares each element in both arrays. If all elements are equal and in the same order, it returns true.

import java.util.Arrays;

public class MultiDimensionalArrayComparison {   
  public static void main(String[] args) {     
    int[][] matrix1 = { {1, 2}, {3, 4} };     
    int[][] matrix2 = { {1, 2}, {3, 4} };     
    
    System.out.println(Arrays.equals(matrix1, matrix2)); // Output: false     
    System.out.println(Arrays.deepEquals(matrix1, matrix2)); // Output: true     
  }   
}

For 2D or multi-dimensional arrays, use Arrays.deepEquals(), not Arrays.equals(): Arrays.equals(matrix1, matrix2) compares outer array references, so it returns false. Arrays.deepEquals(matrix1, matrix2) compares nested elements correctly.

Collections

A collection is a container for objects, a way to group and manage multiple elements as a single unit (like a list of names, a set of IDs, or a map of user data). The Java Collections Framework (JCF) is a set of interfaces and classes in the java.util package that makes it super easy to work with data structures

Interface What It Represents Example Classes
Collection The root interface —
List Ordered collection (duplicates OK) ArrayList, LinkedList
Set Unique elements (no duplicates) HashSet, TreeSet, LinkedHashSet
Queue Elements in a specific order (FIFO) LinkedList, PriorityQueue
Map (not part of Collection) Key-value pairs HashMap, TreeMap, LinkedHashMap

add(element)
remove(element)
contains(element)
isEmpty()
size()
clear()

These come from the Collection interface.

ArrayLists

An ArrayList is a resizable array from Java's java.util package. Unlike normal arrays, it can grow or shrink dynamically as you add or remove elements. It can store objects only (but autoboxing helps).

import java.util.ArrayList;

// Method 1
ArrayList<String> names = new ArrayList<>();

// Method 2: explicit
ArrayList<String> names = new ArrayList<String>();

Declares and initializes list.

// Method 1 (Java 9+)
List<Character> letters = new ArrayList<>(List.of('a', 'b', 'c'));
List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4));
List<String> list = new ArrayList<>(List.of("string1", "string2", "string3"));

// Method 2 (Java 8)
List<Character> letters = new ArrayList<>(Arrays.asList('a', 'b', 'c'));
List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4));
List<String> list = new ArrayList<>(Arrays.asList("string1", "string2", "string3"));

This initializes an ArrayList from a known set of items. new ArrayList<>(List.of(...)) creates a mutable copy.

names.add("Alice");

Adds elements.

String first = names.get(0); // "Alice"

Gets elements.

names.set(1, "Charlie"); // Replace "Bob" with "Charlie"

Replaces an element.

names.remove(0); // Removes "Alice"

Removes element.

int size = names.size(); // Number of elements

Gets the size of the list.

boolean isEmpty = names.isEmpty(); // true or false

Checks if the list is empty.

// Traditional for loop
for (int i = 0; i < names.size(); i++) {   
  System.out.println(names.get(i));   
}

// Enhanced for loop
for (String name : names) {   
  System.out.println(name);   
}

Loops through a list.

ArrayList<Integer> numbers = new ArrayList<>();
numbers.add(10); // int is autoboxed to Integer
numbers.add(20);

Example of autoboxing. Even though ArrayList can't store primitives directly, Java automatically converts them to objects.

names.contains("Alice"); // Returns true

Finds if a list contains an item.

names.indexOf("Charlie"); // Returns index or -1

Finds the index of an item.

names.clear();

Removes all elements from the list.

String[] array = {"a", "b", "c"};
ArrayList<String> list = new ArrayList<>(Arrays.asList(array));

Converts an array to ArrayList.

String[] newArray = list.toArray(new String[0]);

Converts an ArrayList to an array.

// Method 1
ArrayList<Object> mixed = new ArrayList<>();

mixed.add("Hello");
mixed.add(123); // autoboxed to Integer
mixed.add(true); // autoboxed to Boolean
mixed.add(3.14); // autoboxed to Double

// Method 2
public class PersonData {   
  String name;   
  int age;   
  boolean isMember;   
  
  public PersonData(String name, int age, boolean isMember) {     
    this.name = name;     
    this.age = age;     
    this.isMember = isMember;     
  }   
}

ArrayList<PersonData> people = new ArrayList<>();
people.add(new PersonData("Alice", 30, true));

Normally, ArrayList in Java is type-safe, it stores elements of a single type, like ArrayList<String> or ArrayList<Integer>. But if you want to store different types in the same list (like String, Integer, Boolean, etc.) you can do it these ways.

ArrayList<Integer> numbers = new ArrayList<>();
numbers.add(5);
numbers.add(1);
numbers.add(3);

Collections.sort(numbers); // Sorts in ascending order
System.out.println(numbers); // [1, 3, 5]

Collections.sort(numbers, Collections.reverseOrder()); // Sorts in descending order
System.out.println(numbers); // [5, 3, 1]

Sorts a list of numbers.

ArrayList<String> names = new ArrayList<>();
names.add("Charlie");
names.add("Alice");
names.add("Bob");

Collections.sort(names);
System.out.println(names); // [Alice, Bob, Charlie]

Sorts strings alphabetically.

ArrayList<String> names = new ArrayList<>();
names.add("Charlie");
names.add("Alice");
names.add("Bob");
names.sort((a, b) -> Integer.compare(a.length(), b.length()));
System.out.println(names); // [Bob, Alice, Charlie]

// Reversing it
names.sort((a, b) -> Integer.compare(b.length(), a.length()));
System.out.println(names); // [Charlie, Alice, Bob]

Custom sorting with a comparator (e.g., by length).

// Create a Person class for example
class Person {   
  String name;   
  int age;   
  
  Person(String name, int age) {     
    this.name = name;     
    this.age = age;     
  }   
  
  public String toString() {     
    return name + " (" + age + ")";     
  }   
}

// Add Person objects to an ArrayList
ArrayList<Person> people = new ArrayList<>();
people.add(new Person("Alice", 30));
people.add(new Person("Bob", 25));

// Then sort by age
people.sort((p1, p2) -> Integer.compare(p1.age, p2.age));
System.out.println(people); // [Bob (25), Alice (30)]

Sorts objects (e.g., by a field).

LinkedLists

A LinkedList is a doubly-linked list implementation of the List and Deque interfaces. Unlike ArrayList, where elements are stored in a contiguous block of memory, LinkedList stores elements as nodes where each node points to the next and previous.

import java.util.LinkedList;

// Method 1
LinkedList<String> names = new LinkedList<>();

// Method 2
List<String> names = new LinkedList<>(); // Interface type (recommended)

Declares and initializes a LinkedList.

names.add("Alice"); // Add at end
names.addFirst("Bob"); // Add at beginning
names.addLast("Charlie"); // Add at end

Adds elements.

String first = names.get(0);
String last = names.getLast();

Gets elements.

names.set(1, "Daniel"); // Replace element at index 1

Replaces elements.

names.remove(); // Removes first
names.remove(1); // Removes at index 1
names.removeFirst(); // Removes first
names.removeLast(); // Removes last

Removes element.

int size = names.size();

Gets the size of a LinkedList.

boolean found = names.contains("Alice");
int index = names.indexOf("Charlie");

Searches for a value.

// Enhanced for-loop
for (String name : names) {   
  System.out.println(name);   
}

// With iterator
Iterator<String> it = names.iterator();
while (it.hasNext()) {   
  System.out.println(it.next());   
}

Loops through a LinkedList.

HashSets

A HashSet is a part of the Java Collections Framework. It implements the Set interface and stores elements in no particular order, using a hash table for storage. No duplicates allowed. It internally uses a HashMap, uses the hash code of elements to decide where to store them and silently ignores duplicate elements.

// Method 1
import java.util.HashSet;
HashSet<String> names = new HashSet<>();

// Method 2
import java.util.HashSet;
import java.util.Set;
Set<String> names = new HashSet<>(); // Recommended: use the interface type

Creates a HashSet.

// Method 1 (Java 9+)
Set<Character> letters = new HashSet<>(Set.of('a', 'b', 'c'));
Set<String> list = new HashSet<>(Set.of("string1", "string2", "string3"));
Set<Integer> numbers = new HashSet<>(Set.of(1, 2, 3, 4));

// Method 2 (Java 8)
Set<Character> letters = new HashSet<>(Arrays.asList('a', 'b', 'c')); // Doesn't compile
Set<Character> letters = new HashSet<>(); // Correct way
letters.add('a');
letters.add('b');
letters.add('c');
Set<String> list = new HashSet<>(Arrays.asList("string1", "string2", "string3"));
Set<Integer> numbers = new HashSet<>(Arrays.asList(1, 2, 3, 4));

This initializes a HashSet from a known set of items.

names.add("Alice");
names.add("Bob");
names.add("Alice"); // Duplicate, will not be added

Adds elements.

names.remove("Bob");

Removes elements.

names.contains("Alice"); // true or false

Checks existence of elements.

int size = names.size();

Returns the size of a HashSet.

names.clear();

Clears all elements.

names.isEmpty(); // true or false

Checks if a HashSet is empty.

for (String name : names) {   
  System.out.println(name);   
}

Loops through a HashSet.

List<String> list = Arrays.asList("a", "b", "a");
Set<String> set = new HashSet<>(list); // removes duplicate "a"

Converts a list to a set (removes duplicates).

List<String> newList = new ArrayList<>(set);

Converts a set to a list (if you need indexing).

TreeSets

A TreeSet is a sorted set that automatically keeps its elements in ascending order (by default), and ensures that all elements are unique. It is implemented using a Red-Black Tree, which means operations like add, remove, and search all run in O(log n) time. Null not allowed.

import java.util.TreeSet;

// Using it with strings
TreeSet<String> names = new TreeSet<>();

// Using it with integers
TreeSet<Integer> numbers = new TreeSet<>();

Creates a TreeSet.

names.add("Alice");
names.add("Charlie");
names.add("Bob");
names.add("Alice"); // Duplicate, will not be added

System.out.println(names); // Output: [Alice, Bob, Charlie]

System.out.println(names.higher("Bob")); // Returns the next element bigger than "Bob": Charlie
System.out.println(names.lower("Charlie")); // Returns the next element smaller than "Charlie": Bob
System.out.println(names.first()); // Returns the smallest element: Alice
System.out.println(names.last()); // Returns the largest element: Charlie

names.remove("Charlie");
System.out.println(names.contains("Bob")); // true
System.out.println(names.size()); // 2

names.clear(); // Removes all elements

Common operations.

for (String name : names) {   
  System.out.println(name);   
}

Iterates through a TreeSet. Since it's sorted, a simple for-each gives you elements in order.

TreeSet<String> reverseOrder = new TreeSet<>(Collections.reverseOrder());
reverseOrder.add("Java");
reverseOrder.add("Python");
reverseOrder.add("C++");

System.out.println(reverseOrder); // [Python, Java, C++]

You can also sort TreeSet elements in reverse order or by a custom comparator.

LinkedHashSets

A LinkedHashSet is a collection that:

It's implemented using a hash table with a linked list running through it.

import java.util.LinkedHashSet;

LinkedHashSet<String> languages = new LinkedHashSet<>();

Creates a LinkedHashSet.

languages.add("Java");
languages.add("Python");
languages.add("C++");
languages.add("Java"); // Won't be added again

System.out.println(languages); // [Java, Python, C++]

System.out.println(languages.contains("Python")); // true

languages.remove("C++");
System.out.println(languages); // [Java, Python]

System.out.println(languages.size()); // 2
System.out.println(languages.isEmpty()); // false

Common operations.

for (String lang : languages) {   
  System.out.println(lang);   
}

Iterates through a LinkedHashSet. Insertion order is preserved, nice and predictable!

Iterator<String> iterator = languages.iterator();
while (iterator.hasNext()) {   
  String lang = iterator.next();   
  System.out.println(lang);   
}

Uses an iterator. Gives you more control (e.g., if you want to remove items while iterating).

List<String> reversed = new ArrayList<>(languages);
Collections.reverse(reversed);
for (String lang : reversed) {   
  System.out.println(lang);   
}

LinkedHashSet doesn't support reverse iteration out of the box, since it's not index-based like a list. Use LinkedList or TreeSet for reverse iteration, or convert it to a list like in this example.

PriorityQueues

A PriorityQueue is a special type of queue where elements are ordered by priority, not just insertion order. By default, natural ordering is used (e.g., lowest number first). You can define custom order using a Comparator. It's part of the java.util package and implements the Queue interface and uses a binary heap under the hood. Think of it like a to-do list that always gives you the most important task first. It's not thread-safe, use PriorityBlockingQueue for concurrency. Null elements are not allowed but duplicates are.

import java.util.PriorityQueue;

PriorityQueue<Integer> pq = new PriorityQueue<>();

Creates a PriorityQueue.

pq.add(10);
pq.add(5);
pq.add(20);

System.out.println(pq); // Output: [5, 10, 20] (internally organized)

Adds elements.

pq.offer(15);
System.out.println(pq); // Output: [5, 10, 20, 15]

Same as add(), but returns false if it fails instead of throwing an exception.

pq.peek(); // Output: 5

Returns the element with the highest priority (lowest in a min-heap), without removing it.

pq.poll(); // Output: 5
System.out.println(pq); // Output: [10, 15, 20]

Retrieves and removes the head (smallest element).

pq.remove(15);
System.out.println(pq); // Output: [10, 20]

Removes a specific element.

pq.contains(10); // true
pq.contains(99); // false

Checks if the queue contains a specific value.

pq.isEmpty(); // false

Checks if the queue is empty.

pq.size(); // Output: 2

Returns the number of elements in the queue.

pq.clear();
System.out.println(pq); // Output: []

Removes all elements.

for (Integer num : pq) {   
  System.out.println(num);   
}

Loops through the queue. This may print elements in any order, because PriorityQueue uses a heap, not a sorted list.

while (!pq.isEmpty()) {   
  System.out.println(pq.poll()); // Removes and prints smallest each time   
}

Use poll() in a loop to get elements in priority order. But be careful as it empties the queue.

PriorityQueue<Integer> copy = new PriorityQueue<>(pq); // Clone it

while (!copy.isEmpty()) {   
  System.out.println(copy.poll()); // Priority order   
}

Clones the queue before polling to keep the original queue intact, but at the same time gets the elements in order.

HashMaps

A HashMap<K, V> is a part of the Java Collections Framework that lets you store key-value pairs. It uses a hash function to compute a hash code from the key. That hash code decides where the value will be stored in a bucket. If two keys hash to the same bucket, it uses a linked list or tree to resolve collisions.

import java.util.HashMap;

HashMap<String, Integer> map = new HashMap<>();

Creates a HashMap.

// Immutable
Map<String, Integer> map = Map.of("apple", 3, "banana", 5, "orange", 2);

// Mutable, but creates an anonymous subclass (less ideal)
Map<String, Integer> map = new HashMap<>() {{   
  put("aapple", 3);   
  put("banana", 5);   
  put("orange", 2);   
}};

// Classic way, putting values one by one
Map<String, Integer> map = new HashMap<>();
map.put("apple", 3);
map.put("banana", 5);
map.put("orange", 2);

This initializes a HashMap from a known set of items.

map.put("apple", 2);
map.put("banana", 5);
map.put("apple", 3); // Overwrites previous value

Adds or updates entries.

int quantity = map.get("apple"); // returns 3

Gets value by key.

map.containsKey("apple"); // true
map.containsValue(5); // true

Checks if key or value exists.

map.remove("banana");

Removes entry.

int size = map.size();

Gets size of HashMap.

map.clear();

Clears all entries.

for (String key : map.keySet()) {   
  System.out.println(key);   
}

Loops through keys.

for (Integer value : map.values()) {   
  System.out.println(value);   
}

Loops through values.

for (Map.Entry<String, Integer> entry : map.entrySet()) {   
  System.out.println(entry.getKey() + " = " + entry.getValue());   
}

Loops through key-value pairs.

TreeMaps

A TreeMap stores key-value pairs (like HashMaps), automatically sorts the keys in natural order (e.g., alphabetically or numerically). It is implemented using a Red-Black Tree (a type of self-balancing binary search tree). It implements the NavigableMap interface (which extends SortedMap and Map).

import java.util.TreeMap;

TreeMap<String, Integer> map = new TreeMap<>();

Creates a TreeMap.

map.put("Banana", 2);
map.put("Apple", 5);
map.put("Cherry", 3);

map.put("Date", 4); // Adds "Date" => 4
map.put("Apple", 10); // Updates value of "Apple" to 10

Adds or updates a key-value pair.

map.get("Cherry"); // Output: 3

Gets the value associated with a key.

map.remove("Banana");
System.out.println(map); // Output: {Apple=10, Cherry=3, Date=4}

Removes a key (and its value).

map.containsKey("Apple"); // true
map.containsKey("Fig"); // false

Checks if key exists.

map.containsValue(4); // true
map.containsValue(99); // false

Checks if a value exists.

map.size(); // e.g., 3

Gets number of entries.

map.isEmpty(); // false

Checks if map is empty.

map.clear();
System.out.println(map); // Output: {}

Removes all entries.

map.keySet(); // Output: [Apple, Cherry, Date]

Gets all keys (sorted).

map.values(); // Output: [10, 3, 4]

Gets all values (in key order).

map.firstKey(); // Output: Apple

Gets the smallest key.

map.lastKey(); // Output: Date

Gets the largest key.

map.higherKey("Cherry"); // Output: Date

Gets the next key after given one.

map.lowerKey("Cherry"); // Output: Apple

Gets the previous key before given one.

for (Map.Entry<String, Integer> entry : map.entrySet()) {   
  System.out.println(entry.getKey() + " => " + entry.getValue());   
}

Loops through key-value pairs.

LinkedHashMaps

LinkedHashMaps store key-value pairs (just like a HashMap), maintain the insertion order of entries. They allow null keys and values, while offering O(1) time for get() and put() operations (like HashMap). They have an optional access-order mode (great for implementing LRU caches).

import java.util.LinkedHashMap;

LinkedHashMap<String, Integer> map = new LinkedHashMap<>();

Creates a LinkedHashMap.

map.put("Apple", 1);
map.put("Banana", 2);
map.put("Cherry", 3);

map.put("Date", 4); // Adds a new key-value pair
map.put("Apple", 5); // Updates the value for "Apple"

Adds or updates values.

map.get("Banana"); // Output: 2

Retrieves the value for a given key.

map.remove("Cherry"); // Removes "Cherry"

Removes a key and its value.

map.containsKey("Apple"); // true
map.containsKey("Grape"); // false

Checks if a key exists.

map.containsValue(5); // true (after Apple was updated)

Checks if a value exists.

map.size(); // e.g., 3

Gets the number of key-value pairs.

map.isEmpty(); // false

Checks if the map is empty.

for (String key : map.keySet()) {   
  System.out.println(key);   
}
// Output: Apple, Banana, Date

Loops through all keys (in insertion order).

for (Integer value : map.values()) {   
  System.out.println(value);   
}
// Output: 5, 2, 4

Loops through all values (in insertion order).

for (var entry : map.entrySet()) {   
  System.out.println(entry.getKey() + " => " + entry.getValue());   
}

Loops through all key-value pairs (in insertion order).

// Creating a LinkedHashMap for iterating in access order
LinkedHashMap<String, Integer> accessMap = new LinkedHashMap<>(16, 0.75f, true);

// Accessing an item
accessMap.get("Banana"); // Moves "Banana" to end

If you do this iterating with entrySet() will follow access order instead of insertion order.
(16, 0.75f, true) is passed to the constructor of LinkedHashMap, and it means:

Stacks

A Stack is a linear data structure where you add and remove elements from the top. The most recent element added is the first one to be removed. (LIFO = Last In, First Out) It's like undo history, browser back button, or nested function calls.

It's often recommended to use Deque (like ArrayDeque) instead, for better performance and flexibility.

import java.util.Stack;

// Method 1
Stack<String> stack = new Stack<>();

// Method 2
Stack<Integer> numbers = new Stack<>();

Creates a Stack.

stack.push("Java");
stack.push("Python");
stack.push("C++");

Adds elements to the top.

stack.pop(); // Removes "C++"

Removes and returns the top element.

stack.peek();

Returns the top element without removing it.

stack.isEmpty();

Checks if the stack is empty.

stack.size();

Gets the number of elements in the stack.

int position = stack.search("Java");
System.out.println("Position from top: " + position); // Output: 2

int notFound = stack.search("Ruby");
System.out.println(notFound); // Output: -1 (not found)

Finds position of an element (1-based from top).

stack.clear();

Empties the entire Stack.

for (int i = stack.size() - 1; i >= 0; i--) {   
  System.out.println(stack.get(i));   
}

This returns the elements in the same order they'd be popped.

Conditional statements

int age = 18;

if (age >= 18) {   
  System.out.println("You're an adult.");   
}

Example of an if statement.

int age = 16;

if (age >= 18) {   
  System.out.println("Adult");   
} else {   
  System.out.println("Child");   
}

Example of an if-else statement.

int score = 75;

if (score >= 90) {   
  System.out.println("Grade A");   
} else if (score >= 80) {   
  System.out.println("Grade B");   
} else if (score >= 70) {   
  System.out.println("Grade C");   
} else {   
  System.out.println("Fail");   
}

An example to an if-else-if-else chain.

int age = 20;
String result = (age >= 18) ? "Adult" : "Minor";
System.out.println(result); // Adult

The rernary operator (?:) provides a compact way to write simple if-else.

int day = 3;

switch (day) {   
  case 1:     
    System.out.println("Monday");     
    break;     
  case 2:     
    System.out.println("Tuesday");     
    break;     
  case 3:     
    System.out.println("Wednesday");     
    break;     
  default:     
    System.out.println("Another day");     
}

A switch statement is efficient for checking a variable against many discrete values. "break" prevents fall-through (execution of all cases after a match). "default" is optional, runs if no case matches.

int day = 3;
String result = switch (day) {   
  case 1 -> "Monday";   
  case 2 -> "Tuesday";   
  case 3 -> {     
    System.out.println("Logging: mid-week");     
    yield "Wednesday";     
  }   
  case 6, 7 -> "Weekend";   
  default -> "Unknown";   
};

System.out.println(result);

Enhanced switch (Java 14+) has a cleaner syntax, no need for break, and it works as an expression as it can return values directly. "yield" can be used instead of "return" to return a value from a block, which is useful when more logic is needed before determining the value.

Loops

for (int i = 0; i < 5; i++) {   
  System.out.println("i = " + i);   
}

A for loop is used when you know how many times you want to repeat something.
i = 0 - initialization
i < 5 - condition
i++ - update (increment)

int i = 0;
while (i < 5) {   
  System.out.println("i = " + i);   
  i++;   
}

A while loop is used when you don't know ahead of time how many times to loop, but loop while a condition is true. It checks the condition before each iteration.

int i = 0;
do {   
  System.out.println("i = " + i);   
  i++;   
} while (i < 5);

A do-while loop is like a "while" loop, but it always runs at least once because the condition is checked after the first run.

String[] names = {"Alice", "Bob", "Charlie"};

for (String name : names) {   
  System.out.println(name);   
}

An enhanced for-each loop is great for iterating through arrays or collections (like ArrayList) in a simpler, cleaner way. It has no index management and read-only access (can't modify elements directly, unless the element is mutable).

for (var p : people) {   
  p.age++;   
}

You cannot reassign the enhanced-for loop variable, but you can do this.

for (int i = 0; i < 10; i++) {   
  if (i == 5) break;   
  System.out.println(i);   
}

The "break" keyword exits the loop immediately.

for (int i = 0; i < 5; i++) {   
  if (i == 2) continue;   
  System.out.println(i);   
}

The "continue" keyword skips the current iteration and moves to the next one.

Classes and objects

public class Car {   
  // Fields (variables that store data)   
  String brand;   
  int year;   
  
  // Constructor (special method to initialize objects)   
  public Car(String brand, int year) {     
    // ("this" keyword refers to the current object)     
    this.brand = brand;     
    this.year = year;     
  }   
  
  // Method (functions that perform actions)   
  public void startEngine() { // Access modifiers such as public or private control visibility     
    System.out.println(brand + " engine started.");     
  }   
}

This is an example of a class. A class is a blueprint for creating objects. It defines the properties (fields/attributes) and behaviors (methods) that the objects created from it will have.

Car myCar = new Car("Toyota", 2020); // Object of class Car
myCar.startEngine(); // Calling a method on the object

This is an example of creating an object. An object is an instance of a class, a specific thing you create using the class.

Access modifiers

class Counter {   
  static int count = 0; // One shared counter   
  Counter() { count++; }   
}

Counter a = new Counter();
Counter b = new Counter();
System.out.println(Counter.count); // 2

The "static" keyword makes a field or method belong to the class, not the instances. A static field is shared by all objects. A static method can be called without an object.

public class Car {   
  public String brand;   
  
  public void drive() {     
    System.out.println("Driving...");     
  }   
}

The "public" keyword makes a class, method, constructor, or variable accessible from any other class, regardless of the package it belongs to. It's used when you want that element to be globally available throughout your entire application. For example, a public method in a class can be called from any other class, even in different packages.

public class BankAccount {   
  private double balance;   
  
  public void deposit(double amount) {     
    balance += amount;     
  }   
  
  private void logTransaction(String msg) {     
    System.out.println("LOG: " + msg);     
  }   
}

The "private" keyword makes a variable, method, or constructor accessible only within the class it is declared in. It's commonly used to enforce encapsulation, ensuring that internal details of a class (like its fields) are hidden from other classes. You typically use private for fields and helper methods that should not be accessed or modified directly from outside.

class PackageClass {   
  void display() {     
    System.out.println("Default access");     
  }   
}

if you don't specify any access modifier, it's called default access (also known as package-private). This means the class, method, or variable is only accessible within the same package, not from classes in other packages, even if they are subclasses. It's useful when you want to limit access to internal components of a package without making them public.

class Animal {   
  protected void eat() {     
    System.out.println("Animal eats");     
  }   
}

class Dog extends Animal {   
  public void bark() {     
    eat(); // Allowed: protected method in superclass     
    System.out.println("Barks");     
  }   
}

The "protected" keyword allows a class member (like a method or variable) to be accessed within the same package and also by subclasses, even if they are in different packages. It's commonly used in inheritance to allow derived classes to reuse or override functionality while still restricting access from unrelated classes.

Getters and setters

Getters and setters are methods used to access and update private fields of a class. They're part of the encapsulation principle in object-oriented programming, keeping fields private and exposing controlled access.

They are used to:

public class Person {   
  private String name; // Private field   
  
  // Getter   
  public String getName() {     
    return name;     
  }   
  
  // Setter   
  public void setName(String newName) {     
    if (newName != null && !newName.isEmpty()) {       
      this.name = newName;       
    } else {       
      System.out.println("Invalid name");       
    }     
  }   
}

// Usage
Person p = new Person();
p.setName("Alice"); // Sets the name
System.out.println(p.getName()); // Prints: Alice

Example of using getters and setters.

It's best to:

Inheritance

Inheritance allows a class (called a subclass or child class) to inherit fields and methods from another class (called a superclass or parent class). It promotes code reuse and models "is-a" relationships. A class can only extend one class (Java supports single inheritance only). The super keyword is used to call a parent constructor and access parent methods/fields.

class Animal {   
  void eat() {     
    System.out.println("This animal eats food.");     
  }   
}

class Dog extends Animal {   
  void bark() {     
    System.out.println("The dog barks.");     
  }   
}

Dog myDog = new Dog();
myDog.eat(); // Inherited from Animal
myDog.bark(); // Defined in Dog

Example of inheritance.

class Animal {   
  void sound() {     
    System.out.println("Animal makes a sound.");     
  }   
}

class Cat extends Animal {   
  @Override   
  void sound() {     
    System.out.println("Cat meows.");     
  }   
}

Subclasses can override inherited methods using the @Override annotation.

super keyword

The super keyword in Java is used to refer to the immediate parent class of a subclass. It's helpful when working with inheritance.

class Animal {   
  Animal(String name) {     
    System.out.println("Animal name: " + name);     
  }   
}

class Dog extends Animal {   
  Dog() {     
    super("Buddy"); // Call the constructor of Animal     
    System.out.println("Dog constructor");     
  }   
}

You can use "super(...)" to call a constructor of the parent class. This must be the first line in the subclass constructor.

class Animal {   
  void speak() {     
    System.out.println("Animal speaks");     
  }   
}

class Dog extends Animal {   
  void speak() {     
    super.speak(); // Call Animal's speak     
    System.out.println("Dog barks");     
  }   
}

If a subclass overrides a method from the parent, you can use "super.methodName()" to explicitly call the parent's version of the method.

class Animal {   
  String type = "Animal";   
}

class Dog extends Animal {   
  String type = "Dog";   
  
  void printType() {     
    System.out.println(super.type); // Animal     
    System.out.println(this.type); // Dog     
  }   
}

If the parent class has a field that is hidden by a subclass field, you can use "super.fieldName" to access the parent's version.

Constructor overloading

// Class with overloaded constructors
public class Book {   
  private String title;   
  private String author;   
  private int year;   
  private double price;   
  
  // Constructor 1: No parameters (default)   
  public Book() {     
    this.title = "Untitled";     
    this.author = "Unknown";     
    this.year = 0;     
    this.price = 0.0;     
    System.out.println("Default constructor called.");     
  }   
  
  // Constructor 2: Title and Author   
  public Book(String title, String author) {     
    this.title = title;     
    this.author = author;     
    this.year = 0;     
    this.price = 0.0;     
    System.out.println("Constructor with title and author called.");     
  }   
  
  // Constructor 3: Title, Author, and Year   
  public Book(String title, String author, int year) {     
    this.title = title;     
    this.author = author;     
    this.year = year;     
    this.price = 0.0;     
    System.out.println("Constructor with title, author, and year called.");     
  }   
  
  // Constructor 4: All fields   
  public Book(String title, String author, int year, double price) {     
    this.title = title;     
    this.author = author;     
    this.year = year;     
    this.price = price;     
    System.out.println("Constructor with all fields called.");     
  }   
  
  // Method to display book details   
  public void displayInfo() {     
    System.out.println("Title: " + title + ", Author: " + author +     
      ", Year: " + year + ", Price: $" + price);       
  }   
}

// Usage
Book b1 = new Book();
b1.displayInfo();

Book b2 = new Book("1984", "George Orwell");
b2.displayInfo();

Book b3 = new Book("The Hobbit", "J.R.R. Tolkien", 1937);
b3.displayInfo();

Book b4 = new Book("Clean Code", "Robert C. Martin", 2008, 35.99);
b4.displayInfo();

Constructor overloading means having multiple constructors in the same class, each with different parameter lists. It allows different ways to initialize an object.

Method overloading

// Class with overloaded methods
public class Calculator {   
  
  // No parameters   
  public int add() {     
    System.out.println("No parameters provided. Returning 0.");     
    return 0;     
  }   
  
  // One int parameter   
  public int add(int a) {     
    System.out.println("Adding one number: " + a);     
    return a;     
  }   
  
  // Two int parameters   
  public int add(int a, int b) {     
    System.out.println("Adding two integers: " + a + " + " + b);     
    return a + b;     
  }   
  
  // Two double parameters   
  public double add(double a, double b) {     
    System.out.println("Adding two doubles: " + a + " + " + b);     
    return a + b;     
  }   
  
  // Mixed parameters: int and double   
  public double add(int a, double b) {     
    System.out.println("Adding int and double: " + a + " + " + b);     
    return a + b;     
  }   
  
  // Mixed parameters: double and int   
  public double add(double a, int b) {     
    System.out.println("Adding double and int: " + a + " + " + b);     
    return a + b;     
  }   
}

// Usage
Calculator calc = new Calculator();
calc.add(); // No parameters
calc.add(10); // One int
calc.add(10, 20); // Two ints
calc.add(5.5, 6.3); // Two doubles
calc.add(5, 3.2); // int + double
calc.add(2.7, 4); // double + int

Method overloading means that multiple methods can exists with the same name but different parameter lists in the same class. It provides multiple ways to call a method. Method overloading is not based on return type alone. You cannot overload only by changing the return type.

Method overriding

Method overriding happens when a subclass provides a new implementation for a method that is already defined in its superclass. The method in the subclass must have the same name, the same parameter list, the same or compatible return type. It is used to customize behavior in a derived class.

The @Override annotation tells the compiler: "I'm overriding a method from a superclass." It helps catch errors if you mistype the method name or signature and it also improves readability and code safety.

class Animal {   
  void makeSound() {     
    System.out.println("Animal makes a sound");     
  }   
}

class Dog extends Animal {   
  @Override   
  void makeSound() {     
    System.out.println("Dog barks");     
  }   
}

// Usage
Animal myDog = new Dog(); // Polymorphism
myDog.makeSound(); // Output: Dog barks

In this example the Dog class overrides the makeSound() method from Animal. Even though myDog is declared as an Animal, it uses the Dog's version of makeSound() at runtime. This is runtime polymorphism.

class Cat extends Animal {   
  void makesound() { // wrong spelling, no override!     
    System.out.println("Cat meows");     
  }   
}

If you forget "@Override" there is no compiler error because makesound() is a new method, not an override. If you wrote "@Override void makesound()" you'd get a compile-time error.

Abstract classes

An abstract class is a class that can not be instantiated by itself, it needs to be subclassed by another class to use its properties. It is declared using the "abstract" keyword in its class definition. It serves as a blueprint for its subclasses. It is used when you want to define a common interface and behavior for a group of related classes and when some methods need to be shared, and others must be implemented by subclasses.

They can contain:

abstract class Animal {   
  abstract void makeSound(); // abstract method (no body)   
  
  void breathe() { // concrete method     
    System.out.println("Breathing...");     
  }   
}

class Dog extends Animal {   
  @Override   
  void makeSound() {     
    System.out.println("Woof!");     
  }   
}

// Usage
// Animal a = new Animal(); Cannot instantiate abstract class
Animal dog = new Dog(); // Can use abstract type
dog.makeSound(); // Woof!
dog.breathe(); // Breathing...

Example of an abstract class.

Interfaces

An interface is a contract that specifies what a class must do, but not how. It contains abstract method signatures (and optionally default or static methods) but no implementation for abstract methods. An interface defines method names, parameters, and return types. Classes that implement an interface must provide concrete implementations of all abstract methods. Interfaces allow multiple inheritance (a class can implement multiple interfaces).

They can contain:

interface Animal {   
  void makeSound(); // Abstract by default   
}

class Dog implements Animal {   
  @Override   
  public void makeSound() {     
    System.out.println("Woof!");     
  }   
}

public class Main {   
  public static void main(String[] args) {     
    Animal dog = new Dog();     
    dog.makeSound(); // Woof!     
  }   
}

Basic example of an interface.

interface Flyable {   
  void fly();   
}

interface Swimmable {   
  void swim();   
}

class Duck implements Flyable, Swimmable {   
  public void fly() {     
    System.out.println("Duck flies.");     
  }   
  
  public void swim() {     
    System.out.println("Duck swims.");     
  }   
}

Example of using multiple interfaces.

Anonymous classes

An anonymous class is a class without a name, defined and instantiated in a single expression. They're typically used to provide a quick implementation of an interface or extend a class, especially when the implementation is used only once.

interface Greetable {   
  void greet();   
}

Greetable greeter = new Greetable() {   
  @Override   
  public void greet() {     
    System.out.println("Hello from an anonymous class!");     
  }   
};

greeter.greet(); // Output: Hello from an anonymous class!

Syntax example of an anonymous class (with an interface).

class Animal {   
  void makeSound() {     
    System.out.println("Some sound");     
  }   
}

Animal myAnimal = new Animal() {   
  @Override   
  void makeSound() {     
    System.out.println("Anonymous Dog barks!");     
  }   
};

myAnimal.makeSound(); // Output: Anonymous Dog barks!

Example of extending a class with an anonymous class.

Sealed classes

A sealed class (introduced in Java 17) restricts which other classes can extend or implement it. In other words, a sealed class allows to explicitly control its subclasses, by providing:
stronger encapsulation
safer, more predictable inheritance
clearer class hierarchies
better support for pattern matching (Java 21+)

// Before sealed classes, if you wrote:
class Shape {}

// Anyone could do:
class WeirdShape extends Shape {}

This makes it hard to reason about all possible subclasses, especially when doing pattern matching or designing APIs. Sealed classes stop that.

public sealed class Shape permits Circle, Rectangle, Triangle {}

A sealed class must specify which classes are allowed to extend it, using the permits clause. In this example only Circle, Rectangle, and Triangle may extend Shape. Any other attempt will cause a compile-time error.

// final: No one can extend it
public final class Circle extends Shape {}

// sealed: It can still restrict its own subclasses
public sealed class Rectangle extends Shape permits Square {}

// non-sealed: It becomes "open" again, meaning that anyone can extend it
public non-sealed class Triangle extends Shape {}

Every subclass of a sealed class must specify one of three modifiers. This gives a fine-grained control.

String describe(Shape s) {   
  return switch (s) {     
    case Circle c -> "Circle";     
    case Square sq -> "Square";     
    case Triangle t -> "Triangle";     
  };   
}

They improve pattern matching (Java 21+), because the compiler knows all possible subclasses, switch can exhaustively check them. In this example No default needed, because Java knows the hierarchy is complete.

Records

A record (Java 16+) is a special kind of Java class designed to hold immutable data. Java provides the constructor, getters, equals(), hashCode(), and toString() automatically. Think of a record as a concise, built-in way to create data carrier classes (sometimes called "DTOs", "value objects", etc.)

// Without records (old Java):
public class Person {   
  private final String name;   
  private final int age;   
  
  public Person(String name, int age) {     
    this.name = name;     
    this.age = age;     
  }   
  
  public String name() { return name; }   
  public int age() { return age; }   
  
  @Override public boolean equals(Object o) {...}   
  @Override public int hashCode() {...}   
  @Override public String toString() {...}   
}

// With records:
public record Person(String name, int age) {}

The "old" way requires a ton of boilerplate, while with records Java generates everything else ready to use.

// When you write:
public record Person(String name, int age) {}

// Java provides:

// A canonical constructor:
public Person(String name, int age) { ... }

// Read-only accessors (not getters, but accessor methods with the same name as the fields):
String name()
int age()

// equals() and hashCode() (based on all components)

// toString() Outputs: Person[name=Alice, age=30]

// final fields (records are immutable by default)

What records automatically provide.

public record Point(int x, int y) implements Drawable {}

You can implement interfaces with records, but you cannot add mutable fields, setter methods or extend another class (records implicitly extend java.lang.Record).

public record Person(String name, int age) {   
  public String greet() {     
    return "Hello, I'm " + name;     
  }   
}

You can add your own logic.

public record Person(String name, int age) {   
  public Person {     
    if (age < 0) throw new IllegalArgumentException("Age cannot be negative");     
  }   
}

You may override the canonical constructor to add validation. Java automatically assigns the fields unless you fully rewrite the constructor. In this example we used a compact constructor.

public record Person(String name, int age) {   
  public Person(String name, int age) {     
    if (age < 0) throw new IllegalArgumentException();     
    this.name = name;     
    this.age = age;     
  }   
}

You can also use explicit (canonical) constructors.

public record Config(String key, String value) {   
  public static final String VERSION = "1.0";   
}

Records can have static fields, static methods, nested types.

var p2 = new Person(p.name(), p.age() + 1);

Records are immutable, so no setters, ever. If you need a modified value, you can do this.

Inheritance vs Association vs Aggregation vs Composition

class Animal {   
  void makeSound() {     
    System.out.println("Generic sound");     
  }   
}

class Dog extends Animal {   
  void bark() {     
    System.out.println("Bark!");     
  }   
}

Inheritance ("is-a" relationship) is when a subclass inherits fields and methods from a superclass. Useful when one class is a more specific version of another and you want to reuse code or extend behavior. Keep in mind that it promotes tight coupling, Java only supports single inheritance, so no multiple parent classes and it's not ideal for modeling every relationship.

class Patient {   
  String name;   
  
  Patient(String name) {     
    this.name = name;     
  }   
}

class Doctor {   
  String name;   
  
  Doctor(String name) {     
    this.name = name;     
  }   
  
  void diagnose(Patient patient) {     
    System.out.println("Doctor " + this.name + " is diagnosing " + patient.name + ".");     
  }   
}

Association ("uses-a" relationship) is when one object uses or is connected to another. It represents the idea that "object A is associated with object B", without implying ownership. It means that two classes are related, usually with one holding a reference to the other. It can be unidirectional, when only one class knows about the other, or bidirectional, when both classes know about each other. In simple words association is when two objects simply know each other or use each other.

class Engine {   
  void start() {     
    System.out.println("Engine starts.");     
  }   
}

class Car {   
  Engine engine; // Aggregation   
  
  Car(Engine engine) {     
    this.engine = engine;     
  }   
}

Aggregation ("has-a" relationship, weak ownership) is a special form of association where one class has a reference to another and the referenced object represents a part of the whole but can exist independently of the owner. In this example you can create and share an Engine among multiple Car objects. If a Car is destroyed, the Engine can still exist. In simple words aggregation is when one object contains another as a meaningful component (a "part"), but that part can exist on its own.

class Heart {   
  void beat() {     
    System.out.println("Heart is beating.");     
  }   
}

class Human {   
  private Heart heart = new Heart(); // Composition   
  
  void live() {     
    heart.beat();     
  }   
}

Composition ("part-of" relationship, strong ownership) is when a class creates and controls the lifecycle of another class internally. The composed object cannot exist independently. In this example if the Human object is destroyed, the Heart goes with it. It's a stronger coupling than aggregation. With simple words, composition is the same "part of" idea as aggregation, but the part cannot exist independently.

Concept Relationship Object Lifespan Coupling Example Phrase
Inheritance is-a Subclass depends on superclass Tight A Dog is an Animal
Association uses-a Independent Loose A Doctor is diagnosing a Patient
Aggregation has-a Independent Loose A Car has an Engine
Composition part-of Dependent Strong A Heart is part of a Human

final keyword

The final keyword is used to restrict modification. Depending on where it's used, it has different effects.

final int MAX_USERS = 100;
// MAX_USERS = 200; // Compilation error

// For object references
final StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // Contents can change
// sb = new StringBuilder(); // Cannot reassign the reference

A final variable can be assigned only once. Once a value is set, it cannot be changed (i.e., it's a constant).

final class Utility {   
  static void sayHi() {     
    System.out.println("Hi!");     
  }   
}

class MyUtility extends Utility {} // Error: cannot inherit from final class

A final class cannot be extended (no subclassing). Often used for security and performance (e.g., String class).

class Parent {   
  final void display() {     
    System.out.println("This cannot be overridden");     
  }   
}

class Child extends Parent {   
  void display() {} // Not allowed, compilation error   
}

A final method cannot be overridden in subclasses. Used to prevent changing important logic in derived classes.

Wrapper classes and autoboxing

Java has primitive types like int, char, boolean, etc. These are not objects. To work with primitives in places that require objects (like collections), Java provides wrapper classes, which are the object representations of primitive types. Wrapper classes allow primitives to be used in collections (like List<Integer>, since List<int> is invalid). They provide utility methods, e.g., Integer.parseInt("123"), Double.isNaN(x). They support nullability, unlike primitives.

Primitive Type Wrapper Class
byte Byte
short Short
int Integer
long Long
float Float
double Double
char Character
boolean Boolean

int num = 5;
Integer boxed = num; // Autoboxing: int → Integer

Autoboxing is Java's feature that automatically converts a primitive to its wrapper class when needed.

Integer boxed = 10;
int num = boxed; // Unboxing: Integer → int

Unboxing is the reverse: Java automatically converts a wrapper object back to a primitive.

List<Integer> numbers = new ArrayList<>();

numbers.add(5); // Autoboxing: int → Integer
int x = numbers.get(0); // Unboxing: Integer → int

System.out.println(x); // 5

An example of autoboxing & unboxing.

Integer x = null;
int y = x; // NullPointerException

Autoboxing/unboxing is convenient, but comes with performance cost (boxing creates objects). Be cautious with null values.

Expressions and operators

Operator Description Example
+ Addition a + b
- Subtraction a - b
* Multiplication a * b
/ Division a / b
% Modulo (remainder) a % b

Arithmetic operators are used for basic math.

Operator Description Example
= Assignment x = 10
+= Add and assign x += 3
-= Subtract and assign x -= 2
*= Multiply and assign x *= 5
/= Divide and assign x /= 2
%= Modulo and assign x %= 4

Assignment operators are used to assign values.

Operator Description Example
== Equal to a == b
!= Not equal to a != b
> Greater than a > b
< Less than a < b
>= Greater or equal a >= b
<= Less or equal a <= b

Relational / comparison operators are used to compare values (return true or false).

Operator Name Description Example
&& Logical AND Returns true if both operands are true true && false → false
!! Logical OR Returns true if either operands are true true !! false → true
! Logical NOT Inverts the boolean value !true → false

Logical operators are used with boolean expressions.

Operator Description Example
+ Unary plus +a
- Unary minus -a
++ Increment a++ or ++a
-- Decrement a-- or --a
! Logical NOT !flag

Unary operators operate on a single operand.

Operator Description
& Bitwise AND
| Bitwise OR
^ Bitwise XOR
~ Bitwise NOT
<< Left shift
>> Right shift
>>> Unsigned right shift

Bitwise operators operate on bits (advanced, used less commonly).

int result = (a > b) ? a : b;

The ternary operator is basically a short if-else expression.

if (obj instanceof String) {   
  System.out.println("It's a String!");   
}

The instanceof operator checks if an object is an instance of a specific class.

if (obj instanceof String s) {   
  System.out.println(s.toUpperCase());   
}

You can also do pattern matching for instanceof (Java 16+).

int x = (int) 5.5; // explicit casting

The type cast operator converts one data type to another.

User input/output

import java.util.Scanner;

Scanner scanner = new Scanner(System.in);

System.out.print("Enter your name: ");
String name = scanner.nextLine(); // Read a line of text

System.out.print("Enter your age: ");
int age = scanner.nextInt(); // Read an integer

System.out.println("Hi " + name + ", age " + age + "!");

scanner.close();

The Scanner class (from java.util) is a convenient way to read formatted input from System.in, files, or strings.

Method Description Example Input
next() Reads one word (until space) "hello"
nextLine() Reads entire line "hello world"
nextInt() Reads an int 42
nextDouble() Reads a double 3.14
nextBoolean() Reads a boolean true or false
Common scanner methods.

int age = scanner.nextInt();
scanner.nextLine(); // consume the leftover newline
String name = scanner.nextLine();

Always close the Scanner using scanner.close() when you're done. If you use nextInt() or similar followed by nextLine(), add an extra nextLine() to consume the newline.

System.out.print("Hello "); // prints on same line
System.out.println("World!"); // prints with newline
System.out.printf("Age: %d\n", 30); // formatted output

System.out is a PrintStream object that represents the standard output (usually the console). Use it to print messages to the user.

Type casting (widening, narrowing)

Type casting is converting a variable from one data type to another.
Java has two types of casting:
Widening (implicit) casting
Narrowing (explicit) casting

int myInt = 42;
double myDouble = myInt; // automatic casting

System.out.println(myDouble); // Output: 42.0

Widening casting (automatic / implicit) is converting a smaller type to a larger type automatically. It's safe as there is no data loss.
The order of widening is:
byte → short → int → long → float → double

double myDouble = 9.78;
int myInt = (int) myDouble; // explicit casting

System.out.println(myInt); // Output: 9 (decimal part lost)

Narrowing casting (manual / explicit) is converting a larger type into a smaller type manually. It's risky because there might be data loss or cause precision issues.

String s = "123";
int x = Integer.parseInt(s); // Not casting, it's parsing

Casting between non-compatible types (e.g., String → int) requires parsing, not casting.

Exception handling

try {   
  int a = 10 / 0;   
} catch (ArithmeticException e) {   
  System.out.println("Cannot divide by zero!");   
}

An exception is an object that represents an error. It disrupts the normal flow of the program. In this example we can see the basic syntax of try-catch.

try {   
  // risky code   
} catch (Exception e) {   
  // handle error   
} finally {   
  // always executed   
}

The finally block (optional) runs no matter what, even if there's an exception.

Exception Type Description
ArithmeticException E.g., divide by zero
NullPointerException Object not initialized
ArrayIndexOutOfBoundsException Accessing invalid array index
IOException Input/output failure (files, streams)
NumberFormatException Invalid conversion of strings to numbers
Common exception types.

throw new IllegalArgumentException("Invalid value");

You can throw your own exceptions.

public void readFile() throws IOException {   
  // reading file   
}

You can declare that your method might throw an exception.

try {   
  String s = null;   
  System.out.println(s.length());   
} catch (NullPointerException e) Checked vs Unchecked Exceptions {   
  System.out.println("Null value encountered.");   
} catch (Exception e) {   
  System.out.println("Something went wrong.");   
} finally {   
  System.out.println("Cleanup code here.");   
}

Example of handling multiple exceptions.

import java.io.*;

public class FileReaderExample {   
  public static void main(String[] args) {     
    try {       
      FileReader fr = new FileReader("file.txt"); // May throw FileNotFoundException       
    } catch (FileNotFoundException e) {       
      System.out.println("File not found!");       
    }     
  }   
}

Checked exceptions are exceptions that the compiler checks. You must handle them using try-catch or declare them using throws. If you don't catch or declare a checked exception, you'll get a compile-time error.

public class UncheckedExample {   
  public static void main(String[] args) {     
    int a = 10 / 0; // ArithmeticException (unchecked)     
  }   
}

Unchecked exceptions occur at runtime and are not checked by the compiler. You don't have to handle them, but you can.
Other examples for these:
NullPointerException
ArrayIndexOutOfBoundsException
IllegalArgumentException

Use checked exceptions when recovery is likely (e.g., file not found, network issues).
Use unchecked exceptions for programming bugs (e.g., invalid arguments, nulls).

Classic I/O

import java.io.*;

try {   
  FileReader fr = new FileReader("input.txt");   
  BufferedReader br = new BufferedReader(fr);   
  
  String line;   
  while ((line = br.readLine()) != null) {     
    System.out.println(line); // Print each line     
  }   
  
  br.close(); // Closes both BufferedReader and FileReader   
} catch (IOException e) {   
  e.printStackTrace();   
}

In this example we are reading a file. FileReader + BufferedReader are used together to read text files efficiently, line by line.

import java.io.*;

try {   
  FileWriter fw = new FileWriter("output.txt");   
  PrintWriter pw = new PrintWriter(fw);   
  
  pw.println("Hello from Java!");   
  pw.println("Writing to files is easy.");   
  
  pw.close(); // Also closes the FileWriter   
} catch (IOException e) {   
  e.printStackTrace();   
}

In this example we are (over)writing a file.
FileWriter: writes characters to a file.
PrintWriter: adds formatting methods like println().

FileWriter fw = new FileWriter("output.txt", true); // true = append mode
PrintWriter pw = new PrintWriter(fw);

This appends to a file instead of overwriting it.

try (BufferedReader br = new BufferedReader(new FileReader("input.txt"))) {   
  String line;   
  while ((line = br.readLine()) != null) {     
    System.out.println(line);     
  }   
} catch (IOException e) {   
  e.printStackTrace();   
}

This ensures the file is closed automatically.

import java.io.File;

File file = new File("example.txt");
if (file.exists()) {   
  System.out.println("File exists.");   
} else {   
  System.out.println("File does not exist.");   
}

Checks if a file exists.

import java.io.File;

File file = new File("example.txt");
if (file.delete()) {   
  System.out.println("File deleted.");   
} else {   
  System.out.println("Failed to delete the file.");   
}

Deletes a file. It does not delete if the file doesn't exist or is in use, instead it silently fails, and returns false to indicate the deletion did not succeed.

File oldFile = new File("oldName.txt");
File newFile = new File("newName.txt");

if (oldFile.renameTo(newFile)) {   
  System.out.println("File renamed/moved successfully.");   
} else {   
  System.out.println("Rename/move failed.");   
}

Renames or moves a file. It works for renaming and for moving to a different directory (if you give a path).

// Use .mkdir() to create a single directory
File directory = new File("newFolder");
if (directory.mkdir()) {   
  System.out.println("Directory created.");   
} else {   
  System.out.println("Failed to create directory.");   
}

// Use .mkdirs() to create nested directories if needed
File nested = new File("parent/child/grandchild");
nested.mkdirs(); // Creates all non-existent directories in the path

Creates a directory.

File dir = new File("newFolder");
if (dir.delete()) {   
  System.out.println("Directory deleted.");   
} else {   
  System.out.println("Failed to delete directory.");   
}

Deletes a directory. This only deletes empty directories. To delete non-empty ones, you'll need to delete contents recursively.

import java.io.File;

public class DeleteDirectoryRecursively {   
  public static void main(String[] args) {     
    File directory = new File("myFolder");     
    
    if (deleteRecursively(directory)) {       
      System.out.println("Directory and all contents deleted successfully.");       
    } else {       
      System.out.println("Failed to delete directory.");       
    }     
  }   
  
  public static boolean deleteRecursively(File file) {     
    if (file.isDirectory()) {       
      File[] contents = file.listFiles();       
      if (contents != null) {         
        for (File child : contents) {           
          boolean success = deleteRecursively(child);           
          if (!success) {             
            return false;             
          }           
        }         
      }       
    }     
    return file.delete(); // Deletes file or now-empty directory     
  }   
}

This deletes the directory called "myFolder" and its contents recursively.

File file = new File("somepath");

if (file.isFile()) {   
  System.out.println("It is a file.");   
} else if (file.isDirectory()) {   
  System.out.println("It is a directory.");   
}

Checks if path is a file or directory.

File dir = new File("someFolder");
String[] contents = dir.list();

if (contents != null) {   
  for (String fileName : contents) {     
    System.out.println(fileName);     
  }   
}

List files in a directory.

Java NIO

NIO stands for New Input/Output, introduced in Java 1.4 and improved in Java 7 with the java.nio.file package.

It offers:
Non-blocking I/O operations (great for scalability).
Buffers and Channels instead of Streams.
A powerful and simple way to work with files and directories.

Component Description
Path Represents a file/directory path
Files Utility class to work with files
Paths Used to create Path instances
Channel Similar to stream but for NIO (for advanced use)
Buffer Holds data for reading/writing (used in lower-level NIO)
Core concepts of NIO.

import java.nio.file.*;
import java.io.IOException;
import java.util.List;

public class ReadNIO {   
  public static void main(String[] args) throws IOException {     
    Path path = Paths.get("input.txt");     
    List<String> lines = Files.readAllLines(path);     
    
    for (String line : lines) {       
      System.out.println(line);       
    }     
  }   
}

Reads a file.

import java.nio.file.*;
import java.io.IOException;
import java.util.Arrays;

public class WriteNIO {   
  public static void main(String[] args) throws IOException {     
    Path path = Paths.get("output.txt");     
    Files.write(path, Arrays.asList("Hello", "Java NIO is great!"));     
  }   
}

Writes a file or overwrites an existing one.

Files.write(   
  Paths.get("output.txt"),   
  Arrays.asList("New line appended."),   
  StandardOpenOption.APPEND   
);

This appends to a file.

Path path = Paths.get("example.txt");
if (Files.exists(path)) {   
  System.out.println("File exists.");   
} else {   
  System.out.println("File does not exist.");   
}

Checks if a file or directory exists.

try {   
  Files.delete(path); // Can throw NoSuchFileException or DirectoryNotEmptyException   
  System.out.println("Deleted successfully.");   
} catch (IOException e) {   
  System.out.println("Delete failed: " + e.getMessage());   
}

Deletes a file or empty directory. Use Files.deleteIfExists(path) to avoid exceptions if the file doesn't exist.

Path source = Paths.get("old-name.txt");
Path target = Paths.get("new-name.txt");
try {   
  Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);   
  System.out.println("Renamed successfully.");   
} catch (IOException e) {   
  System.out.println("Rename failed: " + e.getMessage());   
}

Renames (moves) a file.

Path dir = Paths.get("myNewDirectory");

try {   
  Files.createDirectory(dir); // For a single directory   
  // For nested directories, use:   
  // Files.createDirectories(Paths.get("parent/child/grandchild"));   
  System.out.println("Directory created.");   
} catch (IOException e) {   
  System.out.println("Create failed: " + e.getMessage());   
}

Creates a directory.

try {   
  Files.delete(Paths.get("myNewDirectory")); // Must be empty   
  System.out.println("Directory deleted.");   
} catch (IOException e) {   
  System.out.println("Delete failed: " + e.getMessage());   
}

Deletes a directory (only if empty). For recursive deletion, use Files.walk() + Files.delete().

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;

public class NioRecursiveDelete {   
  public static void main(String[] args) {     
    Path directory = Paths.get("myDirectoryToDelete");     
    
    try {       
      Files.walk(directory)       
        .sorted(Comparator.reverseOrder()) // Delete children before parent         
        .forEach(path -> {           
          try {             
            Files.delete(path);             
            System.out.println("Deleted: " + path);             
          } catch (IOException e) {             
            System.err.println("Failed to delete: " + path + " - " + e.getMessage());             
          }           
        });         
    } catch (IOException e) {       
      System.err.println("Error walking directory: " + e.getMessage());       
    }     
  }   
}

Recursively deletes a directory.

import java.nio.file.*;

public class PathCheckExample {   
  public static void main(String[] args) {     
    Path path = Paths.get("example.txt");     
    
    if (Files.exists(path)) {       
      if (Files.isDirectory(path)) {         
        System.out.println(path + " is a directory.");         
      } else if (Files.isRegularFile(path)) {         
        System.out.println(path + " is a regular file.");         
      } else {         
        System.out.println(path + " exists but is neither a regular file nor a directory.");         
      }       
    } else {       
      System.out.println(path + " does not exist.");       
    }     
  }   
}

Checks whether a path is a directory or a regular file.

Path dir = Paths.get("some_directory");
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {   
  for (Path entry : stream) {     
    System.out.println(entry.getFileName());     
  }   
}

Lists files in a directory.

Serialization

Serialization is the process of converting a Java object into a byte stream, so it can be saved to a file, sent over a network, stored in a database or deep cloned in some cases. The reverse process, turning a byte stream back into a Java object, is called deserialization.

import java.io.Serializable;

public class Person implements Serializable {   
  private String name;   
  private int age;   
  
  // Constructors, getters, setters   
}

To mark the class as serializable you must implement the serializable interface (a marker interface with no methods).

import java.io.*;

public class SerializeExample {   
  public static void main(String[] args) {     
    Person p = new Person("Alice", 30);     
    
    try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("person.ser"))) {       
      out.writeObject(p);       
      System.out.println("Object serialized.");       
    } catch (IOException e) {       
      e.printStackTrace();       
    }     
  }   
}

This writes the object to a file using ObjectOutputStream.

import java.io.*;

public class DeserializeExample {   
  public static void main(String[] args) {     
    try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("person.ser"))) {       
      Person p = (Person) in.readObject();       
      System.out.println("Name: " + p.getName() + ", Age: " + p.getAge());       
    } catch (IOException | ClassNotFoundException e) {       
      e.printStackTrace();       
    }     
  }   
}

This reads the object from the file using ObjectInputStream.

Fields marked as transient are not serialized, e.g.:
private transient String password;
All fields must also be serializable, or marked transient.
You can define a serialVersionUID for version control of serialized data:
private static final long serialVersionUID = 1L;
If the class structure changes and the serialVersionUID differs, deserialization may fail.

Printf

String name = "Alice";
int age = 25;
System.out.printf("My name is %s and I am %d years old.%n", name, age);

// Output:
My name is Alice and I am 25 years old.

The basic syntax of printf.
%s - string
%d - integer
%n - new line (portable alternative to \n)

Type Specifier Example Output
String %s "Hello" Hello
Integer %d 42 42
Floating-point %f 3.14159 3.141590
Boolean %b true true
Character %c 'A' A
Common format specifiers.

double pi = Math.PI;
System.out.printf("Pi: %.2f%n", pi); // Pi: 3.14

You can control precision (decimal places).

System.out.printf("|%10s|%n", "Java");
System.out.printf("|%-10s|%n", "Java");

// Output:
|      Java|
|Java      |

Aligns output to a certain width.
%10s - right-align in 10 spaces
%-10s - left-align in 10 spaces

int n = 7;
System.out.printf("Padded number: %03d%n", n);

// Output:
Padded number: 007

Formats number to a certain amount of digits.

String item = "Coffee";
double price = 3.5;
int quantity = 2;

System.out.printf("Item: %-10s Price: $%5.2f Qty: %d%n", item, price, quantity);

// Output:
Item: Coffee     Price: $ 3.50 Qty: 2

Combines multiple formats.

String formatted = String.format("Hello %s, you have %d new messages.", "Alice", 3);
System.out.println(formatted);

Creates a formatted string without printing.

Varargs

public class VarargsExample {   
  public static void main(String[] args) {     
    printNumbers(1, 2, 3);     
    printNumbers(10, 20);     
    printNumbers(); // works with no arguments too!     
  }   
  
  public static void printNumbers(int... numbers) {     
    for (int n : numbers) {       
      System.out.println(n);       
    }     
    System.out.println("Total numbers: " + numbers.length);     
  }   
}

// Output:
1
2
3
Total numbers: 3
10
20
Total numbers: 2
Total numbers: 0

Varargs (short for variable arguments) allow you to pass a variable number of arguments to a method, instead of specifying a fixed number. It's written using three dots ... after the parameter type.

// This:
printNumbers(1, 2, 3);

// is the same as this:
printNumbers(new int[] {1, 2, 3});

Behind the scenes, Java treats a varargs parameter as an array.

// Valid:
void example(String name, int... scores) { }

// Invalid:
void example(int... scores, String name) { } // Varargs cannot be last

You can have only one varargs parameter in a method. It must be the last parameter in the list.

public static void greet(String greeting, String... names) {   
  for (String name : names) {     
    System.out.println(greeting + ", " + name + "!");     
  }   
}

public static void main(String[] args) {   
  greet("Hello", "Alice", "Bob", "Charlie");   
}

// Output:
Hello, Alice!
Hello, Bob!
Hello, Charlie!

Example of varargs with other parameters.

int[] nums = {4, 5, 6};
printNumbers(nums);

You can still pass an array to a varargs method. Works exactly like calling printNumbers(4, 5, 6);.

public static void log(String format, Object... args) {   
  System.out.printf("[LOG] " + format + "%n", args);   
}

public static void main(String[] args) {   
  log("User %s logged in at %s", "Alice", "10:00 AM");   
}

// Output:
[LOG] User Alice logged in at 10:00 AM

Real-world example of a logging utility.

Lambda expressions

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// Without lambda:
names.forEach(new Consumer<String>() {   
  @Override   
  public void accept(String name) {     
    System.out.println(name);     
  }   
});

// With lambda:
names.forEach(name -> System.out.println(name));

A lambda expression is essentially a shorter way to write anonymous functions, that is, a block of code that you can pass around and execute later. Before Java 8, you needed anonymous inner classes for simple one-line logic, which was unnecessarily verbose. Lambdas let you write that same logic in a single, elegant expression. They make the code concise, functional, and easier to read.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()   
  .filter(n -> n % 2 == 0) // keep even numbers   
  .map(n -> n * n) // square them   
  .forEach(n -> System.out.println(n)); // print result   

// Output:
4
16

Lambdas really shine with Streams.

Syntax Example Meaning
No parameters () -> 42 Returns 42
One parameter x -> x * 2 Takes x, returns x * 2
Multiple parameters (a, b) -> a + b Adds two numbers
Block body (x, y) -> { int sum = x + y; return sum; } Multi-line body
Syntax alternatives

Predicate<Integer> isEven = n -> n % 2 == 0;
System.out.println(isEven.test(4)); // true

You can store lambdas in variables.

// Works fine:
public class LambdaFinalExample {   
  public static void main(String[] args) {     
    int base = 10; // effectively final (not changed later)     
    
    Runnable r = () -> System.out.println("Base value: " + base);     
    r.run();     
  }   
}
// Output:
Base value: 10

// Throws compilation error (variable modified):
public class LambdaErrorExample {   
  public static void main(String[] args) {     
    int base = 10;     
    
    Runnable r = () -> System.out.println("Base value: " + base);     
    
    base = 20; // causes compile-time error!     
    r.run();     
  }   
}
// Error:
Variable used in lambda expression should be final or effectively final

A lambda expression can access local variables from the enclosing scope, but cannot modify them. Those variables must be effectively final, meaning their value doesn't change after initialization.

The first example works fine because base is not modified after it's assigned. Even though it's not marked final, it's effectively final as its value never changes.

The second example runs to an error because lambdas capture variables by value, not by reference, so if the value could change, Java can't guarantee consistency inside the lambda.

import java.util.concurrent.atomic.AtomicInteger;

public class LambdaMutableExample {   
  public static void main(String[] args) {     
    AtomicInteger counter = new AtomicInteger(0);     
    
    Runnable r = () -> {       
      counter.incrementAndGet();       
      System.out.println("Counter: " + counter.get());       
    };     
    
    r.run(); // Counter: 1     
    r.run(); // Counter: 2     
  }   
}

If you really need to modify a value inside a lambda, you can wrap it in a mutable container. This works because AtomicInteger is mutable, the reference is effectively final, but its contents can change.

Functional interfaces

A functional interface is an interface that has exactly one abstract method. It's designed to be implemented with a lambda expression or a method reference. They make Java's functional programming style possible, used everywhere in the Streams API, event handling, and more. They're the bridge between object-oriented and functional programming in Java. They allow lambdas and method references to work seamlessly.

@FunctionalInterface
interface Greeting {   
  void sayHello(String name);   
}

public class FunctionalInterfaceExample {   
  public static void main(String[] args) {     
    // Using a lambda expression to implement the interface     
    Greeting greet = (name) -> System.out.println("Hello, " + name + "!");     
    greet.sayHello("Java");     
  }   
}

// Output:
Hello, Java!

Basic syntax of a functional interface.

@FunctionalInterface
interface Calculator {   
  int add(int a, int b);   
  
  // int subtract(int a, int b); would cause an error   
}

The @FunctionalInterface Annotation It's optional, but recommended. It tells the compiler to enforce that the interface has only one abstract method. If you accidentally add more, you'll get a compile-time error.

Interface Abstract Method Description Example
Consumer<T> void accept(T t) Performs an action on a value list.forEach(x -> System.out.println(x));
Supplier<T> T get() Supplies (returns) a value without input Supplier<Double> rand = () -> Math.random();
Function<T,R> R apply(T t) Transforms a value from type T to R Function<String, Integer> len = s -> s.length();
Predicate<T> boolean test(T t) Tests a condition, returns true/false Predicate<Integer> even = n -> n % 2 == 0;
BiFunction<T,U,R> R apply(T t, U u) Takes two inputs, returns one output BiFunction<Integer,Integer,Integer> add = (a,b) -> a + b;
Java provides many ready-to-use functional interfaces in the java.util.function package. These are the most common ones.

import java.util.function.*;

public class BuiltInFunctionalExample {   
  public static void main(String[] args) {     
    Predicate<Integer> isEven = n -> n % 2 == 0;     
    Function<String, Integer> length = s -> s.length();     
    Consumer<String> printer = s -> System.out.println("Hello, " + s);     
    Supplier<Double> randomValue = () -> Math.random();     
    
    System.out.println(isEven.test(4)); // true     
    System.out.println(length.apply("Java")); // 4     
    printer.accept("World!"); // Hello, World!     
    System.out.println(randomValue.get()); // e.g., 0.5821     
  }   
}

Example for built-in functional interfaces.

Streams API

A stream is a sequence of elements supporting sequential and parallel aggregate operations. It doesn't store data, it's a pipeline for processing data.

Operation Description
filter() Filters elements based on a condition (predicate)
map() Transforms each element
sorted() Sorts elements
forEach() Performs an action for each element
collect() Gathers the result into a collection or value
reduce() Aggregates elements into a single result
count() Counts the number of elements
Common stream operations

import java.util.*;
import java.util.stream.*;

public class StreamExample {   
  public static void main(String[] args) {     
    List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");     
    
    List<String> result = names.stream()       
      .filter(name -> name.startsWith("C")) // keep names that start with "C"       
      .map(String::toUpperCase) // convert to uppercase       
      .sorted() // sort       
      .collect(Collectors.toList()); // collect to new list       
      
    System.out.println(result); // [CHARLIE]     
  }   
}

This is an example of a basic stream flow.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> squares = numbers.stream()   
  .map(n -> n * n)   
  .collect(Collectors.toList());

Converts list of integers to squares.

long count = numbers.stream()   
  .filter(n -> n > 3)   
  .count();

Counts elements greater than a value.

int sum = numbers.stream()   
  .reduce(0, (a, b) -> a + b); // or .reduce(Integer::sum)

Returns sum of elements.

Map<Integer, List<String>> groupedByLength = names.stream()   
  .collect(Collectors.groupingBy(String::length));

Use collect() to group results into collections like List, Set, or Map.

List<String> result = names.parallelStream()   
  .map(String::toUpperCase)   
  .collect(Collectors.toList());

Parallel streams can utilize multiple cores but aren't always faster. Best for large, CPU-bound tasks.

Collection Stream
Stores data Doesn't store data
Can be modified Immutable (usually)
Eager evaluation (loops, etc.) Lazy evaluation
External iteration (for/while loop) Internal iteration (e.g., forEach())
Stream vs Collection

Optional class

Optional<T> is a container object introduced in Java 8 that may or may not hold a non-null value. It's Java's way of saying: "This value might be missing, but instead of null, I'll give you a safe wrapper to deal with it."

Optional<String> opt1 = Optional.of("Hello"); // value present
Optional<String> opt2 = Optional.empty(); // no value (empty)
Optional<String> opt3 = Optional.ofNullable(null); // may be null or not

You can create an Optional in a few different ways.
Optional.of() - throws NullPointerException if the value is null.
Optional.ofNullable() - safely creates an empty Optional if the value is null.
Optional.empty() - creates an empty Optional directly.

Optional<String> name = Optional.of("Java");

if (name.isPresent()) {   
  System.out.println("Value: " + name.get());   
}

This checks if a value is present. Calling .get() directly is discouraged unless you're sure it's present. See the safer alternatives below.

String result = Optional.ofNullable(null)   
  .orElse("Default");   
System.out.println(result); // Default

Provides a default value if the Optional is empty.

String result = Optional.ofNullable(null)   
  .orElseGet(() -> "Computed Default");

Like orElse(), but takes a Supplier (a lambda that runs only if needed).

String value = Optional.ofNullable(null)   
  .orElseThrow(() -> new IllegalArgumentException("Value missing!"));

Throws an exception if the value is missing.

Optional<String> name = Optional.of("java");
Optional<String> upper = name.map(String::toUpperCase);
System.out.println(upper.get()); // JAVA

Transforms the value if present.

Optional<String> name = Optional.of("Java");
Optional<String> upper = name.flatMap(n -> Optional.of(n.toUpperCase()));

flatMap() is used when the function itself returns an Optional.

Optional<String> name = Optional.of("Java");
name = name.filter(n -> n.startsWith("J"));
System.out.println(name.isPresent()); // true

filter() keeps the value only if it satisfies a condition.

// Without Optional:
if (person != null && person.getAddress() != null) {   
  return person.getAddress().getCity();   
} else {   
  return "Unknown";   
}

// With Optional:
String city = Optional.ofNullable(person)   
  .map(Person::getAddress)   
  .map(Address::getCity)   
  .orElse("Unknown");

This is an example of safe chaining without null checks. No NullPointerException, no messy if checks. It's clean and expressive.

Method references

A method reference is a shorthand way of writing a lambda expression that calls an existing method. It's basically a lambda that just delegates to another method.

// Without method reference:
list.forEach(name -> System.out.println(name));

// With method reference:
list.forEach(System.out::println);

Both do the exact same thing, but the second one is cleaner.

import java.util.*;

public class StaticMethodReference {   
  public static void main(String[] args) {     
    List<Integer> numbers = Arrays.asList(5, 2, 8, 1);     
    
    numbers.sort(Integer::compare); // instead of (a, b) -> Integer.compare(a, b)     
    System.out.println(numbers);     
  }   
}

Reference to a static method.
Basic syntax is:
ClassName::staticMethodName
In the above example Integer::compare replaces the lambda (a, b) -> Integer.compare(a, b).

public class InstanceMethodReference {   
  public static void main(String[] args) {     
    List<String> names = Arrays.asList("Java", "Python", "C++");     
    
    // Create an instance of a printer     
    Printer printer = new Printer();     
    
    names.forEach(printer::printName); // instead of name -> printer.printName(name)     
  }   
}

class Printer {   
  void printName(String name) {     
    System.out.println("Language: " + name);     
  }   
}

Reference to an instance method of a particular object.
Basic syntax is:
instance::instanceMethodName

List<String> names = Arrays.asList("java", "python", "c++");

// Instead of (s1, s2) -> s1.compareToIgnoreCase(s2)
names.sort(String::compareToIgnoreCase);

System.out.println(names);

Reference to an instance method of an arbitrary object of a particular type.
Basic syntax is:
ClassName::instanceMethodName
In this example String::compareToIgnoreCase acts on each pair of String objects.

import java.util.function.Supplier;

public class ConstructorReference {   
  public static void main(String[] args) {     
    Supplier<StringBuilder> builderSupplier = StringBuilder::new;     
    
    StringBuilder sb = builderSupplier.get();     
    sb.append("Hello, Java!");     
    System.out.println(sb);     
  }   
}

Reference to a constructor.
Basic syntax is:
ClassName::new
In the example above StringBuilder::new replaces a lambda like () -> new StringBuilder();

import java.util.*;

public class StreamMethodReference {   
  public static void main(String[] args) {     
    List<String> names = Arrays.asList("java", "python", "c++");     
    
    names.stream()       
      .map(String::toUpperCase)       
      .sorted(String::compareTo)       
      .forEach(System.out::println);       
  }   
}

// Output:
C++
JAVA
PYTHON

You can combile method references with Streams.

var (Java 10+)

// Without var:
String message = "Hello, Java!";

// With var:
var message = "Hello, Java!";

var (introduced in Java 10) is a type inference keyword that allows the compiler to automatically determine the variable's type from the context. In this example the compiler infers that message is of type String.
Java is still statically typed, the type of the variable doesn't change. After inference, the variable behaves exactly as if you had declared it explicitly.

var name; // Error! Type cannot be inferred without initialization
var name = "Java"; // OK

Variables with var must be initialized immediately.

class Example {   
  var field = 10; // Not allowed   
}

var cannot be used for method parameters or fields, it's only for local variables (inside methods, constructors, or blocks).

var data = null; // Compiler can't infer type

var cannot be used with null directly.

var num = 42; // int
var pi = 3.14; // double
var isReady = true; // boolean

Inference with primitives.

var text = "Hello"; // String
var list = new ArrayList<String>(); // ArrayList<String>

Inference with objects.

var map = new HashMap<String, Integer>();
map.put("apple", 3);
map.put("banana", 5);

Inference with generics.

var stream = Files.lines(Path.of("file.txt"));
var user = new User("Alice");

Use var to reduce redundancy when the type is obvious.

for (var entry : map.entrySet()) {   
  System.out.println(entry.getKey() + " -> " + entry.getValue());   
}

Use var to make code cleaner inside loops and streams.

var data = processInput(); // What type is data?

Don't use var when the inferred type isn't obvious, as it reduces readability in complex code.

Concurrency & Multithreading

In Java multithreading is the most common way to achieve concurrency.

A thread is a lightweight unit of execution within a program. A Java process can have multiple threads. Each thread runs independently but shares memory with other threads.

Example, a single program with two threads:
Thread 1 - handles user input
Thread 2 - performs background work

Threads share the same heap (so they can access shared objects) but have separate call stacks.

class MyThread extends Thread {   
  public void run() {     
    System.out.println("Thread running: " + Thread.currentThread().getName());     
  }   
}

public class Example {   
  public static void main(String[] args) {     
    MyThread t1 = new MyThread();     
    t1.start(); // start() runs in a new thread     
  }   
}

Extending the Thread class is one way to create threads. Don't call run() directly, that runs in the same thread.

class MyRunnable implements Runnable {   
  public void run() {     
    System.out.println("Running in thread: " + Thread.currentThread().getName());     
  }   
}

public class Example {   
  public static void main(String[] args) {     
    Thread t1 = new Thread(new MyRunnable());     
    t1.start();     
  }   
}

Implementing the Runnable interface is another way of creating threads. This approach is preferred because you can extend another class.

public class LambdaThreadExample {   
  public static void main(String[] args) {     
    Thread t1 = new Thread(() -> System.out.println("Running in thread: " + Thread.currentThread().getName()));     
    t1.start();     
  }   
}

Using a lambda expression (Java 8+) is another way of creating threads. This is how most Java code looks today, cleaner and modern.

A thread can be in several states:
NEW - Thread created, not started yet.
RUNNABLE - Ready to run, waiting for CPU.
RUNNING - Currently executing.
BLOCKED / WAITING - Waiting for a resource or another thread.
TERMINATED - Execution completed.

class StartExample extends Thread {   
  public void run() {     
    System.out.println("Running in: " + Thread.currentThread().getName());     
  }   
  
  public static void main(String[] args) {     
    StartExample t1 = new StartExample();     
    t1.start(); // starts a new thread     
    System.out.println("Main thread: " + Thread.currentThread().getName());     
  }   
}

start() creates a new thread and executes run() in it. If you call run() directly, it runs in the current thread (not what you usually want).

class SleepExample {   
  public static void main(String[] args) {     
    Thread t1 = new Thread(() -> {       
      System.out.println("Thread started...");       
      try {         
        Thread.sleep(2000); // sleep for 2 seconds         
        System.out.println("Woke up after sleeping!");         
      } catch (InterruptedException e) {         
        System.out.println("Thread was interrupted!");         
      }       
    });     
    
    t1.start();     
  }   
}

Thread.sleep(ms) makes the current thread pause temporarily. It doesn't release any locks it might be holding. It throws InterruptedException if another thread interrupts it.

class JoinExample {   
  public static void main(String[] args) throws InterruptedException {     
    Thread worker = new Thread(() -> {       
      System.out.println("Worker thread running...");       
      try {         
        Thread.sleep(1000);         
      } catch (InterruptedException ignored) {}         
        System.out.println("Worker thread done!");         
    });     
    
    worker.start();     
    
    System.out.println("Main thread waiting for worker...");     
    worker.join(); // Wait until 'worker' finishes     
    System.out.println("Worker finished. Main thread resumes!");     
  }   
}

join() causes the current thread to wait until the target thread completes. It's commonly used to ensure background tasks finish before the program exits.

class InterruptExample {   
  public static void main(String[] args) throws InterruptedException {     
    Thread t1 = new Thread(() -> {       
      try {         
        while (true) {           
          System.out.println("Working...");           
          Thread.sleep(500);           
        }         
      } catch (InterruptedException e) {         
        System.out.println("Thread interrupted!");         
      }       
    });     
    
    t1.start();     
    Thread.sleep(2000); // Let it run a bit     
    t1.interrupt(); // signal the thread to stop     
  }   
}

interrupt() sets a flag on the thread. If the thread is sleeping or waiting, it will throw an InterruptedException. Best practice: threads should check for Thread.interrupted() periodically if doing long-running work.

class IsAliveExample {   
  public static void main(String[] args) throws InterruptedException {     
    Thread t1 = new Thread(() -> {       
      try {         
        Thread.sleep(1000);         
      } catch (InterruptedException ignored) {}       
      System.out.println("Thread work done.");       
    });     
    
    System.out.println("Before start: " + t1.isAlive()); // false     
    t1.start();     
    System.out.println("After start: " + t1.isAlive()); // true     
    t1.join(); // Wait for it to finish     
    System.out.println("After finish: " + t1.isAlive()); // false     
  }   
}

Returns true if the thread is running or waiting to run. Returns false if it has not started yet or has finished execution.

class Counter {   
  private int count = 0;   
  
  public synchronized void increment() {     
    count++;     
  }   
  
  public int getCount() {     
    return count;     
  }   
}

Since threads share memory, multiple threads may try to access or modify the same data simultaneously = race conditions. To prevent this, Java provides synchronization mechanisms, such as the synchronized keyword and the volatile keyword. In the example above only one thread at a time can call increment().

class VolatileExample {   
  private static volatile boolean running = true;   
  
  public static void main(String[] args) throws InterruptedException {     
    Thread worker = new Thread(() -> {       
      while (running) {         
        // With volatile 'running' is always read from main memory         
      }       
      System.out.println("Worker stopped.");       
    });     
    
    worker.start();     
    
    Thread.sleep(1000);     
    System.out.println("Stopping worker...");     
    running = false; // Main thread updates it     
  }   
}

If multiple threads read/write the same variable, the volatile keyword ensures visibility across threads (i.e., changes are seen immediately). Without volatile, one thread might see an outdated value cached in memory.
Use volatile when: you have a simple flag shared between threads (like running, ready, done), you don't need to perform compound operations (++, +=, etc.) on it, you just need visibility, not mutual exclusion.

import java.util.concurrent.*;

public class ExecutorServiceExample {   
  public static void main(String[] args) {     
    ExecutorService executor = Executors.newFixedThreadPool(3); // 3 threads in the pool     
    
    for (int i = 1; i <= 5; i++) {       
      int taskId = i;       
      executor.submit(() -> {         
        System.out.println("Running task " + taskId + " on " + Thread.currentThread().getName());         
      });       
    }     
    
    executor.shutdown(); // Stop accepting new tasks     
  }   
}

Instead of manually creating threads, you use an ExecutorService to manage a thread pool, it handles creation, reuse, and shutdown of threads. This creates a pool of reusable threads (newFixedThreadPool(3)). You submit tasks using submit() or execute(). shutdown() ensures no new tasks are accepted and existing ones finish.

import java.util.concurrent.*;

public class CallableFutureExample {   
  public static void main(String[] args) throws Exception {     
    ExecutorService executor = Executors.newSingleThreadExecutor();     
    
    Callable<Integer> task = () -> {       
      System.out.println("Calculating...");       
      Thread.sleep(1000);       
      return 42;       
    };     
    
    Future<Integer> result = executor.submit(task); // Submit callable     
    
    System.out.println("Result: " + result.get()); // Blocks until done     
    executor.shutdown();     
  }   
}

A Callable is like a Runnable, but it returns a value and can throw exceptions. A Future represents the result of that task. In this example Callable returns a value (here, an Integer). Future.get() blocks until the task completes. You can also check future.isDone() or cancel it with future.cancel(true).

import java.util.concurrent.*;

public class CountDownLatchExample {   
  public static void main(String[] args) throws InterruptedException {     
    CountDownLatch latch = new CountDownLatch(3);     
    
    for (int i = 1; i <= 3; i++) {       
      new Thread(() -> {         
        System.out.println(Thread.currentThread().getName() + " finished task");         
        latch.countDown(); // Decrement the latch         
      }).start();       
    }     
    
    latch.await(); // Wait until count reaches zero     
    System.out.println("All tasks completed!");     
  }   
}

CountDownLatch is a synchronization aid that lets one or more threads wait until a set of other threads complete operations. This example starts with CountDownLatch(3), main thread waits for 3 tasks. Each worker calls countDown(). When the count reaches 0, await() unblocks.

import java.util.concurrent.*;

public class SemaphoreExample {   
  public static void main(String[] args) {     
    Semaphore semaphore = new Semaphore(2); // Allow 2 permits     
    
    for (int i = 1; i <= 5; i++) {       
      int id = i;       
      new Thread(() -> {         
        try {           
          semaphore.acquire(); // Get a permit           
          System.out.println("Thread " + id + " acquired a permit.");           
          Thread.sleep(1000);           
        } catch (InterruptedException ignored) {           
        } finally {           
          System.out.println("Thread " + id + " releasing permit.");           
          semaphore.release(); // Release permit           
        }         
      }).start();       
    }     
  }   
}

Semaphore controls access to a resource, allows up to a set number of threads to access something simultaneously. In this example only 2 threads can hold a permit at once. Others wait until a permit is released. Great for resource limits like database connections.

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {   
  private static final ReentrantLock lock = new ReentrantLock();   
  
  public static void main(String[] args) {     
    Runnable task = () -> {       
      try {         
        if (lock.tryLock()) { // Try to acquire lock           
          System.out.println(Thread.currentThread().getName() + " got the lock.");           
          Thread.sleep(1000);           
        } else {           
          System.out.println(Thread.currentThread().getName() + " couldn't get the lock.");           
        }         
      } catch (InterruptedException ignored) {         
      } finally {         
        if (lock.isHeldByCurrentThread()) {           
          lock.unlock(); // Always unlock!           
          System.out.println(Thread.currentThread().getName() + " released the lock.");           
        }         
      }       
    };     
    
    new Thread(task, "Thread-A").start();     
    new Thread(task, "Thread-B").start();     
  }   
}

ReentrantLock is a more flexible alternative to synchronized, it allows explicit lock/unlock control and supports fairness and try-locking. In this example tryLock() tries to get the lock without waiting. You can check if the current thread owns the lock with isHeldByCurrentThread(). Always unlock in a finally block to avoid deadlocks.

public class WaitNotifySimple {   
  public static void main(String[] args) {     
    Object lock = new Object();     
    
    Thread waiter = new Thread(() -> {       
      synchronized (lock) {         
        try {           
          System.out.println("Thread 1: Waiting...");           
          lock.wait(); // releases the lock and waits           
          System.out.println("Thread 1: Woken up!");           
        } catch (InterruptedException e) {           
          e.printStackTrace();           
        }         
      }       
    });     
    
    Thread notifier = new Thread(() -> {       
      try {         
        Thread.sleep(1000); // wait a bit before notifying         
      } catch (InterruptedException ignored) {}       
      synchronized (lock) {         
        System.out.println("Thread 2: Notifying...");         
        lock.notify(); // wakes up one waiting thread         
      }       
    });     
    
    waiter.start();     
    notifier.start();     
  }   
}

// Output:
Thread 1: Waiting...
Thread 2: Notifying...
Thread 1: Woken up!

wait() makes a thread pause and release the lock, waiting until it's notified. notify() wakes up one waiting thread.
In this example:
Thread 1 starts and calls wait() - releases the lock and pauses.
Thread 2 later calls notify() - wakes Thread 1.
Thread 1 reacquires the lock and continues running.

public class NotifyAllSimple {   
  public static void main(String[] args) {     
    Object lock = new Object();     
    
    Runnable waiter = () -> {       
      synchronized (lock) {         
        try {           
          System.out.println(Thread.currentThread().getName() + " waiting...");           
          lock.wait();           
          System.out.println(Thread.currentThread().getName() + " woken up!");           
        } catch (InterruptedException e) {           
          e.printStackTrace();           
        }         
      }       
    };     
    
    new Thread(waiter, "Thread A").start();     
    new Thread(waiter, "Thread B").start();     
    
    new Thread(() -> {       
      try { Thread.sleep(1000); } catch (InterruptedException ignored) {}       
      synchronized (lock) {         
        System.out.println("Notifier: Waking all threads!");         
        lock.notifyAll(); // wakes both A and B         
      }       
    }).start();     
  }   
}

// Output:
Thread A waiting...
Thread B waiting...
Notifier: Waking all threads!
Thread A woken up!
Thread B woken up!

Same thing as the example above with wait() and notify(), but with multiple waiting threads. notifyAll() wakes up all waiting threads.

public class FileDownloaderExample {   
  public static void main(String[] args) {     
    // List of files to download     
    String[] files = {"file1.zip", "file2.zip", "file3.zip"};     
    
    for (String file : files) {       
      Thread downloadThread = new Thread(() -> {         
        System.out.println("Downloading " + file + "...");         
        try {           
          Thread.sleep(2000); // simulate time to download           
        } catch (InterruptedException e) {           
          e.printStackTrace();           
        }         
        System.out.println(file + " downloaded!");         
      });       
      downloadThread.start();       
    }     
    
    System.out.println("All downloads started...");     
  }   
}

In this example we try download multiple files at the same time. We simulate the downloads with Thread.sleep() instead of real networking, but it shows how concurrency speeds things up. Each file download runs in its own thread, so all downloads happen at the same time. If you ran them sequentially, it would take around 6 seconds (3 x 2). But with threads, it only takes about 2 seconds total because they run concurrently.

Parallelism

You can achieve parallelism when multiple threads run on multiple cores.

import java.util.concurrent.*;

public class ParallelExecutorExample {   
  public static void main(String[] args) throws InterruptedException {     
    ExecutorService executor = Executors.newFixedThreadPool(3); // 3 threads     
    
    Runnable task = () -> {       
      String name = Thread.currentThread().getName();       
      System.out.println("Running on: " + name);       
      try {         
        Thread.sleep(1000);         
      } catch (InterruptedException ignored) {}       
    };     
    
    for (int i = 0; i < 5; i++) {       
      executor.submit(task);       
    }     
    
    executor.shutdown();     
    executor.awaitTermination(5, TimeUnit.SECONDS);     
    System.out.println("All tasks finished.");     
  }   
}

This example shows how to use ExecutorService with a thread pool. A fixed-size pool uses multiple threads that can run truly in parallel (if your CPU has multiple cores). If your CPU has 4 cores, these threads will likely run truly in parallel.

import java.util.*;
import java.util.stream.*;

public class ParallelStreamExample {   
  public static void main(String[] args) {     
    List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);     
    
    numbers.parallelStream()     
      .forEach(n -> {         
        System.out.println("Processing " + n + " on " + Thread.currentThread().getName());         
        try { Thread.sleep(500); } catch (InterruptedException ignored) {}         
      });       
      
    System.out.println("Done!");     
  }   
}

Parallel streams (functional style) are the simplest way to achieve parallelism, Java splits work across multiple threads automatically. The parallelStream() call automatically uses a ForkJoinPool behind the scenes, distributing work across cores.

import java.util.concurrent.*;

class SumTask extends RecursiveTask<Long> {   
  private final long[] numbers;   
  private final int start, end;   
  private static final int THRESHOLD = 3;   
  
  SumTask(long[] numbers, int start, int end) {     
    this.numbers = numbers;     
    this.start = start;     
    this.end = end;     
  }   
  
  @Override   
  protected Long compute() {     
    if (end - start <= THRESHOLD) {       
      long sum = 0;       
      for (int i = start; i < end; i++) sum += numbers[i];       
      return sum;       
    }     
    
    int mid = (start + end) / 2;     
    SumTask left = new SumTask(numbers, start, mid);     
    SumTask right = new SumTask(numbers, mid, end);     
    
    left.fork(); // run in parallel     
    return right.compute() + left.join();     
  }   
}

public class ForkJoinExample {   
  public static void main(String[] args) {     
    long[] data = {1, 2, 3, 4, 5, 6, 7, 8};     
    ForkJoinPool pool = new ForkJoinPool();     
    long sum = pool.invoke(new SumTask(data, 0, data.length));     
    System.out.println("Sum: " + sum);     
  }   
}

For more advanced parallel computation, especially recursive tasks (like splitting a big job into smaller ones) the Fork/Join Framework can be used. This is what the parallelStream() API is built upon.

public class CoresExample {   
  public static void main(String[] args) {     
    int cores = Runtime.getRuntime().availableProcessors();     
    System.out.println("Available CPU cores: " + cores);     
  }   
}

You can determine the number of available CPU cores using Runtime.getRuntime().availableProcessors();.

// Sequential version:
import java.util.*;
import java.util.stream.*;

public class SequentialProcessingExample {   
  public static void main(String[] args) {     
    List<String> images = Arrays.asList("img1.jpg", "img2.jpg", "img3.jpg", "img4.jpg", "img5.jpg");     
    
    long start = System.currentTimeMillis();     
    
    images.stream()     
      .map(SequentialProcessingExample::processImage)       
      .forEach(System.out::println);       
      
    long end = System.currentTimeMillis();     
    System.out.println("Sequential time: " + (end - start) + " ms");     
  }   
  
  private static String processImage(String fileName) {     
    try {       
      Thread.sleep(1000); // simulate expensive processing       
    } catch (InterruptedException ignored) {}     
    return "Processed " + fileName + " by " + Thread.currentThread().getName();     
  }   
}

// Parallel version:
import java.util.*;
import java.util.stream.*;

public class ParallelProcessingExample {   
  public static void main(String[] args) {     
    List<String> images = Arrays.asList("img1.jpg", "img2.jpg", "img3.jpg", "img4.jpg", "img5.jpg");     
    
    long start = System.currentTimeMillis();     
    
    images.parallelStream() // Parallel magic     
      .map(ParallelProcessingExample::processImage)       
      .forEach(System.out::println);       
      
    long end = System.currentTimeMillis();     
    System.out.println("Parallel time: " + (end - start) + " ms");     
  }   
  
  private static String processImage(String fileName) {     
    try {       
      Thread.sleep(1000);       
    } catch (InterruptedException ignored) {}     
    return "Processed " + fileName + " by " + Thread.currentThread().getName();     
  }   
}

This example shows the difference betweeen sequential and parallel execution by simulating image processing. The first example takes around 5 seconds because it processes the images one by one. The seconds example takes around 1-2 seconds on a 4 core machine, because multimple images are being processed at the same time.

Generics

Generics let you create classes, interfaces, and methods that can work with any type, while still maintaining type safety. They were introduced in Java 5 to eliminate the need for casting and to catch type errors at compile time.

// Without generics:
import java.util.*;

public class NoGenericsExample {   
  public static void main(String[] args) {     
    List list = new ArrayList(); // raw type     
    list.add("Java");     
    list.add(42);     
    
    // Needs casting and may throw ClassCastException     
    String text = (String) list.get(1); // Runtime error     
  }   
}

// With generics:
import java.util.*;

public class GenericsExample {   
  public static void main(String[] args) {     
    List<String> names = new ArrayList<>();     
    names.add("Java");     
    names.add("Python");     
    
    String name = names.get(0); // No casting needed     
    System.out.println(name);     
  }   
}

The first example, without generics, compiles but crashes at runtime because "Java" and 42 are mixed types. In the second example, with generics, the compiler enforces that only Strings can be added to the list. If you try names.add(42); it won't even compile.

class Box<T> { // T is a type parameter   
  private T value;   
  
  public void set(T value) { this.value = value; }   
  public T get() { return value; }   
}

public class GenericClassExample {   
  public static void main(String[] args) {     
    Box<String> stringBox = new Box<>();     
    stringBox.set("Hello");     
    System.out.println(stringBox.get());     
    
    Box<Integer> intBox = new Box<>();     
    intBox.set(100);     
    System.out.println(intBox.get());     
  }   
}

You can define your own generic class like this. In this example T can be any type, String, Integer, custom class, etc.

public class GenericMethodExample {   
  public static <T> void printTwice(T value) {     
    System.out.println(value);     
    System.out.println(value);     
  }   
  
  public static void main(String[] args) {     
    printTwice("Hi!");     
    printTwice(123);     
    printTwice(3.14);     
  }   
}

You can also make methods generic, even inside non-generic classes. In this example the method automatically infers the type of T.

class Calculator<T extends Number> {   
  public double add(T a, T b) {     
    return a.doubleValue() + b.doubleValue();     
  }   
}

// Invalid:
Calculator<String> c2 = new Calculator<>(); // Error: String not a Number

// Valid:
Calculator<Integer> c1 = new Calculator<>();
System.out.println(c1.add(3, 5));

You can restrict generic types using bounds.

import java.util.*;

public class WildcardExample {   
  static void printList(List<?> list) {     
    for (Object o : list)     
      System.out.println(o);       
  }   
  
  public static void main(String[] args) {     
    List<String> names = Arrays.asList("Java", "Python");     
    List<Integer> numbers = Arrays.asList(1, 2, 3);     
    printList(names);     
    printList(numbers);     
  }   
}

Wildcards are used when you want flexibility in what type a method accepts.
? - Any type
? extends T - Any subtype of T
? super T - Any supertype of T

public class ApiExample {   
  public static void main(String[] args) {     
    Response<User> userResponse = new Response<>(true, "User fetched", new User("Alice"));     
    Response<Product> productResponse = new Response<>(true, "Product fetched", new Product("Laptop"));     
    
    System.out.println(userResponse.getMessage() + ": " + userResponse.getData());     
    System.out.println(productResponse.getMessage() + ": " + productResponse.getData());     
  }   
}

class Response<T> {   
  private boolean success;   
  private String message;   
  private T data;   
  
  public Response(boolean success, String message, T data) {     
    this.success = success;     
    this.message = message;     
    this.data = data;     
  }   
  
  public boolean isSuccess() { return success; }   
  public String getMessage() { return message; }   
  public T getData() { return data; }   
}

class User {   
  String name;   
  public User(String name) { this.name = name; }   
  public String toString() { return name; }   
}

class Product {   
  String title;   
  public Product(String title) { this.title = title; }   
  public String toString() { return title; }   
}

This example shows a backend that sends data to clients. Different API endpoints return different types; User, Product, Order, etc. Instead of writing a different Response class for each one, you can use a generic type parameter.
In this example Response<T> adapts to any data type; User, Product, etc. so you get compile-time type safety, no casting, no runtime type errors.

import java.util.*;

public class RepositoryExample {   
  public static void main(String[] args) {     
    Repository<User> userRepo = new Repository<>();     
    userRepo.save(new User("Alice"));     
    userRepo.save(new User("Bob"));     
    
    Repository<Product> productRepo = new Repository<>();     
    productRepo.save(new Product("Laptop"));     
    productRepo.save(new Product("Phone"));     
    
    System.out.println("Users: " + userRepo.findAll());     
    System.out.println("Products: " + productRepo.findAll());     
  }   
}

class Repository<T> {   
  private List<T> items = new ArrayList<>();   
  
  public void save(T item) {     
    items.add(item);     
    System.out.println(item + " saved!");     
  }   
  
  public List<T> findAll() {     
    return items;     
  }   
}

class User {   
  String name;   
  public User(String name) { this.name = name; }   
  public String toString() { return "User(" + name + ")"; }   
}

class Product {   
  String title;   
  public Product(String title) { this.title = title; }   
  public String toString() { return "Product(" + title + ")"; }   
}

Let's say you want to manage data for multiple entities (User, Product, etc.) with one reusable repository.
In the example above you can reuse Repository<T> for any data type, and you get compile-time safety, a Repository<User> can't accidentally store a Product.

Annotations

@Override
public String toString() {   
  return "Hello!";   
}

An annotation is a kind of metadata, information about your code that doesn't change what the code does directly, but can be used by the compiler, tools, or frameworks at compile-time or runtime.
They start with an @ symbol and they are used to:
Give information to the compiler (@Override, @Deprecated)
Provide configuration for frameworks (like Spring's @RestController)
Help runtime processing (reflection)
Reduce boilerplate code (e.g., Lombok's @Getter, @Builder)

In this example @Override tells the compiler "This method overrides a superclass method." If it doesn't then the compiler gives an error.

Common built-in annotations:
@Override - Ensures the method overrides a superclass method
@Deprecated - Marks a method or class as outdated
@SuppressWarnings - Tells the compiler to ignore certain warnings
@FunctionalInterface - Ensures an interface has exactly one abstract method
@SafeVarargs - Suppresses warnings for generic varargs methods

// Creating custom annotations:
import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RunNow {   
  int times() default 1; // optional element with default value   
}

// Using custom annotations:
public class MyService {   
  @RunNow(times = 3)   
  public void doSomething() {     
    System.out.println("Running something...");     
  }   
}

// Processing custom annotations (via Reflection):
import java.lang.reflect.*;

public class AnnotationProcessor {   
  public static void main(String[] args) throws Exception {     
    MyService service = new MyService();     
    
    for (Method method : service.getClass().getDeclaredMethods()) {       
      if (method.isAnnotationPresent(RunNow.class)) {         
        RunNow annotation = method.getAnnotation(RunNow.class);         
        for (int i = 0; i < annotation.times(); i++) {           
          method.invoke(service); // call annotated method           
        }         
      }       
    }     
  }   
}

// Output:
Running something...
Running something...
Running something...

You can define your own annotations using @interface.
@Retention - how long the annotation is kept (SOURCE, CLASS, or RUNTIME)
@Target - where it can be used (METHOD, FIELD, TYPE, etc.)
Elements inside define configurable parameters (like times)
In this example the annotation controls behavior at runtime, this is the foundation of what frameworks like Spring and JUnit do behind the scenes.

Policy Description Example
SOURCE Discarded during compilation Used by tools like Lombok
CLASS Present in class file, not at runtime Default
RUNTIME Available via reflection at runtime Used in Spring, JUnit, etc.
Retention policy options
Target Description
TYPE Class, interface, enum
METHOD Method
FIELD Field or property
PARAMETER Method parameter
CONSTRUCTOR Constructor
LOCAL_VARIABLE Local variable
ANNOTATION_TYPE Another annotation
Target options

// Define the custom annotation
import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME) // keep at runtime so reflection can see it
@Target(ElementType.METHOD) // can only be used on methods
public @interface Test {   
}

// Create a class with some "tests"
public class MyTests {   
  
  @Test   
  public void testAddition() {     
    int result = 2 + 3;     
    if (result == 5) {       
      System.out.println("testAddition PASSED");       
    } else {       
      System.out.println("testAddition FAILED");       
    }     
  }   
  
  @Test   
  public void testDivision() {     
    int result = 10 / 2;     
    if (result == 5) {       
      System.out.println("testDivision PASSED");       
    } else {       
      System.out.println("testDivision FAILED");       
    }     
  }   
  
  // Not annotated, should not be executed automatically   
  public void helperMethod() {     
    System.out.println("Helper method (not a test)");     
  }   
}

// Create the test runner (the framework)
import java.lang.reflect.Method;

public class TestRunner {   
  public static void main(String[] args) throws Exception {     
    MyTests testClass = new MyTests();     
    
    // Loop through all declared methods     
    for (Method method : MyTests.class.getDeclaredMethods()) {       
      // Check if method has the @Test annotation       
      if (method.isAnnotationPresent(Test.class)) {         
        System.out.println("Running: " + method.getName());         
        method.invoke(testClass); // run the test         
      }       
    }     
  }   
}

// Output:
Running: testAddition
testAddition PASSED
Running: testDivision
testDivision PASSED

In this example we build a mini JUnit-style framework using annotations.
First we make our own @Test annotation that marks methods to be automatically executed.
Then we mark some methods with @Test and some without, just like in real tests.
Then we scan the class, find all methods annotated with @Test, and run them automatically. This is where reflection comes in.

Reflection

Reflection in Java is a mechanism that allows a program to inspect and manipulate classes, methods, fields, and constructors at runtime, even if you don't know their names at compile time.

Useful to:
Discover class names, fields, and methods dynamically.
Access private fields or methods (carefully).
Instantiate objects and invoke methods at runtime.
Build frameworks like Spring, JUnit, Hibernate, etc.

import java.lang.reflect.*;

public class ReflectionExample {   
  public static void main(String[] args) {     
    try {       
      // Load the class dynamically by name       
      Class<?> clazz = Class.forName("java.util.ArrayList");       
      
      System.out.println("Class Name: " + clazz.getName());       
      System.out.println("Methods:");       
      
      // List all declared methods       
      for (Method method : clazz.getDeclaredMethods()) {         
        System.out.println(" " + method.getName());         
      }       
      
    } catch (ClassNotFoundException e) {       
      e.printStackTrace();       
    }     
  }   
}

In this example we are inspecting a class at runtime.
Class.forName("java.util.ArrayList") loads the class at runtime.
clazz.getDeclaredMethods() returns all methods (including private ones).

You can use similar calls for:
clazz.getDeclaredFields()
clazz.getDeclaredConstructors()

import java.lang.reflect.*;

class Person {   
  private String name;   
  
  public Person(String name) {     
    this.name = name;     
  }   
  
  private void sayHello() {     
    System.out.println("Hello, my name is " + name);     
  }   
}

public class ReflectAccess {   
  public static void main(String[] args) throws Exception {     
    Class<?> clazz = Person.class;     
    
    // Create a new instance     
    Constructor<?> constructor = clazz.getConstructor(String.class);     
    Object person = constructor.newInstance("Alice");     
    
    // Access private field     
    Field field = clazz.getDeclaredField("name");     
    field.setAccessible(true);     
    field.set(person, "Bob"); // Change name to "Bob"     
    
    // Invoke private method     
    Method method = clazz.getDeclaredMethod("sayHello");     
    method.setAccessible(true);     
    method.invoke(person); // Prints: Hello, my name is Bob     
  }   
}

This example demonstrates creating and modifying objects at runtime.
Reflection can bypass access control using setAccessible(true).
You can create objects, modify private fields, and invoke private methods, things that are normally impossible directly.

Class / Interface Method Purpose
Class getDeclaredMethods() Returns all methods declared in the class
Class getDeclaredFields() Returns all fields
Class getConstructors() Returns all public constructors
Class newInstance() Creates a new instance (deprecated in favor of Constructor.newInstance())
Field get() / set() Gets or sets a field’s value
Method invoke() Calls a method dynamically
AccessibleObject setAccessible(true) Allows access to private members
Common reflection methods

Enums

A Java enum (short for enumeration) is a special type that represents a fixed set of constant values. They are actually classes with fields, methods, constructors (private). This makes them incredibly powerful.

Examples of things that naturally fit enums:
Days of the week
Directions (NORTH, SOUTH, EAST, WEST)
Status codes (SUCCESS, ERROR, PENDING)
User roles (ADMIN, USER, GUEST)
Enums make the code more type-safe, readable, and organized.

They are not the good for:
Dynamic values (e.g., user input)
Anything that changes at runtime

enum Day {   
  MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY   
}

Day today = Day.MONDAY;

if (today == Day.MONDAY) {   
  System.out.println("Start of the week!");   
}

Basic enum example.

enum Status {   
  SUCCESS(200),   
  NOT_FOUND(404),   
  SERVER_ERROR(500);   
  
  private final int code;   
  
  Status(int code) { // constructor     
    this.code = code;     
  }   
  
  public int getCode() {     
    return code;     
  }   
}

Status s = Status.SUCCESS;
System.out.println(s.getCode()); // 200

Enum with fields and constructor.

enum Operation {   
  ADD {     
    public double apply(double a, double b) {       
      return a + b;       
    }     
  },   
  MULTIPLY {     
    public double apply(double a, double b) {       
      return a * b;       
    }     
  };   
  
  public abstract double apply(double a, double b);   
}

System.out.println(Operation.ADD.apply(3, 4)); // 7.0
System.out.println(Operation.MULTIPLY.apply(3, 4)); // 12.0

Enum with methods.

for (Day day : Day.values()) {   
  System.out.println(day);   
}

Returns an array of all enum constants.

Day d = Day.valueOf("MONDAY");

Converts a string to an enum constant.

String s = Day.FRIDAY.name(); // "FRIDAY"

Returns the constant's name as a string.

int index = Day.MONDAY.ordinal(); // 0

Returns the index (starting at 0).

switch (today) {   
  case MONDAY -> System.out.println("Back to work!");   
  case FRIDAY -> System.out.println("Almost weekend!");   
  default -> System.out.println("Just another day.");   
}

Enums in switch statements.

interface Printable {   
  void print();   
}

enum Color implements Printable {   
  RED, GREEN, BLUE;   
  
  public void print() {     
    System.out.println("Color: " + this.name());     
  }   
}

Enums cannot extend classes, but they can implement interfaces.

Inner/nested classes

In Java, you can define a class inside another class. These are called nested classes.

class Outer {   
  static class Nested {     
    void print() {       
      System.out.println("Inside static nested class");       
    }     
  }   
}

public class Main {   
  public static void main(String[] args) {     
    Outer.Nested nested = new Outer.Nested(); // no Outer object needed     
    nested.print();     
  }   
}

Static nested classes:
Declared with the static keyword
Do not need an instance of the outer class
Behave like normal classes that just happen to live inside another class
Cannot access instance members of the outer class directly

class Outer {   
  private String message = "Hello from Outer";   
  
  class Inner {     
    void print() {       
      System.out.println(message); // can access outer fields       
    }     
  }   
}

public class Main {   
  public static void main(String[] args) {     
    Outer outer = new Outer();     
    Outer.Inner inner = outer.new Inner(); // note the syntax     
    inner.print();     
  }   
}

Inner classes (non-static / member inner class):
Can access all outer class members, including private
Must be created from an outer object
Used when the inner class naturally belongs to a specific outer class instance

class Outer {   
  void doSomething() {     
    class LocalInner {       
      void print() {         
        System.out.println("I'm inside a method!");         
      }       
    }     
    
    LocalInner inner = new LocalInner();     
    inner.print();     
  }   
}

Local inner classes (inside methods):
Scoped to the method only
Often used for simple helper logic
Can access final or effectively-final variables

class Outer {   
  
  void doSomething() {     
    
    class LocalInner {       
      void print() {         
        System.out.println("I'm inside a method!");         
      }       
    }     
    
    LocalInner inner = new LocalInner();     
    inner.print();     
  }   
}

Local inner classes (inside methods):
Scoped to the method only
Often used for simple helper logic
Can access final or effectively-final variables

// Implementing an interface:
Runnable r = new Runnable() {   
  @Override   
  public void run() {     
    System.out.println("Running in anonymous inner class!");     
  }   
};

new Thread(r).start();

// Extending a class anonymously:
Button button = new Button();

button.onClick(new OnClickListener() {   
  @Override   
  public void handle() {     
    System.out.println("Button clicked!");     
  }   
});

Anonymous inner classes.
These are inner classes with no name, often used for:
Runnable tasks
Event handlers
Implementing interfaces quickly
Overriding methods on the fly
Modern Java often uses lambda expressions instead.

Packages and imports

A package is a way to group related classes, interfaces, and sub-packages together. Think of it like folders on a computer that help keep files organized.
They help:
Avoid name conflicts (you can have two classes named Student in different packages)
Organize large projects (grouping by function: model, service, controller, etc.)
Control access (public, protected, default access behave differently across packages)
Reusability (makes libraries clean and easy to import)

Some common examples for built-in packages:
java.util - Collections, Scanner, Random, etc.
java.io - File I/O
java.nio.file - odern file I/O
java.net - Networking
java.time - Date/time API

package com.example.project.utils;

public class MathHelper {   
  public static int add(int a, int b) {     
    return a + b;     
  }   
}

This is an example of a user-defined package. The file must be located in the folder structure:
com/example/project/utils/MathHelper.java

// Example without import:
java.util.ArrayList list = new java.util.ArrayList();

// With import:
import java.util.ArrayList;
ArrayList list = new ArrayList();

The import keyword allows you to use classes from a package without writing the full package name every time.

import java.util.List;

This is a single-type import. It imports one specific class.

import java.util.*;

This is a wildcard import. It imports all classes in the package (but NOT sub-packages). It does not import files like java.util.concurrent.*.

import static java.lang.Math.*;

double x = sqrt(16); // instead of Math.sqrt()

Static import (special case) allows you to access static methods/fields without class name.

// animals/Dog.java:
package animals;

public class Dog {   
  public void bark() {     
    System.out.println("Woof!");     
  }   
}

// main/Main.java:
package main;

import animals.Dog;

public class Main {   
  public static void main(String[] args) {     
    Dog dog = new Dog();     
    dog.bark();     
  }   
}

If a class is in one package and wants to use a class from another package, you must import it.

Java modules (JPMS)

JPMS (Java Platform Module System) allows you to group related classes into named, strongly-encapsulated units called modules.
Java modules provide:
Strong encapsulation (you expose only what you want (no more "everything in the package is visible"))
Reliable configuration (a module explicitly lists which other modules it depends on)
Smaller, faster applications (unused modules can be removed (jlink, jmod))

JPMS replaces the old "class-path chaos" with a more controlled module-path, similar to how modern languages organize code.

module my.module {   
  requires other.module;   
  exports com.example.api;   
}

Every module has an optional but very important descriptor.
The module-info.java file:
Names the module
Lists module dependencies
Controls what packages the module exposes to others

exports com.example.api; // public API
// internal packages are hidden

exports keyword makes a package visible outside the module. Packages not exported are completely hidden, meaning strong encapsulation. Hidden packages behave as if they don't exist at all to other modules.

requires java.sql;
requires utils.module;

requires keyword specifies dependencies.

opens com.example.model;

opens keyword allows runtime reflection on a package (important for frameworks like Spring, Jackson). Without opens, reflection (e.g., Jackson reading JSON into objects) will fail.

requires transitive C;

requires transitive keyword basically means "If you use me, you automatically depend on my dependency too.". In this example module A depends on B, and B depends on C, so users of A automatically depend on C.

uses com.example.PaymentProcessor;

provides com.example.PaymentProcessor   
  with com.example.StripeProcessor;

uses / provides ... with are used for Service Loader (plugin architecture). This enables modular, pluggable applications.

// Directory structure:
project/
  â””── src/           
          â”œâ”€â”€ greetings.api/           
          â”‚    â”œâ”€â”€ module-info.java           
          â”‚    â””── com/example/api/Greeter.java           
          â”‚           
          â””── greetings.app/             
                  â”œâ”€â”€ module-info.java             
                  â”œâ”€â”€ com/example/app/Main.java             
                  â””── com/example/app/EnglishGreeter.java

// src/greetings.api/module-info.java:
module greetings.api {   
  exports com.example.api;   
}

// src/greetings.api/com/example/api/Greeter.java:
package com.example.api;

public interface Greeter {   
  String greet(String name);   
}

// src/greetings.app/module-info.java:
module greetings.app {   
  requires greetings.api;   
}

// src/greetings.app/com/example/app/EnglishGreeter.java:
package com.example.app;

import com.example.api.Greeter;

public class EnglishGreeter implements Greeter {   
  @Override   
  public String greet(String name) {     
    return "Hello, " + name + "!";     
  }   
}

// src/greetings.app/com/example/app/Main.java:
package com.example.app;

public class Main {   
  public static void main(String[] args) {     
    EnglishGreeter greeter = new EnglishGreeter();     
    System.out.println(greeter.greet("Java modules"));     
  }   
}

// Output:
Hello, Java Modules!

In this example module greetings.api contains:
an interface Greeter
a module descriptor exporting the API

Module greetings.app depends on the API module:
uses the Greeter interface
implements it
runs a Main class

Unit testing

// Class to test:
public class Calculator {   
  public int add(int a, int b) {     
    return a + b;     
  }   
}

// Unit test:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {   
  
  @Test   
  void testAdd() {     
    Calculator calc = new Calculator();     
    assertEquals(5, calc.add(2, 3));     
  }   
}

This is a basic JUnit 5 example. JUnit is the most commonly used framework.

Common JUnit 5 annotations:
@Test - Marks a method as a test.
@BeforeEach - Runs before every test method. Used for setup.
@AfterEach - Runs after every test. Used for cleanup.
@BeforeAll - Runs once before all tests. Must be static.
@AfterAll - Runs once after all tests. Must be static.
@Disabled - Skips a test.

class CalculatorTest {   
  
  private Calculator calc;   
  
  @BeforeEach   
  void setup() {     
    calc = new Calculator();     
  }   
  
  @Test   
  void testAdd() {     
    assertEquals(7, calc.add(3, 4));     
  }   
  
  @Test   
  void testAddNegative() {     
    assertEquals(-1, calc.add(2, -3));     
  }   
}

A test example using setup/teardown.

The most common assertions:
assertEquals(expected, actual);
assertNotEquals(unexpected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(value);
assertNotNull(value);
assertThrows(Exception.class, () -> { ... });

@Test
void testDivideByZero() {   
  assertThrows(ArithmeticException.class, () -> {     
    int result = 10 / 0;     
  });   
}

Example of testing exceptions.

// Code to test:
public int safeDivide(int a, int b) {   
  if (b == 0) {     
    throw new IllegalArgumentException("Cannot divide by zero");     
  }   
  return a / b;   
}

// Unit test:
@Test
void testSafeDivideThrows() {   
  IllegalArgumentException ex =   
    assertThrows(IllegalArgumentException.class, () -> {       
      safeDivide(10, 0);       
  });   
  
  assertEquals("Cannot divide by zero", ex.getMessage());   
}

Testing your own method for exceptions.

@Mock
UserRepository repo;

@Test
void testFetchUser() {   
  when(repo.findName()).thenReturn("Alice");   
  
  assertEquals("Alice", repo.findName());   
}

If a class depends on something external, you "mock" it. The example above shows how to use Mockito, a popular library for mocking dependencies.

In Maven projects, test are usually organized like this:
src/main/java - real source code
src/test/java - unit tests

Naming conventions:
Class - Calculator
Test class - CalculatorTest

// Class to test:
public class UserService {   
  public boolean isValidUsername(String name) {     
    return name != null && name.length() >= 3;     
  }   
}

// Test:
class UserServiceTest {   
  
  private UserService service;   
  
  @BeforeEach   
  void init() {     
    service = new UserService();     
  }   
  
  @Test   
  void validName() {     
    assertTrue(service.isValidUsername("Bob"));     
  }   
  
  @Test   
  void tooShort() {     
    assertFalse(service.isValidUsername("Hi"));     
  }   
  
  @Test   
  void nullName() {     
    assertFalse(service.isValidUsername(null));     
  }   
}

This is an example of testing a service.

// Method to test that runs asynchronously:
public CompletableFuture<string> fetchData() {   
  return CompletableFuture.supplyAsync(() -> {     
    try {       
      Thread.sleep(100); // simulate delay       
    } catch (InterruptedException ignored) {}     
    return "Hello";     
  });   
}

// Test:
@Test
void testAsyncFetch() throws Exception {   
  CompletableFuture<string> future = fetchData();   
  
  // Block until result is ready (or timeout)   
  String result = future.get(1, TimeUnit.SECONDS);   
  
  assertEquals("Hello", result);   
}

In this example we test a method that runs asynchronously. .get() waits for completion and timeout prevents hanging forever.

import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;

@Suite
@SelectClasses({   
  UserServiceTest.class,   
  OrderServiceTest.class,   
  PaymentServiceTest.class   
})
public class AllServiceTests {
}

A Test Suite is a class used to group several test classes and run them as one. This runs all tests inside the 3 test classes. You can execute it from your IDE, Maven (mvn test), Gradle, CI pipeline.

@Suite
@SelectPackages("com.example.tests")
public class AllTestsInPackage {
}

This selects packages instead of classes. With this everything inside com.example.tests will run.

@Suite
@SelectPackages("com.example")
@IncludeTags("slow")
@ExcludeTags("integration")
public class SlowUnitTests {
}

This is how to include/exclude tests based on tags. It's useful when grouping by type.

Date & time

Java has two different systems for date and time operations:
Old, legacy API (java.util.Date, java.util.Calendar)
Modern, recommended API (java.time.*, introduced in Java 8)

The modern API is the one you should always use, as it is clean, immutable, thread-safe, and much easier to work with.

Date date = new Date();
SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd");
System.out.println(f.format(date));

This is an example of the legacy API (old and not recommended).
java.util.Date
java.util.Calendar
java.text.SimpleDateFormat (not thread-safe)
These are still used in old codebases but should be avoided.
Instead use java.time (such as import java.time.LocalDate).

Class Purpose
LocalDate Date (no time): 2025-01-01
LocalTime Time (no date): 15:30:20
LocalDateTime Date + time: 2025-01-01T10:00
ZonedDateTime Date + time + timezone
Instant Moment in time (timestamp, milliseconds since epoch)
Duration Time-based amount (hours, minutes, seconds)
Period Date-based amount (days, months, years)
DateTimeFormatter Formatting & parsing
Classes of modern Java Date/Time API (java.time)

LocalDate date = LocalDate.now(); // 2025-04-02

Gets the local date.

LocalTime time = LocalTime.now(); // 15:22:12.274318900

Gets the local time.

LocalDateTime dateTime = LocalDateTime.now(); // 2024-11-05T18:46:47.274318900

Gets the local date and time.

Instant timestamp = Instant.now(); // 2026-06-23T20:04:12.274318900Z

Gets the exact timestamp. UTC time with a trailing Z, meaning Zulu time (UTC). Instant is always in UTC, unlike LocalDateTime.

ZonedDateTime zdt = ZonedDateTime.now(ZoneId.of("Europe/Berlin"));

Gets the date and time of a certain time zone.

LocalDateTime ldt = LocalDateTime.now();
ZonedDateTime berlinTime = ldt.atZone(ZoneId.of("Europe/Berlin"));

Converts a LocalDateTime to ZonedDateTime.

LocalDate date = LocalDate.now();
String formatted = date.format(DateTimeFormatter.ofPattern("dd-MM-yyyy"));

Formats a date.

String input = "2025-10-20";
LocalDate date = LocalDate.parse(input);

Parses strings into dates.

DateTimeFormatter f = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate date = LocalDate.parse("20/10/2025", f);

Formats string to custom date.

LocalDate date = LocalDate.now().plusDays(5);
LocalTime time = LocalTime.now().minusHours(2);
LocalDateTime dt = LocalDateTime.now().plusMonths(1).minusMinutes(30);

Adds and subtracts time.

Period p = Period.between(LocalDate.of(2020, 1, 1), LocalDate.now());
System.out.println(p.getYears());

Gets the period between dates (dates: years, months, days).

Duration d = Duration.between(Instant.now(), Instant.now().plusSeconds(100));
System.out.println(d.getSeconds());

Gets the duration between time (time: seconds, hours).

long millis = Instant.now().toEpochMilli();

Gets epoch millis.

Instant instant = Instant.ofEpochMilli(1700000000000L);

Converts epoch millis to Instant.

LocalDateTime dt = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());

Converts Instant to LocalDateTime.

// Date to Instant:
Instant instant = date.toInstant();

// Instant to Date:
Date date = Date.from(instant);

// Calendar to Instant:
Instant instant = calendar.toInstant();

Converts between old and new APIs.

Build tools

Build tools are programs that automate tasks needed to build, run, test, package, and deploy Java applications. In modern Java development, Maven and Gradle are the two major build tools.
They handle things such as:
Compiling .java → .class
Managing dependencies (external libraries like Gson, JUnit, etc.)
Running tests
Packaging your app (JAR, WAR, EAR)
Running your application
Creating documentation
Automating workflows (CI/CD)

<project>   
  <modelVersion>4.0.0</modelVersion>   
  
  <groupId>com.example</groupId>   
  <artifactId>demo-app</artifactId>   
  <version>1.0</version>   
  
  <dependencies>     
    <dependency>     
      <groupId>com.google.code.gson</groupId>       
      <artifactId>gson</artifactId>       
      <version>2.10</version>       
    </dependency>     
  </dependencies>   
</project>

This is an example of a Maven pom.xml. Apache Maven is the most widely used build tool with an XML-based configuration.
Its key features are:
Uses XML (pom.xml) for configuration
Strong convention-over-configuration
Has a large plugin ecosystem
Great dependency management
Builds follow a fixed lifecycle: clean, compile, test, package, install, deploy

Common Maven commands:
mvn compile
mvn test
mvn package
mvn clean install

plugins {   
  id 'java'   
}

repositories {   
  mavenCentral()   
}

dependencies {   
  implementation 'com.google.code.gson:gson:2.10'   
}

An example of a Gradle build file (Groovy). Gradle is more modern, faster and flexible build tool. It uses Groovy or Kotlin scripts.
Its key features are:
Uses Groovy or Kotlin DSL instead of XML
Faster builds with incremental compilation
Highly customizable
Great for large or complex projects

Common Gradle commands:
gradle build
gradle clean
gradle test
gradle run (if application plugin is applied)

Javadoc

Javadoc is a documentation tool built into the JDK. It allows you to write comments directly in your code, and then generate clean HTML documentation from those comments. It's the official way to document Java code, classes, methods, fields, interfaces, etc.

/**   
  * This is a Javadoc comment.   
  */

This is how a Javadoc comment looks like.

Tag What it documents
@param Describes a method parameter
@return Describes return value
@throws or @exception What exceptions can be thrown
@author Declares author
@version Version info
@deprecated Marks code as deprecated
@see Adds a link to related code
@since Since which version
@link Inline link inside text
Common Javadoc tags

/**   
  * A simple utility class for mathematical operations.   
  *   
  * @author John   
  * @version 1.0   
  * @since 2024   
  */   
public class MathUtils {   
  
  /**     
    * Adds two integers.     
    *     
    * @param a the first number     
    * @param b the second number     
    * @return the sum of a and b     
    */   
  public int add(int a, int b) {     
    return a + b;     
  }   
  
  /**     
    * Divides two integers.     
    *     
    * @param a numerator     
    * @param b denominator (must not be zero)     
    * @return the result of division     
    * @throws IllegalArgumentException if b is zero     
    */   
  public int divide(int a, int b) {     
    if (b == 0) throw new IllegalArgumentException("b cannot be zero");     
    return a / b;     
  }   
}

Example of documenting a class.

/**   
  * Uses {@link MathUtils#add(int, int)} to perform addition.   
  */

You can reference methods, classes, fields inside text. This example produces a hyperlink in the output documentation.

javadoc MathUtils.java

If your file is MathUtils.java running this command will HTML files in the directory.

javadoc -d docs -sourcepath src com.example.myapp

This generates documentation into a folder called docs.

Timers

import java.util.Timer;
import java.util.TimerTask;

public class Main {   
  public static void main(String[] args) {     
    Timer timer = new Timer();     
    
    TimerTask task = new TimerTask() {       
      @Override       
      public void run() {         
        System.out.println("Tick: " + System.currentTimeMillis());         
      }       
    };     
    
    timer.scheduleAtFixedRate(task, 0, 1000); // start immediately, repeat every 1 second     
  }   
}

Timer and TimerTask (classic timer) are used to schedule tasks for one-time or repeated execution. In this example we run a task every 1 second.

The downsides of using Timer and TimerTask is that it uses a single thread, therefore long tasks block the timer, it's not ideal for modern apps, and it doesn't handle concurrency well.

Common methods:
schedule(task, delay)
schedule(task, delay, period)
scheduleAtFixedRate(task, delay, period)
cancel() - stops the timer

import java.util.concurrent.*;

public class Main {   
  public static void main(String[] args) {     
    ScheduledExecutorService scheduler =     
      Executors.newScheduledThreadPool(1);       
      
    scheduler.scheduleAtFixedRate(() -> {       
      System.out.println("Running task at " + System.currentTimeMillis());       
    }, 0, 2, TimeUnit.SECONDS);     
  }   
}

ScheduledExecutorService (recommended modern timer) is part of the java.util.concurrent package. In this example we run a task every 2 seconds.

The advantages of ScheduledExecutorService is that it uses thread pools, can run multiple tasks without blocking, provides more predictable timing and better error handling.

Common methods:
schedule(Runnable, delay, unit)
scheduleAtFixedRate(...) - Runs at regular intervals even if tasks take time.
scheduleWithFixedDelay(...) - Runs after the previous task finishes + delay.
shutdown()

Monitoring

Runtime runtime = Runtime.getRuntime();

The Runtime class gives you access to the JVM environment. This is how to obtain it.

runtime.totalMemory(); // 16252928

Returns the total memory (in bytes) that the JVM has currently allocated. This it the amount of memory currently reserved from the OS.

runtime.freeMemory(); // 8021696

Returns the amount of unused memory (in bytes) inside the allocated space.

runtime.maxMemory(); // 259522560

Returns the maximum memory the JVM is allowed to use.

long start = System.currentTimeMillis();

// Code to measure
Thread.sleep(200);

long end = System.currentTimeMillis();
System.out.println("Elapsed: " + (end - start) + " ms");

System.currentTimeMillis() measures milliseconds since Unix epoch (January 1, 1970 00:00:00 UTC). It's good for logging timestamps, measuring long-duration tasks (>= 1 ms), displaying time to the user. It's not precise for profiling, because the resolution is system-dependent and might tick every ~1-10 ms.

long start = System.nanoTime();

// Code to measure
for (int i = 0; i < 1_000_000; i++);

long end = System.nanoTime();
System.out.println("Elapsed: " + (end - start) + " ns");

System.nanoTime() measures a monotonic time source (only useful for measuring elapsed time) and nanosecond precision (but not always nanosecond accuracy). It's good for benchmarking, profiling short operations and high-precision interval timing. It cannot be used as a timestamp as it does not represent a real date.

Shell commands

try {   
  Process process = Runtime.getRuntime().exec("ls -l");   
  process.waitFor();   
  
  BufferedReader reader = new BufferedReader(     
    new InputStreamReader(process.getInputStream())     
  );   
  
  String line;   
  while ((line = reader.readLine()) != null) {     
    System.out.println(line);     
  }   
  
} catch (Exception e) {   
  e.printStackTrace();   
}

exec() is simple but less flexible than ProcessBuilder. It does not handle arguments well unless you pass them as arrays.

ProcessBuilder pb = new ProcessBuilder("ls", "-l");
pb.redirectErrorStream(true); // Merges stderr + stdout

try {   
  Process process = pb.start();   
  
  BufferedReader reader = new BufferedReader(     
    new InputStreamReader(process.getInputStream())     
  );   
  
  String line;   
  while ((line = reader.readLine()) != null) {     
    System.out.println(line);     
  }   
  
  process.waitFor();   
  
} catch (Exception e) {   
  e.printStackTrace();   
}

ProcessBuilder gives more control: environment variables, working directory, redirecting output, etc.

// Linux/macOS:
new ProcessBuilder("bash", "-c", "echo Hello world");

// Windows:
new ProcessBuilder("cmd.exe", "/c", "echo Hello world");

Running commands on Windows requires different parameters than on Linux/macOS.

ProcessBuilder pb = new ProcessBuilder("ls");
Process process = pb.start();

BufferedReader stdOut =   
  new BufferedReader(new InputStreamReader(process.getInputStream()));   
  
BufferedReader stdErr =   
  new BufferedReader(new InputStreamReader(process.getErrorStream()));   
  
String line;

// stdout
while ((line = stdOut.readLine()) != null) {   
  System.out.println("OUTPUT: " + line);   
}

// stderr
while ((line = stdErr.readLine()) != null) {   
  System.err.println("ERROR: " + line);   
}

This example shows how to capture output and errors separately. It's useful when external programs fail.

int exitCode = process.waitFor();
System.out.println("Exit code: " + exitCode);

This example shows how to check for exit code.
0 - success
Nonzero - failure

new ProcessBuilder("bash", "script.sh").start();

Runs a bash script.

new ProcessBuilder("powershell.exe", "-File", "script.ps1").start();

Runs a PowerShell script.

new ProcessBuilder("cmd.exe", "/c", "script.bat").start();

Runs a batch file.

ProcessBuilder pb = new ProcessBuilder("ls");
pb.directory(new File("/home/user/projects"));

Sets the working directory.

ProcessBuilder pb = new ProcessBuilder("printenv");
pb.environment().put("MY_VAR", "123");

This example shows how to pass environment variables.

This document's home with other cheat sheets you might be interested in:
https://gitlab.com/davidvarga/it-cheat-sheets

Sources:
https://www.wikipedia.org/
https://stackoverflow.com/
https://www.w3schools.com/
https://www.geeksforgeeks.org/

License:
GNU General Public License v3.0 or later