Java โ Complete Beginner Tutorial
6 modules ยท 17 examples ยท Click โถ Run on any example to see the output
๐ก How to use this tutorial
Read each explanation, study the code, then click โถ Run to see the output.
Variables & Data Types
Every Java program lives inside a class, and execution starts at the main() method. Java is a statically typed language โ every variable must have a declared type. There are two categories of types: primitive types (int, double, boolean, char) that store values directly, and reference types (String, arrays, objects) that store references to objects in memory.
Primitive Data Types
Java has 8 primitive types. The most commonly used are int (whole numbers), double (decimal numbers), boolean (true/false), and char (a single character). Primitives are stored directly in memory and are not objects โ they don't have methods. They are fast and efficient.
public class DataTypes {
public static void main(String[] args) {
// Integer types
int age = 25;
long population = 8_000_000_000L; // L suffix for long
// Floating point
double price = 9.99;
float rating = 4.5f; // f suffix for float
// Boolean
boolean isStudent = true;
boolean hasPassed = false;
// Character (single quotes)
char grade = 'A';
// Print them
System.out.println("Age: " + age);
System.out.println("Population: " + population);
System.out.println("Price: " + price);
System.out.println("Rating: " + rating);
System.out.println("Student: " + isStudent);
System.out.println("Grade: " + grade);
// Arithmetic
int sum = age + 10;
double tax = price * 0.15;
System.out.println("Sum: " + sum);
System.out.printf("Tax: %.2f%n", tax);
}
}Java uses double by default for decimal literals and int for whole numbers. Use the L suffix for long literals and f for float. The underscore in 8_000_000_000L is purely cosmetic โ Java ignores it, but it makes big numbers readable.
Strings and String Methods
In Java, String is a class (not a primitive). Strings are immutable โ once created, their content cannot be changed. Java provides a rich String API with dozens of useful methods. String concatenation uses + and is very common in Java code. For building strings dynamically, StringBuilder is much more efficient.
public class Strings {
public static void main(String[] args) {
String name = "Alice Smith";
String lang = "Java";
// Concatenation
String greeting = "Hello, " + name + "!";
System.out.println(greeting);
// String methods
System.out.println(name.length()); // 11
System.out.println(name.toUpperCase()); // ALICE SMITH
System.out.println(name.toLowerCase()); // alice smith
System.out.println(name.contains("Alice")); // true
System.out.println(name.startsWith("Ali")); // true
System.out.println(name.replace("Alice", "Bob")); // Bob Smith
System.out.println(name.trim()); // removes whitespace
System.out.println(name.charAt(0)); // A
System.out.println(name.substring(6)); // Smith
// String formatting
String info = String.format("Name: %s, Lang: %s", name, lang);
System.out.println(info);
// StringBuilder โ efficient string building
StringBuilder sb = new StringBuilder();
sb.append("Hello");
sb.append(", ");
sb.append(name);
sb.append("!");
System.out.println(sb.toString());
}
}Never compare Strings with == in Java โ use .equals() instead. The == operator checks if two variables point to the same object in memory, not if their contents are the same. String.equals("hello") compares the actual characters.
Variables, Constants, and Type Casting
Java variables must be declared before use. The final keyword makes a variable a constant โ its value cannot be changed after assignment. Type casting lets you convert between numeric types. Widening conversions (int to double) are automatic; narrowing conversions (double to int) require an explicit cast.
public class Variables {
// Class-level constant (convention: ALL_CAPS)
static final double PI = 3.14159;
static final int MAX_SCORE = 100;
public static void main(String[] args) {
// Local variables โ must be initialised before use
int x = 10;
int y = 3;
// Integer division vs floating point
int divInt = x / y; // 3 (truncates)
double divDouble = (double) x / y; // 3.333...
System.out.println("Int division: " + divInt);
System.out.printf("Double division: %.3f%n", divDouble);
// Widening cast (automatic)
int score = 85;
double scoreDouble = score; // int -> double, no cast needed
// Narrowing cast (explicit)
double pi = 3.99;
int piInt = (int) pi; // truncates to 3
System.out.println("Score as double: " + scoreDouble);
System.out.println("Pi truncated: " + piInt);
System.out.println("Constant PI: " + PI);
System.out.println("Max score: " + MAX_SCORE);
// var (Java 10+) โ type inferred by compiler
var message = "Hello Java!";
var count = 42;
System.out.println(message + " Count: " + count);
}
}The var keyword (Java 10+) lets the compiler infer the type, similar to how Python and JavaScript work. It is useful for reducing verbosity, but the variable is still strongly typed โ var just means "figure out the type for me". You cannot reassign a var to a different type.
Control Flow
Java has the same control flow tools as most C-family languages. if/else handles decisions, switch handles multiple fixed conditions, and Java has three loop types: for (when you know the count), while (when you check a condition first), and do-while (when you always run at least once). Java 14+ also has an enhanced switch expression with arrow syntax.
if / else if / else
Java if statements require parentheses around the condition and curly braces around the body (curly braces are optional for single statements but always recommended). You can chain multiple conditions with else if. Java also has a ternary operator for compact one-line conditionals.
public class ControlFlow {
public static void main(String[] args) {
int score = 73;
// Basic if / else if / else
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 if (score >= 60) {
System.out.println("Grade: D");
} else {
System.out.println("Grade: F");
}
// Ternary operator โ compact if/else
String result = score >= 60 ? "PASS" : "FAIL";
System.out.println("Result: " + result);
// Logical operators
boolean isWeekend = true;
boolean isSunny = false;
if (isWeekend && isSunny) {
System.out.println("Go to the beach!");
} else if (isWeekend || isSunny) {
System.out.println("Could be a nice day.");
} else {
System.out.println("Back to work.");
}
// Checking multiple values with && and ||
int age = 20;
if (age >= 18 && age <= 65) {
System.out.println("Working age group.");
}
}
}Always use curly braces {} even for single-line if bodies. Without them, adding a second line to what looks like an if body will silently break your logic โ the second line runs regardless of the condition. This is a very common bug.
switch and switch Expressions
The classic switch statement tests a value against multiple cases. Each case needs a break to prevent "fall-through" (where execution continues into the next case). Java 14+ introduced switch expressions with arrow syntax โ no fall-through, cleaner code, and they return a value.
public class SwitchDemo {
public static void main(String[] args) {
// Classic switch statement
int day = 3;
String dayName;
switch (day) {
case 1:
dayName = "Monday";
break;
case 2:
dayName = "Tuesday";
break;
case 3:
dayName = "Wednesday";
break;
case 4:
dayName = "Thursday";
break;
case 5:
dayName = "Friday";
break;
default:
dayName = "Weekend";
}
System.out.println("Day: " + dayName);
// Modern switch expression (Java 14+)
String season = "WINTER";
String activity = switch (season) {
case "SPRING" -> "Go hiking";
case "SUMMER" -> "Go swimming";
case "AUTUMN" -> "Jump in leaves";
case "WINTER" -> "Stay inside";
default -> "Unknown season";
};
System.out.println("Activity: " + activity);
// switch on String
String lang = "Java";
switch (lang) {
case "Java":
case "Kotlin":
System.out.println("JVM language");
break;
case "Python":
System.out.println("Scripting language");
break;
default:
System.out.println("Other language");
}
}
}In the classic switch, forgetting break causes fall-through โ execution continues into the next case until it hits a break or the end of the switch. Sometimes fall-through is intentional (like the Java/Kotlin case above), but usually it is a bug. The modern arrow switch avoids this entirely.
for, while, and do-while Loops
Java has three loop types. The classic for loop is best when you know the number of iterations. The enhanced for-each loop is cleanest for iterating over arrays and collections. while loops check the condition before each iteration. do-while always runs at least once.
public class Loops {
public static void main(String[] args) {
// Classic for loop
System.out.println("Counting up:");
for (int i = 1; i <= 5; i++) {
System.out.print(i + " ");
}
System.out.println();
// Enhanced for-each (best for arrays/collections)
String[] fruits = {"Apple", "Banana", "Cherry"};
System.out.println("Fruits:");
for (String fruit : fruits) {
System.out.println(" - " + fruit);
}
// while loop
System.out.println("while loop:");
int n = 1;
while (n <= 4) {
System.out.print(n + " ");
n++;
}
System.out.println();
// do-while โ always runs at least once
System.out.println("do-while:");
int count = 0;
do {
System.out.print(count + " ");
count++;
} while (count < 3);
System.out.println();
// break and continue
System.out.println("break/continue:");
for (int i = 0; i < 10; i++) {
if (i == 3) continue; // skip 3
if (i == 7) break; // stop at 7
System.out.print(i + " ");
}
}
}Prefer the enhanced for-each loop whenever you just need to read every element in an array or collection. It is cleaner, less error-prone, and removes the possibility of off-by-one errors. Use the classic for loop only when you need the index.
Methods & Classes
A method is a named block of code that performs a task. Methods prevent repetition โ write once, call many times. In Java, every method lives inside a class. Methods have a return type (or void if they return nothing), a name, and zero or more parameters. Classes are blueprints for objects โ they bundle data (fields) and behaviour (methods) together.
Defining and Calling Methods
A method declaration specifies the return type, name, and parameters. static methods belong to the class itself (not an instance). void means the method returns nothing. When a method returns a value, use the return keyword. Java supports method overloading โ multiple methods with the same name but different parameter lists.
public class Methods {
// Static method โ belongs to the class
static void greet(String name) {
System.out.println("Hello, " + name + "!");
}
// Returns a value
static int add(int a, int b) {
return a + b;
}
// Method overloading โ same name, different parameters
static double add(double a, double b) {
return a + b;
}
// Method with multiple parameters
static String formatScore(String player, int score, int max) {
double percent = (double) score / max * 100;
return String.format("%s: %d/%d (%.1f%%)", player, score, max, percent);
}
// Recursive method
static int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
public static void main(String[] args) {
greet("Alice");
greet("Bob");
System.out.println("3 + 4 = " + add(3, 4));
System.out.println("1.5 + 2.5 = " + add(1.5, 2.5));
System.out.println(formatScore("Alice", 87, 100));
System.out.println(formatScore("Bob", 63, 100));
System.out.println("5! = " + factorial(5));
System.out.println("10! = " + factorial(10));
}
}Method overloading lets you use the same method name for related operations. Java picks the right method based on the argument types at compile time. This is different from method overriding (which applies to inheritance and happens at runtime).
Classes and Objects
A class is a blueprint. An object is an instance of that blueprint. Classes have fields (variables that store data) and methods (functions that define behaviour). The constructor is a special method called when creating an object with new. The this keyword refers to the current object.
// Define the class (blueprint)
class Person {
// Fields
String name;
int age;
String city;
// Constructor โ runs when object is created
Person(String name, int age, String city) {
this.name = name; // this.name = field, name = parameter
this.age = age;
this.city = city;
}
// Methods
void introduce() {
System.out.println("Hi! I am " + name +
", " + age + " years old, from " + city + ".");
}
boolean isAdult() {
return age >= 18;
}
String getStatus() {
return name + " is " + (isAdult() ? "an adult" : "a minor");
}
}
public class ClassDemo {
public static void main(String[] args) {
// Create objects with new
Person alice = new Person("Alice", 25, "Johannesburg");
Person bob = new Person("Bob", 16, "Cape Town");
// Call methods on objects
alice.introduce();
bob.introduce();
System.out.println(alice.getStatus());
System.out.println(bob.getStatus());
// Access fields directly
System.out.println(alice.name + " is " + alice.age);
// Objects are independent
alice.age = 26;
System.out.println("After birthday: " + alice.age);
System.out.println("Bob unchanged: " + bob.age);
}
}Each object created with new gets its own copy of the fields. Changing alice.age does not affect bob.age. This is the fundamental concept of objects โ independent instances of the same blueprint, each with their own state.
Object-Oriented Programming
OOP has four pillars: Encapsulation (hiding data behind methods), Inheritance (classes extending other classes), Polymorphism (same interface, different behaviour), and Abstraction (hiding complexity). Java is built around these concepts. The access modifiers private, protected, and public control visibility. The extends keyword creates inheritance. Interfaces define contracts.
Encapsulation โ Getters and Setters
Encapsulation means hiding a class's fields from outside access and providing controlled access through public getter and setter methods. Fields are declared private, then public methods expose them safely. This lets you add validation, logging, or computed values without changing the external interface.
class BankAccount {
private String owner; // private โ not directly accessible
private double balance; // private โ protected from direct change
BankAccount(String owner, double initialBalance) {
this.owner = owner;
this.balance = initialBalance >= 0 ? initialBalance : 0;
}
// Getter โ read-only access to balance
public double getBalance() {
return balance;
}
public String getOwner() {
return owner;
}
// Methods with validation
public void deposit(double amount) {
if (amount <= 0) {
System.out.println("Deposit must be positive.");
return;
}
balance += amount;
System.out.printf("Deposited R%.2f. Balance: R%.2f%n", amount, balance);
}
public void withdraw(double amount) {
if (amount <= 0) {
System.out.println("Withdrawal must be positive.");
} else if (amount > balance) {
System.out.println("Insufficient funds.");
} else {
balance -= amount;
System.out.printf("Withdrew R%.2f. Balance: R%.2f%n", amount, balance);
}
}
}
public class EncapsDemo {
public static void main(String[] args) {
BankAccount acc = new BankAccount("Alice", 1000.00);
System.out.println("Owner: " + acc.getOwner());
System.out.printf("Balance: R%.2f%n", acc.getBalance());
acc.deposit(500);
acc.withdraw(200);
acc.withdraw(2000); // Should fail
acc.deposit(-50); // Should fail
}
}Making fields private and providing getters/setters is called the JavaBeans convention. Many frameworks (Spring, Jackson, Hibernate) depend on this pattern to automatically read and write your class fields. It is standard Java style.
Inheritance and Polymorphism
Inheritance lets a class (subclass) inherit fields and methods from another class (superclass) using extends. The subclass can add new fields/methods and override existing ones. Polymorphism means you can treat a subclass object as its parent type โ Java calls the correct overridden method at runtime.
// Superclass (parent)
class Animal {
String name;
Animal(String name) {
this.name = name;
}
// This method will be overridden
void makeSound() {
System.out.println(name + " makes a sound.");
}
void describe() {
System.out.println("I am " + name + " the " +
getClass().getSimpleName());
}
}
// Subclasses (children)
class Dog extends Animal {
Dog(String name) {
super(name); // call parent constructor
}
@Override
void makeSound() {
System.out.println(name + " says: Woof!");
}
}
class Cat extends Animal {
Cat(String name) {
super(name);
}
@Override
void makeSound() {
System.out.println(name + " says: Meow!");
}
}
public class InheritanceDemo {
public static void main(String[] args) {
Dog rex = new Dog("Rex");
Cat whiskers = new Cat("Whiskers");
rex.makeSound(); // Dog's version
whiskers.makeSound(); // Cat's version
rex.describe(); // Inherited from Animal
whiskers.describe(); // Inherited from Animal
// Polymorphism โ store as parent type
Animal[] animals = { new Dog("Buddy"), new Cat("Luna") };
System.out.println("\nAll animals speak:");
for (Animal a : animals) {
a.makeSound(); // Calls the correct subclass method
}
}
}The @Override annotation tells the compiler you intend to override a parent method. If you misspell the method name, the compiler catches it immediately. Without @Override, a misspelling just creates a new method silently โ a very hard bug to find.
Interfaces and Abstraction
An interface is a contract โ it declares what methods a class must implement without specifying how. A class implements an interface using the implements keyword and must provide all the declared methods. A class can implement multiple interfaces, which Java uses instead of multiple inheritance.
// Interface โ defines a contract
interface Drawable {
void draw(); // must implement
default String getType() { // default has implementation
return "Shape";
}
}
interface Resizable {
void resize(double factor);
}
// Class implementing two interfaces
class Circle implements Drawable, Resizable {
double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
public void draw() {
System.out.printf("Drawing circle with radius %.1f%n", radius);
}
@Override
public void resize(double factor) {
radius *= factor;
System.out.printf("Circle resized. New radius: %.1f%n", radius);
}
}
class Rectangle implements Drawable, Resizable {
double width, height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public void draw() {
System.out.printf("Drawing rectangle %.1f x %.1f%n", width, height);
}
@Override
public void resize(double factor) {
width *= factor;
height *= factor;
System.out.printf("Rectangle resized to %.1f x %.1f%n", width, height);
}
}
public class InterfaceDemo {
public static void main(String[] args) {
Drawable[] shapes = { new Circle(5), new Rectangle(4, 3) };
for (Drawable shape : shapes) {
shape.draw();
System.out.println("Type: " + shape.getType());
}
Circle c = new Circle(10);
c.resize(0.5);
c.draw();
}
}Interfaces define what, classes define how. Program to interfaces, not implementations โ store objects as their interface type (Drawable shape = new Circle(5)) rather than the concrete type. This makes your code flexible and easy to change.
Collections & Generics
Java arrays have a fixed size. The Collections framework provides dynamic, flexible data structures. The most important ones are ArrayList (dynamic array), HashMap (key-value store), HashSet (unique values), and LinkedList (fast insertion/removal). Generics let you specify the type stored in a collection: ArrayList<String> means a list that can only hold Strings โ the compiler enforces this.
ArrayList โ Dynamic Lists
ArrayList is like an array that grows automatically. You add items with add(), access by index with get(), remove with remove(), and check the size with size(). The <String> in ArrayList<String> is the generic type parameter โ it means this list can only hold Strings, catching type errors at compile time.
import java.util.ArrayList;
import java.util.Collections;
public class ArrayListDemo {
public static void main(String[] args) {
// Create an ArrayList of Strings
ArrayList<String> languages = new ArrayList<>();
// Adding elements
languages.add("Java");
languages.add("Python");
languages.add("Go");
languages.add("Rust");
languages.add("JavaScript");
System.out.println("Languages: " + languages);
System.out.println("Size: " + languages.size());
// Access by index
System.out.println("First: " + languages.get(0));
System.out.println("Last: " + languages.get(languages.size() - 1));
// Check if contains
System.out.println("Has Java? " + languages.contains("Java"));
// Remove by value
languages.remove("Go");
System.out.println("After removing Go: " + languages);
// Sort alphabetically
Collections.sort(languages);
System.out.println("Sorted: " + languages);
// Iterate with for-each
System.out.println("All languages:");
for (String lang : languages) {
System.out.println(" " + lang);
}
// ArrayList of integers
ArrayList<Integer> scores = new ArrayList<>();
scores.add(85);
scores.add(92);
scores.add(78);
System.out.println("Max score: " + Collections.max(scores));
}
}ArrayList<Integer> uses Integer (capital I), not int. Java cannot use primitives in generics, so it uses wrapper classes: Integer for int, Double for double, Boolean for boolean. Java auto-converts between them (autoboxing), so scores.add(85) works even though 85 is a primitive.
HashMap โ Key-Value Pairs
HashMap stores data as key-value pairs, like a dictionary. Each key maps to exactly one value. Keys must be unique. Access is extremely fast โ O(1) average. Use HashMap when you need to look up values by a label rather than by position.
import java.util.HashMap;
import java.util.Map;
public class HashMapDemo {
public static void main(String[] args) {
// HashMap<KeyType, ValueType>
HashMap<String, Integer> scores = new HashMap<>();
// Adding key-value pairs
scores.put("Alice", 92);
scores.put("Bob", 78);
scores.put("Carol", 88);
scores.put("Dave", 95);
System.out.println("All scores: " + scores);
System.out.println("Size: " + scores.size());
// Retrieve by key
System.out.println("Alice's score: " + scores.get("Alice"));
// Check if key exists
System.out.println("Has Eve? " + scores.containsKey("Eve"));
// Default value if key not found
int eveScore = scores.getOrDefault("Eve", 0);
System.out.println("Eve's score (default): " + eveScore);
// Update a value
scores.put("Bob", 82); // overwrites existing
System.out.println("Bob updated: " + scores.get("Bob"));
// Remove
scores.remove("Dave");
// Iterate over entries
System.out.println("\nFinal scores:");
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
String grade = entry.getValue() >= 90 ? "A" : "B";
System.out.printf(" %s: %d (%s)%n",
entry.getKey(), entry.getValue(), grade);
}
// word count example
String sentence = "java is great and java is fast";
HashMap<String, Integer> wordCount = new HashMap<>();
for (String word : sentence.split(" ")) {
wordCount.put(word, wordCount.getOrDefault(word, 0) + 1);
}
System.out.println("\nWord count: " + wordCount);
}
}HashMap does not guarantee insertion order. If you need insertion order, use LinkedHashMap. If you need sorted keys, use TreeMap. For most use cases, HashMap is the right choice โ it is the fastest.
Exceptions & Practical Java
Exceptions are Java's way of handling errors at runtime. Instead of crashing, you catch the exception and handle it gracefully. The try/catch/finally block is the core mechanism. Java also has two categories: checked exceptions (you must handle them) and unchecked exceptions (runtime errors you can optionally handle). This module also covers arrays, the Scanner for user input, and putting it all together.
try / catch / finally
Wrap risky code in a try block. If an exception occurs, execution jumps to the matching catch block. The finally block always runs โ whether an exception occurred or not. This is used for cleanup like closing files or database connections.
public class ExceptionsDemo {
// Method that throws a checked exception
static double divide(int a, int b) {
if (b == 0) {
throw new ArithmeticException("Cannot divide by zero!");
}
return (double) a / b;
}
static int parseAge(String input) {
return Integer.parseInt(input); // throws NumberFormatException
}
public static void main(String[] args) {
// Basic try/catch
try {
double result = divide(10, 2);
System.out.println("10 / 2 = " + result);
double bad = divide(5, 0); // throws exception
System.out.println("This won't print");
} catch (ArithmeticException e) {
System.out.println("Math error: " + e.getMessage());
}
// Multiple catch blocks
String[] inputs = {"25", "abc", null};
for (String input : inputs) {
try {
int age = parseAge(input);
System.out.println("Parsed age: " + age);
} catch (NumberFormatException e) {
System.out.println("Not a number: " + input);
} catch (NullPointerException e) {
System.out.println("Input was null!");
}
}
// finally โ always runs
System.out.println("\nWith finally:");
try {
System.out.println("Trying...");
int[] arr = new int[3];
arr[10] = 5; // ArrayIndexOutOfBoundsException
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("Array error: " + e.getMessage());
} finally {
System.out.println("Finally always runs.");
}
}
}Catch the most specific exception type first, most general last. Catching Exception (the base class) too early can hide bugs. A good rule: only catch exceptions you know how to handle. If you cannot handle it meaningfully, let it propagate up to where it can be handled.
Arrays and Useful Patterns
Arrays in Java have a fixed size set at creation. They are zero-indexed. The Arrays utility class provides sorting, searching, and printing. Knowing how to work with arrays is fundamental โ they are the basis for all Java collections.
import java.util.Arrays;
public class ArraysDemo {
// Find maximum in an array
static int findMax(int[] arr) {
int max = arr[0];
for (int num : arr) {
if (num > max) max = num;
}
return max;
}
// Calculate average
static double average(int[] arr) {
int sum = 0;
for (int num : arr) sum += num;
return (double) sum / arr.length;
}
public static void main(String[] args) {
// Declare and initialise
int[] scores = {85, 92, 78, 95, 88, 73, 90};
System.out.println("Scores: " + Arrays.toString(scores));
System.out.println("Length: " + scores.length);
System.out.println("Max: " + findMax(scores));
System.out.printf("Average: %.1f%n", average(scores));
// Sort
Arrays.sort(scores);
System.out.println("Sorted: " + Arrays.toString(scores));
// 2D array
int[][] grid = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
System.out.println("\n3x3 Grid:");
for (int[] row : grid) {
for (int val : row) {
System.out.printf("%3d", val);
}
System.out.println();
}
// String array
String[] names = {"Charlie", "Alice", "Bob"};
Arrays.sort(names);
System.out.println("\nSorted names: " + Arrays.toString(names));
System.out.println("Found Alice at: " + Arrays.binarySearch(names, "Alice"));
}
}Arrays.binarySearch() requires the array to be sorted first โ otherwise results are unpredictable. For searching an unsorted array, use a simple for loop. For dynamic lists that grow and shrink, always prefer ArrayList over arrays.
Putting It All Together โ Student Grade System
This final example combines classes, ArrayList, HashMap, methods, and exception handling into a mini grade management system. Study how the pieces connect โ this is the kind of structure you will write in real Java programs.
import java.util.ArrayList;
import java.util.HashMap;
class Student {
private String name;
private ArrayList<Integer> grades;
Student(String name) {
this.name = name;
this.grades = new ArrayList<>();
}
void addGrade(int grade) {
if (grade < 0 || grade > 100) {
throw new IllegalArgumentException(
"Grade must be 0-100, got: " + grade);
}
grades.add(grade);
}
double getAverage() {
if (grades.isEmpty()) return 0;
int sum = 0;
for (int g : grades) sum += g;
return (double) sum / grades.size();
}
String getLetterGrade() {
double avg = getAverage();
if (avg >= 90) return "A";
if (avg >= 80) return "B";
if (avg >= 70) return "C";
if (avg >= 60) return "D";
return "F";
}
String getName() { return name; }
@Override
public String toString() {
return String.format("%s: avg=%.1f (%s)",
name, getAverage(), getLetterGrade());
}
}
public class GradeSystem {
public static void main(String[] args) {
ArrayList<Student> students = new ArrayList<>();
Student alice = new Student("Alice");
alice.addGrade(92); alice.addGrade(88); alice.addGrade(95);
Student bob = new Student("Bob");
bob.addGrade(74); bob.addGrade(68); bob.addGrade(71);
Student carol = new Student("Carol");
carol.addGrade(55); carol.addGrade(62); carol.addGrade(58);
students.add(alice);
students.add(bob);
students.add(carol);
System.out.println("=== Grade Report ===");
for (Student s : students) {
System.out.println(s);
}
// Count grades
HashMap<String, Integer> gradeCounts = new HashMap<>();
for (Student s : students) {
String letter = s.getLetterGrade();
gradeCounts.put(letter, gradeCounts.getOrDefault(letter, 0) + 1);
}
System.out.println("\nGrade distribution: " + gradeCounts);
// Exception handling
try {
alice.addGrade(150); // invalid
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
}
}Notice how this example uses every concept from the tutorial: a class with private fields and public methods, ArrayList to store dynamic data, HashMap to aggregate results, a custom exception with a meaningful message, and String.format for clean output. Real Java programs are just compositions of these same patterns.
You finished the Java tutorial!
You now know Java's type system, control flow, methods, object-oriented programming, collections, and exception handling. These are the core building blocks of every real Java application.