Java for Beginners | Install the JDK and Write Hello World
Key takeaways
Start Java today: install OpenJDK 21, set JAVA_HOME, compile with javac, run on the JVM, and explore IntelliJ, Eclipse, or VS Code—plus syntax, classes, and your first programs.
Introduction
Write once, run anywhere
Java is an object-oriented language (originally from Sun, now stewarded by Oracle). Write Once, Run Anywhere (WORA) means you compile to bytecode once, then the Java Virtual Machine (JVM) on each platform interprets or JIT-compiles it—similar in spirit to shipping one blueprint and building with local materials. Highlights:
- Platform independence via the JVM
- Object-oriented programming model
- Garbage collection for automatic memory management
- Strong static typing with compile-time checks
- Huge standard library and ecosystem
- Built-in threading primitives
- Security features (classloader, bytecode verification) Where Java shows up:
- Enterprise: Spring, large services
- Android app development
- Web: Servlets, JSP, Spring Boot
- Big data: Hadoop, Spark
- Games: e.g. Minecraft
What makes Java practical for large systems is less the syntax than the runtime. The JVM starts by interpreting bytecode, watches which methods run often, and compiles those “hot” methods to native code with a JIT compiler that can use information only available at runtime, such as which implementation of an interface is actually called. That is why a Java service is often slower in its first seconds than after it has warmed up. Garbage collection removes a whole class of bugs that C and C++ programmers fight (double frees, dangling pointers), at the cost of some memory overhead and occasional GC work that modern collectors keep short. The trade-off to keep in mind is startup time and memory footprint: for small command-line tools, Java has traditionally been heavier than Go or native code, which is what projects like GraalVM native images address.
Installing the JDK
JVM vs JRE vs JDK
| Role | |
|---|---|
| JVM | Executes bytecode |
| JRE | JVM + standard libraries (runtime) |
| JDK | JRE + javac, tools, debuggers |
Developers install a JDK.
Since Java 11, most vendors no longer ship a separate JRE, so in practice you install a JDK everywhere, and applications that need a trimmed runtime build one with jlink. Which vendor you choose matters little for learning: Temurin, Corretto, Microsoft’s build and Oracle’s OpenJDK builds are compiled from the same OpenJDK source code. Prefer a long-term support (LTS) version; 21 is used throughout this series, and 25 is the newest LTS.
Windows
- Download Oracle JDK or free OpenJDK / Temurin (recommended).
- Install, e.g.
C:\Program Files\Java\jdk-21 - Set environment variables:
JAVA_HOME=C:\Program Files\Java\jdk-21
PATH=%JAVA_HOME%\bin;%PATH%
Verify:
java -version
javac -version
JAVA_HOME is not used by java itself; it is how build tools (Maven, Gradle), IDEs and application servers find the JDK. PATH is what makes java and javac work in a terminal. The Temurin installer can set both for you if you tick the options during installation. Environment changes only reach new terminal windows, so if javac is “not recognized as an internal or external command” right after installing, open a new terminal before debugging anything else. If both commands work but print different versions, another Java installation earlier on PATH is winning; the FAQ at the end covers that case.
macOS (Homebrew)
brew install openjdk@21
sudo ln -sfn /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk \
/Library/Java/JavaVirtualMachines/openjdk-21.jdk
java -version
Homebrew’s openjdk@21 is “keg-only”, meaning it is not linked into your PATH automatically; the symlink into /Library/Java/JavaVirtualMachines lets macOS’s /usr/bin/java wrapper and /usr/libexec/java_home find it. Without that step, java -version prints “The operation couldn’t be completed. Unable to locate a Java Runtime.” The path above is for Apple Silicon (/opt/homebrew); on Intel Macs Homebrew lives under /usr/local. With several JDKs installed, export JAVA_HOME=$(/usr/libexec/java_home -v 21) in your shell profile selects one.
Linux
# Ubuntu/Debian
sudo apt update
sudo apt install openjdk-21-jdk
# RHEL/CentOS
sudo yum install java-21-openjdk-devel
java -version
javac -version
Hello World
HelloWorld.java
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, Java!");
}
}
public class HelloWorld: public class name must match the file name.public static void main(String[] args): entry point.System.out.println: print a line to standard output.
Compile and run
javac HelloWorld.java # produces HelloWorld.class
java HelloWorld # runs on the JVM
HelloWorld.java → javac → HelloWorld.class → java → JVM → output
Note the asymmetry: javac takes a file name (HelloWorld.java), while java takes a class name (HelloWorld, no extension). Typing java HelloWorld.class fails with Error: Could not find or load main class HelloWorld.class, because the JVM looks for a class literally named HelloWorld.class. Since Java 11 you can also skip the separate compile step for a single file with java HelloWorld.java, which compiles in memory and runs; that is handy for experiments, while real projects use javac through a build tool.
static on main means the JVM can call it without creating a HelloWorld object first, and String[] args receives the command-line arguments. In Java 25, compact source files allow a shorter form, void main() { IO.println("Hello"); }, without an explicit class; the full form above remains valid everywhere and is what you will see in existing code.
Command-line arguments
public class Args {
public static void main(String[] args) {
System.out.println("Arg count: " + args.length);
for (int i = 0; i < args.length; i++) {
System.out.println("args[" + i + "]: " + args[i]);
}
}
}
javac Args.java
java Args hello java 123
Arguments are split on spaces by the shell and always arrive as strings, so "123" needs Integer.parseInt(args[2]) to become a number, and that call throws NumberFormatException for input like 12a. Unlike C, args[0] is the first real argument, not the program name. Accessing args[0] when no arguments were given throws ArrayIndexOutOfBoundsException, which is why checking args.length first, as this example does, is a habit worth building.
IDE setup
IntelliJ IDEA (popular)
- IntelliJ IDEA Community (free)
- New project: Java, JDK 21, build system as needed
- Run: Shift+F10, Debug: Shift+F9
Eclipse
- Eclipse IDE for Java Developers
- File → New → Java Project
VS Code
- Install Extension Pack for Java
- Command Palette: Java: Create Java Project
For learning, IntelliJ IDEA Community is the most common choice and needs the least configuration; VS Code is lighter if you already use it for other languages. Whichever you pick, run the first program from a terminal at least once. IDEs hide the javac/java steps and the classpath, and knowing what they do on your behalf makes errors like “could not find or load main class” much easier to understand later, when a build tool or a server runs your code without the IDE.
Basic syntax
Variables and types
public class Variables {
public static void main(String[] args) {
byte b = 127;
short s = 32767;
int age = 25;
long population = 7_800_000_000L;
float pi = 3.14f;
double price = 19.99;
boolean isActive = true;
char grade = 'A';
String name = "Jane Doe";
String message = "Hello!";
System.out.println("Name: " + name);
System.out.println("Age: " + age);
System.out.println("Price: " + price);
}
}
The first eight types (byte through char, plus boolean) are primitives: they hold the value directly and have fixed sizes on every platform (int is always 32 bits, long 64), unlike C where sizes vary. String is a class, so name holds a reference to an object. Two suffixes matter: 7_800_000_000L needs the L because an integer literal without it is an int and this value does not fit (the compiler reports “integer number too large”), and 3.14f needs the f because a decimal literal is a double by default. The underscores are only for readability. double is a binary floating-point type, so 0.1 + 0.2 prints 0.30000000000000004; that is expected, and it is why money should not be stored in double (see the bank account example).
Constants
public class Constants {
public static void main(String[] args) {
final int MAX_SIZE = 100;
final double PI = 3.14159;
final String APP_NAME = "MyApp";
System.out.println("Max size: " + MAX_SIZE);
}
}
Operators
public class Operators {
public static void main(String[] args) {
int a = 10, b = 3;
System.out.println("Sum: " + (a + b));
System.out.println("Diff: " + (a - b));
System.out.println("Product: " + (a * b));
System.out.println("Quotient: " + (a / b));
System.out.println("Remainder: " + (a % b));
System.out.println(a > b);
System.out.println(a == b);
System.out.println(a != b);
boolean x = true, y = false;
System.out.println(x && y);
System.out.println(x || y);
System.out.println(!x);
int count = 0;
count++;
System.out.println(count);
count--;
System.out.println(count);
}
}
a / b prints 3, not 3.333...: dividing two int values performs integer division and discards the remainder. Make one operand a double ((double) a / b) to get a fractional result. The parentheses in "Sum: " + (a + b) also matter; without them, "Sum: " + a + b evaluates left to right as string concatenation and prints Sum: 103. && and || short-circuit, so in obj != null && obj.isReady() the second part never runs on a null reference.
Conditionals
public class Conditionals {
public static void main(String[] args) {
int score = 85;
if (score >= 90) {
System.out.println("A");
} else if (score >= 80) {
System.out.println("B");
} else if (score >= 70) {
System.out.println("C");
} else {
System.out.println("F");
}
String day = "Monday";
switch (day) {
case "Monday":
System.out.println("Monday");
break;
case "Friday":
System.out.println("Friday");
break;
default:
System.out.println("Weekday");
}
String result = (score >= 60) ? "Pass" : "Fail";
System.out.println(result);
}
}
In the classic switch, each case falls through into the next unless you write break, a frequent source of bugs where two branches run. The arrow form (case "Mon" -> ...), shown in the modern Java section, has no fall-through and is preferable in new code. Switching on a String compares with equals, which is correct; comparing strings yourself with == is not (see the beginner mistakes below).
Loops
public class Loops {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
System.out.println("i = " + i);
}
int count = 0;
while (count < 3) {
System.out.println("count = " + count);
count++;
}
int num = 0;
do {
System.out.println("num = " + num);
num++;
} while (num < 3);
int[] numbers = {1, 2, 3, 4, 5};
for (int n : numbers) {
System.out.println(n);
}
}
}
Methods
Definition and overloading
public class Calculator {
public static int add(int a, int b) {
return a + b;
}
public static void printSum(int a, int b) {
System.out.println("Sum: " + (a + b));
}
public static int add(int a, int b, int c) {
return a + b + c;
}
public static double add(double a, double b) {
return a + b;
}
public static void main(String[] args) {
System.out.println("add(10,20): " + add(10, 20));
System.out.println("add(10,20,30): " + add(10, 20, 30));
System.out.println("add(10.5,20.3): " + add(10.5, 20.3));
printSum(5, 7);
}
}
Varargs
public class VarArgs {
public static int sum(int... numbers) {
int total = 0;
for (int n : numbers) {
total += n;
}
return total;
}
public static void main(String[] args) {
System.out.println(sum(1, 2, 3));
System.out.println(sum(1, 2, 3, 4, 5));
System.out.println(sum(10));
}
}
Overloading lets several methods share a name as long as their parameter lists differ; the compiler picks one from the argument types at compile time. The return type alone is not enough to distinguish overloads. Varargs (int... numbers) is compiled to an int[] parameter, so inside the method numbers is an ordinary array, and sum() with no arguments passes an empty array. Combining overloads with varargs can make calls ambiguous, so keep varargs for genuinely variable-length cases.
Classes and objects
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public Person() {
this("Unknown", 0);
}
public void introduce() {
System.out.println("Hi, I'm " + name + ".");
System.out.println("Age: " + age);
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
if (age >= 0) {
this.age = age;
}
}
}
public class Main {
public static void main(String[] args) {
Person person1 = new Person("Jane Doe", 25);
Person person2 = new Person("John Smith", 30);
Person person3 = new Person();
person1.introduce();
person2.introduce();
System.out.println("Name: " + person1.getName());
person1.setAge(26);
System.out.println("New age: " + person1.getAge());
}
}
The fields are private, and all access goes through methods. That is what lets setAge reject negative values: no code outside the class can write person.age = -5. this.name = name is needed because the parameter has the same name as the field and would otherwise shadow it; without this., the assignment would copy the parameter to itself and leave the field null. this("Unknown", 0) in the second constructor calls the first one, so the initialization logic lives in one place. Each of these two classes is public, so each must be in its own file (Person.java and Main.java); javac *.java compiles both, and java Main runs the one with main.
Arrays
public class ArrayDemo {
public static void main(String[] args) {
int[] numbers1 = new int[5];
numbers1[0] = 10;
numbers1[1] = 20;
int[] numbers2 = {1, 2, 3, 4, 5};
int[] numbers3 = new int[]{10, 20, 30};
System.out.println("Length: " + numbers2.length);
for (int i = 0; i < numbers2.length; i++) {
System.out.println("numbers2[" + i + "] = " + numbers2[i]);
}
for (int num : numbers2) {
System.out.println(num);
}
int[][] matrix = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
System.out.println(matrix[1][2]);
}
}
Arrays have a fixed length set at creation, and new int[5] fills them with default values (0 for numbers, false for booleans, null for objects). Indexes run from 0 to length - 1; numbers2[5] throws ArrayIndexOutOfBoundsException at runtime instead of reading random memory as C would. Printing an array directly, System.out.println(numbers2), shows something like [I@1b6d3586 (type code and hash), not the contents; use java.util.Arrays.toString(numbers2). When you need a collection that grows, use ArrayList instead of an array.
Practical examples
Example 1: Calculator (Scanner)
import java.util.Scanner;
public class CalculatorApp {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("First number: ");
double num1 = scanner.nextDouble();
System.out.print("Operator (+, -, *, /): ");
String operator = scanner.next();
System.out.print("Second number: ");
double num2 = scanner.nextDouble();
double result = 0;
switch (operator) {
case "+":
result = num1 + num2;
break;
case "-":
result = num1 - num2;
break;
case "*":
result = num1 * num2;
break;
case "/":
if (num2 != 0) {
result = num1 / num2;
} else {
System.out.println("Cannot divide by zero.");
return;
}
break;
default:
System.out.println("Invalid operator.");
return;
}
System.out.println("Result: " + result);
scanner.close();
}
}
Scanner reads tokens separated by whitespace. nextDouble() throws InputMismatchException if the token is not a number, and it parses according to the system locale, so on a machine set to a locale that uses a comma as decimal separator, 3.5 is rejected while 3,5 is accepted. new Scanner(System.in).useLocale(java.util.Locale.US) makes the behavior predictable. Another classic trap: after nextInt() or nextDouble(), a following nextLine() returns an empty string, because it reads the rest of the line the number was on. The early return statements skip scanner.close(), which is harmless here; in larger programs, try (Scanner scanner = new Scanner(System.in)) { ... } closes it on every path. Closing a scanner on System.in also closes standard input for the rest of the program, so only do it once, at the end.
Example 2: Student records
public class Student {
private String name;
private int studentId;
private double gpa;
public Student(String name, int studentId, double gpa) {
this.name = name;
this.studentId = studentId;
this.gpa = gpa;
}
public void printInfo() {
System.out.println("=== Student ===");
System.out.println("Name: " + name);
System.out.println("ID: " + studentId);
System.out.println("GPA: " + gpa);
}
public String getGrade() {
if (gpa >= 4.0) return "A";
else if (gpa >= 3.0) return "B";
else if (gpa >= 2.0) return "C";
else return "F";
}
public static void main(String[] args) {
Student s1 = new Student("Jane Doe", 20240001, 3.8);
Student s2 = new Student("John Smith", 20240002, 4.2);
s1.printInfo();
System.out.println("Grade band: " + s1.getGrade());
System.out.println();
s2.printInfo();
System.out.println("Grade band: " + s2.getGrade());
}
}
Example 3: Bank account
public class BankAccount {
private String accountNumber;
private String owner;
private double balance;
public BankAccount(String accountNumber, String owner) {
this.accountNumber = accountNumber;
this.owner = owner;
this.balance = 0.0;
}
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
System.out.println("Deposited: " + amount);
System.out.println("Balance: " + balance);
} else {
System.out.println("Deposit must be positive.");
}
}
public void withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
System.out.println("Withdrew: " + amount);
System.out.println("Balance: " + balance);
} else {
System.out.println("Withdrawal failed (insufficient funds or invalid amount).");
}
}
public void printInfo() {
System.out.println("=== Account ===");
System.out.println("Number: " + accountNumber);
System.out.println("Owner: " + owner);
System.out.println("Balance: " + balance);
}
public static void main(String[] args) {
BankAccount account = new BankAccount("123-456-789", "Jane Doe");
account.printInfo();
account.deposit(10000);
account.deposit(5000);
account.withdraw(3000);
account.withdraw(20000);
account.printInfo();
}
}
The class keeps balance private and only changes it through deposit and withdraw, which enforce the rules. That is encapsulation doing real work: no caller can overdraw the account by assigning to the field. Two things would change in production code. Money would use java.math.BigDecimal or a long count of cents instead of double, because binary floating point cannot represent most decimal amounts exactly and rounding errors accumulate. And failed operations would throw an exception (for example IllegalArgumentException) or return a result, rather than printing a message, so the caller can react. printInfo() printing 10000.0 for a whole amount is another hint that double is the wrong type here.
Common beginner mistakes
Class name vs file name
Public class Hello must live in Hello.java. Otherwise javac reports error: class Hello is public, should be declared in a file named Hello.java. The check is case-sensitive, even on Windows, where the file system itself is not.
Wrong main signature
Must be public static void main(String[] args) (in Java 21 and earlier). A typo such as Main or a missing static compiles fine, because it is just another method, and fails only at launch with Error: Main method not found in class Hello, please define the main method as: public static void main(String[] args).
Missing semicolons
Every statement needs ; where Java requires it. The compiler reports ';' expected usually on the line after the real mistake, or at the end of the offending line, so look one line up when the reported line looks fine.
Case sensitivity
System not system. The error is cannot find symbol or package system does not exist.
Comparing strings with ==
== compares references, so input == "yes" can be false even when input contains yes, typically when the string was read from Scanner or built at runtime. It sometimes appears to work with literals because identical literals share one object, which makes the bug intermittent. Always use input.equals("yes"), or "yes".equals(input) to be safe when input may be null.
Compile and run pipeline
Source (.java) → javac → bytecode (.class) → JVM (class loading, verification, JIT) → native execution
Same .class runs on Windows, macOS, and Linux JVMs.
Portability has one direction, though: bytecode compiled by a newer javac does not run on an older JVM. Running a class compiled with JDK 21 on Java 17 fails with UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 61.0. Class file version 65 is Java 21 and 61 is Java 17. javac --release 17 produces bytecode for an older target and also checks that you do not use newer library APIs. The class loader verifies bytecode before running it, which is part of why a corrupted or hand-edited .class file is rejected instead of crashing the JVM.
Conventions and habits
- Follow conventions:
PascalCasetypes,camelCasemembers,UPPER_SNAKE_CASEconstants. - Use
//,/* */, and Javadoc/** */for documentation. - Organize code with
packageandimport. - Debug with IDE breakpoints and step commands.
- Prefer LTS releases such as 21 or 25 for new projects.
- Move to a build tool (Maven or Gradle) as soon as you use a third-party library; managing classpaths and JAR files by hand does not scale past a few files.
Modern Java snapshots
| Version | Notes |
|---|---|
| Java 8 | Lambdas, streams, Optional |
| Java 11 | HTTP client, single-file java Foo.java, var in lambda parameters, LTS (var itself arrived in 10) |
| Java 17 | Sealed classes, records, pattern matching for instanceof, LTS |
| Java 21 | Virtual threads, record patterns, pattern matching for switch, LTS |
| Java 25 | Compact source files and instance main, LTS |
LTS releases (8, 11, 17, 21, 25) are common choices for production.
// Java 10+: local variable type inference
var name = "Jane";
var age = 25;
var list = new java.util.ArrayList<String>();
// Java 14+: switch expressions (illustrative)
String day = "Mon";
String kind = switch (day) {
case "Mon", "Tue", "Wed", "Thu", "Fri" -> "Weekday";
case "Sat", "Sun" -> "Weekend";
default -> "Unknown";
};
// Java 16+: records (example name distinct from class Person above)
record Contact(String name, int age) {}
Contact c = new Contact("Jane", 25);
System.out.println(c.name());
See release notes when upgrading.
These snippets are fragments that belong inside a method (a record declaration can also sit at the top level of a file). var does not make Java dynamically typed: the compiler infers the type once from the initializer (String, int, ArrayList<String>) and it cannot change later, so var x; without an initializer does not compile. Switch expressions return a value and must cover every case, which is why default is required for a String. A record generates the constructor, accessors (c.name(), not getName()), equals, hashCode and toString for an immutable data carrier, replacing a lot of boilerplate like the Person getters above.
Related Articles
Frequently Asked Questions (FAQ)
Q. Why do java -version and javac -version report different versions?
A. Usually more than one JDK or JRE is installed and a different java appears earlier on PATH than %JAVA_HOME%\bin. On Windows, an installer may have added its own Java entry ahead of yours, so check with where java; on macOS and Linux use which -a java, and on Linux update-alternatives --config java to pick the default. The mismatch matters because a class compiled by a newer javac fails on an older JVM with UnsupportedClassVersionError, so make JAVA_HOME and the first java on PATH point to the same JDK 21.