Skip to content
Author Nejat Hakan
eMail nejat.hakan@outlook.de
PayPal Me https://paypal.me/nejathakan


Java programming language

Introduction

Welcome to the world of Java programming on the Linux platform! Java is a powerful, versatile, object-oriented programming language known for its "Write Once, Run Anywhere" (WORA) philosophy. This means compiled Java code can run on any platform that supports Java without needing recompilation, making it exceptionally suitable for the diverse landscape of Linux distributions and server environments.

What is Java?

Java was developed by Sun Microsystems (now owned by Oracle) and first released in 1995. Its design goals included simplicity, object-orientation, robustness, security, and platform independence. Key components enable this:

  1. Java Virtual Machine (JVM): An abstract computing machine that enables a computer to run Java programs. The JVM interprets compiled Java bytecode (intermediate language) and translates it into native machine code for the underlying operating system and hardware. This abstraction layer is the core of Java's platform independence. The JVM manages memory (garbage collection) and provides a secure environment. Different vendors offer JVM implementations optimized for various platforms, including numerous Linux distributions.
  2. Java Runtime Environment (JRE): The software package that contains what is required to run Java programs. It includes the JVM, Java class libraries (the standard library providing pre-built functionalities like I/O, networking, data structures, etc.), and other components necessary for execution. If you only need to run Java applications on your Linux system, installing the JRE is sufficient.
  3. Java Development Kit (JDK): The superset of the JRE. The JDK includes everything in the JRE plus development tools necessary for developing Java applications. These tools include the Java compiler (javac), the Java archiver (jar), the Java debugger (jdb), documentation generator (javadoc), and other utilities. As a developer learning Java on Linux, you will need to install the JDK.

Why Learn Java on Linux?

Linux is a dominant operating system in server environments, cloud computing, and embedded systems, areas where Java is heavily utilized. Learning Java on Linux provides several advantages:

  • Industry Relevance: Many enterprise applications, big data processing frameworks (like Hadoop and Spark), and Android applications are built using Java and often deployed on Linux servers.
  • Open Source Ecosystem: Both Linux and much of the Java ecosystem (like OpenJDK, Apache libraries, build tools like Maven and Gradle) are open source, fostering a collaborative and accessible development environment.
  • Powerful Command Line: Linux provides a powerful command-line interface (CLI) which integrates well with Java development tools, build systems, and version control systems like Git.
  • Customization and Control: Linux offers deep control over the system environment, allowing developers to fine-tune performance and manage dependencies effectively.

Setting up the Java Development Environment on Linux

Before writing any Java code, you need to install the JDK on your Linux system. You generally have two main choices for JDK distributions: OpenJDK (the open-source reference implementation) and Oracle JDK (a commercial build from Oracle, though often free for development use). For most learning and development purposes, OpenJDK is excellent and readily available in most Linux distribution repositories.

Installation Steps (using package managers):

The exact package names might vary slightly between distributions and versions. Always prefer installing from your distribution's official repositories for better integration and easier updates.

  1. Update Package Lists: Open your terminal and refresh your package manager's list of available packages.

    • On Debian/Ubuntu-based systems (like Mint, Pop!_OS):
      sudo apt update
      
    • On Fedora/CentOS/RHEL-based systems:
      sudo dnf update
      # or for older systems:
      # sudo yum update
      
  2. Search for Available JDK Packages: You can search to see which versions are available. We typically want a recent Long-Term Support (LTS) version like JDK 11, 17, or 21, or the latest stable release.

    • On Debian/Ubuntu:
      apt search openjdk
      # Look for packages like 'openjdk-17-jdk', 'openjdk-21-jdk'
      
    • On Fedora/CentOS/RHEL:
      dnf search openjdk
      # Look for packages like 'java-17-openjdk-devel', 'java-21-openjdk-devel'
      # (Note: '-devel' usually includes the JDK, while the base package is just the JRE)
      
  3. Install the Chosen JDK: Install the desired JDK version. Let's assume you choose OpenJDK 17.

    • On Debian/Ubuntu:
      sudo apt install openjdk-17-jdk
      
    • On Fedora/CentOS/RHEL:
      sudo dnf install java-17-openjdk-devel
      
      Follow the prompts to complete the installation.
  4. Verify Installation: Check if Java is installed correctly and which version is active.

    java -version
    javac -version
    
    Both commands should output the version number you installed (e.g., OpenJDK 17.x.x). If javac -version gives an error but java -version works, you might have only installed the JRE, not the full JDK (ensure you installed the -jdk or -devel package).

  5. Managing Multiple Java Versions (Optional but common): Linux systems often provide tools to manage multiple installed Java versions.

    • On Debian/Ubuntu: Use update-alternatives.
      # List available Java installations
      sudo update-alternatives --config java
      sudo update-alternatives --config javac
      
      # Select the desired version from the list presented
      
    • On Fedora/CentOS/RHEL: Use alternatives.
      # List available Java installations
      sudo alternatives --config java
      sudo alternatives --config javac
      
      # Select the desired version from the list presented
      
  6. Setting the JAVA_HOME Environment Variable (Recommended): Many Java-based tools (like Maven, Gradle, Tomcat) rely on the JAVA_HOME environment variable to locate the JDK installation directory. While not always strictly necessary if using alternatives correctly, it's good practice to set it.

    • Find the JDK installation path. This often involves following the symbolic links managed by alternatives. A common way is:
      # For 'java' alternative
      readlink -f $(which java)
      # This might give something like /usr/lib/jvm/java-17-openjdk-amd64/bin/java
      # Your JAVA_HOME would then be /usr/lib/jvm/java-17-openjdk-amd64
      
    • Edit your shell configuration file. For Bash (the default shell on most Linux systems), this is usually ~/.bashrc. Open it with a text editor (like nano, vim, or gedit):
      nano ~/.bashrc
      
    • Add the following line at the end, replacing the path with the one you found:
      export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
      export PATH=$PATH:$JAVA_HOME/bin
      
      • export JAVA_HOME=...: Sets the variable.
      • export PATH=$PATH:$JAVA_HOME/bin: Adds the JDK's bin directory (containing javac, java, etc.) to your system's execution path, ensuring the commands are found directly. This might be redundant if update-alternatives is correctly configured, but it doesn't hurt.
    • Save the file (e.g., in nano, press Ctrl+X, then Y, then Enter).
    • Apply the changes to your current terminal session:
      source ~/.bashrc
      
      Or simply close and reopen your terminal.
    • Verify JAVA_HOME is set:
      echo $JAVA_HOME
      
      This should print the path you set.

You now have a fully functional Java Development Kit installed and configured on your Linux system, ready to compile and run Java applications.

Workshop Install JDK and Run a Simple Hello Linux Program

This workshop guides you through compiling and running your very first Java program directly from the Linux terminal.

Objective: Verify your JDK installation by creating, compiling, and running a basic "Hello, Linux!" Java program.

Steps:

  1. Open Your Terminal: Launch your preferred terminal emulator on Linux.

  2. Create a Project Directory: Make a dedicated directory for this small project to keep things organized.

    mkdir ~/javabasics
    cd ~/javabasics
    

    • mkdir ~/javabasics: Creates a directory named javabasics inside your home directory (~).
    • cd ~/javabasics: Changes the current working directory to the newly created javabasics.
  3. Create the Java Source File: Use a text editor (like nano, vim, gedit, or any other editor you prefer) to create a file named HelloLinux.java.

    nano HelloLinux.java
    

  4. Write the Java Code: Enter the following Java code into the text editor:

    /*
     * This is a simple Java program.
     * File Name : HelloLinux.java
     */
    public class HelloLinux {
        // The main method begins program execution.
        public static void main(String[] args) {
            // Print "Hello, Linux!" to the console.
            System.out.println("Hello, Linux!");
            System.out.println("Java Version: " + System.getProperty("java.version"));
            System.out.println("Running on OS: " + System.getProperty("os.name") + " " + System.getProperty("os.version"));
        }
    }
    
    • public class HelloLinux: Declares a public class named HelloLinux. The file name must match the public class name (HelloLinux.java).
    • public static void main(String[] args): This is the main method. It's the entry point for any Java application.
      • public: Accessible from anywhere.
      • static: Belongs to the class itself, not a specific object. You can run it without creating an object of HelloLinux.
      • void: Does not return any value.
      • main: Special name recognized by the JVM as the starting point.
      • (String[] args): Accepts an array of strings as command-line arguments.
    • System.out.println(...): A standard Java statement to print a line of text to the console (standard output).
    • System.getProperty(...): A way to access system properties like the Java version or OS name.
  5. Save and Exit: Save the file and exit the text editor (e.g., in nano, press Ctrl+X, then Y, then Enter).

  6. Compile the Java Code: Use the javac command (the Java compiler) to compile your source file (.java) into bytecode (.class).

    javac HelloLinux.java
    

    • If there are no errors, this command will silently create a new file named HelloLinux.class in the same directory (~/javabasics).
    • You can verify its creation using the ls command:
      ls
      # You should see both HelloLinux.java and HelloLinux.class
      
    • If you encounter errors, read them carefully. They usually indicate typos or syntax mistakes in your .java file. Re-open the file with your editor, fix the errors, save, and try compiling again.
  7. Run the Compiled Code: Use the java command (the Java Virtual Machine launcher) to execute your compiled bytecode. Note that you provide the class name without the .class extension.

    java HelloLinux
    

  8. Observe the Output: You should see the following output in your terminal:

    Hello, Linux!
    Java Version: [Your installed Java version, e.g., 17.0.x]
    Running on OS: Linux [Your Linux kernel version]
    

Congratulations! You have successfully set up your Java development environment on Linux, written your first Java program, compiled it using javac, and executed it using the java command. This fundamental compile-run cycle is central to Java development, especially when working directly in the terminal.

1. Basic Java Concepts

This section covers the foundational building blocks of the Java language. Understanding these concepts is crucial before moving on to more complex topics like Object-Oriented Programming.

Your First Java Program Revisited

Let's dissect the HelloLinux.java program more formally to understand its structure:

// Single-line comment

/*
 * Multi-line comment block.
 * Used for longer explanations.
 */

/**
 * Javadoc comment. Used to generate documentation.
 * Describes the class or method below it.
 * @author Your Name
 * @version 1.0
 */
public class HelloLinux { // Class declaration: defines a blueprint named HelloLinux

    // Method declaration: defines the main entry point
    public static void main(String[] args) {
        // Statement: An instruction to be executed. Ends with a semicolon (;).
        System.out.println("Hello, Linux!"); // Prints text to the console.

        // Another statement accessing system properties.
        System.out.println("OS: " + System.getProperty("os.name"));
    } // End of main method

} // End of HelloLinux class

Key Components:

  • Comments: Ignored by the compiler, used for human-readable explanations. Java supports single-line (//), multi-line (/* ... */), and Javadoc (/** ... */) comments.
  • Class Declaration: public class ClassName { ... }. Every Java application must contain at least one class. The public keyword is an access modifier (more on this later). The class name (HelloLinux) should follow CamelCase convention (starting with an uppercase letter). The code for the class resides within the curly braces {}.
  • main Method: public static void main(String[] args) { ... }. This is the special method the JVM looks for to start execution.
    • public: Accessible from outside the class.
    • static: Can be called without creating an object of the class.
    • void: Indicates the method doesn't return a value.
    • main: The name the JVM searches for.
    • String[] args: Declares a parameter named args, which is an array of String objects. This array holds any command-line arguments passed to the program when it's executed.
  • Statements: Instructions within methods that perform actions. Each statement typically ends with a semicolon (;). System.out.println("...") is a statement that calls the println method from the System.out object to display output.
  • Blocks: Code enclosed in curly braces {} defines a block. Blocks define scope for variables and group statements (e.g., the class body, the method body).

Variables and Data Types

Variables are containers for storing data values. In Java, every variable must be declared with a specific data type before it can be used. Java is a statically-typed language, meaning the type of a variable is checked at compile time.

Java has two categories of data types:

1. Primitive Data Types:
These are the most basic data types built into the language. They store simple values directly.

  • Integer Types:
    • byte: 8-bit signed integer. Range: -128 to 127. Use when memory saving is critical.
      byte smallNumber = 100;
      
    • short: 16-bit signed integer. Range: -32,768 to 32,767. Less common.
      short mediumNumber = 30000;
      
    • int: 32-bit signed integer. Range: -231 to 231-1 (approx. -2.1 billion to 2.1 billion). The most commonly used integer type.
      int age = 30;
      int population = 1500000;
      
    • long: 64-bit signed integer. Range: -263 to 263-1. Used for very large whole numbers. Suffix L or l is required for literal values outside the int range.
      long nationalDebt = 25000000000000L;
      
  • Floating-Point Types: Used for numbers with fractional parts.
    • float: 32-bit single-precision floating-point. Suffix F or f is required for literal values. Use when precision is less critical than memory usage.
      float price = 19.99F;
      
    • double: 64-bit double-precision floating-point. More precise and generally the default choice for decimal values. Suffix D or d is optional.
      double pi = 3.1415926535;
      double accountBalance = 12345.67;
      
  • Character Type:
    • char: 16-bit Unicode character. Represents a single character, enclosed in single quotes (').
      char grade = 'A';
      char symbol = '$';
      char newline = '\n'; // Special escape sequence for newline
      
  • Boolean Type:
    • boolean: Represents one bit of information, but its size isn't precisely defined by the spec. Can only have two values: true or false. Used for logical conditions.
      boolean isLoggedIn = true;
      boolean hasErrors = false;
      

2. Reference Data Types (Non-Primitive):
These refer to objects (instances of classes). They don't store the object data directly but hold a memory address (a reference) where the object is located on the heap.

  • Classes: Blueprints for creating objects (e.g., String, Scanner, ArrayList, or custom classes you define like HelloLinux).
    String greeting = "Hello there!"; // String is a built-in class
    Scanner inputReader = new Scanner(System.in); // Scanner object
    HelloLinux myApp = new HelloLinux(); // Object of our custom class
    
  • Interfaces: A contract specifying methods a class must implement.
  • Arrays: Fixed-size containers holding elements of the same type (primitive or reference).
    int[] numbers = {10, 20, 30}; // Array of integers
    String[] names = new String[5]; // Array of 5 String references (initially null)
    

Variable Declaration and Initialization:

// Declaration: reserves memory based on type
int count;
String message;

// Initialization: assigns an initial value
count = 10;
message = "Initialization complete.";

// Declaration and Initialization combined
double salary = 75000.50;
boolean isActive = true;

Type Casting: Converting a value from one data type to another.

  • Widening Casting (Implicit): Converting a smaller type to a larger type size. Done automatically by Java.
    int myInt = 9;
    double myDouble = myInt; // Automatic casting from int to double
    System.out.println(myDouble); // Output: 9.0
    
  • Narrowing Casting (Explicit): Converting a larger type to a smaller size type. Requires manual casting using (targetType) and may result in data loss.
    double piApprox = 3.14;
    int integerPi = (int) piApprox; // Explicit cast from double to int
    System.out.println(integerPi); // Output: 3 (fractional part lost)
    
    int largeInt = 300;
    byte smallByte = (byte) largeInt; // Explicit cast from int to byte
    System.out.println(smallByte); // Output: 44 (value wraps around due to overflow)
    

Variable Scope: The region of the program where a variable is accessible.

  • Local Variables: Declared inside a method or block. Only accessible within that method/block after their declaration. They don't have default values and must be initialized before use.
  • Instance Variables: Declared inside a class but outside any method. Belong to an object (instance) of the class. Each object has its own copy. They have default values (0 for numbers, false for boolean, \u0000 for char, null for references).
  • Class Variables (Static Variables): Declared with the static keyword inside a class but outside any method. Belong to the class itself, not individual objects. There's only one copy shared among all objects of the class. Also have default values.

Operators

Operators perform operations on variables and values (operands).

  • Arithmetic Operators: + (addition, also string concatenation), - (subtraction), * (multiplication), / (division), % (modulus/remainder).
    int a = 10;
    int b = 3;
    System.out.println("a + b = " + (a + b)); // 13
    System.out.println("a / b = " + (a / b)); // 3 (integer division truncates)
    System.out.println("a % b = " + (a % b)); // 1 (remainder)
    double c = 10.0;
    System.out.println("c / b = " + (c / b)); // 3.333... (floating-point division)
    
  • Relational Operators: Used for comparison, result in a boolean value. == (equal to), != (not equal to), > (greater than), < (less than), >= (greater than or equal to), <= (less than or equal to).
    int x = 5;
    int y = 7;
    System.out.println("x == y: " + (x == y)); // false
    System.out.println("x < y: " + (x < y));  // true
    
  • Logical Operators: Combine boolean expressions. && (logical AND - short-circuiting), || (logical OR - short-circuiting), ! (logical NOT).
    boolean condition1 = true;
    boolean condition2 = false;
    System.out.println("condition1 && condition2: " + (condition1 && condition2)); // false
    System.out.println("condition1 || condition2: " + (condition1 || condition2)); // true
    System.out.println("!condition1: " + (!condition1)); // false
    
    Short-circuiting means the second operand is evaluated only if necessary (e.g., in A && B, if A is false, B is not evaluated).
  • Bitwise Operators: Operate on individual bits of integer types. & (bitwise AND), | (bitwise OR), ^ (bitwise XOR), ~ (bitwise complement), << (left shift), >> (signed right shift), >>> (unsigned right shift). Less common in general application programming but useful in specific low-level tasks.
  • Assignment Operators: Assign values to variables. = (simple assignment), +=, -=, *=, /=, %= (compound assignment).
    int score = 0;
    score += 10; // Equivalent to score = score + 10; (score is now 10)
    score *= 2;  // Equivalent to score = score * 2; (score is now 20)
    
  • Ternary Operator: A shorthand for an if-else statement. condition ? value_if_true : value_if_false.
    int age = 20;
    String category = (age >= 18) ? "Adult" : "Minor";
    System.out.println(category); // Output: Adult
    
  • Increment/Decrement Operators: ++ (increment by 1), -- (decrement by 1). Can be prefix (++var) or postfix (var++).
    • Prefix: Increments/decrements value before using it in the expression.
    • Postfix: Increments/decrements value after using it in the expression.
      int counter = 5;
      System.out.println(++counter); // Output: 6 (counter becomes 6, then printed)
      System.out.println(counter++); // Output: 6 (current value 6 printed, then counter becomes 7)
      System.out.println(counter);   // Output: 7
      

Control Flow Statements

These statements alter the normal sequential execution of code, allowing for decision-making and repetition.

1. Decision Making:

  • if Statement: Executes a block of code only if a condition is true.
    int temperature = 15;
    if (temperature < 20) {
        System.out.println("Wear a jacket.");
    }
    
  • if-else Statement: Executes one block if the condition is true, and another block if it's false.
    int score = 75;
    if (score >= 60) {
        System.out.println("Passed!");
    } else {
        System.out.println("Failed.");
    }
    
  • if-else if-else Statement: Checks multiple conditions in sequence.
    int grade = 85;
    if (grade >= 90) {
        System.out.println("Grade: A");
    } else if (grade >= 80) {
        System.out.println("Grade: B");
    } else if (grade >= 70) {
        System.out.println("Grade: C");
    } else {
        System.out.println("Grade: D or F");
    }
    
  • switch Statement: Selects one of many code blocks to be executed based on the value of an expression (typically int, byte, short, char, String, or enum).
    int dayOfWeek = 3; // 1=Mon, 2=Tue, ...
    String dayName;
    
    switch (dayOfWeek) {
        case 1:
            dayName = "Monday";
            break; // Exits the switch statement
        case 2:
            dayName = "Tuesday";
            break;
        case 3:
            dayName = "Wednesday";
            break; // IMPORTANT! Without break, execution "falls through"
        case 4:
            dayName = "Thursday";
            break;
        case 5:
            dayName = "Friday";
            break;
        case 6:
        case 7: // Multiple cases can lead to the same block
            dayName = "Weekend";
            break;
        default: // Optional: Executes if no case matches
            dayName = "Invalid day";
            break;
    }
    System.out.println("Today is " + dayName); // Output: Today is Wednesday
    
    Modern switch expressions (Java 14+) offer a more concise syntax:
    // Example using switch expression (requires Java 14+)
    /*
    dayName = switch (dayOfWeek) {
        case 1 -> "Monday";
        case 2 -> "Tuesday";
        case 3 -> "Wednesday";
        case 4 -> "Thursday";
        case 5 -> "Friday";
        case 6, 7 -> "Weekend";
        default -> "Invalid day";
    };
    System.out.println("Today is " + dayName);
    */
    

2. Looping (Iteration):

  • for Loop: Executes a block of code a specific number of times. Ideal when the number of iterations is known beforehand.
    // Print numbers 0 to 4
    for (int i = 0; i < 5; i++) { // initialization; condition; update
        System.out.println("Iteration: " + i);
    }
    
  • while Loop: Executes a block of code as long as a condition is true. The condition is checked before each iteration.
    int count = 0;
    while (count < 3) {
        System.out.println("Count is: " + count);
        count++;
    }
    
  • do-while Loop: Similar to while, but the condition is checked after the block executes. Guarantees the block runs at least once.
    int tries = 0;
    do {
        System.out.println("Attempting connection... Try #" + (tries + 1));
        // Simulate connection attempt logic here
        tries++;
    } while (tries < 3 /* && !connectionSuccessful */); // Condition checked after the block
    
  • Enhanced for Loop (For-Each Loop): Provides a simpler way to iterate over elements in an array or collection.
    String[] fruits = {"Apple", "Banana", "Orange"};
    for (String fruit : fruits) { // For each String 'fruit' in the 'fruits' array
        System.out.println("Fruit: " + fruit);
    }
    
    // Also works with collections (covered later)
    // List<Integer> numbers = List.of(1, 2, 3);
    // for (int number : numbers) {
    //     System.out.println(number);
    // }
    

3. Branching Statements:

  • break: Terminates the innermost switch, for, while, or do-while statement immediately.
    for (int i = 0; i < 10; i++) {
        if (i == 5) {
            break; // Exit the loop when i is 5
        }
        System.out.print(i + " "); // Output: 0 1 2 3 4
    }
    System.out.println("\nLoop finished.");
    
  • continue: Skips the current iteration of the innermost for, while, or do-while loop and proceeds to the next iteration.
    for (int i = 0; i < 5; i++) {
        if (i == 2) {
            continue; // Skip iteration when i is 2
        }
        System.out.print(i + " "); // Output: 0 1 3 4
    }
    System.out.println("\nLoop finished.");
    
  • return: Exits the current method. Can optionally return a value if the method's return type is not void. We saw return implicitly at the end of the void main method.

Input and Output

Basic interaction with the user or the system often involves reading input and displaying output.

Output:

We've already been using the primary method for standard output:

  • System.out.println(data): Prints the data followed by a newline character.
  • System.out.print(data): Prints the data without a newline character.
  • System.out.printf(formatString, args...): Prints formatted output, similar to C's printf. Allows for precise control over alignment, padding, and number formats.
    String name = "Alice";
    int age = 30;
    double balance = 123.456;
    
    System.out.printf("User: %s, Age: %d\n", name, age); // %s for string, %d for integer, \n for newline
    System.out.printf("Balance: %.2f\n", balance); // %.2f for float/double with 2 decimal places
    System.out.printf("Padded Age: [%5d]\n", age); // %5d reserves 5 spaces, right-aligned
    System.out.printf("Left Padded Name: [%-10s]\n", name); // %-10s reserves 10 spaces, left-aligned
    

Input:

Reading input, typically from the keyboard (standard input), commonly involves the Scanner class from the java.util package.

import java.util.Scanner; // Import the Scanner class

public class UserInputDemo {
    public static void main(String[] args) {
        // 1. Create a Scanner object linked to standard input (System.in)
        Scanner input = new Scanner(System.in);

        // 2. Prompt the user
        System.out.print("Enter your name: ");
        // 3. Read a line of text
        String userName = input.nextLine();

        // 2. Prompt for age
        System.out.print("Enter your age: ");
        // 3. Read an integer
        int userAge = input.nextInt();

        // 2. Prompt for salary
        System.out.print("Enter your desired salary: ");
        // 3. Read a double
        double userSalary = input.nextDouble();

        // Consume the leftover newline character after nextDouble() or nextInt()
        // This is important if you plan to call nextLine() afterwards
        input.nextLine();

        // Display the collected information
        System.out.println("\n--- User Information ---");
        System.out.printf("Name: %s\n", userName);
        System.out.printf("Age: %d\n", userAge);
        System.out.printf("Desired Salary: %.2f\n", userSalary);

        // 4. Close the Scanner (important to release system resources)
        input.close();
    }
}

Explanation:

  1. import java.util.Scanner;: Makes the Scanner class available.
  2. Scanner input = new Scanner(System.in);: Creates a Scanner object named input that reads from the standard input stream (System.in, usually the keyboard).
  3. input.nextLine(): Reads the entire line of text input until the user presses Enter.
  4. input.nextInt(): Reads the next token of input as an int.
  5. input.nextDouble(): Reads the next token of input as a double.
  6. Important: After reading numbers (nextInt, nextDouble, etc.), the newline character (\n) entered by the user remains in the input buffer. If the next input you want to read is a full line using nextLine(), it will consume this leftover newline and return an empty string immediately. To prevent this, call input.nextLine(); after reading a number if you intend to read a line subsequently.
  7. input.close();: Releases the resources associated with the Scanner. It's crucial to close scanners linked to system resources like System.in or files when you are finished with them.

Workshop Simple Calculator Application

Objective: Build a command-line calculator that takes two numbers and an operator from the user and performs the corresponding arithmetic operation. This will practice variables, data types, operators, control flow (switch or if-else if), and user input (Scanner).

Steps:

  1. Navigate to Your Project Directory: Open your Linux terminal and cd into the directory where you want to save this project (e.g., ~/javabasics).

    cd ~/javabasics
    

  2. Create the Java Source File: Create a file named Calculator.java.

    nano Calculator.java
    

  3. Write the Java Code: Enter the following code into the editor. Read the comments carefully to understand each part.

    import java.util.Scanner; // Needed for user input
    
    public class Calculator {
    
        public static void main(String[] args) {
    
            Scanner scanner = new Scanner(System.in); // Create Scanner object
    
            System.out.println("=== Simple Command-Line Calculator ===");
    
            // --- Get First Number ---
            System.out.print("Enter the first number: ");
            double num1;
            // Basic input validation loop
            while (!scanner.hasNextDouble()) {
                System.out.println("Invalid input. Please enter a valid number.");
                System.out.print("Enter the first number: ");
                scanner.next(); // Consume the invalid input
            }
            num1 = scanner.nextDouble();
    
            // --- Get Operator ---
            System.out.print("Enter the operator (+, -, *, /): ");
            char operator;
            // Loop until a valid operator is entered
            while (true) {
                String opInput = scanner.next(); // Read operator as a string
                if (opInput.length() == 1 && "+-*/".contains(opInput)) {
                    operator = opInput.charAt(0); // Get the first character
                    break; // Exit loop if valid
                } else {
                    System.out.println("Invalid operator. Please enter +, -, *, or /.");
                    System.out.print("Enter the operator: ");
                }
            }
    
            // --- Get Second Number ---
            System.out.print("Enter the second number: ");
            double num2;
            while (!scanner.hasNextDouble()) {
                System.out.println("Invalid input. Please enter a valid number.");
                System.out.print("Enter the second number: ");
                scanner.next(); // Consume invalid input
            }
            num2 = scanner.nextDouble();
    
    
            // --- Perform Calculation ---
            double result = 0.0;
            boolean calculationSuccessful = true; // Flag to track success
    
            switch (operator) {
                case '+':
                    result = num1 + num2;
                    break;
                case '-':
                    result = num1 - num2;
                    break;
                case '*':
                    result = num1 * num2;
                    break;
                case '/':
                    if (num2 != 0) { // Check for division by zero
                        result = num1 / num2;
                    } else {
                        System.out.println("Error: Division by zero is not allowed.");
                        calculationSuccessful = false; // Mark calculation as failed
                    }
                    break;
                // Default case is not strictly needed here due to input validation,
                // but good practice in general switches.
                default:
                    System.out.println("Internal error: Invalid operator encountered.");
                    calculationSuccessful = false;
                    break;
            }
    
            // --- Display Result ---
            if (calculationSuccessful) {
                System.out.printf("\nResult: %.2f %c %.2f = %.2f\n", num1, operator, num2, result);
            } else {
                System.out.println("Calculation could not be performed.");
            }
    
            // --- Close Scanner ---
            scanner.close(); // Release resources
            System.out.println("\nCalculator finished.");
        }
    }
    
  4. Save and Exit: Save the file (Ctrl+X, Y, Enter in nano).

  5. Compile the Code:

    javac Calculator.java
    
    Fix any compilation errors if they occur.

  6. Run the Calculator:

    java Calculator
    

  7. Interact with the Program: The calculator will prompt you to enter the first number, the operator, and the second number. Try different inputs, including invalid ones (like letters for numbers or incorrect operators) to see how the input validation works. Also, test division by zero.

    Example Interaction:

    === Simple Command-Line Calculator ===
    Enter the first number: 10.5
    Enter the operator (+, -, *, /): *
    Enter the second number: 3
    
    Result: 10.50 * 3.00 = 31.50
    
    Calculator finished.
    
    Another Example (Division by Zero):
    === Simple Command-Line Calculator ===
    Enter the first number: 5
    Enter the operator (+, -, *, /): /
    Enter the second number: 0
    Error: Division by zero is not allowed.
    Calculation could not be performed.
    
    Calculator finished.
    
    Example (Invalid Input):
    === Simple Command-Line Calculator ===
    Enter the first number: abc
    Invalid input. Please enter a valid number.
    Enter the first number: 12
    Enter the operator (+, -, *, /): x
    Invalid operator. Please enter +, -, *, or /.
    Enter the operator: +
    Enter the second number: 5
    
    Result: 12.00 + 5.00 = 17.00
    
    Calculator finished.
    

This workshop provided practical experience with core Java syntax, input/output handling, error checking (division by zero, basic input validation), and control flow structures (while, switch).

2. Object-Oriented Programming (OOP) Fundamentals

Object-Oriented Programming (OOP) is a programming paradigm based on the concept of "objects", which can contain data in the form of fields (often known as attributes or properties) and code in the form of procedures (often known as methods). Java was designed from the ground up as an object-oriented language. Understanding OOP principles is fundamental to writing effective Java code.

Introduction to OOP

OOP aims to model real-world entities and their interactions within software. It provides a way to structure programs so that properties and behaviors are bundled together. The four main principles of OOP are:

  1. Encapsulation: Bundling data (attributes) and methods (behaviors) that operate on the data within a single unit (an object). It also involves restricting direct access to some of an object's components (data hiding), which is a key aspect of protecting data integrity. Think of a car: the engine, wheels, and steering (data/attributes) are encapsulated within the car object. You interact with it through defined interfaces like the steering wheel, accelerator, and brakes (methods), without needing to know the intricate details of how the engine works internally.
  2. Inheritance: A mechanism where a new class (subclass or derived class) inherits properties and methods from an existing class (superclass or base class). This promotes code reuse and establishes a hierarchical relationship ("is-a" relationship). For example, a Car class and a Truck class can both inherit common properties like numberOfWheels and engineType from a more general Vehicle class. Car "is-a" Vehicle, and Truck "is-a" Vehicle.
  3. Polymorphism: Literally meaning "many forms". It allows objects of different classes to respond to the same message (method call) in different ways. This enables flexibility and extensibility. For example, both a Car object and a Truck object might have a startEngine() method, but the internal implementation of how the engine starts could be different for each. You can call vehicle.startEngine() regardless of whether vehicle currently refers to a Car or a Truck.
  4. Abstraction: Hiding complex implementation details and exposing only the essential features of an object. It focuses on what an object does rather than how it does it. Abstract classes and interfaces are key tools for achieving abstraction in Java. For example, when you drive a car, you interact with the abstract concept of "steering" via the steering wheel, without needing to understand the underlying mechanics of the steering rack, power steering pump, etc.

Objects and Classes:

  • Class: A blueprint or template for creating objects. It defines the common properties (attributes/fields) and behaviors (methods) that all objects of that class will have. Example: The blueprint for a "Dog". It defines that a dog has attributes like breed, age, color, and behaviors like bark(), wagTail(), eat().
  • Object: An instance of a class. It is a concrete entity created from the class blueprint, residing in memory. Each object has its own state (specific values for its attributes) but shares the behavior defined by the class. Example: Your specific dog, "Fido", is an object of the Dog class. Fido might have breed = "Labrador", age = 3, color = "Golden". Another dog object, "Buddy", might have different attribute values. Both Fido and Buddy can perform the bark() action defined in the Dog class.

Classes and Objects

Let's see how to define a class and create objects from it in Java.

Defining a Class:

// File: Dog.java
public class Dog {
    // Instance Variables (Attributes/Fields)
    // These define the state of a Dog object
    String breed;
    int age;
    String color;
    String name;

    // Constructor: A special method used to initialize objects when they are created.
    // It has the same name as the class and no return type.
    public Dog(String dogName, String dogBreed, int dogAge, String dogColor) {
        System.out.println("Creating a new Dog object named " + dogName);
        // 'this' keyword refers to the current object being created.
        // It distinguishes instance variables from constructor parameters.
        this.name = dogName;
        this.breed = dogBreed;
        this.age = dogAge;
        this.color = dogColor;
    }

    // Instance Methods (Behaviors)
    // These define what a Dog object can do
    void bark() {
        System.out.println(name + " says: Woof woof!");
    }

    void wagTail() {
        System.out.println(name + " is wagging its tail.");
    }

    void displayInfo() {
        System.out.println("--- Dog Info ---");
        System.out.println("Name: " + this.name); // 'this' is optional here but clarifies
        System.out.println("Breed: " + this.breed);
        System.out.println("Age: " + this.age);
        System.out.println("Color: " + this.color);
        System.out.println("----------------");
    }
}

Explanation:

  • Instance Variables: breed, age, color, name are declared within the class but outside any method. Each Dog object will have its own copy of these variables.
  • Constructor: public Dog(...). This method is called automatically when you create a new Dog object using the new keyword. Its purpose is to initialize the object's state (instance variables).
  • this Keyword: Inside an instance method or constructor, this refers to the current object whose method or constructor is being called. It's often used to differentiate between instance variables and parameters or local variables with the same name (e.g., this.name = dogName;).
  • Instance Methods: bark(), wagTail(), displayInfo() define the actions a Dog object can perform. They operate on the instance variables of the specific object they are called on.

Creating Objects (Instantiation):

You create objects using the new keyword followed by a call to the class's constructor.

// File: DogPark.java
public class DogPark {
    public static void main(String[] args) {
        // Create (instantiate) Dog objects using the 'new' keyword and the constructor
        Dog fido = new Dog("Fido", "Labrador", 3, "Golden"); // Calls the Dog constructor
        Dog buddy = new Dog("Buddy", "Poodle", 5, "White");

        // Access object attributes (generally discouraged directly, prefer methods - see Encapsulation)
        // System.out.println(fido.name); // Output: Fido

        // Call object methods
        fido.displayInfo();
        buddy.displayInfo();

        fido.bark();
        buddy.wagTail();
        fido.wagTail();
    }
}

Compiling and Running:

  1. Save the code above into two files: Dog.java and DogPark.java in the same directory (e.g., ~/javabasics).
  2. Compile both files. You can use a wildcard or list them:
    cd ~/javabasics
    javac Dog.java DogPark.java
    # or
    # javac *.java
    
    This will create Dog.class and DogPark.class.
  3. Run the DogPark class (because it contains the main method):
    java DogPark
    

Output:

Creating a new Dog object named Fido
Creating a new Dog object named Buddy
--- Dog Info ---
Name: Fido
Breed: Labrador
Age: 3
Color: Golden
----------------
--- Dog Info ---
Name: Buddy
Breed: Poodle
Age: 5
Color: White
----------------
Fido says: Woof woof!
Buddy is wagging its tail.
Fido is wagging its tail.

Methods

Methods define the behavior of objects. They encapsulate a sequence of statements to perform a specific task.

Defining Methods:

accessModifier staticKeyword returnType methodName(parameterList) {
    // Method body: statements to perform the task
    // Optionally includes a 'return' statement if returnType is not void
}
  • accessModifier: Controls visibility (e.g., public, private, protected, default). See Encapsulation.
  • staticKeyword: (Optional) If present (static), the method belongs to the class itself, not a specific object. It's called using the class name (e.g., Math.sqrt(9)). If absent, it's an instance method and must be called on an object (e.g., fido.bark()).
  • returnType: The data type of the value the method returns (e.g., int, double, String, Dog, void if it returns nothing).
  • methodName: Name of the method (usually follows camelCaseStartingWithLowercase).
  • parameterList: (Optional) A comma-separated list of input parameters, each with a type and name (e.g., (int count, String message)). These act like local variables within the method.
  • Method Body: Contains the code executed when the method is called.
  • return Statement: Used to send a value back from the method. The type of the returned value must match the returnType. Methods with void return type don't need an explicit return statement, or can use return; to exit early.

Example Methods:

public class CalculatorUtil {

    // Instance method (needs a CalculatorUtil object)
    public double add(double n1, double n2) {
        return n1 + n2; // Returns a double value
    }

    // Static method (can be called directly using CalculatorUtil.addStatic)
    public static double addStatic(double n1, double n2) {
        return n1 + n2;
    }

    // Method with no parameters and no return value
    public void printWelcomeMessage() {
        System.out.println("Welcome to CalculatorUtil!");
    }

    // Method with multiple parameters
    public static void printSum(int a, int b) {
        System.out.printf("%d + %d = %d\n", a, b, (a + b));
    }
}

Method Overloading (Compile-time Polymorphism):

Defining multiple methods within the same class that have the same name but different parameter lists (different number of parameters, different types of parameters, or different order of types). The compiler determines which method to call based on the arguments provided.

public class Greeter {

    public void greet() { // Method 1
        System.out.println("Hello!");
    }

    public void greet(String name) { // Method 2: Overloaded with one String parameter
        System.out.println("Hello, " + name + "!");
    }

    public void greet(String name, int times) { // Method 3: Overloaded with String and int
        for (int i = 0; i < times; i++) {
            System.out.println("Hello, " + name + "!");
        }
    }

    // Invalid overloading: Only changing return type is NOT allowed
    // public String greet(String name) { return "Hello, " + name; } // COMPILE ERROR

    public static void main(String[] args) {
        Greeter greeter = new Greeter();
        greeter.greet();                // Calls Method 1
        greeter.greet("Alice");         // Calls Method 2
        greeter.greet("Bob", 3);        // Calls Method 3
    }
}

Passing Arguments:

  • Pass-by-Value: Java always passes arguments by value.
    • For primitive types, a copy of the value is passed to the method. Changes made to the parameter inside the method do not affect the original variable outside the method.
    • For reference types (objects, arrays), a copy of the reference (memory address) is passed. This means the parameter inside the method points to the same object as the variable outside. If the method modifies the state of the object (changes its instance variables), the changes will be reflected outside the method. However, if the method reassigns the parameter to point to a new object, the original variable outside the method remains unchanged.

Encapsulation

Encapsulation protects the internal state of an object by restricting direct access to its instance variables. Access is typically controlled through public methods (getters and setters).

Access Modifiers: Keywords that control the visibility (accessibility) of classes, interfaces, variables, and methods.

  1. public: Accessible from any other class, anywhere. (Least restrictive).
  2. protected: Accessible within its own package and by subclasses (even if they are in different packages).
  3. (Default/Package-Private): (No keyword used) Accessible only within its own package.
  4. private: Accessible only within the class in which it is declared. (Most restrictive).

Best Practice: Make instance variables private and provide public methods (getters and setters) to access or modify them if needed.

// File: BankAccount.java
public class BankAccount {
    // Instance variables are private - cannot be accessed directly from outside
    private String accountNumber;
    private double balance;
    private String ownerName;

    // Constructor
    public BankAccount(String accountNumber, String ownerName, double initialBalance) {
        this.accountNumber = accountNumber;
        this.ownerName = ownerName;
        // Apply validation: Balance cannot be negative initially
        if (initialBalance >= 0) {
            this.balance = initialBalance;
        } else {
            this.balance = 0;
            System.err.println("Warning: Initial balance cannot be negative. Set to 0.");
        }
    }

    // Getter method for balance (read-only access)
    public double getBalance() {
        return this.balance;
    }

    // Getter method for account number
    public String getAccountNumber() {
        return this.accountNumber; // Account number is usually immutable after creation
    }

    // Getter method for owner name
    public String getOwnerName() {
        return ownerName;
    }

    // Setter method for owner name (allows modification)
    public void setOwnerName(String ownerName) {
        // Could add validation here if needed (e.g., non-empty name)
        this.ownerName = ownerName;
    }

    // Method to deposit funds (modifies balance)
    public void deposit(double amount) {
        if (amount > 0) {
            this.balance += amount;
            System.out.printf("Deposited: %.2f. New balance: %.2f\n", amount, this.balance);
        } else {
            System.out.println("Deposit amount must be positive.");
        }
    }

    // Method to withdraw funds (modifies balance)
    public boolean withdraw(double amount) {
        if (amount <= 0) {
            System.out.println("Withdrawal amount must be positive.");
            return false;
        } else if (amount > this.balance) {
            System.out.println("Insufficient funds.");
            return false;
        } else {
            this.balance -= amount;
            System.out.printf("Withdrew: %.2f. New balance: %.2f\n", amount, this.balance);
            return true;
        }
    }
}

// File: BankDemo.java
public class BankDemo {
    public static void main(String[] args) {
        BankAccount acc1 = new BankAccount("ACC123", "Alice", 1000.0);

        // Cannot access private members directly:
        // System.out.println(acc1.balance); // COMPILE ERROR!
        // acc1.balance = -500; // COMPILE ERROR!

        // Use public getter methods
        System.out.println("Account Owner: " + acc1.getOwnerName());
        System.out.printf("Initial Balance: %.2f\n", acc1.getBalance());

        // Use public methods to interact with the object's state
        acc1.deposit(500.0);
        acc1.withdraw(200.0);
        acc1.withdraw(2000.0); // Insufficient funds

        // Modify owner name using setter
        acc1.setOwnerName("Alice Wonderland");

        System.out.printf("Final Balance for %s: %.2f\n", acc1.getOwnerName(), acc1.getBalance());
    }
}

Benefits of Encapsulation:

  • Data Hiding: Protects internal state from outside interference or misuse.
  • Control: Allows validation logic within setters (e.g., ensuring balance doesn't become negative inappropriately).
  • Flexibility: The internal implementation of the class can change without affecting the classes that use it, as long as the public methods (the interface) remain the same.
  • Maintainability: Code is easier to understand and maintain because related data and behavior are grouped together.

Inheritance

Inheritance allows a class (subclass) to inherit attributes and methods from another class (superclass). It models an "is-a" relationship.

Syntax: Use the extends keyword.

// File: Vehicle.java (Superclass)
public class Vehicle {
    protected String brand; // protected: accessible by subclasses
    protected int year;

    public Vehicle(String brand, int year) {
        System.out.println("Vehicle constructor called");
        this.brand = brand;
        this.year = year;
    }

    public void start() {
        System.out.println("Vehicle starting...");
    }

    public void stop() {
        System.out.println("Vehicle stopping...");
    }

    public void displayInfo() {
        System.out.println("Brand: " + brand + ", Year: " + year);
    }
}

// File: Car.java (Subclass)
public class Car extends Vehicle { // Car inherits from Vehicle
    private int numberOfDoors;

    // Constructor for Car
    public Car(String brand, int year, int numberOfDoors) {
        // Call the superclass constructor *first* using super()
        super(brand, year); // Must be the first statement in subclass constructor
        System.out.println("Car constructor called");
        this.numberOfDoors = numberOfDoors;
    }

    // Method specific to Car
    public void openTrunk() {
        System.out.println("Car trunk opened.");
    }

    // Method Overriding: Providing a specific implementation for an inherited method
    @Override // Annotation: Good practice, checks if method actually overrides
    public void start() {
        // Optionally call the superclass method
        // super.start();
        System.out.println(brand + " car engine starting with a key turn...");
    }

    // Overriding displayInfo to include door count
    @Override
    public void displayInfo() {
        super.displayInfo(); // Call the superclass version first
        System.out.println("Number of Doors: " + numberOfDoors);
    }
}

// File: ElectricCar.java (Subclass of Car)
public class ElectricCar extends Car {
    private int batteryCapacityKWh;

    public ElectricCar(String brand, int year, int numberOfDoors, int batteryCapacity) {
        super(brand, year, numberOfDoors); // Calls Car constructor
        System.out.println("ElectricCar constructor called");
        this.batteryCapacityKWh = batteryCapacity;
    }

    // Overriding start method again
    @Override
    public void start() {
        System.out.println(brand + " electric car silently starting...");
    }

    public void charge() {
        System.out.println("Charging the " + brand + "...");
    }

    @Override
    public void displayInfo() {
        super.displayInfo(); // Calls Car's displayInfo
        System.out.println("Battery Capacity: " + batteryCapacityKWh + " kWh");
    }
}


// File: InheritanceDemo.java
public class InheritanceDemo {
    public static void main(String[] args) {
        System.out.println("--- Creating a Car ---");
        Car myCar = new Car("Toyota", 2021, 4);
        myCar.start();       // Calls Car's overridden start()
        myCar.displayInfo(); // Calls Car's overridden displayInfo()
        myCar.openTrunk();   // Calls Car's specific method
        myCar.stop();        // Calls inherited Vehicle's stop()

        System.out.println("\n--- Creating an Electric Car ---");
        ElectricCar myTesla = new ElectricCar("Tesla", 2023, 4, 100);
        myTesla.start();       // Calls ElectricCar's overridden start()
        myTesla.charge();      // Calls ElectricCar's specific method
        myTesla.displayInfo(); // Calls ElectricCar's overridden displayInfo()
        myTesla.openTrunk();   // Calls inherited Car's openTrunk()
        myTesla.stop();        // Calls inherited Vehicle's stop()
    }
}

Key Concepts in Inheritance:

  • extends: Keyword used by the subclass to inherit from the superclass.
  • super Keyword:
    • super(): Used in the subclass constructor to call the superclass constructor. Must be the first statement. If not explicitly called, the compiler implicitly calls the superclass's no-argument constructor (super();). If the superclass doesn't have a no-argument constructor, you must explicitly call another superclass constructor.
    • super.methodName(): Used to call a method from the superclass.
    • super.variableName: Used to access an instance variable from the superclass (less common, especially if variables are private).
  • Method Overriding: When a subclass provides its own specific implementation of a method that is already defined in its superclass. The method signature (name, parameters) must be the same. The @Override annotation is recommended to ensure you are actually overriding and not accidentally overloading or creating a new method.
  • final Keyword:
    • final class: Cannot be subclassed (cannot be extended). public final class String { ... }
    • final method: Cannot be overridden by subclasses.
    • final variable: Value cannot be changed after initialization (creates a constant if also static).
  • Single Inheritance: Java supports single inheritance for classes, meaning a class can only extend one direct superclass. This avoids the "diamond problem" found in some languages with multiple inheritance. However, a class can implement multiple interfaces (see Abstraction).

Polymorphism

Polymorphism allows objects to be treated as instances of their superclass, yet invoke the subclass's specific overridden methods at runtime.

Run-time Polymorphism (Method Overriding):

This is achieved through inheritance and method overriding. A superclass reference variable can hold an object of any of its subclasses. When a method is called on that reference, the JVM determines at runtime which version of the method to execute based on the actual object type being referred to.

// Using Vehicle, Car, ElectricCar classes from Inheritance section

public class PolymorphismDemo {
    public static void main(String[] args) {
        // Superclass reference can hold subclass objects
        Vehicle vehicle1 = new Car("Honda", 2020, 4); // Vehicle ref, Car object
        Vehicle vehicle2 = new ElectricCar("Nissan", 2022, 5, 60); // Vehicle ref, ElectricCar object
        Vehicle genericVehicle = new Vehicle("Generic", 2019); // Vehicle ref, Vehicle object

        System.out.println("--- Calling start() via Vehicle references ---");
        vehicle1.start();       // RUNTIME: Calls Car's start()
        vehicle2.start();       // RUNTIME: Calls ElectricCar's start()
        genericVehicle.start(); // RUNTIME: Calls Vehicle's start()

        System.out.println("\n--- Calling displayInfo() via Vehicle references ---");
        vehicle1.displayInfo(); // RUNTIME: Calls Car's displayInfo()
        vehicle2.displayInfo(); // RUNTIME: Calls ElectricCar's displayInfo()
        genericVehicle.displayInfo(); // RUNTIME: Calls Vehicle's displayInfo()

        // Cannot call subclass-specific methods directly via superclass reference
        // vehicle1.openTrunk(); // COMPILE ERROR! Vehicle type doesn't know openTrunk()
        // vehicle2.charge();    // COMPILE ERROR! Vehicle type doesn't know charge()

        // Example using an array of Vehicles
        System.out.println("\n--- Processing Vehicles in an array ---");
        Vehicle[] fleet = {vehicle1, vehicle2, genericVehicle};

        for (Vehicle v : fleet) {
            System.out.println("\nProcessing a " + v.getClass().getSimpleName()); // Get actual class name
            v.start(); // Polymorphic call - correct start() is executed
            v.displayInfo(); // Polymorphic call

            // If we need to call subclass-specific methods, we need type checking and casting
            if (v instanceof ElectricCar) {
                System.out.println("It's an electric car!");
                ElectricCar ec = (ElectricCar) v; // Downcasting (safe due to instanceof check)
                ec.charge();
            } else if (v instanceof Car) {
                System.out.println("It's a regular car (or subclass like ElectricCar, but handled above).");
                Car c = (Car) v; // Downcasting
                c.openTrunk();
            }
        }
    }
}

Explanation:

  • We create Car and ElectricCar objects but store them in Vehicle reference variables (vehicle1, vehicle2).
  • When vehicle1.start() is called, even though vehicle1 is declared as Vehicle, the JVM knows the actual object is a Car, so it executes the Car class's start() method. This decision happens at runtime.
  • instanceof Operator: Checks if an object is an instance of a particular class or interface (or a subclass/implementing class thereof). Returns true or false.
  • Casting:
    • Upcasting: Casting a subclass object to a superclass reference (e.g., Vehicle v = new Car(...)). This is done implicitly and is always safe.
    • Downcasting: Casting a superclass reference (that actually points to a subclass object) back to the subclass type (e.g., Car c = (Car) v;). This requires an explicit cast and can cause a ClassCastException at runtime if the object isn't actually an instance of the target subclass (or one of its subclasses). Use instanceof before downcasting for safety.

Compile-time Polymorphism (Method Overloading): As discussed earlier, this involves having multiple methods with the same name but different parameters in the same class. The compiler decides which method to call based on the method signature at compile time. This is less flexible than run-time polymorphism.

Abstraction

Abstraction hides implementation details and shows only essential features. It helps manage complexity. Java provides two primary mechanisms for abstraction:

1. Abstract Classes:

  • A class declared with the abstract keyword.
  • Cannot be instantiated directly (you cannot do new AbstractClassName();).
  • Can contain both abstract methods (methods without a body) and concrete methods (methods with implementation).
  • If a class contains one or more abstract methods, the class must be declared abstract.
  • Subclasses that inherit from an abstract class must either implement (provide a body for) all inherited abstract methods, or they must also be declared abstract.
  • Used to provide a common base class for related subclasses, enforcing a certain structure while allowing flexibility in implementation.
// File: Shape.java (Abstract Class)
abstract class Shape {
    private String color;

    public Shape(String color) {
        this.color = color;
    }

    // Concrete method (implemented here)
    public String getColor() {
        return color;
    }

    // Abstract method (no implementation, must be implemented by subclasses)
    // Represents an essential feature (calculating area) but how it's done depends on the specific shape.
    public abstract double calculateArea();

    // Another abstract method
    public abstract void draw();
}

// File: Circle.java (Concrete Subclass)
class Circle extends Shape {
    private double radius;

    public Circle(String color, double radius) {
        super(color); // Call Shape constructor
        this.radius = radius;
    }

    // Implementing the abstract method calculateArea
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }

    // Implementing the abstract method draw
    @Override
    public void draw() {
        System.out.println("Drawing a " + getColor() + " circle with radius " + radius);
    }
}

// File: Rectangle.java (Concrete Subclass)
class Rectangle extends Shape {
    private double width;
    private double height;

    public Rectangle(String color, double width, double height) {
        super(color);
        this.width = width;
        this.height = height;
    }

    // Implementing the abstract method calculateArea
    @Override
    public double calculateArea() {
        return width * height;
    }

    // Implementing the abstract method draw
    @Override
    public void draw() {
        System.out.println("Drawing a " + getColor() + " rectangle with width " + width + " and height " + height);
    }
}

// File: AbstractionDemo.java
public class AbstractionDemo {
    public static void main(String[] args) {
        // Shape myShape = new Shape("Red"); // COMPILE ERROR! Cannot instantiate abstract class

        Shape circle = new Circle("Red", 5.0);
        Shape rectangle = new Rectangle("Blue", 4.0, 6.0);

        circle.draw(); // Calls Circle's draw()
        System.out.println("Circle Area: " + circle.calculateArea()); // Calls Circle's calculateArea()

        rectangle.draw(); // Calls Rectangle's draw()
        System.out.println("Rectangle Area: " + rectangle.calculateArea()); // Calls Rectangle's calculateArea()

        // Using polymorphism with abstract classes
        Shape[] shapes = {circle, rectangle};
        System.out.println("\n--- Processing shapes polymorphically ---");
        for (Shape s : shapes) {
            s.draw(); // Correct draw() method is called based on actual object type
            System.out.printf("Area of %s %s: %.2f\n", s.getColor(), s.getClass().getSimpleName(), s.calculateArea());
        }
    }
}

2. Interfaces:

  • A completely abstract "class" declared using the interface keyword.
  • Can only contain abstract methods (methods without a body - prior to Java 8), static methods (Java 8+), default methods (implemented methods - Java 8+), private methods (Java 9+), and public static final constants (fields).
  • Methods in an interface are implicitly public abstract (unless default, static, or private).
  • Fields in an interface are implicitly public static final.
  • A class uses the implements keyword to implement an interface.
  • A class can implement multiple interfaces (providing a form of multiple inheritance for type but not implementation, unless default methods are used).
  • If a class implements an interface, it must provide implementations for all the abstract methods declared in that interface (or be declared abstract itself).
  • Interfaces define a contract - what methods a class must provide, without specifying how. They are used to achieve loose coupling and define roles or capabilities.
// File: Playable.java (Interface)
interface Playable {
    // public static final implicitly
    int MAX_VOLUME = 100;

    // public abstract implicitly
    void play();
    void stop();
    void setVolume(int level); // Abstract method
}

// File: Loggable.java (Another Interface)
interface Loggable {
    void log(String message); // public abstract implicitly
}


// File: MusicPlayer.java (Class implementing two interfaces)
class MusicPlayer implements Playable, Loggable {
    private boolean isPlaying = false;
    private int currentVolume = 50;

    @Override
    public void play() {
        if (!isPlaying) {
            isPlaying = true;
            log("Music playback started.");
            System.out.println("Music playing...");
        }
    }

    @Override
    public void stop() {
        if (isPlaying) {
            isPlaying = false;
            log("Music playback stopped.");
            System.out.println("Music stopped.");
        }
    }

    @Override
    public void setVolume(int level) {
        if (level >= 0 && level <= MAX_VOLUME) { // Can use interface constant
            this.currentVolume = level;
            log("Volume set to " + level);
            System.out.println("Volume set to " + this.currentVolume);
        } else {
            log("Invalid volume level attempt: " + level);
            System.out.println("Invalid volume level.");
        }
    }

    // Implementation for the Loggable interface
    @Override
    public void log(String message) {
        // In a real app, this might write to a file or console with timestamp
        System.out.println("[LOG] " + message);
    }
}

// File: VideoPlayer.java (Class implementing one interface)
class VideoPlayer implements Playable {
    private String currentVideo = "None";

    @Override
    public void play() {
        System.out.println("Playing video: " + currentVideo);
    }

    @Override
    public void stop() {
        System.out.println("Stopping video: " + currentVideo);
    }

     @Override
    public void setVolume(int level) {
         if (level >= 0 && level <= Playable.MAX_VOLUME) { // Qualify constant if needed
            System.out.println("Video volume set to " + level);
        }
    }

    public void loadVideo(String filename) {
        this.currentVideo = filename;
        System.out.println("Loaded video: " + filename);
    }
}


// File: InterfaceDemo.java
public class InterfaceDemo {
    public static void main(String[] args) {
        // Use interface type as reference (polymorphism)
        Playable player1 = new MusicPlayer();
        Playable player2 = new VideoPlayer();

        player1.play();
        player1.setVolume(75);
        player1.stop();

        // player1.log("Test"); // COMPILE ERROR! Playable type doesn't have log()

        // To access MusicPlayer specific methods (like log), need casting or different reference
        MusicPlayer mp = (MusicPlayer) player1;
        mp.log("Music player test log.");

        System.out.println();

        player2.play(); // Calls VideoPlayer's play()
        // player2.loadVideo("movie.mp4"); // COMPILE ERROR! Playable type doesn't know loadVideo()

        // Need to cast to access VideoPlayer methods
        if (player2 instanceof VideoPlayer) {
            VideoPlayer vp = (VideoPlayer) player2;
            vp.loadVideo("movie.mp4");
            vp.play(); // Calls VideoPlayer's play() again
            vp.setVolume(90);
            vp.stop();
        }

        // Use the Loggable interface type
        Loggable logger = new MusicPlayer();
        logger.log("This message comes via the Loggable interface reference.");
        // logger.play(); // COMPILE ERROR! Loggable type doesn't know play()
    }
}

Abstract Class vs. Interface:

Feature Abstract Class Interface
Instantiation Cannot be instantiated Cannot be instantiated
Methods Abstract and Concrete methods Abstract, Default, Static, Private methods
Variables Instance & Static variables allowed Only public static final constants
Implementation Class extends (single) Class implements (multiple)
Constructor Has constructor(s) No constructor
Purpose Base for closely related classes (is-a relationship), code sharing Define a contract/capability (has-a capability), achieve loose coupling
Keyword abstract, extends interface, implements
Access Modifiers Can use all access modifiers Methods implicitly public (or private), Fields implicitly public static final
State (Instance Vars) Can have state Cannot have instance state (only constants)

Choose an abstract class when you want to provide a common base implementation and state for related subclasses. Choose an interface when you want to define a role or capability that unrelated classes can implement.

Workshop Modeling a Simple University System

Objective: Apply OOP principles (Classes, Objects, Encapsulation, potentially simple Inheritance) to model basic entities in a university context like Student, Course, and Professor.

Steps:

  1. Directory Setup: In your terminal, navigate to your Java projects directory (e.g., ~/javabasics) and create a subdirectory for this workshop.

    cd ~/javabasics
    mkdir university
    cd university
    

  2. Define the Course Class: Create Course.java. This class will represent a university course. Use encapsulation.

    nano Course.java
    
    Code:
    public class Course {
        private String courseCode; // e.g., "CS101"
        private String courseName; // e.g., "Introduction to Computer Science"
        private int maxCapacity;
        private int currentEnrollment;
    
        // Constructor
        public Course(String courseCode, String courseName, int maxCapacity) {
            this.courseCode = courseCode;
            this.courseName = courseName;
            if (maxCapacity > 0) {
                this.maxCapacity = maxCapacity;
            } else {
                this.maxCapacity = 30; // Default capacity
                System.err.println("Warning: Invalid capacity for " + courseCode + ". Setting to default 30.");
            }
            this.currentEnrollment = 0; // Starts empty
        }
    
        // Getters
        public String getCourseCode() {
            return courseCode;
        }
    
        public String getCourseName() {
            return courseName;
        }
    
        public int getMaxCapacity() {
            return maxCapacity;
        }
    
        public int getCurrentEnrollment() {
            return currentEnrollment;
        }
    
        // Method to enroll a student (if space available)
        public boolean enrollStudent() {
            if (currentEnrollment < maxCapacity) {
                currentEnrollment++;
                System.out.println("Student enrolled in " + courseCode + ". Current enrollment: " + currentEnrollment);
                return true;
            } else {
                System.out.println("Enrollment failed for " + courseCode + ". Course is full.");
                return false;
            }
        }
    
        // Method to drop a student
        public boolean dropStudent() {
            if (currentEnrollment > 0) {
                currentEnrollment--;
                 System.out.println("Student dropped from " + courseCode + ". Current enrollment: " + currentEnrollment);
                return true;
            } else {
                System.out.println("Cannot drop student from " + courseCode + ". Course is empty.");
                return false;
            }
        }
    
        // Display course info
        public void displayInfo() {
            System.out.println("--- Course Information ---");
            System.out.println("Code: " + courseCode);
            System.out.println("Name: " + courseName);
            System.out.println("Capacity: " + maxCapacity);
            System.out.println("Enrolled: " + currentEnrollment);
            System.out.println("------------------------");
        }
    }
    
    Save and exit (Ctrl+X, Y, Enter).

  3. Define the Student Class: Create Student.java. This class represents a student.

    nano Student.java
    
    Code:
    public class Student {
        private String studentId;
        private String name;
        private int yearLevel;
        // We could add a list of enrolled courses here later (Intermediate concepts)
    
        public Student(String studentId, String name, int yearLevel) {
            this.studentId = studentId;
            this.name = name;
            if (yearLevel > 0) {
                this.yearLevel = yearLevel;
            } else {
                this.yearLevel = 1; // Default year level
            }
        }
    
        // Getters
        public String getStudentId() {
            return studentId;
        }
    
        public String getName() {
            return name;
        }
    
        public int getYearLevel() {
            return yearLevel;
        }
    
        // Setters (Example)
        public void setName(String name) {
            this.name = name;
        }
    
        public void incrementYearLevel() {
            this.yearLevel++;
            System.out.println(name + " moved to year " + yearLevel);
        }
    
        // Display student info
        public void displayInfo() {
            System.out.println("--- Student Information ---");
            System.out.println("ID: " + studentId);
            System.out.println("Name: " + name);
            System.out.println("Year Level: " + yearLevel);
            System.out.println("-------------------------");
        }
    }
    
    Save and exit.

  4. Define the Professor Class (Optional - Simple Version): Create Professor.java.

    nano Professor.java
    
    Code:
    public class Professor {
        private String employeeId;
        private String name;
        private String department;
        // Could add a list of courses taught
    
        public Professor(String employeeId, String name, String department) {
            this.employeeId = employeeId;
            this.name = name;
            this.department = department;
        }
    
        // Getters
        public String getEmployeeId() {
            return employeeId;
        }
    
        public String getName() {
            return name;
        }
    
        public String getDepartment() {
            return department;
        }
    
        // Setter
        public void setDepartment(String department) {
            this.department = department;
        }
    
        // Display info
        public void displayInfo() {
            System.out.println("--- Professor Information ---");
            System.out.println("ID: " + employeeId);
            System.out.println("Name: " + name);
            System.out.println("Department: " + department);
            System.out.println("---------------------------");
        }
    
        // Example behavior
        public void teachCourse(Course course) {
            System.out.println("Professor " + this.name + " is teaching " + course.getCourseCode() + " - " + course.getCourseName());
        }
    }
    
    Save and exit.

  5. Create the Main Application Class: Create UniversityDemo.java to create instances and demonstrate interactions.

    nano UniversityDemo.java
    
    Code:
    public class UniversityDemo {
        public static void main(String[] args) {
            System.out.println("--- Setting up University Entities ---");
    
            // Create Courses
            Course cs101 = new Course("CS101", "Intro to Programming", 3); // Small capacity for testing
            Course math201 = new Course("MA201", "Calculus II", 50);
    
            // Create Students
            Student alice = new Student("S1001", "Alice Smith", 1);
            Student bob = new Student("S1002", "Bob Johnson", 2);
            Student charlie = new Student("S1003", "Charlie Brown", 1);
            Student diana = new Student("S1004", "Diana Prince", 1); // Try to enroll 4th student in CS101
    
            // Create Professor
            Professor davis = new Professor("P501", "Dr. Davis", "Computer Science");
    
            System.out.println("\n--- Displaying Initial Info ---");
            cs101.displayInfo();
            math201.displayInfo();
            alice.displayInfo();
            bob.displayInfo();
            davis.displayInfo();
    
            System.out.println("\n--- Performing Actions ---");
    
            // Professor teaches a course
            davis.teachCourse(cs101);
            davis.teachCourse(math201);
    
            // Students enroll in courses
            System.out.println("\nEnrolling students in " + cs101.getCourseCode() + ":");
            if (cs101.enrollStudent()) { // Alice enrolls
                System.out.println(alice.getName() + " successfully enrolled.");
            }
            if (cs101.enrollStudent()) { // Bob enrolls
                 System.out.println(bob.getName() + " successfully enrolled.");
            }
             if (cs101.enrollStudent()) { // Charlie enrolls
                 System.out.println(charlie.getName() + " successfully enrolled.");
            }
             if (cs101.enrollStudent()) { // Diana tries (should fail)
                 System.out.println(diana.getName() + " attempting enrollment...");
            } else {
                 System.out.println(diana.getName() + " enrollment failed.");
            }
    
            System.out.println("\nEnrolling students in " + math201.getCourseCode() + ":");
            if (math201.enrollStudent()) { // Alice enrolls
                System.out.println(alice.getName() + " successfully enrolled.");
            }
             if (math201.enrollStudent()) { // Bob enrolls
                System.out.println(bob.getName() + " successfully enrolled.");
            }
    
            // Display updated course info
            System.out.println("\n--- Updated Course Info ---");
            cs101.displayInfo();
            math201.displayInfo();
    
            // Student actions
            System.out.println("\n--- Student Actions ---");
            bob.incrementYearLevel();
            bob.displayInfo();
    
            // Drop a student
            System.out.println("\nDropping a student from " + cs101.getCourseCode() + ":");
            if (cs101.dropStudent()) {
                System.out.println("A student was dropped.");
            }
            cs101.displayInfo();
    
    
            System.out.println("\n--- University Demo Finished ---");
        }
    }
    
    Save and exit.

  6. Compile All Java Files:

    javac *.java
    
    Address any compilation errors by checking your code in the respective files.

  7. Run the Demonstration:

    java UniversityDemo
    

  8. Analyze the Output: Observe how objects are created, how their methods are called, how encapsulation protects data (e.g., currentEnrollment is managed by enrollStudent/dropStudent), and how different objects interact (e.g., Professor interacting with Course).

This workshop demonstrates the practical application of fundamental OOP concepts like classes, objects, constructors, methods, and encapsulation to model a simplified real-world scenario. You can extend this model further by adding more attributes, methods, or even introducing inheritance (e.g., UndergraduateStudent and GraduateStudent extending Student).

3. Intermediate Java Concepts

Building upon the fundamentals of OOP, this section delves into more advanced features of the Java language that are essential for building robust and scalable applications.

Packages

As applications grow, organizing code becomes crucial. Packages in Java are used to group related classes, interfaces, and sub-packages, preventing naming conflicts and controlling access. Think of them like directories or folders for your code.

Declaring a Package:

To place a class or interface inside a package, use the package keyword as the very first statement in the source file.

// File: com/mycompany/utils/StringUtils.java
package com.mycompany.utils; // Package declaration

public class StringUtils {
    public static boolean isEmpty(String str) {
        return str == null || str.trim().length() == 0;
    }

    // Other utility methods...
}

Package Naming Convention:

Packages are named in reverse domain name order to ensure uniqueness globally. For example, if a company has the domain mycompany.com, its packages would start with com.mycompany. Sub-packages further categorize the code (e.g., com.mycompany. C for core functionalities, com.mycompany.ui for user interface components). Names are typically all lowercase.

Directory Structure:

The package structure must match the directory structure on the file system where the source (.java) and compiled (.class) files reside. For the com.mycompany.utils.StringUtils class:

  • The source file StringUtils.java must be located in <source_root>/com/mycompany/utils/.
  • The compiled file StringUtils.class must be located in <class_output_root>/com/mycompany/utils/.

<source_root> and <class_output_root> are base directories for your source code and compiled code, respectively (often src/main/java and target/classes in build tools like Maven/Gradle).

Using Classes from Packages (import):

To use a class or interface from a different package, you have three options:

  1. Fully Qualified Name: Refer to the class using its full package name every time.

    // File: com/mycompany/app/MainApp.java
    package com.mycompany.app;
    
    public class MainApp {
        public static void main(String[] args) {
            String test = "  ";
            // Using fully qualified name
            boolean empty = com.mycompany.utils.StringUtils.isEmpty(test);
            System.out.println("Is empty? " + empty); // true
    
            java.util.ArrayList<String> names = new java.util.ArrayList<>(); // Tedious
            names.add("Alice");
            System.out.println(names.get(0));
        }
    }
    

  2. Single Type Import: Import a specific class or interface using the import keyword after the package declaration (if any) and before the class definition. This is the most common approach.

    // File: com/mycompany/app/MainApp.java
    package com.mycompany.app;
    
    // Import specific classes
    import com.mycompany.utils.StringUtils;
    import java.util.ArrayList; // Import ArrayList from java.util
    
    public class MainApp {
        public static void main(String[] args) {
            String test = "  ";
            // Now we can use the simple name
            boolean empty = StringUtils.isEmpty(test);
            System.out.println("Is empty? " + empty);
    
            ArrayList<String> names = new ArrayList<>(); // Much cleaner
            names.add("Bob");
            System.out.println(names.get(0));
        }
    }
    

  3. Import on Demand (Wildcard): Import all public classes and interfaces from a package using the asterisk (*) wildcard. Generally discouraged for larger projects as it can make it unclear where a class comes from and potentially lead to naming conflicts if multiple imported packages have classes with the same name.

    package com.mycompany.app;
    
    import com.mycompany.utils.*; // Imports StringUtils and any other public class in utils
    import java.util.*; // Imports ArrayList, Scanner, List, Map, etc.
    
    public class MainApp {
        public static void main(String[] args) {
            boolean empty = StringUtils.isEmpty(""); // Works
            ArrayList<String> list = new ArrayList<>(); // Works
            Scanner scanner = new Scanner(System.in); // Works
        }
    }
    

Static Import:

Allows importing public static members (fields and methods) of a class, so you can use them without qualifying them with the class name.

// Import static members PI and sqrt from Math class
import static java.lang.Math.PI;
import static java.lang.Math.sqrt;
// Import all static members from System.out
import static java.lang.System.out;

public class StaticImportDemo {
    public static void main(String[] args) {
        double radius = 5.0;
        // Using imported static members directly
        double area = PI * radius * radius;
        double num = 16.0;
        double root = sqrt(num);

        // Using static member 'out' from System
        out.println("Radius: " + radius); // Instead of System.out.println
        out.printf("Area: %.2f\n", area);
        out.printf("Square root of %.1f is %.1f\n", num, root);
    }
}
Use static imports sparingly, primarily for constants or frequently used utility methods, to avoid cluttering the namespace.

CLASSPATH Environment Variable:

The CLASSPATH tells the Java Virtual Machine (JVM) and the Java compiler (javac) where to find user-defined classes and packages. When you run java com.mycompany.app.MainApp, the JVM needs to locate the MainApp.class file within the com/mycompany/app directory structure.

  • Default: By default, the JVM looks in the current directory (.) and potentially internal libraries.
  • Setting CLASSPATH: You can explicitly set the CLASSPATH environment variable on Linux:
    # Set for the current terminal session
    export CLASSPATH=/path/to/your/classes:/path/to/another/lib.jar:.
    # Separate directories/JARs with colon (:) on Linux/macOS, semicolon (;) on Windows
    
  • Using -cp or -classpath: It's generally better practice to specify the classpath directly when compiling or running, rather than relying on the environment variable. This makes builds more reproducible.
    # Assume compiled classes are in a 'bin' directory
    # Compiling (source files in 'src')
    javac -d bin -sourcepath src src/com/mycompany/app/MainApp.java src/com/mycompany/utils/StringUtils.java
    
    # Running (telling java where to find the compiled classes)
    java -cp bin com.mycompany.app.MainApp
    # Or if using a JAR file
    # java -cp bin:lib/someDependency.jar com.mycompany.app.MainApp
    
    Modern build tools like Maven and Gradle handle classpath management automatically, which is highly recommended for any non-trivial project.

Exception Handling

Exceptions are events that occur during program execution that disrupt the normal flow of instructions (e.g., dividing by zero, trying to access a file that doesn't exist, accessing a null object reference). Java's exception handling mechanism provides a robust way to detect, report, and manage these errors.

Exception Hierarchy:

All exception types are subclasses of the java.lang.Throwable class. Key branches:

  • Error: Represents serious problems that applications usually shouldn't try to catch (e.g., OutOfMemoryError, StackOverflowError). These typically indicate unrecoverable conditions in the JVM itself.
  • Exception: Represents conditions that applications might want to catch and handle.
    • Checked Exceptions: Subclasses of Exception but not subclasses of RuntimeException. These are exceptions that the compiler forces you to handle explicitly, either by catching them or declaring that your method throws them. They represent exceptional conditions that can reasonably be expected to occur in a correct program (e.g., IOException, SQLException, FileNotFoundException).
    • Unchecked Exceptions (Runtime Exceptions): Subclasses of RuntimeException. The compiler does not require you to handle these explicitly. They often indicate programming errors (e.g., NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException, IllegalArgumentException). While you can catch them, it's often better to fix the underlying code bug.

Handling Exceptions (try, catch, finally):

  • try block: Encloses the code that might potentially throw an exception.
  • catch block: Follows the try block. Catches and handles a specific type of exception. You can have multiple catch blocks to handle different exception types. The first matching catch block (based on the exception type or its superclasses) is executed.
  • finally block: (Optional) Follows the try block or the last catch block. This block always executes, regardless of whether an exception occurred in the try block or whether a caught exception was handled. It's typically used for cleanup code (e.g., closing files, releasing network connections, closing database connections) to ensure resources are released properly.
import java.io.FileReader;
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;

public class ExceptionHandlingDemo {

    public static void main(String[] args) {
        readFile("mydata.txt");
        System.out.println("-----");
        readFile("nonexistent.txt"); // This will cause FileNotFoundException
        System.out.println("-----");
        performCalculation(10, 0); // This will cause ArithmeticException
        System.out.println("-----");
        processString(null); // This will cause NullPointerException
        System.out.println("Program finished main method.");
    }

    public static void readFile(String filename) {
        BufferedReader reader = null; // Declare outside try to access in finally
        try {
            System.out.println("Attempting to open file: " + filename);
            reader = new BufferedReader(new FileReader(filename)); // May throw FileNotFoundException
            String line;
            System.out.println("File opened successfully. Reading contents:");
            while ((line = reader.readLine()) != null) { // May throw IOException
                System.out.println(line);
            }
            System.out.println("Finished reading file.");

        } catch (FileNotFoundException fnfEx) { // Catch specific checked exception
            System.err.println("Error: File not found - " + fnfEx.getMessage());
            // fnfEx.printStackTrace(); // Useful for debugging - prints stack trace

        } catch (IOException ioEx) { // Catch more general checked exception
            System.err.println("Error reading file: " + ioEx.getMessage());
            // ioEx.printStackTrace();

        } catch (Exception ex) { // Catch any other potential exceptions (less specific)
            System.err.println("An unexpected error occurred: " + ex.getMessage());
            // ex.printStackTrace();

        } finally {
            System.out.println("Entering finally block for readFile...");
            try {
                if (reader != null) {
                    System.out.println("Closing the file reader.");
                    reader.close(); // May also throw IOException, hence nested try-catch
                }
            } catch (IOException closeEx) {
                System.err.println("Error closing file reader: " + closeEx.getMessage());
            }
            System.out.println("Exiting finally block for readFile.");
        }
    }

    public static void performCalculation(int a, int b) {
        try {
            System.out.printf("Calculating %d / %d...\n", a, b);
            int result = a / b; // May throw ArithmeticException (unchecked)
            System.out.println("Result: " + result);
        } catch (ArithmeticException ae) {
            System.err.println("Error: Cannot divide by zero. " + ae.getMessage());
        }
        // No finally block needed here as no resources need cleanup
        System.out.println("Calculation attempt finished.");
    }

     public static void processString(String text) {
        try {
            System.out.println("Processing string: " + text);
            System.out.println("Length: " + text.length()); // May throw NullPointerException (unchecked)
        } catch (NullPointerException npe) {
            System.err.println("Error: Cannot process a null string. " + npe.getMessage());
        }
         System.out.println("String processing attempt finished.");
    }
}

Declaring Exceptions (throws):

If a method might throw a checked exception but doesn't handle it with try-catch, it must declare that it throws the exception using the throws keyword in its signature. This signals to the caller method that it needs to handle or declare the exception itself.

import java.io.IOException;

public class ThrowsDemo {

    // This method might throw IOException (checked), so it declares it
    public static void readFileAndThrow(String filename) throws IOException {
        // Simplified example - assumes file exists for brevity
        // In reality, use try-finally or try-with-resources here too
        java.io.Reader reader = new java.io.FileReader(filename);
        System.out.println("File opened in readFileAndThrow");
        // ... read logic ...
        reader.close(); // close can also throw IOException
        System.out.println("File closed in readFileAndThrow");

        // Simulate another potential IO error
        if (filename.contains("error")) {
            throw new IOException("Simulated I/O error in readFileAndThrow");
        }
    }

    // This method calls readFileAndThrow, so it must handle or declare IOException
    public static void main(String[] args) /* throws IOException */ { // Option 1: Declare
        try { // Option 2: Handle
            System.out.println("Calling readFileAndThrow with goodfile.txt");
            // Create a dummy file first for this to run without FileNotFoundException
            // In Linux terminal: touch goodfile.txt
            // In Linux terminal: touch errorfile.txt
            readFileAndThrow("goodfile.txt");

            System.out.println("\nCalling readFileAndThrow with errorfile.txt");
            readFileAndThrow("errorfile.txt"); // This will throw the simulated IOException

            System.out.println("This line won't be reached if errorfile throws exception.");

        } catch (IOException e) {
            System.err.println("Caught IOException in main: " + e.getMessage());
            // e.printStackTrace();
        } finally {
             System.out.println("Main method's finally block.");
        }
         System.out.println("Main method finished normally (or after catching).");
    }
}

throw Keyword:

Used to manually throw an exception object. You can throw existing exception types or custom exception types.

public class ThrowDemo {
    public static void checkAge(int age) {
        if (age < 18) {
            // Manually throw an unchecked exception
            throw new IllegalArgumentException("Access denied - You must be at least 18 years old.");
        } else {
            System.out.println("Access granted - Age is sufficient.");
        }
    }

    public static void main(String[] args) {
        try {
            checkAge(25); // OK
            checkAge(15); // Throws IllegalArgumentException
            System.out.println("This won't be printed after age 15 check.");
        } catch (IllegalArgumentException e) {
            System.err.println("Error: " + e.getMessage());
        }
         System.out.println("Program continues after age check.");
    }
}

Try-with-Resources (Java 7+):

A simplified syntax for handling resources (like file streams, network sockets, database connections) that implement the java.lang.AutoCloseable or java.io.Closeable interface. It ensures that the resource is automatically closed at the end of the try block, whether it completes normally or throws an exception. This eliminates the need for explicit finally blocks just for closing resources.

import java.io.FileReader;
import java.io.BufferedReader;
import java.io.IOException;

public class TryWithResourcesDemo {

    public static void main(String[] args) {
        readFileEfficiently("mydata.txt"); // Assumes mydata.txt exists
        System.out.println("-----");
        readFileEfficiently("nonexistent.txt");
    }

    public static void readFileEfficiently(String filename) {
        System.out.println("Attempting to read with try-with-resources: " + filename);
        // Declare resources within the try(...) parentheses
        // They will be automatically closed in reverse order of declaration
        try (BufferedReader reader = new BufferedReader(new FileReader(filename))) {
            String line;
            System.out.println("File opened. Reading contents:");
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
            System.out.println("Finished reading file.");
            // No explicit reader.close() needed! It happens automatically.

        } catch (IOException e) { // Still catch potential exceptions during read/open
            System.err.println("Error during file operation: " + e.getMessage());
            // Throwable.getSuppressed() can be used here to see exceptions during close
            // if an exception was already thrown in the try block.
        }
        // The finally block for closing is implicitly handled by try-with-resources
        System.out.println("Exiting readFileEfficiently method.");
    }
}
Strongly prefer try-with-resources over manual finally blocks for managing resources that implement AutoCloseable.

Custom Exceptions:

You can create your own exception classes by extending Exception (for checked) or RuntimeException (for unchecked). This allows you to represent application-specific error conditions more clearly.

// Custom checked exception
class InsufficientFundsException extends Exception {
    private double shortfall;

    public InsufficientFundsException(String message, double shortfall) {
        super(message); // Pass message to the superclass (Exception) constructor
        this.shortfall = shortfall;
    }

    public double getShortfall() {
        return shortfall;
    }
}

// Example usage (simplified BankAccount)
class SimpleAccount {
    private double balance;

    public SimpleAccount(double balance) { this.balance = balance; }

    // Method declares it can throw our custom checked exception
    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) {
            double needed = amount - balance;
            throw new InsufficientFundsException("Withdrawal amount exceeds balance.", needed);
        }
        balance -= amount;
        System.out.printf("Withdrew %.2f. New balance: %.2f\n", amount, balance);
    }
}

// Demo
public class CustomExceptionDemo {
    public static void main(String[] args) {
        SimpleAccount account = new SimpleAccount(100.0);
        try {
            account.withdraw(50.0);  // OK
            account.withdraw(70.0);  // Throws InsufficientFundsException
            System.out.println("This line is skipped.");
        } catch (InsufficientFundsException e) {
            System.err.println("Transaction Failed: " + e.getMessage());
            System.err.printf("Amount short: %.2f\n", e.getShortfall());
            // e.printStackTrace();
        }
        System.out.println("Program continues.");
    }
}

Working with Arrays and Collections

Storing and managing groups of objects or primitive values is a common requirement. Java provides built-in arrays and a rich Collections Framework.

Arrays:

  • Fixed-size, ordered containers holding elements of the same data type (primitive or reference).
  • Size is determined at creation and cannot be changed later.
  • Elements are accessed using a zero-based index (array[0], array[1], ...).
  • Provide fast access based on index but inefficient insertion/deletion in the middle.
public class ArrayDemo {
    public static void main(String[] args) {
        // --- Declaration and Initialization ---

        // 1. Declare and allocate size, initialize with default values
        int[] numbers = new int[5]; // Array of 5 integers, initialized to 0
        String[] names = new String[3]; // Array of 3 Strings, initialized to null
        boolean[] flags = new boolean[2]; // Array of 2 booleans, initialized to false

        // 2. Declare and initialize with specific values (Array Initializer)
        double[] scores = {95.5, 88.0, 75.5, 99.9}; // Size inferred from values (4)
        String[] weekdays = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};

        // --- Accessing Elements ---
        System.out.println("First number (default): " + numbers[0]); // Output: 0
        System.out.println("First score: " + scores[0]); // Output: 95.5
        System.out.println("Third weekday: " + weekdays[2]); // Output: Wed

        // --- Modifying Elements ---
        numbers[0] = 100;
        numbers[1] = 200;
        // numbers[5] = 500; // THROWS ArrayIndexOutOfBoundsException at runtime! Index 5 is invalid for size 5.

        System.out.println("First number (modified): " + numbers[0]); // Output: 100

        // --- Getting Array Length ---
        System.out.println("Length of scores array: " + scores.length); // Output: 4
        System.out.println("Length of names array: " + names.length); // Output: 3

        // --- Iterating through Arrays ---
        System.out.println("\nIterating through weekdays using standard for loop:");
        for (int i = 0; i < weekdays.length; i++) {
            System.out.print(weekdays[i] + " ");
        }
        System.out.println();

        System.out.println("\nIterating through scores using enhanced for loop (for-each):");
        for (double score : scores) {
            System.out.print(score + " ");
        }
        System.out.println();

        // --- Arrays Utility Class ---
        // java.util.Arrays provides helpful static methods
        java.util.Arrays.sort(scores); // Sorts the array in place
        System.out.println("\nScores after sorting: " + java.util.Arrays.toString(scores)); // Convenient printing

        int[] numsToSort = {5, 2, 8, 1, 9};
        java.util.Arrays.sort(numsToSort);
        System.out.println("Sorted numsToSort: " + java.util.Arrays.toString(numsToSort)); // [1, 2, 5, 8, 9]

        // Binary search (requires sorted array)
        int index = java.util.Arrays.binarySearch(numsToSort, 8);
        System.out.println("Index of 8: " + index); // Output: 3
        index = java.util.Arrays.binarySearch(numsToSort, 3);
        System.out.println("Index of 3 (not present): " + index); // Output: negative value (-3)

        // Filling an array
        java.util.Arrays.fill(numbers, -1); // Fills the entire array with -1
        System.out.println("Numbers after fill: " + java.util.Arrays.toString(numbers)); // [-1, -1, -1, -1, -1]

        // Copying arrays
        int[] numbersCopy = java.util.Arrays.copyOf(numsToSort, numsToSort.length);
        System.out.println("Copied array: " + java.util.Arrays.toString(numbersCopy));

        // Multi-dimensional Arrays (Arrays of arrays)
        int[][] matrix = {
            {1, 2, 3},
            {4, 5, 6},
            {7, 8, 9}
        };
        System.out.println("\nElement at matrix[1][1]: " + matrix[1][1]); // Output: 5
        System.out.println("Number of rows: " + matrix.length); // 3
        System.out.println("Number of columns in row 0: " + matrix[0].length); // 3
    }
}

Java Collections Framework:

Provides a suite of interfaces and classes for managing groups of objects more flexibly than arrays. Located primarily in the java.util package.

Key Interfaces:

  • Collection<E>: The root interface. Represents a group of objects (elements). Defines basic methods like add(), remove(), size(), isEmpty(), contains(), iterator().
  • List<E>: An ordered collection (sequence) that allows duplicate elements. Elements can be accessed by their integer index. Common implementations:
    • ArrayList<E>: Resizable array implementation. Fast random access (get(index)), relatively fast adds at the end. Slower insertion/deletion in the middle (requires shifting elements). Good general-purpose list.
    • LinkedList<E>: Doubly-linked list implementation. Fast insertion/deletion at the beginning, end, or middle (if you have an iterator at the position). Slower random access (requires traversing the list). Also implements Deque (double-ended queue).
  • Set<E>: A collection that does not allow duplicate elements. Order is generally not guaranteed (except for specific implementations). Useful for storing unique items. Common implementations:
    • HashSet<E>: Uses a hash table for storage. Offers fast add, remove, contains operations (average O(1) time). Does not maintain insertion order. Requires elements to have consistent hashCode() and equals() methods.
    • LinkedHashSet<E>: Like HashSet, but also maintains the order in which elements were inserted. Slightly slower than HashSet due to the linked list overhead.
    • TreeSet<E>: Stores elements in a sorted order (natural ordering or specified by a Comparator). Uses a tree structure (Red-Black tree). Slower than HashSet (O(log n) for operations) but provides sorted iteration. Requires elements to be comparable (implement Comparable or provide a Comparator).
  • Map<K, V>: An object that maps keys to values. Keys must be unique. Each key maps to exactly one value. Not technically a Collection (doesn't extend it) but part of the framework. Common implementations:
    • HashMap<K, V>: Uses a hash table. Fast put, get, containsKey operations (average O(1)). Order is not guaranteed. Allows one null key and multiple null values. Requires keys to have consistent hashCode() and equals().
    • LinkedHashMap<K, V>: Like HashMap, but maintains insertion order (or access order). Slower than HashMap.
    • TreeMap<K, V>: Stores key-value pairs sorted by key (natural ordering or Comparator). Uses a tree structure. Slower than HashMap (O(log n)) but provides sorted iteration over keys. Keys must be comparable.
  • Queue<E>: A collection designed for holding elements prior to processing. Typically orders elements FIFO (First-In, First-Out). Common implementations:
    • LinkedList<E> (also implements Queue)
    • PriorityQueue<E> (elements ordered by priority, not FIFO)
  • Deque<E>: (Double-Ended Queue) Extends Queue. Allows element addition/removal from both ends. Implementations: LinkedList<E>, ArrayDeque<E>.

Generics (<E>, <K, V>):

Collections use generics to provide type safety. List<String> means the list can only hold String objects. This allows the compiler to catch errors where you try to add an incompatible type (e.g., adding an Integer to a List<String>). It also eliminates the need for manual casting when retrieving elements.

Iterators:

An Iterator is an object used to traverse (iterate over) the elements of a collection. It provides a standard way to access elements sequentially without exposing the underlying structure.

  • iterator(): Method in the Collection interface that returns an Iterator for the collection.
  • hasNext(): Returns true if the iteration has more elements.
  • next(): Returns the next element in the iteration and advances the iterator.
  • remove(): (Optional) Removes the last element returned by next() from the underlying collection.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

public class CollectionsDemo {
    public static void main(String[] args) {

        // --- List Example (ArrayList) ---
        System.out.println("--- List (ArrayList) ---");
        List<String> fruits = new ArrayList<>(); // Use Interface type for reference
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Orange");
        fruits.add("Apple"); // Duplicates allowed

        System.out.println("Fruits list: " + fruits);
        System.out.println("Element at index 1: " + fruits.get(1)); // Banana
        System.out.println("Size: " + fruits.size()); // 4
        System.out.println("Contains 'Banana'? " + fruits.contains("Banana")); // true

        fruits.remove("Apple"); // Removes the first occurrence
        System.out.println("After removing first 'Apple': " + fruits);

        System.out.println("Iterating with enhanced for loop:");
        for (String fruit : fruits) {
            System.out.print(fruit + " ");
        }
        System.out.println();

        System.out.println("Iterating with Iterator:");
        Iterator<String> fruitIterator = fruits.iterator();
        while (fruitIterator.hasNext()) {
            String fruit = fruitIterator.next();
            System.out.print(fruit + " ");
            // Example of removing during iteration (use iterator's remove)
            // if (fruit.equals("Banana")) {
            //    fruitIterator.remove();
            // }
        }
        System.out.println();


        // --- Set Example (HashSet & TreeSet) ---
        System.out.println("\n--- Set (HashSet) ---");
        Set<String> uniqueColors = new HashSet<>();
        uniqueColors.add("Red");
        uniqueColors.add("Green");
        uniqueColors.add("Blue");
        boolean addedRedAgain = uniqueColors.add("Red"); // Returns false, set not changed

        System.out.println("Unique colors (HashSet - order not guaranteed): " + uniqueColors);
        System.out.println("Added 'Red' again? " + addedRedAgain); // false
        System.out.println("Contains 'Green'? " + uniqueColors.contains("Green")); // true

        System.out.println("\n--- Set (TreeSet) ---");
        Set<Integer> sortedNumbers = new TreeSet<>();
        sortedNumbers.add(50);
        sortedNumbers.add(10);
        sortedNumbers.add(90);
        sortedNumbers.add(10); // Duplicate, ignored

        System.out.println("Sorted numbers (TreeSet - natural order): " + sortedNumbers);


        // --- Map Example (HashMap) ---
        System.out.println("\n--- Map (HashMap) ---");
        Map<String, Integer> studentAges = new HashMap<>();
        studentAges.put("Alice", 20);
        studentAges.put("Bob", 22);
        studentAges.put("Charlie", 20);
        studentAges.put("Alice", 21); // Updates value for existing key "Alice"

        System.out.println("Student ages map: " + studentAges);
        System.out.println("Age of Bob: " + studentAges.get("Bob")); // 22
        System.out.println("Contains key 'David'? " + studentAges.containsKey("David")); // false
        System.out.println("Contains value 21? " + studentAges.containsValue(21)); // true

        studentAges.remove("Charlie");
        System.out.println("After removing Charlie: " + studentAges);

        System.out.println("\nIterating through Map keys:");
        for (String name : studentAges.keySet()) { // Get set of keys
            System.out.println("Key: " + name);
        }

        System.out.println("\nIterating through Map values:");
        for (Integer age : studentAges.values()) { // Get collection of values
            System.out.println("Value: " + age);
        }

        System.out.println("\nIterating through Map entries (key-value pairs):");
        for (Map.Entry<String, Integer> entry : studentAges.entrySet()) { // Get set of entries
            System.out.println("Key=" + entry.getKey() + ", Value=" + entry.getValue());
        }
    }
}

String Manipulation

Strings (java.lang.String) are used extensively to represent text. In Java, String objects are immutable, meaning their state (the sequence of characters) cannot be changed after creation. Operations that appear to modify a String actually create and return a new String object.

public class StringDemo {
    public static void main(String[] args) {
        String greeting = "Hello";
        String name = "World";

        // Concatenation: Creates new strings
        String message1 = greeting + ", " + name + "!"; // Using + operator
        String message2 = greeting.concat(", ").concat(name).concat("!"); // Using concat() method

        System.out.println("message1: " + message1);
        System.out.println("message2: " + message2);

        // Immutability example
        String original = "Java";
        String modified = original.toUpperCase(); // Creates a NEW string "JAVA"

        System.out.println("original: " + original); // Output: Java (original is unchanged)
        System.out.println("modified: " + modified); // Output: JAVA

        // Common String methods
        String text = "  Java Programming Language  ";
        System.out.println("\nOriginal text: [" + text + "]");
        System.out.println("Length: " + text.length()); // 29
        System.out.println("Character at index 3: " + text.charAt(3)); // 'J'
        System.out.println("Substring (index 7 to 18): [" + text.substring(7, 19) + "]"); // [Programming L] (end index is exclusive)
        System.out.println("Substring (from index 19): [" + text.substring(19) + "]"); // [anguage  ]
        System.out.println("Lowercase: [" + text.toLowerCase() + "]");
        System.out.println("Uppercase: [" + text.toUpperCase() + "]");
        System.out.println("Trimmed (removes leading/trailing whitespace): [" + text.trim() + "]");
        System.out.println("Starts with '  Java': " + text.startsWith("  Java")); // true
        System.out.println("Ends with 'ge  ': " + text.endsWith("ge  ")); // true
        System.out.println("Contains 'Program': " + text.contains("Program")); // true
        System.out.println("Index of 'Lang': " + text.indexOf("Lang")); // 19
        System.out.println("Index of 'a' (first): " + text.indexOf('a')); // 4
        System.out.println("Index of 'a' (last): " + text.lastIndexOf('a')); // 23
        System.out.println("Replace 'a' with '@': [" + text.replace('a', '@') + "]");
        System.out.println("Replace 'Java' with 'Python': [" + text.replace("Java", "Python") + "]");

        // Splitting a string
        String csvData = "Apple,Banana,Orange,Grape";
        String[] fruitsArray = csvData.split(","); // Split by comma
        System.out.println("\nSplitting CSV:");
        for (String fruit : fruitsArray) {
            System.out.println("- " + fruit.trim());
        }

        // Joining an array into a string (Java 8+)
        String joinedFruits = String.join(" | ", fruitsArray);
        System.out.println("Joined fruits: " + joinedFruits);

        // Checking for emptiness
        String emptyStr = "";
        String blankStr = "   ";
        System.out.println("\nChecking emptiness:");
        System.out.println("emptyStr.isEmpty(): " + emptyStr.isEmpty()); // true
        System.out.println("blankStr.isEmpty(): " + blankStr.isEmpty()); // false
        System.out.println("emptyStr.isBlank(): " + emptyStr.isBlank()); // true (Java 11+)
        System.out.println("blankStr.isBlank(): " + blankStr.isBlank()); // true (Java 11+)

        // String comparison
        String s1 = "Hello";
        String s2 = "Hello"; // Literal, often points to same object in string pool
        String s3 = new String("Hello"); // Explicitly creates new object
        String s4 = "HELLO";

        System.out.println("\nString Comparison:");
        System.out.println("s1 == s2: " + (s1 == s2)); // true (usually, due to string pool)
        System.out.println("s1 == s3: " + (s1 == s3)); // false (different objects)
        System.out.println("s1.equals(s2): " + s1.equals(s2)); // true (compares content)
        System.out.println("s1.equals(s3): " + s1.equals(s3)); // true (compares content)
        System.out.println("s1.equals(s4): " + s1.equals(s4)); // false (case-sensitive)
        System.out.println("s1.equalsIgnoreCase(s4): " + s1.equalsIgnoreCase(s4)); // true (ignores case)
        // ALWAYS use .equals() or .equalsIgnoreCase() to compare string content, not ==.
    }
}

StringBuilder and StringBuffer:

Since String is immutable, performing many modifications (like concatenating in a loop) can be inefficient as it creates numerous intermediate String objects. For mutable string operations, use:

  • StringBuilder: Mutable sequence of characters. Not thread-safe (faster). Use when modifications happen within a single thread.
  • StringBuffer: Mutable sequence of characters. Thread-safe (synchronized methods, slower). Use when modifications might happen across multiple threads (less common nowadays; concurrent collections are often preferred).

public class StringBuilderDemo {
    public static void main(String[] args) {
        // Inefficient way using String concatenation in a loop
        String resultString = "";
        long startTime = System.nanoTime();
        for (int i = 0; i < 10000; i++) {
            resultString += i; // Creates many intermediate String objects
        }
        long endTime = System.nanoTime();
        System.out.println("String concatenation time: " + (endTime - startTime) / 1e6 + " ms");
        // System.out.println("Length: " + resultString.length());

        // Efficient way using StringBuilder
        StringBuilder sb = new StringBuilder();
        startTime = System.nanoTime();
        for (int i = 0; i < 10000; i++) {
            sb.append(i); // Modifies the internal buffer of StringBuilder
        }
        endTime = System.nanoTime();
        System.out.println("StringBuilder append time: " + (endTime - startTime) / 1e6 + " ms");

        // Get the final String
        String finalResult = sb.toString();
        // System.out.println("Length: " + finalResult.length());

        // Other StringBuilder methods
        StringBuilder builder = new StringBuilder("Start");
        builder.append(" Middle"); // Appends
        builder.insert(5, "->"); // Inserts at index 5
        builder.replace(0, 5, "Begin"); // Replaces characters from index 0 to 4
        builder.delete(5, 8); // Deletes characters from index 5 to 7
        builder.reverse(); // Reverses the content

        System.out.println("\nBuilder result: " + builder.toString());
    }
}
Use StringBuilder (or StringBuffer if thread safety is strictly required) when you need to perform multiple modifications to a sequence of characters.

File I/O

Java provides APIs for reading from and writing to files, essential for data persistence and interaction with the file system. There are two main packages:

  • java.io (Older API): Stream-based (byte streams like FileInputStream, FileOutputStream; character streams like FileReader, FileWriter). Often uses Decorator pattern (wrapping streams like BufferedReader, PrintWriter). Blocking I/O.
  • java.nio (New I/O - Java 1.4+): Buffer-oriented, channel-based. Offers non-blocking I/O capabilities (more scalable for high-concurrency network applications) and more comprehensive file system operations (Files, Paths, Path). Generally preferred for new development, especially for more advanced file operations or non-blocking needs.

Reading Files (java.io - Character Stream):

Using BufferedReader for efficient reading of text files line by line.

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.io.FileNotFoundException;

public class ReadFileIO {
    public static void main(String[] args) {
        String filename = "sample.txt"; // Create this file first

        // Create a sample file using Linux terminal:
        // echo "This is line 1." > sample.txt
        // echo "This is line 2." >> sample.txt
        // echo "End of file." >> sample.txt

        System.out.println("--- Reading with java.io (BufferedReader) ---");

        // Use try-with-resources for automatic closing
        try (FileReader fileReader = new FileReader(filename); // Opens connection to file (character based)
             BufferedReader bufferedReader = new BufferedReader(fileReader)) { // Wraps for efficiency

            System.out.println("Reading file: " + filename);
            String line;
            int lineNum = 1;
            while ((line = bufferedReader.readLine()) != null) { // Read line by line
                System.out.printf("Line %d: %s\n", lineNum++, line);
            }
            System.out.println("Finished reading.");

        } catch (FileNotFoundException e) {
            System.err.println("Error: File not found '" + filename + "'. " + e.getMessage());
        } catch (IOException e) {
            System.err.println("Error reading file '" + filename + "': " + e.getMessage());
            // e.printStackTrace();
        }
    }
}

Writing Files (java.io - Character Stream):

Using FileWriter and PrintWriter for efficient writing of text data.

import java.io.FileWriter;
import java.io.PrintWriter;
import java.io.IOException;

public class WriteFileIO {
    public static void main(String[] args) {
        String filename = "output.txt";
        boolean append = false; // Set to true to add to the end, false to overwrite

        System.out.println("--- Writing with java.io (PrintWriter) ---");
        System.out.println("Mode: " + (append ? "Append" : "Overwrite"));

        // Use try-with-resources
        try (FileWriter fileWriter = new FileWriter(filename, append); // Opens file connection, boolean for append mode
             PrintWriter printWriter = new PrintWriter(fileWriter)) { // Wraps for println/printf methods

            System.out.println("Writing to file: " + filename);

            printWriter.println("This data is written from Java.");
            printWriter.println("Using PrintWriter makes writing text easy.");
            printWriter.printf("We can write formatted numbers like %.2f and integers %d.\n", 123.456, 789);
            printWriter.println("End of write operation.");

            System.out.println("Finished writing.");

        } catch (IOException e) {
            System.err.println("Error writing to file '" + filename + "': " + e.getMessage());
            // e.printStackTrace();
        }

        // You can verify the contents using cat in Linux terminal:
        // cat output.txt
    }
}

Reading Files (java.nio):

Using Files and Paths for a more modern approach.

import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
import java.util.List;
import java.io.BufferedReader; // Can still combine nio Path with io BufferedReader

public class ReadFileNIO {
    public static void main(String[] args) {
        String filename = "sample.txt"; // Use the same file created earlier
        Path filePath = Paths.get(filename); // Create a Path object

        System.out.println("--- Reading with java.nio (Files.readAllLines) ---");
        try {
            // Reads the entire file into a List<String> - careful with very large files!
            List<String> lines = Files.readAllLines(filePath, StandardCharsets.UTF_8); // Specify encoding
            System.out.println("Reading all lines from: " + filePath.toAbsolutePath());
            int lineNum = 1;
            for (String line : lines) {
                System.out.printf("Line %d: %s\n", lineNum++, line);
            }
            System.out.println("Finished reading all lines.");

        } catch (IOException e) {
            System.err.println("Error reading file: " + e.getMessage());
        }

        System.out.println("\n--- Reading with java.nio (Files.newBufferedReader) ---");
        // More memory-efficient for large files - combines nio Path with io stream processing
        try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8)) {
            System.out.println("Reading line by line using BufferedReader from Path:");
            String line;
            int lineNum = 1;
             while ((line = reader.readLine()) != null) {
                System.out.printf("Line %d: %s\n", lineNum++, line);
            }
             System.out.println("Finished reading line by line.");
        } catch (IOException e) {
             System.err.println("Error reading file: " + e.getMessage());
        }

        System.out.println("\n--- Reading with java.nio (Files.lines - Stream API) ---");
        // Java 8 Stream API approach - very concise, processes lazily
         try {
            System.out.println("Reading using Stream API:");
            Files.lines(filePath, StandardCharsets.UTF_8) // Returns a Stream<String>
                 .map(line -> "Stream Line: " + line) // Example processing
                 .forEach(System.out::println); // Terminal operation
             System.out.println("Finished reading with Stream.");
         } catch (IOException e) {
             System.err.println("Error reading file with stream: " + e.getMessage());
         }
    }
}

Writing Files (java.nio):

import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.io.BufferedWriter; // Can combine nio Path with io BufferedWriter
import java.io.IOException;
import java.util.Arrays;
import java.util.List;

public class WriteFileNIO {
    public static void main(String[] args) {
        String filename = "output_nio.txt";
        Path filePath = Paths.get(filename);

        System.out.println("--- Writing with java.nio (Files.write) ---");
        List<String> linesToWrite = Arrays.asList(
            "Written using Files.write.",
            "This is line 2.",
            "Supports StandardOpenOption for append/create."
        );

        try {
            // Writes a collection of lines. Overwrites by default.
            // Use StandardOpenOption.APPEND to append, CREATE to create if not exists, etc.
            Files.write(filePath, linesToWrite, StandardCharsets.UTF_8,
                        StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); // Overwrite
            System.out.println("Wrote lines to " + filePath.toAbsolutePath());

            // Append example
            Files.write(filePath, Arrays.asList("This line was appended."), StandardCharsets.UTF_8,
                        StandardOpenOption.APPEND);
             System.out.println("Appended a line.");

        } catch (IOException e) {
             System.err.println("Error writing file: " + e.getMessage());
        }

        System.out.println("\n--- Writing with java.nio (Files.newBufferedWriter) ---");
        // More efficient for writing large amounts of text gradually
        try (BufferedWriter writer = Files.newBufferedWriter(filePath, StandardCharsets.UTF_8,
                                     StandardOpenOption.CREATE, StandardOpenOption.APPEND /* Append again */)) {
            System.out.println("Writing using BufferedWriter from Path (appending):");
            writer.newLine(); // Write a newline separator
            writer.write("Using BufferedWriter for efficient writing.");
            writer.newLine();
            writer.write("Line written via BufferedWriter.");
            writer.newLine();
             System.out.println("Finished writing with BufferedWriter.");

        } catch (IOException e) {
             System.err.println("Error writing file: " + e.getMessage());
        }

        // Verify with: cat output_nio.txt
    }
}

Other java.nio.file.Files utilities:

The Files class provides many useful static methods for file system operations:

  • exists(Path path, LinkOption... options): Check if a file or directory exists.
  • createFile(Path path, FileAttribute<?>... attrs): Create a new empty file.
  • createDirectory(Path dir, FileAttribute<?>... attrs): Create a new directory.
  • copy(Path source, Path target, CopyOption... options): Copy a file or directory.
  • move(Path source, Path target, CopyOption... options): Move or rename a file or directory.
  • delete(Path path): Delete a file or directory (directory must be empty).
  • deleteIfExists(Path path): Delete if it exists, don't throw exception if not.
  • size(Path path): Get the size of a file in bytes.
  • isDirectory(Path path, LinkOption... options): Check if a path is a directory.
  • isRegularFile(Path path, LinkOption... options): Check if a path is a regular file.
  • walk(Path start, FileVisitOption... options): Traverse a file tree (useful for recursive operations).

Workshop Building a Simple File Explorer

Objective: Create a command-line tool in Java that allows the user to list the contents of a directory, display the contents of a text file, and copy a file. This will practice java.nio.file APIs, exception handling, user input (Scanner), and control flow.

Steps:

  1. Directory Setup: Navigate to your Java projects directory (e.g., ~/javabasics) and create a subdirectory for this workshop.

    cd ~/javabasics
    mkdir fileexplorer
    cd fileexplorer
    
    Create some dummy files and directories for testing within fileexplorer:
    echo "Content of file1.txt" > file1.txt
    echo "Another text file." > file2.doc
    mkdir subdir1
    echo "Content inside subdir1" > subdir1/nestedfile.txt
    mkdir emptydir
    touch binaryfile.bin # An empty file, we won't read its content
    

  2. Create the Java Source File: Create FileExplorer.java.

    nano FileExplorer.java
    

  3. Write the Java Code: Enter the following code. Pay attention to the use of Path, Files, try-catch, and the Scanner.

    import java.io.BufferedReader;
    import java.io.IOException;
    import java.nio.file.*;
    import java.nio.charset.StandardCharsets;
    import java.util.Scanner;
    import java.util.stream.Stream; // For listing directory contents
    
    public class FileExplorer {
    
        private static Scanner scanner = new Scanner(System.in);
        private static Path currentDirectory = Paths.get(".").toAbsolutePath().normalize(); // Start in current dir
    
        public static void main(String[] args) {
            System.out.println("=== Simple Java File Explorer ===");
    
            String command;
            do {
                System.out.println("\nCurrent Directory: " + currentDirectory);
                System.out.print("Enter command (ls, cd <dir>, cat <file>, cp <src> <dest>, exit): ");
                command = scanner.nextLine().trim();
                executeCommand(command);
            } while (!command.equalsIgnoreCase("exit"));
    
            System.out.println("Exiting File Explorer. Goodbye!");
            scanner.close();
        }
    
        private static void executeCommand(String fullCommand) {
            String[] parts = fullCommand.split("\\s+", 3); // Split into max 3 parts (cmd, arg1, arg2)
            String command = parts[0].toLowerCase();
    
            try {
                switch (command) {
                    case "ls":
                        listFiles();
                        break;
                    case "cd":
                        if (parts.length > 1) {
                            changeDirectory(parts[1]);
                        } else {
                            System.out.println("Usage: cd <directory_name>");
                        }
                        break;
                    case "cat":
                        if (parts.length > 1) {
                            displayFileContent(parts[1]);
                        } else {
                            System.out.println("Usage: cat <filename>");
                        }
                        break;
                    case "cp":
                        if (parts.length > 2) {
                            copyFile(parts[1], parts[2]);
                        } else {
                            System.out.println("Usage: cp <source_file> <destination_file>");
                        }
                        break;
                    case "exit":
                        // Handled by the main loop
                        break;
                    default:
                        System.out.println("Unknown command: " + command);
                        break;
                }
            } catch (InvalidPathException ipe) {
                 System.err.println("Error: Invalid path specified. " + ipe.getMessage());
            } catch (NoSuchFileException nsfe) {
                 System.err.println("Error: File or directory not found. " + nsfe.getMessage());
            } catch (DirectoryNotEmptyException dnee) {
                 System.err.println("Error: Directory is not empty (cannot delete/overwrite?). " + dnee.getMessage());
            } catch (FileAlreadyExistsException faee) {
                 System.err.println("Error: Destination file already exists. " + faee.getMessage());
            } catch (IOException ioe) {
                // Catch general IO errors
                System.err.println("An I/O error occurred: " + ioe.getMessage());
                // ioe.printStackTrace(); // Uncomment for detailed debugging
            } catch (Exception e) {
                // Catch any other unexpected errors
                 System.err.println("An unexpected error occurred: " + e.getMessage());
                 // e.printStackTrace(); // Uncomment for detailed debugging
            }
        }
    
        // --- Command Implementations ---
    
        private static void listFiles() throws IOException {
            System.out.println("\n--- Contents of " + currentDirectory + " ---");
            // Use try-with-resources for the DirectoryStream
            try (Stream<Path> stream = Files.list(currentDirectory)) {
                stream.forEach(path -> {
                    String type = Files.isDirectory(path) ? "[DIR]" : "[FILE]";
                    System.out.printf("%-6s %s\n", type, path.getFileName());
                });
            }
            System.out.println("---------------------------------");
        }
    
        private static void changeDirectory(String target) throws IOException {
            Path newPath;
            if (target.equals("..")) {
                 // Go up one level, handle potential null if already at root
                 Path parent = currentDirectory.getParent();
                 newPath = (parent != null) ? parent : currentDirectory;
            } else {
                // Resolve target relative to current directory
                newPath = currentDirectory.resolve(target).normalize();
            }
    
            if (Files.isDirectory(newPath)) {
                currentDirectory = newPath.toAbsolutePath().normalize(); // Update current directory
                 System.out.println("Changed directory to: " + currentDirectory);
            } else {
                System.out.println("Error: '" + target + "' is not a valid directory.");
            }
        }
    
        private static void displayFileContent(String filename) throws IOException {
            Path filePath = currentDirectory.resolve(filename).normalize();
    
            if (!Files.isRegularFile(filePath)) {
                System.out.println("Error: '" + filename + "' is not a regular file or does not exist.");
                return;
            }
            if (!Files.isReadable(filePath)) {
                 System.out.println("Error: Cannot read file '" + filename + "'. Permission denied?");
                 return;
            }
    
            System.out.println("\n--- Content of " + filename + " ---");
            // Use try-with-resources for BufferedReader
            try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8)) {
                String line;
                while ((line = reader.readLine()) != null) {
                    System.out.println(line);
                }
            } catch (IOException e) {
                 System.err.println("Error reading file content: " + e.getMessage());
            } // Other exceptions like MalformedInputException could be caught if needed
            System.out.println("--- End of Content ---");
        }
    
        private static void copyFile(String sourceFilename, String destFilename) throws IOException {
            Path sourcePath = currentDirectory.resolve(sourceFilename).normalize();
            Path destPath = currentDirectory.resolve(destFilename).normalize();
    
            if (!Files.isRegularFile(sourcePath)) {
                System.out.println("Error: Source '" + sourceFilename + "' is not a regular file or does not exist.");
                return;
            }
    
            // Use StandardCopyOption.REPLACE_EXISTING to overwrite, or handle FileAlreadyExistsException
            CopyOption[] options = { StandardCopyOption.REPLACE_EXISTING }; // Overwrite if exists
    
            Files.copy(sourcePath, destPath, options);
            System.out.println("Copied '" + sourceFilename + "' to '" + destFilename + "'");
        }
    }
    
  4. Save and Exit: Save the file (Ctrl+X, Y, Enter in nano).

  5. Compile the Code:

    javac FileExplorer.java
    
    Fix any compilation errors.

  6. Run the File Explorer:

    java FileExplorer
    

  7. Interact with the Program: Try the different commands:

    • ls: Should show file1.txt, file2.doc, subdir1, emptydir, binaryfile.bin, FileExplorer.java, FileExplorer.class.
    • cat file1.txt: Should display "Content of file1.txt".
    • cat non_existent.txt: Should show a "File or directory not found" error.
    • cd subdir1: Should change the current directory prompt.
    • ls: Should now show nestedfile.txt.
    • cat nestedfile.txt: Should display its content.
    • cd ..: Should go back to the fileexplorer directory.
    • cp file1.txt copy_of_file1.txt: Should create a copy.
    • ls: Should show the new copy_of_file1.txt.
    • cat copy_of_file1.txt: Should show the same content as file1.txt.
    • cp file1.txt subdir1: Should copy file1.txt into the subdir1 directory (if subdir1 exists and is a directory, otherwise error). Check with ls subdir1.
    • exit: Should terminate the program.

This workshop provides hands-on experience with java.nio.file for common file system tasks, reinforcing concepts of paths, file operations, directory listing, exception handling, and basic command-line argument parsing within a structured application.

4. Advanced Java Concepts

This section explores more sophisticated Java features essential for developing complex, concurrent, and networked applications, as well as techniques for introspection and modern modularity.

Multithreading and Concurrency

Modern applications often need to perform multiple tasks simultaneously (or appear to) for responsiveness and performance. Java provides extensive support for multithreading (allowing multiple threads of execution within a single program) and concurrency (managing access to shared resources by multiple threads safely).

Threads:

A thread is the smallest unit of execution within a process. A Java program starts with a main thread (the one executing the main method). You can create additional threads to perform tasks concurrently.

Creating Threads:

  1. Extend the Thread Class: Create a subclass of java.lang.Thread and override its run() method to define the task the thread will execute.

    class MyThread extends Thread {
        private String threadName;
    
        public MyThread(String name) {
            this.threadName = name;
            System.out.println("Creating " +  threadName );
        }
    
        @Override
        public void run() { // This method contains the code executed by the thread
            System.out.println("Running " +  threadName );
            try {
                for(int i = 4; i > 0; i--) {
                    System.out.println("Thread: " + threadName + ", Count: " + i);
                    // Let the thread sleep for a short time
                    Thread.sleep(50); // Sleep for 50 milliseconds
                }
            } catch (InterruptedException e) {
                System.out.println("Thread " +  threadName + " interrupted.");
                Thread.currentThread().interrupt(); // Re-set interrupt status
            }
            System.out.println("Thread " +  threadName + " exiting.");
        }
    }
    
    public class ThreadDemo {
       public static void main(String args[]) {
          MyThread t1 = new MyThread("Thread-1");
          MyThread t2 = new MyThread("Thread-2");
    
          // Start the threads - calls the run() method implicitly
          t1.start();
          t2.start();
    
          System.out.println("Main thread finished initiating child threads.");
          // Main thread continues execution concurrently with t1 and t2
       }
    }
    

  2. Implement the Runnable Interface: Create a class that implements the java.lang.Runnable interface. This interface has a single method: run(). Then, create a Thread object, passing an instance of your Runnable class to its constructor. This approach is generally preferred as it separates the task (Runnable) from the execution mechanism (Thread) and allows your task class to extend a different superclass if needed (Java only allows single class inheritance).

    class MyRunnable implements Runnable {
        private String taskName;
        private Thread thread; // Optional: reference back to the thread controlling this runnable
    
        public MyRunnable(String name) {
            this.taskName = name;
            System.out.println("Creating Runnable task: " + taskName);
        }
    
        @Override
        public void run() {
            System.out.println("Running task: " + taskName);
            try {
                for(int i = 0; i < 5; i++) {
                    System.out.println("Task: " + taskName + ", Processing step: " + i);
                    Thread.sleep(40);
                }
            } catch (InterruptedException e) {
                System.out.println("Task " + taskName + " interrupted.");
                 Thread.currentThread().interrupt();
            }
            System.out.println("Task " + taskName + " finished.");
        }
    
        // Optional method to start the thread associated with this runnable
        public void start() {
             System.out.println("Starting thread for task: " + taskName);
             if (thread == null) {
                 thread = new Thread(this, taskName + "-Thread"); // Pass Runnable 'this' and a thread name
                 thread.start();
             }
        }
    }
    
    public class RunnableDemo {
        public static void main(String args[]) {
            MyRunnable r1 = new MyRunnable("Task-A");
            MyRunnable r2 = new MyRunnable("Task-B");
    
            // Option 1: Create Threads directly
            // Thread tA = new Thread(r1, "Thread-A");
            // Thread tB = new Thread(r2, "Thread-B");
            // tA.start();
            // tB.start();
    
            // Option 2: Use start method within Runnable (if implemented)
            r1.start();
            r2.start();
    
            System.out.println("Main thread finished initiating Runnable tasks.");
        }
    }
    

Thread Lifecycle:

Threads go through various states:

  • NEW: Thread object created but start() not yet called.
  • RUNNABLE: Thread is eligible to be run by the JVM's thread scheduler. It might be currently running or waiting for the CPU.
  • BLOCKED: Thread is waiting to acquire a monitor lock (e.g., entering a synchronized block/method).
  • WAITING: Thread is waiting indefinitely for another thread to perform a particular action (e.g., waiting on an object's monitor via object.wait(), waiting for another thread to finish via thread.join()).
  • TIMED_WAITING: Thread is waiting for a specified amount of time (e.g., Thread.sleep(millis), object.wait(millis), thread.join(millis)).
  • TERMINATED: Thread has completed its execution (exited the run() method).

Synchronization:

When multiple threads access and modify shared, mutable data, race conditions can occur, leading to unpredictable results. Synchronization mechanisms ensure that only one thread can access the critical section (code interacting with shared data) at a time.

  1. synchronized Methods: Add the synchronized keyword to a method declaration. When a thread invokes a synchronized instance method, it acquires an intrinsic lock (monitor lock) on the object (this). No other thread can execute any synchronized instance method on the same object until the lock is released (when the method returns). For static methods, the lock is on the Class object.

    class Counter {
        private int count = 0;
    
        // This method is synchronized on the Counter object instance
        public synchronized void increment() {
            int current = count;
            // Simulate some processing time - increases chance of race condition without sync
            try { Thread.sleep(1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            count = current + 1;
        }
    
        public synchronized int getCount() {
            return count;
        }
    }
    

  2. synchronized Blocks: Synchronize only a specific block of code within a method, rather than the entire method. This allows for finer-grained locking and can improve concurrency if other parts of the method don't need synchronization. You specify the object whose lock should be acquired.

    class AnotherCounter {
        private long count = 0;
        private final Object lockObject = new Object(); // Explicit lock object
    
        public void increment() {
            // Non-critical code can go here...
            System.out.println(Thread.currentThread().getName() + " entering increment");
    
            // Synchronize only the critical section accessing 'count'
            synchronized (lockObject) { // Acquire lock on lockObject
                 System.out.println(Thread.currentThread().getName() + " acquired lock");
                 long current = count;
                 try { Thread.sleep(1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                 count = current + 1;
                  System.out.println(Thread.currentThread().getName() + " releasing lock, count=" + count);
            } // Lock released here
    
             // Other non-critical code...
        }
    
        public long getCount() {
             synchronized(lockObject) { // Need synchronization for reading too for visibility
                return count;
             }
        }
    }
    
    Using a dedicated private Object for locking (lockObject) is often preferred over synchronizing on this, as it prevents external code from potentially interfering with your object's lock.

volatile Keyword:

Ensures that reads and writes to a variable are atomic (for long and double) and that changes made by one thread are immediately visible to other threads (addresses memory visibility issues). It does not provide mutual exclusion like synchronized. Useful for simple flags or status indicators shared between threads where complex atomic operations aren't needed.

class Worker extends Thread {
    // Volatile guarantees visibility of changes across threads
    private volatile boolean running = true;

    public void run() {
        while (running) {
            System.out.println("Worker thread is running...");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
        System.out.println("Worker thread stopped.");
    }

    public void shutdown() {
        System.out.println("Shutdown requested...");
        this.running = false; // Change is guaranteed to be visible to the worker thread
    }
}

public class VolatileDemo {
    public static void main(String[] args) throws InterruptedException {
        Worker worker = new Worker();
        worker.start();

        Thread.sleep(500); // Let the worker run for a bit

        worker.shutdown(); // Request shutdown from main thread
        worker.join(); // Wait for worker thread to terminate
        System.out.println("Main thread finished.");
    }
}
Without volatile, the worker thread might cache the value of running and never see the change made by the main thread, potentially leading to an infinite loop.

java.util.concurrent Package:

Provides a higher-level, more flexible, and often more performant framework for managing concurrency, introduced in Java 5.

  • Executor Framework (Executor, ExecutorService, Executors): Decouples task submission from task execution. Manages thread pools efficiently, reducing the overhead of creating new threads for each task.
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.TimeUnit;
    
    public class ExecutorDemo {
        public static void main(String[] args) {
            // Create a fixed-size thread pool with 2 threads
            ExecutorService executor = Executors.newFixedThreadPool(2);
    
            System.out.println("Submitting tasks to executor...");
    
            // Submit Runnable tasks
            for (int i = 1; i <= 5; i++) {
                int taskID = i;
                executor.submit(() -> { // Using lambda expression for Runnable
                    System.out.println("Executing Task " + taskID + " on thread: " + Thread.currentThread().getName());
                    try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                    System.out.println("Finished Task " + taskID);
                });
            }
    
            // Initiate shutdown - stops accepting new tasks, waits for existing tasks to finish
            System.out.println("Executor shutdown initiated.");
            executor.shutdown();
    
            try {
                // Wait for tasks to complete for up to 1 minute
                if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
                    System.err.println("Tasks did not complete in time, forcing shutdown.");
                    executor.shutdownNow(); // Attempt to interrupt running tasks
                }
            } catch (InterruptedException ie) {
                executor.shutdownNow();
                Thread.currentThread().interrupt();
            }
    
            System.out.println("All tasks completed. Executor terminated.");
        }
    }
    
  • Concurrent Collections (ConcurrentHashMap, CopyOnWriteArrayList, etc.): Thread-safe collection implementations designed for high concurrency, often offering better performance than synchronizing access to standard collections (e.g., Collections.synchronizedMap(new HashMap<>())).
  • Atomic Variables (AtomicInteger, AtomicBoolean, AtomicReference, etc.): Provide lock-free, thread-safe operations on single variables using hardware-level atomic instructions (Compare-And-Swap - CAS). Often more efficient than using synchronized for simple counters or flags.
    import java.util.concurrent.atomic.AtomicInteger;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.TimeUnit;
    
    public class AtomicDemo {
        // Using AtomicInteger for thread-safe counting without explicit locks
        private static AtomicInteger atomicCount = new AtomicInteger(0);
    
        public static void main(String[] args) throws InterruptedException {
            ExecutorService executor = Executors.newFixedThreadPool(10);
            int numberOfTasks = 1000;
    
            for (int i = 0; i < numberOfTasks; i++) {
                executor.submit(() -> {
                    // Atomically increment the count
                    int currentVal = atomicCount.incrementAndGet();
                    System.out.println(Thread.currentThread().getName() + " incremented count to: " + currentVal);
                    try { Thread.sleep(1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                });
            }
    
            executor.shutdown();
            executor.awaitTermination(10, TimeUnit.SECONDS);
    
            System.out.println("\nFinal Atomic Count: " + atomicCount.get()); // Should be 1000
        }
    }
    
  • Locks (Lock, ReentrantLock, ReadWriteLock): More flexible locking mechanisms than the intrinsic synchronized approach. ReentrantLock provides features like timed lock waits, interruptible lock waits, and fairness policies. ReadWriteLock allows multiple readers to hold the lock concurrently while writers require exclusive access, improving performance for read-heavy workloads.
  • Synchronizers (CountDownLatch, CyclicBarrier, Semaphore, Phaser): Utility classes to coordinate the control flow between threads (e.g., waiting for multiple operations to complete, limiting concurrent access to a resource).

Concurrency is a complex topic. Key considerations include thread safety, liveness (avoiding deadlock, livelock, starvation), and performance. The java.util.concurrent package provides powerful tools, but understanding the underlying principles is essential for using them correctly.

Networking

Java provides a comprehensive API for network programming, primarily in the java.net package, allowing applications to communicate over networks using protocols like TCP/IP and UDP.

Sockets:

Sockets are the endpoints for network communication. Java provides classes for both client and server sockets.

  • Socket (Client): Represents one end of a TCP connection. Used by a client to connect to a server.
  • ServerSocket (Server): Listens for incoming TCP connection requests from clients. When a connection is accepted, it creates a Socket object to handle communication with that specific client.

Simple TCP Client-Server Example:

Server (SimpleServer.java):

import java.net.ServerSocket;
import java.net.Socket;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class SimpleServer {
    public static void main(String[] args) {
        int port = 12345; // Port number to listen on (> 1024 recommended for non-root)

        System.out.println("Server starting on port " + port + "...");

        try (ServerSocket serverSocket = new ServerSocket(port)) { // Create server socket

            while (true) { // Keep listening indefinitely
                System.out.println("Waiting for a client connection...");
                // Accept() blocks until a client connects, then returns a Socket for that client
                try (Socket clientSocket = serverSocket.accept();
                     // Get input/output streams for the client socket
                     PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true); // Auto-flush enabled
                     BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())))
                {
                    System.out.println("Client connected from: " + clientSocket.getInetAddress().getHostAddress());

                    // Send a welcome message to the client
                    String timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_TIME);
                    out.println("Welcome! Server time is " + timestamp);

                    // Read messages from the client and echo them back
                    String inputLine;
                    while ((inputLine = in.readLine()) != null) {
                        System.out.println("Received from client: " + inputLine);
                        if ("bye".equalsIgnoreCase(inputLine.trim())) {
                            out.println("Goodbye!");
                            break; // Exit loop if client says bye
                        }
                        out.println("Server Echo: " + inputLine); // Echo back
                    }
                    System.out.println("Client disconnected.");

                } catch (IOException e) {
                     System.err.println("Error handling client connection: " + e.getMessage());
                }
                // The client Socket, PrintWriter, and BufferedReader are auto-closed here by try-with-resources
            }

        } catch (IOException e) {
            System.err.println("Could not listen on port " + port + ": " + e.getMessage());
            // e.printStackTrace();
        }
        // The ServerSocket is auto-closed here
    }
}

Client (SimpleClient.java):

import java.net.Socket;
import java.net.UnknownHostException;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.IOException;
import java.util.Scanner;

public class SimpleClient {
    public static void main(String[] args) {
        String hostname = "localhost"; // Server hostname or IP address
        int port = 12345; // Server port number

        System.out.println("Attempting to connect to server " + hostname + ":" + port + "...");

        // Use try-with-resources for Socket and streams
        try (Socket socket = new Socket(hostname, port); // Connect to the server
             PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
             BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
             Scanner consoleInput = new Scanner(System.in)) // To read user input from console
        {
            System.out.println("Connected successfully!");

            // Read the initial welcome message from the server
            String serverResponse = in.readLine();
            System.out.println("Server: " + serverResponse);

            String userInput;
            System.out.print("Enter message to send (or 'bye' to quit): ");
            while ((userInput = consoleInput.nextLine()) != null) {
                out.println(userInput); // Send user input to server

                // Read the server's echo response
                serverResponse = in.readLine();
                System.out.println("Server: " + serverResponse);

                if ("goodbye!".equalsIgnoreCase(serverResponse.trim())) {
                    break; // Exit if server said goodbye
                }
                 if ("bye".equalsIgnoreCase(userInput.trim())) {
                    break; // Exit if user typed bye
                }
                System.out.print("Enter message to send (or 'bye' to quit): ");
            }

        } catch (UnknownHostException e) {
            System.err.println("Don't know about host " + hostname + ": " + e.getMessage());
        } catch (IOException e) {
            System.err.println("Couldn't get I/O for the connection to " + hostname + ": " + e.getMessage());
            // e.printStackTrace();
        }
        // Socket, streams, and Scanner are auto-closed here

        System.out.println("Client finished.");
    }
}

Running the Example:

  1. Compile both files:
    javac SimpleServer.java SimpleClient.java
    
  2. Open a terminal and run the server:
    java SimpleServer
    # It will print "Server starting..." and "Waiting for a client..."
    
  3. Open another terminal and run the client:
    java SimpleClient
    # It will attempt to connect, print "Connected successfully!", and the server's welcome message.
    
  4. In the client terminal, type messages and press Enter. The server should receive them (check server terminal output) and echo them back to the client.
  5. Type bye in the client terminal to disconnect. Both client and server should indicate disconnection. The server will then wait for a new client.
  6. Stop the server by pressing Ctrl+C in its terminal.

URL and URLConnection:

Used for working with higher-level protocols like HTTP/HTTPS.

  • URL: Represents a Uniform Resource Locator. Used to parse and represent URLs.
  • URLConnection: An abstract class representing an active connection to the resource pointed to by a URL. Subclasses like HttpURLConnection handle specific protocols. Used to fetch content, get headers, etc.
import java.net.URL;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.IOException;
import java.util.List;
import java.util.Map;

public class URLDemo {
    public static void main(String[] args) {
        String urlString = "https://www.google.com"; // Example URL

        System.out.println("Attempting to connect to: " + urlString);

        try {
            URL url = new URL(urlString);

            // Open a connection (returns URLConnection, cast to HttpURLConnection for HTTP specifics)
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();

            // Set request method (GET is default)
            connection.setRequestMethod("GET");
            // Set request headers (optional)
            connection.setRequestProperty("User-Agent", "Simple-Java-Client");

            // Connect (implicitly done when getting response code or input stream)
            // connection.connect(); // Not always necessary

            // Get response code (e.g., 200 OK, 404 Not Found)
            int responseCode = connection.getResponseCode();
            System.out.println("Response Code: " + responseCode + " " + connection.getResponseMessage());

            if (responseCode == HttpURLConnection.HTTP_OK) { // Success
                System.out.println("\n--- Response Headers ---");
                Map<String, List<String>> headers = connection.getHeaderFields();
                for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
                    // Header key can be null for the status line
                    String key = entry.getKey() == null ? "[Status Line]" : entry.getKey();
                    System.out.println(key + ": " + entry.getValue());
                }

                System.out.println("\n--- Response Body (first few lines) ---");
                // Get the input stream to read the response body
                try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
                    String line;
                    int lineCount = 0;
                    while ((line = reader.readLine()) != null && lineCount < 15) { // Read only first 15 lines
                        System.out.println(line);
                        lineCount++;
                    }
                    if (line != null) {
                         System.out.println("... (content truncated)");
                    }
                }
            } else {
                System.out.println("Request failed.");
                // Can read error stream if needed: connection.getErrorStream()
            }

            // Disconnect (releases resources)
            connection.disconnect();
            System.out.println("\nConnection disconnected.");

        } catch (MalformedURLException e) {
            System.err.println("Invalid URL: " + e.getMessage());
        } catch (IOException e) {
             System.err.println("Error during connection: " + e.getMessage());
             // e.printStackTrace();
        }
    }
}

Networking often involves handling I/O streams and exceptions carefully. For more complex applications, consider using libraries like Apache HttpClient or OkHttp, which provide more features and convenience. Non-blocking I/O using java.nio (specifically SocketChannel, ServerSocketChannel, and Selector) is crucial for building highly scalable servers that can handle many concurrent connections efficiently.

Generics In-Depth

Generics, introduced in Java 5, allow types (classes and interfaces) to be parameters when defining classes, interfaces, and methods. This enhances type safety and code reusability.

Type Erasure:

A key concept to understand is that generic type information exists only at compile time. The compiler uses generics to perform type checking, but then erases the generic type information, replacing type parameters with their bounds (usually Object) or specific types if bounded. This ensures backward compatibility with older Java code that didn't use generics.

List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();

// At runtime, both stringList and intList are just ArrayList objects.
// The JVM doesn't know they were declared with <String> or <Integer>.
System.out.println(stringList.getClass() == intList.getClass()); // Output: true

Consequences:

  • You cannot use instanceof with parameterized types directly (e.g., if (list instanceof ArrayList<String>) is illegal). You check the raw type: if (list instanceof ArrayList).
  • You cannot create instances of type parameters (e.g., new T() inside a generic class MyClass<T>).
  • You cannot create arrays of parameterized types directly (e.g., new List<String>[10] is illegal). You can create new List<?>[10] (using wildcards).

Wildcards (?):

Wildcards represent an unknown type. They are useful when you want to write methods that can operate on generic types in a more flexible way.

  1. Upper Bounded Wildcard (? extends Type): Represents an unknown type that is Type or a subclass of Type. Used when you only need to read from the structure (producer). You can safely get elements as Type, but you cannot safely add elements (except null) because you don't know the exact subtype.

    import java.util.List;
    import java.util.ArrayList;
    
    class Animal { void makeSound() { System.out.println("Generic animal sound"); } }
    class Dog extends Animal { @Override void makeSound() { System.out.println("Woof!"); } }
    class Cat extends Animal { @Override void makeSound() { System.out.println("Meow!"); } }
    
    public class UpperBoundWildcard {
        // This method can accept List<Animal>, List<Dog>, List<Cat>
        public static void printAnimalSounds(List<? extends Animal> animals) {
            System.out.println("--- Printing Sounds ---");
            for (Animal animal : animals) { // Safe to read as Animal
                animal.makeSound();
            }
            // animals.add(new Dog()); // COMPILE ERROR! Cannot add - might be List<Cat>
            // animals.add(new Animal()); // COMPILE ERROR! Cannot add - might be List<Dog>
        }
    
        public static void main(String[] args) {
            List<Dog> dogs = new ArrayList<>();
            dogs.add(new Dog());
            dogs.add(new Dog());
    
            List<Cat> cats = new ArrayList<>();
            cats.add(new Cat());
    
            List<Animal> mixed = new ArrayList<>();
            mixed.add(new Animal());
            mixed.add(new Dog());
    
            printAnimalSounds(dogs);
            printAnimalSounds(cats);
            printAnimalSounds(mixed);
        }
    }
    

  2. Lower Bounded Wildcard (? super Type): Represents an unknown type that is Type or a superclass of Type. Used when you only need to add elements of type Type (or its subtypes) to the structure (consumer). You can safely add Type or its subtypes, but when you read elements, you can only guarantee they are Object.

    import java.util.List;
    import java.util.ArrayList;
    
    public class LowerBoundWildcard {
        // This method can accept List<Number>, List<Object>
        // It can add Integer objects (or subtypes like Integer itself) to the list
        public static void addIntegers(List<? super Integer> list, int count) {
            System.out.println("--- Adding Integers ---");
            for (int i = 1; i <= count; i++) {
                Integer num = i * 10;
                list.add(num); // Safe to add Integer (or subtype)
                System.out.println("Added: " + num);
            }
            // Object obj = list.get(0); // Reading only guarantees Object
        }
    
        public static void main(String[] args) {
            List<Integer> intList = new ArrayList<>();
            List<Number> numList = new ArrayList<>();
            List<Object> objList = new ArrayList<>();
            // List<Double> doubleList = new ArrayList<>(); // Cannot use List<Double>
    
            addIntegers(intList, 3);
            addIntegers(numList, 2);
            addIntegers(objList, 4);
            // addIntegers(doubleList, 1); // COMPILE ERROR! Double is not a supertype of Integer
    
            System.out.println("\nintList: " + intList);
            System.out.println("numList: " + numList);
            System.out.println("objList: " + objList);
        }
    }
    
    PECS Principle: Producer Extends, Consumer Super. Use extends when you only get values out (produce), use super when you only put values in (consume).

  3. Unbounded Wildcard (?): Represents any unknown type. Often used when the type doesn't matter (e.g., printList(List<?> list) just calls list.size() or iterates to print using Object). It's read-only in practice (you can only retrieve Object).

Bounded Type Parameters:

You can restrict the types that can be used as type arguments by specifying bounds.

// T must be a subtype of Number and must implement Comparable<T>
class Stats<T extends Number & Comparable<T>> {
    private T[] nums;

    public Stats(T[] nums) { this.nums = nums; }

    public double average() {
        double sum = 0.0;
        for(T num : nums) {
            sum += num.doubleValue(); // Safe because T extends Number
        }
        return sum / nums.length;
    }

    // Can compare because T implements Comparable<T>
    public boolean isGreaterThan(Stats<?> other) { // Use wildcard for flexibility
        // Basic comparison - could be more sophisticated
        return this.average() > other.average();
    }
}

public class BoundedTypeParamDemo {
    public static void main(String[] args) {
        Integer[] iNums = {1, 2, 3, 4, 5};
        Stats<Integer> intStats = new Stats<>(iNums);
        System.out.printf("Integer Average: %.2f\n", intStats.average());

        Double[] dNums = {1.1, 2.2, 3.3, 4.4, 5.5};
        Stats<Double> doubleStats = new Stats<>(dNums);
         System.out.printf("Double Average: %.2f\n", doubleStats.average());

        System.out.println("Int stats > Double stats? " + intStats.isGreaterThan(doubleStats)); // false

        // String[] sNums = {"a", "b"};
        // Stats<String> stringStats = new Stats<>(sNums); // COMPILE ERROR! String is not a Number
    }
}

Reflection API

Reflection allows a Java program to examine or "introspect" itself at runtime. You can inspect classes, interfaces, fields, methods, and constructors, and even manipulate them dynamically (e.g., call methods or change field values) without knowing their names at compile time. Located in the java.lang.reflect package.

Use Cases:

  • Frameworks (like Spring, JUnit) use reflection extensively for dependency injection, annotation processing, test discovery, etc.
  • Debugging tools and profilers.
  • Object serialization/deserialization libraries.
  • Creating dynamic proxies.

Key Classes:

  • Class: Represents a class or interface. Get it using object.getClass(), MyClass.class, or Class.forName("com.example.MyClass").
  • Field: Represents a class field (member variable).
  • Method: Represents a class or instance method.
  • Constructor: Represents a class constructor.
  • Modifier: Provides static methods to decode access modifiers (public, private, static, final, etc.).

Example:

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;

class ReflectTarget {
    public String publicField = "Public Value";
    private int privateField = 123;
    protected static final double PI_APPROX = 3.14;

    public ReflectTarget() {
        System.out.println("Default constructor called.");
    }

    private ReflectTarget(String msg) {
        System.out.println("Private constructor called with: " + msg);
    }

    public void publicMethod(String param) {
        System.out.println("Public method called with: " + param);
    }

    private String privateMethod() {
        System.out.println("Private method called.");
        return "Private Result";
    }

    public static void staticMethod() {
         System.out.println("Static method called.");
    }
}


public class ReflectionDemo {
    public static void main(String[] args) {
        try {
            // Get Class object
            Class<?> clazz = Class.forName("ReflectTarget");
            // Or: Class<?> clazz = ReflectTarget.class;
            // Or: ReflectTarget obj = new ReflectTarget(); Class<?> clazz = obj.getClass();

            System.out.println("--- Class Info ---");
            System.out.println("Class Name: " + clazz.getName());
            System.out.println("Simple Name: " + clazz.getSimpleName());
            System.out.println("Package: " + clazz.getPackage().getName());
            System.out.println("Is Interface? " + clazz.isInterface());
            System.out.println("Modifiers: " + Modifier.toString(clazz.getModifiers())); // e.g., public

            System.out.println("\n--- Constructors ---");
            Constructor<?>[] constructors = clazz.getDeclaredConstructors(); // Get all constructors
            for (Constructor<?> c : constructors) {
                System.out.println("  " + Modifier.toString(c.getModifiers()) + " " + c.getName() +
                                   Arrays.toString(c.getParameterTypes()));
            }

            System.out.println("\n--- Fields ---");
            Field[] fields = clazz.getDeclaredFields(); // Get all declared fields (public, private, etc.)
            // Field[] publicFields = clazz.getFields(); // Get only public fields (including inherited)
            for (Field f : fields) {
                 System.out.println("  " + Modifier.toString(f.getModifiers()) + " " +
                                   f.getType().getSimpleName() + " " + f.getName());
            }

            System.out.println("\n--- Methods ---");
            Method[] methods = clazz.getDeclaredMethods(); // Get all declared methods
            // Method[] publicMethods = clazz.getMethods(); // Get public methods (including inherited)
            for (Method m : methods) {
                 System.out.println("  " + Modifier.toString(m.getModifiers()) + " " +
                                   m.getReturnType().getSimpleName() + " " + m.getName() +
                                   Arrays.toString(m.getParameterTypes()));
            }

            System.out.println("\n--- Dynamic Instantiation & Method Invocation ---");
            // Instantiate using default public constructor
            Constructor<?> defaultConstructor = clazz.getConstructor(); // Get public default constructor
            Object instance = defaultConstructor.newInstance(); // Create instance

            // Invoke public method
            Method publicMethod = clazz.getMethod("publicMethod", String.class); // Get method by name and param types
            publicMethod.invoke(instance, "Dynamic Message"); // Invoke on instance with arguments

            // Invoke static method (instance is null)
            Method staticMethod = clazz.getMethod("staticMethod");
            staticMethod.invoke(null); // No instance needed for static methods

            System.out.println("\n--- Accessing Private Members ---");
            // Access private field
            Field privateField = clazz.getDeclaredField("privateField");
            privateField.setAccessible(true); // *** IMPORTANT: Bypass access control ***
            int value = (int) privateField.get(instance); // Get value from instance
            System.out.println("  Private field value (before): " + value);
            privateField.set(instance, 456); // Set new value
            System.out.println("  Private field value (after): " + privateField.get(instance));
            privateField.setAccessible(false); // Best practice: Reset accessibility

            // Invoke private method
            Method privateMethod = clazz.getDeclaredMethod("privateMethod");
            privateMethod.setAccessible(true); // Bypass access control
            Object result = privateMethod.invoke(instance); // Invoke on instance
            System.out.println("  Private method result: " + result);
            privateMethod.setAccessible(false); // Reset accessibility

             // Instantiate using private constructor
            Constructor<?> privateConstructor = clazz.getDeclaredConstructor(String.class);
            privateConstructor.setAccessible(true);
            Object instance2 = privateConstructor.newInstance("Secret Message");
            privateConstructor.setAccessible(false);


        } catch (ClassNotFoundException e) {
             System.err.println("Error: Class not found. " + e.getMessage());
        } catch (NoSuchMethodException e) {
             System.err.println("Error: Method not found. " + e.getMessage());
        } catch (NoSuchFieldException e) {
             System.err.println("Error: Field not found. " + e.getMessage());
        } catch (IllegalAccessException e) {
             System.err.println("Error: Access denied (check setAccessible). " + e.getMessage());
        } catch (java.lang.reflect.InvocationTargetException e) {
             System.err.println("Error: Exception thrown by invoked method/constructor. " + e.getTargetException());
             // e.getTargetException().printStackTrace();
        } catch (InstantiationException e) {
             System.err.println("Error: Cannot instantiate abstract class or interface. " + e.getMessage());
        } catch (Exception e) { // Catch other potential reflection exceptions
             System.err.println("A reflection error occurred: " + e.getMessage());
             // e.printStackTrace();
        }
    }
}

Caution: Reflection is powerful but has drawbacks:

  • Performance Overhead: Reflection operations are significantly slower than direct code execution.
  • Security Restrictions: Security Managers can restrict reflective access. Calling setAccessible(true) might fail in secured environments.
  • Reduced Compile-Time Safety: Errors like incorrect method names or parameter types are only caught at runtime, not compile time.
  • Code Obscurity: Overuse can make code harder to understand and maintain. Use reflection judiciously when its benefits (like framework flexibility) outweigh the costs.

Annotations

Annotations provide a way to add metadata (data about data) to Java source code elements (classes, methods, fields, parameters, etc.). They don't directly affect code execution but can be processed by the compiler or at runtime (using reflection) for various purposes.

Built-in Annotations:

  • @Override: Indicates that a method is intended to override a method in a superclass. The compiler checks if the method actually overrides; otherwise, it issues an error. (Seen previously).
  • @Deprecated: Marks a code element (class, method, etc.) as outdated and discouraged from use. The compiler generates a warning if deprecated elements are used.
  • @SuppressWarnings: Instructs the compiler to suppress specific warnings (e.g., @SuppressWarnings("unchecked") for unchecked generic operations, @SuppressWarnings("deprecation") for using deprecated elements). Use sparingly.
  • @FunctionalInterface: (Java 8+) Indicates that an interface is intended to be a functional interface (having exactly one abstract method). The compiler verifies this.
  • @SafeVarargs: (Java 7+) Asserts that a method using varargs with generic types does not perform unsafe operations on its varargs parameter. Suppresses certain compiler warnings.

Defining Custom Annotations:

Use the @interface keyword. Annotations can have elements (like methods in an interface) which define the metadata values.

import java.lang.annotation.*;

// Define where the annotation can be applied (e.g., TYPE, METHOD, FIELD)
@Target({ElementType.TYPE, ElementType.METHOD})
// Define when the annotation is available (SOURCE, CLASS, RUNTIME)
@Retention(RetentionPolicy.RUNTIME) // Make it available via reflection at runtime
@Inherited // Subclasses will inherit this annotation from superclass (only for TYPE target)
@Documented // Include this annotation in generated Javadoc
public @interface MyAnnotation {
    // Annotation elements (like methods)
    String description(); // Required element
    String author() default "Unknown"; // Element with a default value
    int version() default 1;
    String[] reviewers() default {}; // Array element
}

Using Annotations:

Apply the annotation using @ followed by the annotation name, providing values for required elements.

@MyAnnotation(description = "Main application class", author = "Alice", version = 2, reviewers = {"Bob", "Charlie"})
public class AnnotatedClass {

    @MyAnnotation(description = "Important field") // Uses default author and version
    private String importantData;

    @MyAnnotation(description = "Processing method", author = "Alice")
    public void processData(@MyAnnotation(description = "Input parameter") String input) { // Annotate parameter
        System.out.println("Processing: " + input);
    }

    @Deprecated // Using a built-in annotation
    public void oldMethod() {
        System.out.println("This is an old method.");
    }

    @SuppressWarnings("deprecation") // Suppress warning for using oldMethod
    public void useOldMethod() {
        oldMethod();
    }
}

Processing Annotations (Runtime using Reflection):

import java.lang.reflect.Method;
import java.util.Arrays;

public class AnnotationProcessor {
    public static void main(String[] args) {
        Class<?> clazz = AnnotatedClass.class;

        System.out.println("--- Processing Class Annotations ---");
        if (clazz.isAnnotationPresent(MyAnnotation.class)) {
            MyAnnotation classAnnotation = clazz.getAnnotation(MyAnnotation.class);
            System.out.println("Class Annotation Found:");
            System.out.println("  Description: " + classAnnotation.description());
            System.out.println("  Author: " + classAnnotation.author());
            System.out.println("  Version: " + classAnnotation.version());
            System.out.println("  Reviewers: " + Arrays.toString(classAnnotation.reviewers()));
        } else {
            System.out.println("Class annotation not found.");
        }

        System.out.println("\n--- Processing Method Annotations ---");
        for (Method method : clazz.getDeclaredMethods()) {
             if (method.isAnnotationPresent(MyAnnotation.class)) {
                MyAnnotation methodAnnotation = method.getAnnotation(MyAnnotation.class);
                 System.out.println("Method '" + method.getName() + "' Annotation Found:");
                 System.out.println("  Description: " + methodAnnotation.description());
                 System.out.println("  Author: " + methodAnnotation.author());
                 // ... access other elements ...
             }
             if (method.isAnnotationPresent(Deprecated.class)) {
                  System.out.println("Method '" + method.getName() + "' is @Deprecated.");
             }
        }
        // Can similarly process field annotations (clazz.getDeclaredFields())
        // and parameter annotations (method.getParameterAnnotations())
    }
}

Annotations are widely used in frameworks like JUnit (e.g., @Test, @BeforeEach), Spring (e.g., @Component, @Autowired, @RestController), Jakarta EE/JPA (e.g., @Entity, @Id, @Column), etc., to configure behavior, enable dependency injection, define ORM mappings, and more, often replacing older XML configuration approaches.

Introduction to Java Modules (JPMS)

The Java Platform Module System (JPMS), introduced in Java 9, addresses several long-standing issues with the traditional classpath mechanism, particularly for large applications:

  • Strong Encapsulation: Prevents code in one JAR (module) from accessing internal implementation classes in another JAR, even if they are public. Only explicitly exported packages are accessible.
  • Reliable Configuration: Modules declare explicit dependencies (requires) on other modules. The JVM checks these dependencies at launch time, preventing "classpath hell" issues where missing dependencies cause runtime errors (NoClassDefFoundError).
  • Scalability: Enables the creation of custom Java runtime images containing only the modules required by an application, reducing deployment size.

Key Concepts:

  • Module: A collection of related packages, resources, and a module descriptor (module-info.java). Typically packaged as a modular JAR file.
  • Module Descriptor (module-info.java): A file at the root of the module's source code that defines the module's properties:
    • module <module.name> { ... }: Declares the module.
    • requires <other.module.name>;: Specifies a dependency on another module.
    • exports <package.name>;: Makes the public types in the specified package accessible to other modules.
    • opens <package.name>;: Allows other modules to use reflection on the types in the specified package.
    • uses <service.interface>;: Declares that the module uses a service (defined by an interface).
    • provides <service.interface> with <implementation.class>;: Declares that the module provides an implementation of a service interface.

Example Structure:

src/
├── com.greetings              <-- Module Name
│   ├── module-info.java       <-- Module Descriptor
│   └── com/greetings/Main.java
└── org.astro                  <-- Module Name
    ├── module-info.java       <-- Module Descriptor
    └── org/astro/World.java

src/com.greetings/module-info.java:

module com.greetings {
    // This module depends on the org.astro module
    requires org.astro;
    // It doesn't export any packages for others to use directly
}

src/com.greetings/com/greetings/Main.java:

package com.greetings;

// Import class from the required module
import org.astro.World;

public class Main {
    public static void main(String[] args) {
        System.out.println("Greetings!");
        System.out.println("From Astro: " + World.name());
    }
}

src/org.astro/module-info.java:

module org.astro {
    // Exports the org.astro package, making World accessible
    exports org.astro;
}

src/org.astro/org/astro/World.java:

package org.astro;

// This class is public AND in an exported package, so it's accessible
public class World {
    public static String name() {
        return "World";
    }
}

Compiling and Running (using module path):

Assume code is in src/ directory and we want compiled output in mods/.

  1. Compile org.astro:

    javac -d mods/org.astro \
          src/org.astro/module-info.java src/org.astro/org/astro/World.java
    

  2. Compile com.greetings (referencing the compiled org.astro):

    javac --module-path mods \
          -d mods/com.greetings \
          src/com.greetings/module-info.java src/com.greetings/com/greetings/Main.java
    

    • --module-path mods: Tells javac where to find required modules (org.astro in this case).
  3. Run the application:

    java --module-path mods \
         --module com.greetings/com.greetings.Main
    

    • --module-path mods: Tells java where to find the application modules.
    • --module com.greetings/com.greetings.Main: Specifies the main module and the main class to execute.

Benefits: Improved reliability, security (strong encapsulation), maintainability, and potential for smaller deployment sizes. JPMS is particularly relevant for large applications and libraries. Build tools like Maven and Gradle have support for building modular projects.

Workshop Multi-threaded Web Crawler

Objective: Build a simple multi-threaded web crawler that starts from a given URL, fetches the page content, extracts links, and concurrently crawls those links up to a certain depth, while avoiding revisiting URLs. This demonstrates multithreading (ExecutorService), networking (URL, basic HTML parsing), collections (Set, Queue, ConcurrentHashMap), and potentially atomic variables.

Disclaimer: This is a very basic educational crawler. Real-world crawlers are far more complex, respecting robots.txt, handling errors robustly, managing politeness delays, using sophisticated parsing, etc. Run this responsibly and only on websites you have permission to crawl or on test sites. Do not overload servers.

Steps:

  1. Directory Setup:

    cd ~/javabasics
    mkdir webcrawler
    cd webcrawler
    

  2. Create WebCrawler.java:

    nano WebCrawler.java
    

  3. Write the Java Code: This code uses an ExecutorService for concurrency, a ConcurrentHashMap to track visited URLs safely across threads, and a simple regex for link extraction.

    import java.io.BufferedReader;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.net.HttpURLConnection;
    import java.net.MalformedURLException;
    import java.net.URL;
    import java.nio.charset.StandardCharsets;
    import java.util.concurrent.*;
    import java.util.regex.Matcher;
    import java.util.regex.Pattern;
    
    public class WebCrawler {
    
        private final ExecutorService executorService;
        // Use ConcurrentHashMap for thread-safe tracking of visited URLs
        // Key: URL string, Value: Boolean (true if visited/visiting)
        private final ConcurrentHashMap<String, Boolean> visitedUrls;
        private final int maxDepth;
        private final String startUrl;
    
        // Simple regex to find href attributes in <a> tags (basic, misses many cases)
        // Needs improvement for real-world scenarios (e.g., handling relative URLs, different protocols)
        private static final Pattern LINK_PATTERN = Pattern.compile(
            "href=\"(http[s]?://[^\"]+)\"", Pattern.CASE_INSENSITIVE);
    
        public WebCrawler(String startUrl, int maxDepth, int poolSize) {
            this.startUrl = startUrl;
            this.maxDepth = maxDepth;
            this.executorService = Executors.newFixedThreadPool(poolSize);
            this.visitedUrls = new ConcurrentHashMap<>();
        }
    
        public void startCrawling() {
            System.out.println("Starting crawl from: " + startUrl + " with depth " + maxDepth);
            // Submit the initial task
            submitCrawlTask(startUrl, 0);
    
            // Shutdown executor when tasks are done
            executorService.shutdown();
            try {
                // Wait a long time for completion (adjust as needed)
                if (!executorService.awaitTermination(60, TimeUnit.MINUTES)) {
                    System.err.println("Crawler did not finish in time, forcing shutdown.");
                    executorService.shutdownNow();
                }
            } catch (InterruptedException e) {
                System.err.println("Crawler interrupted during shutdown wait.");
                executorService.shutdownNow();
                Thread.currentThread().interrupt();
            }
            System.out.println("\nCrawling finished. Visited " + visitedUrls.size() + " unique URLs.");
        }
    
        private void submitCrawlTask(String urlString, int depth) {
            // Check if already visited or processing THIS specific URL
            // putIfAbsent is atomic: returns null if key was absent, otherwise returns existing value
            if (depth > maxDepth || visitedUrls.putIfAbsent(normalizeUrl(urlString), Boolean.TRUE) != null) {
                 // System.out.println("Skipping (visited or depth exceeded): " + urlString);
                return; // Already visited/visiting or depth exceeded
            }
    
            // Submit the task to the executor service
            executorService.submit(() -> crawl(urlString, depth));
        }
    
        private void crawl(String urlString, int currentDepth) {
            String normalizedUrl = normalizeUrl(urlString);
            System.out.printf("[Depth %d] Crawling: %s (Thread: %s)\n",
                              currentDepth, normalizedUrl, Thread.currentThread().getName());
    
            try {
                URL url = new URL(normalizedUrl);
                HttpURLConnection connection = (HttpURLConnection) url.openConnection();
                connection.setRequestMethod("GET");
                connection.setConnectTimeout(5000); // 5 seconds connect timeout
                connection.setReadTimeout(10000); // 10 seconds read timeout
                connection.setRequestProperty("User-Agent", "SimpleJavaCrawler/1.0");
    
                int responseCode = connection.getResponseCode();
    
                if (responseCode == HttpURLConnection.HTTP_OK) {
                    // Only process HTML content (basic check)
                    String contentType = connection.getContentType();
                    if (contentType != null && contentType.toLowerCase().contains("text/html")) {
                         try (BufferedReader reader = new BufferedReader(
                                 new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)))
                         {
                            String line;
                            StringBuilder content = new StringBuilder();
                            while ((line = reader.readLine()) != null) {
                                content.append(line).append("\n");
                                // Basic link extraction
                                Matcher matcher = LINK_PATTERN.matcher(line);
                                while (matcher.find()) {
                                    String foundUrl = matcher.group(1);
                                    // Submit new tasks for found links, increasing depth
                                    submitCrawlTask(foundUrl, currentDepth + 1);
                                }
                            }
                            // Optionally process the content here (e.g., save to file, analyze)
                            // System.out.println("Content fetched for: " + normalizedUrl);
                        }
                    } else {
                         System.out.printf("[Depth %d] Skipped non-HTML content (%s) at: %s\n", currentDepth, contentType, normalizedUrl);
                    }
                } else {
                     System.err.printf("[Depth %d] Failed: %d %s for URL: %s\n",
                                       currentDepth, responseCode, connection.getResponseMessage(), normalizedUrl);
                }
                connection.disconnect();
    
            } catch (MalformedURLException e) {
                System.err.printf("[Depth %d] Invalid URL format: %s - %s\n", currentDepth, urlString, e.getMessage());
            } catch (IOException e) {
                 System.err.printf("[Depth %d] I/O Error crawling %s: %s\n", currentDepth, urlString, e.getMessage());
            } catch (Exception e) {
                 System.err.printf("[Depth %d] Unexpected error crawling %s: %s\n", currentDepth, urlString, e.getMessage());
                 // e.printStackTrace(); // For debugging unexpected errors
            }
        }
    
        // Basic URL normalization (remove fragment, trailing slash)
        private String normalizeUrl(String urlString) {
            try {
                URL url = new URL(urlString);
                String path = url.getPath();
                if (path.isEmpty() || path.equals("/")) {
                     path = ""; // Use empty path instead of "/" for consistency if needed
                } else if (path.endsWith("/")) {
                     path = path.substring(0, path.length() - 1);
                }
                // Rebuild without fragment and potentially trailing slash
                return new URL(url.getProtocol(), url.getHost(), url.getPort(), path).toString();
            } catch (MalformedURLException e) {
                return urlString; // Return original if invalid
            }
        }
    
        public static void main(String[] args) {
            // ** IMPORTANT: Use a URL you own or have permission to crawl, or a test site. **
            // Example: Start with a simple site. Be mindful of server load.
            String startUrl = "http://info.cern.ch/hypertext/WWW/TheProject.html"; // Example: The first website
            int maxDepth = 2;    // Limit crawl depth
            int poolSize = 5;    // Number of concurrent threads
    
            if (args.length > 0) startUrl = args[0];
            if (args.length > 1) maxDepth = Integer.parseInt(args[1]);
            if (args.length > 2) poolSize = Integer.parseInt(args[2]);
    
            WebCrawler crawler = new WebCrawler(startUrl, maxDepth, poolSize);
            crawler.startCrawling();
        }
    }
    
  4. Compile the Code:

    javac WebCrawler.java
    

  5. Run the Crawler:

    # Example run (use a permitted URL!)
    java WebCrawler http://info.cern.ch/hypertext/WWW/TheProject.html 2 5
    # Or provide your own URL, depth, and pool size
    # java WebCrawler <your_start_url> <depth> <pool_size>
    

  6. Observe the Output: You will see multiple threads concurrently fetching pages, extracting links, and submitting new tasks. The output will show which thread is crawling which URL and at what depth. Errors (like connection issues or non-HTML content) will also be reported. The crawl stops when all reachable links within the specified depth have been visited or attempted.

This workshop illustrates how to combine ExecutorService for managing worker threads, ConcurrentHashMap for thread-safe state management (visited URLs), and basic networking (HttpURLConnection) to perform a concurrent task like web crawling. Remember the limitations and ethical considerations of web crawling.

5. Java Development Tools and Practices on Linux

Beyond the core language features, becoming a proficient Java developer involves mastering essential tools and practices commonly used in professional software development, especially within the Linux environment.

Build Tools (Maven, Gradle)

Manually compiling Java code with javac and managing dependencies (JAR files) and the classpath becomes unmanageable for projects of any significant size. Build automation tools streamline this process. Maven and Gradle are the two most popular build tools in the Java ecosystem.

Why Use Build Tools?

  • Dependency Management: Automatically download required libraries (dependencies) from central repositories (like Maven Central) and manage transitive dependencies (dependencies of your dependencies).
  • Standardized Project Structure: Define conventional directory layouts for source code, resources, and tests, making projects easier to navigate and understand.
  • Build Lifecycle: Define standard phases (like compile, test, package, install, deploy) that automate the build process.
  • Plugin Ecosystem: Extend functionality through numerous plugins (e.g., for code generation, documentation, static analysis, deployment).
  • Reproducibility: Ensure builds are consistent across different environments.

Apache Maven:

  • Uses XML (pom.xml - Project Object Model) for configuration.
  • Relies heavily on conventions and a fixed, well-defined lifecycle.
  • Often considered easier to get started with for simpler projects due to its rigid structure.

Standard Maven Directory Layout:

my-maven-app/
├── pom.xml             <-- Maven Project Configuration
└── src/
    ├── main/
    │   ├── java/       <-- Application source code (e.g., com/mycompany/App.java)
    │   └── resources/  <-- Application resources (e.g., config files, properties)
    └── test/
        ├── java/       <-- Test source code (e.g., com/mycompany/AppTest.java)
        └── resources/  <-- Test resources

Example pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <!-- Basic project coordinates -->
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.mycompany.app</groupId> <!-- Usually reverse domain name -->
    <artifactId>my-maven-app</artifactId> <!-- Project name -->
    <version>1.0-SNAPSHOT</version>   <!-- Project version -->
    <packaging>jar</packaging> <!-- Type of artifact (jar, war, pom) -->

    <properties>
        <!-- Define properties for consistent versions, encodings etc. -->
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>11</maven.compiler.source> <!-- Java source version -->
        <maven.compiler.target>11</maven.compiler.target> <!-- Java target bytecode version -->
        <junit.version>5.9.2</junit.version> <!-- JUnit 5 version -->
    </properties>

    <dependencies>
        <!-- Declare project dependencies -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope> <!-- Dependency only needed for testing -->
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <!-- Add other dependencies here -->
        <!-- Example: Google Guava library -->
        <!--
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>31.1-jre</version>
        </dependency>
        -->
    </dependencies>

    <build>
        <plugins>
            <!-- Configure Maven plugins -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M7</version> <!-- Plugin for running tests -->
            </plugin>
            <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-jar-plugin</artifactId>
                 <version>3.3.0</version>
                 <configuration>
                     <archive>
                         <manifest>
                             <!-- Specify the main class for executable JAR -->
                             <mainClass>com.mycompany.app.App</mainClass>
                         </manifest>
                     </archive>
                 </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Common Maven Commands (run in the project root directory containing pom.xml):

  • mvn compile: Compiles source code (src/main/java) into target/classes.
  • mvn test: Compiles test code (src/test/java) and runs unit tests using the Surefire plugin.
  • mvn package: Compiles, tests, and packages the application (e.g., creates a JAR file in the target/ directory).
  • mvn install: Compiles, tests, packages, and installs the artifact into your local Maven repository (~/.m2/repository), making it available for other local projects.
  • mvn clean: Deletes the target/ directory (cleans previous build artifacts).
  • mvn dependency:tree: Shows the project's dependency hierarchy.
  • mvn site: Generates project documentation and reports.

Gradle:

  • Uses Groovy or Kotlin Domain Specific Languages (DSLs) for build scripts (build.gradle or build.gradle.kts).
  • Offers more flexibility and often faster performance (due to incremental builds and build cache).
  • Can have a steeper learning curve initially due to its flexibility and DSLs.
  • Widely used for Android development and increasingly popular for other Java projects.

Standard Gradle Directory Layout: (Often the same as Maven's)

my-gradle-app/
├── build.gradle        <-- Gradle build script (Groovy DSL)
├── settings.gradle     <-- Settings for multi-project builds (optional for single)
└── src/
    ├── main/
    │   ├── java/
    │   └── resources/
    └── test/
        ├── java/
        └── resources/

Example build.gradle (Groovy DSL):

plugins {
    // Apply the java plugin to add support for Java
    id 'java'
    // Apply the application plugin to add support for building executable applications
    id 'application'
}

// Define project metadata
group = 'com.mycompany.app' // Same as groupId in Maven
version = '1.0-SNAPSHOT'   // Same as version in Maven

repositories {
    // Use Maven Central for resolving dependencies
    mavenCentral()
}

// Define Java compatibility
java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}

// Specify the main class for the application plugin
application {
    mainClass = 'com.mycompany.app.App' // Your main class
}

dependencies {
    // Define dependencies
    // Use JUnit Jupiter for testing.
    testImplementation(platform('org.junit:junit-bom:5.9.2')) // JUnit 5 BOM
    testImplementation('org.junit.jupiter:junit-jupiter')

    // Example: Google Guava library
    // implementation 'com.google.guava:guava:31.1-jre'

    // Use 'implementation' for compile and runtime dependencies
    // Use 'testImplementation' for test-only dependencies
    // Use 'runtimeOnly' for runtime-only dependencies
}

// Configure the test task
tasks.named('test') {
    // Use JUnit Platform for running tests.
    useJUnitPlatform()
}

// Optional: Configure JAR manifest (often handled by application plugin)
/*
tasks.jar {
    manifest {
        attributes(
            'Main-Class': application.mainClass // Reference main class from application plugin
        )
    }
}
*/

Common Gradle Commands (run in the project root directory):

Use the Gradle Wrapper (gradlew script) which downloads and uses the correct Gradle version for the project.

  • ./gradlew build: Compiles, tests, and assembles the project (creates JAR in build/libs). This runs compileJava, processResources, classes, test, jar, etc.
  • ./gradlew test: Compiles and runs unit tests.
  • ./gradlew run: Compiles and runs the application (using the application plugin).
  • ./gradlew clean: Deletes the build/ directory.
  • ./gradlew dependencies: Shows the project's dependency hierarchy.
  • ./gradlew tasks: Lists all available tasks for the project.

Installation on Linux:

  • Maven:
    # Debian/Ubuntu
    sudo apt update
    sudo apt install maven
    
    # Fedora/CentOS/RHEL
    sudo dnf update
    sudo dnf install maven
    
    # Verify
    mvn -version
    
  • Gradle: Often best installed using SDKMAN! (Software Development Kit Manager) for easy version management, or download from the Gradle website.
    # Install SDKMAN! (Follow instructions at https://sdkman.io/install)
    curl -s "https://get.sdkman.io" | bash
    source "$HOME/.sdkman/bin/sdkman-init.sh"
    
    # Install Gradle using SDKMAN!
    sdk install gradle <desired_version> # e.g., sdk install gradle 8.0.2
    # Or install latest stable: sdk install gradle
    
    # Verify
    gradle -version
    
    Alternatively, if your project includes the Gradle Wrapper (gradlew), you usually don't need a system-wide Gradle installation.

Version Control (Git)

Version Control Systems (VCS) track changes to files over time, allowing developers to revert to previous versions, collaborate effectively, and manage different lines of development (branches). Git is the de facto standard distributed VCS.

Key Git Concepts:

  • Repository (Repo): A database containing the entire history of your project files. Can be local (on your machine) or remote (hosted on services like GitHub, GitLab, Bitbucket).
  • Working Directory: The local checkout of your project files where you make modifications.
  • Staging Area (Index): An intermediate area where you prepare changes before committing them. Allows you to selectively choose which modifications go into the next commit.
  • Commit: A snapshot of your project's state at a specific point in time, stored in the repository history. Each commit has a unique ID (SHA-1 hash), an author, a timestamp, and a commit message describing the changes.
  • Branch: An independent line of development. The default branch is usually main (or master). Branches allow developers to work on new features or bug fixes without affecting the main codebase until ready.
  • Merge: Combining changes from different branches back together.
  • Remote: A reference to another Git repository, typically hosted on a server (e.g., origin usually refers to the repository you cloned from).
  • Clone: Creating a local copy of a remote repository.
  • Push: Sending your local committed changes to a remote repository.
  • Pull: Fetching changes from a remote repository and merging them into your local branch.
  • Fetch: Fetching changes from a remote repository without automatically merging them.

Basic Git Workflow on Linux:

  1. Installation:

    # Debian/Ubuntu
    sudo apt update
    sudo apt install git
    
    # Fedora/CentOS/RHEL
    sudo dnf update
    sudo dnf install git
    
    # Verify
    git --version
    

  2. Configuration (First time only):

    git config --global user.name "Your Name"
    git config --global user.email "your.email@example.com"
    # Optional: Set default editor, enable color output, etc.
    # git config --global core.editor nano
    # git config --global color.ui auto
    

  3. Initializing a Repository (New Project):

    cd /path/to/your/project
    git init # Creates a hidden .git directory to store the repository history
    

  4. Cloning a Repository (Existing Project):

    git clone <repository_url> # e.g., git clone https://github.com/user/repo.git
    cd repo # Change into the cloned directory
    

  5. Making Changes: Edit files in your working directory.

  6. Checking Status: See which files have been modified, added, or are untracked.

    git status
    

  7. Staging Changes: Add modified/new files to the staging area.

    git add <filename>       # Stage a specific file
    git add .                # Stage all changes in the current directory and subdirectories
    # Use 'git status' again to see staged changes
    

  8. Committing Changes: Save the staged changes to the repository history with a descriptive message.

    git commit -m "Your descriptive commit message"
    # Example: git commit -m "Add user login feature"
    
    Write clear, concise commit messages explaining why the change was made.

  9. Viewing History:

    git log             # Show commit history (press 'q' to quit)
    git log --oneline   # Show compact history
    git log --graph     # Show history with branch graph
    

  10. Working with Branches:

    git branch             # List local branches (* indicates current branch)
    git branch <new-branch-name> # Create a new branch
    git checkout <branch-name>   # Switch to an existing branch (updates working directory)
    git checkout -b <new-branch-name> # Create and switch to a new branch in one step
    # Make changes, stage, and commit on the new branch...
    git checkout main      # Switch back to the main branch
    git merge <branch-name> # Merge changes from <branch-name> into the current branch (main)
                           # May result in merge conflicts if changes overlap
    git branch -d <branch-name> # Delete a local branch (after merging, usually)
    

  11. Working with Remotes:

    git remote -v         # List configured remote repositories (usually 'origin')
    # Assuming you cloned or added a remote named 'origin':
    git fetch origin        # Fetch changes from origin (doesn't merge)
    git pull origin main    # Fetch changes from origin's main branch and merge into current local branch
                           # Often simplified to 'git pull' if tracking is set up
    git push origin main    # Push local commits from your main branch to origin's main branch
                           # May require authentication (SSH key or HTTPS token)
    git push origin <local-branch-name> # Push a local feature branch to the remote
    

.gitignore File:

Create a file named .gitignore in the project root to list files and directories that Git should ignore (e.g., compiled class files, build output, IDE configuration, logs).

Example .gitignore for Java projects:

# Compiled class files
*.class

# Build tool output directories
target/
build/
out/

# Log files
*.log

# IDE settings
.idea/
*.iml
.settings/
.classpath
.project

# Package files
*.jar
*.war
*.ear

# Maven wrapper artifacts
.mvn/
mvnw
mvnw.cmd

# Gradle wrapper artifacts
.gradle/
gradlew
gradlew.bat
gradle/wrapper/gradle-wrapper.jar

# OS generated files
.DS_Store
Thumbs.db

Integrating Git with build tools like Maven/Gradle is standard practice. Committing pom.xml or build.gradle ensures that collaborators can build the project with the correct dependencies.

Debugging Java Applications

Debugging is the process of finding and fixing errors (bugs) in your code. Java offers several debugging tools and techniques.

Using System.out.println (Basic):

The simplest form of debugging is inserting print statements to check variable values or trace execution flow. Effective for simple issues but quickly becomes cumbersome and clutters code.

public void processValue(int x) {
    System.out.println("DEBUG: Entering processValue with x = " + x); // Trace entry
    int result = x * 2 + 5;
    System.out.println("DEBUG: Intermediate result = " + result); // Check value
    // ... more logic ...
    System.out.println("DEBUG: Exiting processValue"); // Trace exit
}

Using an IDE Debugger (Recommended):

Integrated Development Environments (IDEs) like IntelliJ IDEA, Eclipse, and VS Code (with Java extensions) provide powerful graphical debuggers. These are the standard tools for efficient debugging on Linux (or any platform).

Common IDE Debugger Features:

  • Breakpoints: Mark specific lines of code where execution should pause.
    • Conditional Breakpoints: Pause only if a certain condition is true.
  • Stepping: Control execution flow after pausing at a breakpoint:
    • Step Over (F8 common): Execute the current line and pause at the next line in the same method. If the current line contains a method call, execute the entire method without stepping into it.
    • Step Into (F7 common): If the current line contains a method call, move execution to the first line inside that method.
    • Step Out (Shift+F8 common): Continue execution until the current method returns, then pause at the line where the method was called.
    • Resume Program (F9 common): Continue execution until the next breakpoint is hit or the program terminates.
  • Variable Inspection: View the current values of local variables, instance variables (this), and static variables when execution is paused.
  • Watch Expressions: Evaluate custom expressions or monitor specific variables continuously.
  • Call Stack: Shows the sequence of method calls that led to the current execution point. Useful for understanding how execution got there.
  • Changing Values: Modify variable values on-the-fly during a debug session to test different scenarios.
  • HotSwap: (If supported by JVM) Allows replacing changed code while the application is running in debug mode, avoiding a full restart for minor changes (limited capabilities).

Steps for IDE Debugging (General):

  1. Set Breakpoints: Click in the editor's gutter next to the line numbers where you want execution to pause.
  2. Start in Debug Mode: Find the "Debug" button or menu option (often a bug icon) instead of the normal "Run" button. This starts your application with the debugger attached.
  3. Trigger Breakpoint: Perform actions in your application that will cause execution to reach a breakpoint.
  4. Use Stepping Controls: Use Step Over, Step Into, Step Out, and Resume to navigate the code.
  5. Inspect Variables & Call Stack: Examine the values in the Variables/Debug view and the Call Stack view to understand the program's state.
  6. Identify and Fix: Analyze the state and execution flow to find the cause of the bug. Stop the debugger, fix the code, and repeat.

Using jdb (Command-Line Debugger):

Java includes a basic command-line debugger, jdb. It's less user-friendly than IDE debuggers but can be useful in environments without a GUI or for specific scripting scenarios.

  1. Compile with Debug Info: Ensure your .java files are compiled with debug information enabled (this is the default for javac, but ensure the -g flag isn't disabled).
    javac -g MyClass.java
    
  2. Start jdb and Attach/Launch:

    • Launch a new JVM: jdb MyMainClass
    • Attach to a running JVM: You need to start the target JVM with debug agent flags:
      # Start your app enabling remote debugging on port 8000
      java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000 YourMainClass
      
      Then attach jdb in another terminal:
      jdb -attach 8000
      
      (suspend=y makes the JVM wait for debugger attachment before starting).
  3. jdb Commands:

    • stop at MyClass:<line_number>: Set a breakpoint.
    • stop in my.package.MyClass.myMethod: Set a breakpoint at the beginning of a method.
    • run: Start execution (if launched with jdb).
    • cont: Continue execution until next breakpoint.
    • step: Step into method calls.
    • next: Step over method calls.
    • step up: Step out of the current method.
    • print <variable_name> or dump <object_expr>: Inspect variable values.
    • locals: Show local variables in the current stack frame.
    • where: Show the call stack.
    • list: Show source code around the current line.
    • threads: List all threads.
    • thread <thread_id>: Switch to a specific thread.
    • exit: Quit jdb.

jdb requires more manual command typing but provides the core debugging functionalities.

Unit Testing (JUnit)

Unit testing involves writing code to test individual units (typically methods or classes) of your application in isolation. This helps ensure components work correctly, catches regressions early, and facilitates refactoring. JUnit is the most popular framework for unit testing in Java.

Key Concepts:

  • Test Case: A class containing test methods. Typically named *Test (e.g., CalculatorTest).
  • Test Method: A method within a test case designed to test a specific aspect of a unit. Annotated with @Test. Must be public void and take no arguments (usually).
  • Assertions: Static methods (usually from org.junit.jupiter.api.Assertions) used within test methods to check expected outcomes. If an assertion fails, the test method fails. Examples: assertEquals(), assertTrue(), assertFalse(), assertNotNull(), assertThrows().
  • Test Runner: Executes the test methods and reports the results (e.g., IDE test runners, Maven Surefire plugin, Gradle test task).
  • Setup/Teardown Annotations:
    • @BeforeEach: Method runs before each test method in the class. Used for setting up common test fixtures.
    • @AfterEach: Method runs after each test method. Used for cleaning up resources.
    • @BeforeAll: Static method runs once before all tests in the class. Used for expensive setup (e.g., database connection).
    • @AfterAll: Static method runs once after all tests in the class. Used for expensive teardown.

Example (Testing a Simple Calculator):

Assume you have a Calculator.java class:

// src/main/java/com/mycompany/calc/Calculator.java
package com.mycompany.calc;

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

    public int subtract(int a, int b) {
        return a - b;
    }

    public int divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("Cannot divide by zero");
        }
        return a / b; // Integer division
    }
}

Now, write the unit test class (src/test/java/.../CalculatorTest.java):

// src/test/java/com/mycompany/calc/CalculatorTest.java
package com.mycompany.calc;

// Static imports for assertions and annotations make tests cleaner
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.*; // For @Test, @BeforeEach etc.

class CalculatorTest {

    private Calculator calculator; // Instance under test

    @BeforeEach // Runs before each @Test method
    void setUp() {
        System.out.println("Setting up calculator instance for a test...");
        calculator = new Calculator(); // Create a fresh instance for each test
    }

    @AfterEach
    void tearDown() {
         System.out.println("Finished test. Tearing down.");
         calculator = null; // Help garbage collection (optional here)
    }

    @BeforeAll // Runs once before any tests start
    static void setupAll() {
        System.out.println("--- Starting Calculator Tests ---");
    }

     @AfterAll // Runs once after all tests finish
    static void tearDownAll() {
        System.out.println("--- Finished All Calculator Tests ---");
    }


    @Test // Marks this as a test method
    @DisplayName("Test adding two positive numbers") // Optional descriptive name
    void testAddPositiveNumbers() {
        // Arrange (Setup) - done in @BeforeEach mostly
        int num1 = 5;
        int num2 = 10;
        int expected = 15;

        // Act (Execute the method under test)
        int actual = calculator.add(num1, num2);

        // Assert (Verify the result)
        assertEquals(expected, actual, "5 + 10 should equal 15"); // Message optional
    }

    @Test
    void testAddNegativeNumbers() {
        assertEquals(-5, calculator.add(-2, -3), "Adding negative numbers");
    }

     @Test
    void testAddPositiveAndNegative() {
        assertEquals(2, calculator.add(5, -3), "Adding positive and negative");
    }

    @Test
    void testSubtract() {
        assertEquals(5, calculator.subtract(10, 5));
        assertEquals(-5, calculator.subtract(5, 10));
    }

    @Test
    void testDivideValid() {
         assertEquals(2, calculator.divide(10, 5));
         assertEquals(0, calculator.divide(3, 5)); // Integer division
    }

    @Test
    void testDivideByZero() {
         // Assert that executing calculator.divide(1, 0) throws an IllegalArgumentException
         Exception exception = assertThrows(IllegalArgumentException.class, () -> {
             calculator.divide(1, 0);
         });

         // Optionally, assert something about the exception message
         String expectedMessage = "Cannot divide by zero";
         String actualMessage = exception.getMessage();
         assertTrue(actualMessage.contains(expectedMessage),
                    "Exception message should contain '" + expectedMessage + "'");
    }

    // Other test methods for different scenarios...
}

Running Tests:

  • With Maven: Ensure JUnit 5 dependencies are in pom.xml (as shown previously) and the Surefire plugin is configured. Run:
    mvn test
    
    Maven will find classes ending in Test, *Test, or TestCase, execute their @Test methods, and generate reports in target/surefire-reports/.
  • With Gradle: Ensure JUnit 5 dependencies are in build.gradle (as shown previously) and the test task uses useJUnitPlatform(). Run:
    ./gradlew test
    
    Gradle runs the tests and generates HTML reports in build/reports/tests/test/index.html.
  • With IDE: Right-click on the test file, test class, or package and select "Run Tests". The IDE will execute the tests and display results in a dedicated test runner view.

Unit testing is a fundamental practice for building reliable software. Aim for good test coverage, write clear and focused tests, and run them frequently (ideally as part of an automated build process).

Logging Frameworks

While System.out.println is useful for quick checks, it's inadequate for logging in real applications. Proper logging frameworks provide:

  • Log Levels: Control verbosity (e.g., TRACE, DEBUG, INFO, WARN, ERROR, FATAL). You can configure the application to only output messages above a certain level (e.g., INFO and above in production, DEBUG in development).
  • Configuration: Configure log output destinations (console, files, databases, network), formatting, rotation policies, etc., often externally without code changes.
  • Performance: Optimized for minimal impact on application performance.
  • Contextual Information: Automatically include timestamps, thread names, class/method names, etc.

Popular Choices:

  1. Logback: The successor to Log4j 1.x. Powerful, fast, flexible configuration via XML or Groovy. Native implementation of SLF4j. Often considered the preferred modern choice.
  2. Log4j 2: A complete rewrite of Log4j 1.x. Very powerful, high-performance (especially asynchronous logging), flexible configuration (XML, JSON, YAML, properties). Also implements SLF4j API.
  3. SLF4j (Simple Logging Facade for Java): Not a logging implementation itself, but an abstraction layer (facade). Allows you to write your code against the SLF4j API and then plug in a specific logging framework (Logback, Log4j 2, java.util.logging) at deployment time using binding JARs. This is the recommended approach as it decouples your application code from the specific logging implementation.
  4. java.util.logging (JUL): Java's built-in logging framework. Less flexible and powerful than Logback/Log4j 2, configuration can be cumbersome.

Using SLF4j with Logback (Recommended):

  1. Add Dependencies (Maven Example):

    <dependencies>
        <!-- SLF4j API -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.7</version> <!-- Use latest SLF4j 2.x -->
        </dependency>
        <!-- Logback Implementation (Binding) -->
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.4.7</version> <!-- Use latest Logback -->
            <!-- <scope>runtime</scope> Usually runtime, but needed compile-time for direct config if needed -->
        </dependency>
        <!-- logback-core is pulled in transitively by logback-classic -->
    </dependencies>
    

  2. Create Logback Configuration File (src/main/resources/logback.xml): Logback looks for this file on the classpath by default.

    <configuration>
    
        <!-- Appender to write to the Console -->
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <!-- Encoder to format log messages -->
            <encoder>
                <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
                <!--
                    %d: Date
                    %thread: Thread name
                    %-5level: Log level, left-justified in 5 chars
                    %logger{36}: Logger name (usually class name), max 36 chars
                    %msg: The log message
                    %n: Newline
                 -->
            </encoder>
        </appender>
    
        <!-- Appender to write to a File -->
        <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>logs/my-app.log</file> <!-- Log file name -->
    
            <!-- Rolling policy: Create new file daily or when size reaches 10MB -->
            <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                <!-- daily rollover -->
                <fileNamePattern>logs/my-app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
                <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                    <!-- or whenever the file size reaches 10MB -->
                    <maxFileSize>10MB</maxFileSize>
                </timeBasedFileNamingAndTriggeringPolicy>
                <!-- keep 30 days' worth of history -->
                <maxHistory>30</maxHistory>
            </rollingPolicy>
    
            <encoder>
                <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
            </encoder>
        </appender>
    
        <!-- Configure Log Levels -->
        <!-- Set the root logger level to INFO and attach appenders -->
        <root level="INFO">
            <appender-ref ref="STDOUT" /> <!-- Log INFO and above to console -->
            <appender-ref ref="FILE" />   <!-- Log INFO and above to file -->
        </root>
    
        <!-- Set specific logger levels (e.g., for noisy libraries or specific packages) -->
        <logger name="com.mycompany.app.specific" level="DEBUG" additivity="false">
             <!-- Log DEBUG and above from this package ONLY to console -->
             <appender-ref ref="STDOUT" />
        </logger>
         <logger name="org.eclipse.jetty" level="WARN" /> <!-- Reduce logging from Jetty -->
    
    </configuration>
    

  3. Use SLF4j API in Your Code:

    package com.mycompany.app;
    
    // Import SLF4j classes
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    public class App {
    
        // Get a logger instance, typically named after the class
        private static final Logger logger = LoggerFactory.getLogger(App.class);
    
        public static void main(String[] args) {
            logger.info("Application starting..."); // Will be logged if root level is INFO or lower
    
            App app = new App();
            app.performTask("Some Data");
    
            logger.warn("This is a warning message."); // Will be logged
            logger.error("This is an error message."); // Will be logged
    
             // These will only be logged if the root level or specific logger level is DEBUG/TRACE
            logger.debug("Debugging information: {}", generateDebugInfo()); // Use {} for deferred construction
            logger.trace("Entering specific detailed block...");
    
             try {
                 int result = 10 / 0; // Cause exception
             } catch (Exception e) {
                 logger.error("An exception occurred during calculation", e); // Log exception with stack trace
             }
    
            logger.info("Application finished.");
        }
    
        public void performTask(String data) {
            logger.debug("Performing task with data: {}", data); // Logged if level is DEBUG
            if (data == null) {
                logger.warn("Received null data for task.");
                return;
            }
            // ... task logic ...
            logger.info("Task performed successfully for data starting with: {}", data.substring(0, Math.min(5, data.length())));
        }
    
        private static String generateDebugInfo() {
             System.out.println("--- Generating Debug Info ---"); // Illustrate deferred execution
             // Simulate complex debug string generation
             return "User=" + System.getProperty("user.name") + ", Java=" + System.getProperty("java.version");
        }
    }
    

Using SLF4j with a robust backend like Logback provides flexible, configurable, and performant logging essential for developing and maintaining applications.

Workshop Setting up a Maven/Gradle Project with Git, JUnit, and Logging

Objective: Create a simple Java project using either Maven or Gradle, initialize a Git repository, add JUnit 5 for unit testing, and integrate SLF4j with Logback for logging.

Steps (Choose either Maven or Gradle):

Option A: Using Maven

  1. Create Project Directory:

    cd ~/javabasics # Or your preferred workspace
    mkdir maven-demo-project
    cd maven-demo-project
    

  2. Initialize Git Repository:

    git init
    

  3. Create pom.xml: Create a file named pom.xml in the maven-demo-project directory with the following content:

    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>com.example.demoproject</groupId>
        <artifactId>maven-demo-project</artifactId>
        <version>1.0.0</version>
        <packaging>jar</packaging>
    
        <properties>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
            <maven.compiler.source>11</maven.compiler.source>
            <maven.compiler.target>11</maven.compiler.target>
            <junit.version>5.9.2</junit.version>
            <slf4j.version>2.0.7</slf4j.version>
            <logback.version>1.4.7</logback.version>
        </properties>
    
        <dependencies>
            <!-- SLF4j API -->
            <dependency>
                <groupId>org.slf4j</groupId>
                <artifactId>slf4j-api</artifactId>
                <version>${slf4j.version}</version>
            </dependency>
            <!-- Logback Implementation -->
            <dependency>
                <groupId>ch.qos.logback</groupId>
                <artifactId>logback-classic</artifactId>
                <version>${logback.version}</version>
            </dependency>
    
            <!-- JUnit 5 Jupiter -->
            <dependency>
                <groupId>org.junit.jupiter</groupId>
                <artifactId>junit-jupiter-api</artifactId>
                <version>${junit.version}</version>
                <scope>test</scope>
            </dependency>
            <dependency>
                <groupId>org.junit.jupiter</groupId>
                <artifactId>junit-jupiter-engine</artifactId>
                <version>${junit.version}</version>
                <scope>test</scope>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                 <!-- Compiler Plugin -->
                 <plugin>
                     <groupId>org.apache.maven.plugins</groupId>
                     <artifactId>maven-compiler-plugin</artifactId>
                     <version>3.10.1</version>
                     <configuration>
                         <source>${maven.compiler.source}</source>
                         <target>${maven.compiler.target}</target>
                     </configuration>
                 </plugin>
                <!-- Surefire Plugin (for running tests) -->
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-surefire-plugin</artifactId>
                    <version>3.0.0-M7</version>
                </plugin>
                 <!-- JAR Plugin (to specify main class for executable JAR) -->
                <plugin>
                     <groupId>org.apache.maven.plugins</groupId>
                     <artifactId>maven-jar-plugin</artifactId>
                     <version>3.3.0</version>
                     <configuration>
                         <archive>
                             <manifest>
                                 <mainClass>com.example.demoproject.App</mainClass>
                             </manifest>
                         </archive>
                     </configuration>
                </plugin>
            </plugins>
        </build>
    </project>
    

  4. Create Directory Structure:

    mkdir -p src/main/java/com/example/demoproject
    mkdir -p src/main/resources
    mkdir -p src/test/java/com/example/demoproject
    

  5. Create Main Application Class (src/main/java/com/example/demoproject/App.java):

    package com.example.demoproject;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    public class App {
        private static final Logger logger = LoggerFactory.getLogger(App.class);
    
        public String getGreeting() {
            logger.info("Generating greeting...");
            return "Hello from Maven Demo Project!";
        }
    
        public static void main(String[] args) {
            logger.info("Application Starting (Maven)");
            App app = new App();
            System.out.println(app.getGreeting());
            logger.info("Application Finished (Maven)");
        }
    }
    

  6. Create Logback Configuration (src/main/resources/logback.xml): (Copy the example logback.xml from the Logging section above).

  7. Create Unit Test Class (src/test/java/com/example/demoproject/AppTest.java):

    package com.example.demoproject;
    
    import static org.junit.jupiter.api.Assertions.*;
    import org.junit.jupiter.api.*;
    
    class AppTest {
    
        private App app;
    
        @BeforeEach
        void setUp() {
            app = new App();
        }
    
        @Test
        @DisplayName("App should have a greeting")
        void testAppHasGreeting() {
            assertNotNull(app.getGreeting(), "App greeting should not be null");
        }
    
        @Test
        @DisplayName("Greeting should contain expected text")
        void testGreetingContent() {
            assertTrue(app.getGreeting().contains("Maven Demo Project"), "Greeting text mismatch");
        }
    }
    

  8. Create .gitignore: Create a .gitignore file in the maven-demo-project root with the content from the Git section example above.

  9. Build and Test:

    mvn clean package # Clean, compile, test, package
    
    You should see BUILD SUCCESS. Check the target/ directory for the JAR file and target/surefire-reports/ for test results. Log messages (INFO level) should appear during the build/test phase if tests trigger logging, or when running the app.

  10. Run the Application:

    java -jar target/maven-demo-project-1.0.0.jar
    
    You should see the "Hello..." message printed to the console, and the INFO log messages from App.main.

  11. Commit to Git:

    git add .
    git commit -m "Initial project setup with Maven, JUnit, SLF4j/Logback"
    # Optional: Create a remote repository (e.g., on GitHub) and push
    # git remote add origin <your_remote_repo_url>
    # git push -u origin main
    

Option B: Using Gradle

  1. Create Project Directory:

    cd ~/javabasics # Or your preferred workspace
    mkdir gradle-demo-project
    cd gradle-demo-project
    

  2. Initialize Git Repository:

    git init
    

  3. Initialize Gradle Project (using init task): This automatically creates basic files and the wrapper.

    gradle init
    # Select project type: 2 (application)
    # Select implementation language: 3 (Java)
    # Select build script DSL: 1 (Groovy) or 2 (Kotlin) - We'll use Groovy for this example
    # Select test framework: 4 (JUnit Jupiter)
    # Project name: (accept default: gradle-demo-project)
    # Source package: (accept default or enter e.g., com.example.demoproject)
    
    This creates build.gradle, settings.gradle, gradlew, gradlew.bat, and the src directory structure with sample App.java and AppTest.java.

  4. Modify build.gradle: Open the generated build.gradle file and add the SLF4j/Logback dependencies. Also ensure Java version is set.

    plugins {
        id 'java'
        id 'application' // Keep the application plugin
    }
    
    group = 'com.example.demoproject' // Adjust if needed
    version = '1.0.0'                 // Adjust if needed
    
    repositories {
        mavenCentral()
    }
    
    java { // Ensure Java compatibility is set
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    
    application {
        // Define the main class for the application.
        mainClass = 'com.example.demoproject.App' // Update if your package/class name differs
    }
    
    dependencies {
        // SLF4j API
        implementation 'org.slf4j:slf4j-api:2.0.7' // Use latest SLF4j 2.x
    
        // Logback Implementation (binding) - runtimeOnly is typical for logging implementations
        runtimeOnly 'ch.qos.logback:logback-classic:1.4.7' // Use latest Logback
    
        // JUnit Jupiter testing dependencies (likely added by 'gradle init')
        testImplementation(platform('org.junit:junit-bom:5.9.2'))
        testImplementation('org.junit.jupiter:junit-jupiter')
    }
    
    tasks.named('test') {
        // Use JUnit Platform for running tests (likely added by 'gradle init')
        useJUnitPlatform()
    }
    

  5. Review/Modify Main Application Class (src/main/java/.../App.java): gradle init creates a basic App.java. Modify it to include logging:

    // Adjust package declaration if needed
    package com.example.demoproject;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    public class App {
        private static final Logger logger = LoggerFactory.getLogger(App.class);
    
        public String getGreeting() {
            logger.info("Generating greeting...");
            return "Hello from Gradle Demo Project!";
        }
    
        public static void main(String[] args) {
            logger.info("Application Starting (Gradle)");
            System.out.println(new App().getGreeting());
            logger.info("Application Finished (Gradle)");
        }
    }
    

  6. Create Logback Configuration (src/main/resources/logback.xml):

    mkdir -p src/main/resources # Ensure directory exists
    # Create logback.xml inside src/main/resources
    nano src/main/resources/logback.xml
    
    (Copy the example logback.xml from the Logging section above).

  7. Review/Modify Unit Test Class (src/test/java/.../AppTest.java): gradle init creates a basic AppTest.java. Modify it if necessary:

    // Adjust package declaration if needed
    package com.example.demoproject;
    
    import org.junit.jupiter.api.Test;
    import static org.junit.jupiter.api.Assertions.*;
    
    class AppTest {
        @Test void appHasAGreeting() {
            App classUnderTest = new App();
            assertNotNull(classUnderTest.getGreeting(), "app should have a greeting");
            assertTrue(classUnderTest.getGreeting().contains("Gradle Demo Project"), "Greeting text mismatch");
        }
    }
    

  8. Create/Verify .gitignore: gradle init usually creates a good starting .gitignore. Review it and add any missing entries (like logs/ if you log to files).

  9. Build and Test: Use the Gradle wrapper (gradlew).

    ./gradlew clean build # Clean, compile, test, assemble
    
    You should see BUILD SUCCESSFUL. Check the build/libs/ directory for the JAR and build/reports/tests/test/index.html for test reports.

  10. Run the Application: Use the run task provided by the application plugin.

    ./gradlew run
    
    You should see the "Hello..." message and the INFO log messages printed to the console.

  11. Commit to Git:

    git add .
    git commit -m "Initial project setup with Gradle, JUnit, SLF4j/Logback"
    # Optional: Create remote and push
    # git remote add origin <your_remote_repo_url>
    # git push -u origin main
    

This workshop provides a practical foundation for starting Java projects using standard build tools, incorporating version control, testing, and logging – essential skills for any Java developer.

Conclusion

This exploration has taken you from the fundamental syntax and object-oriented principles of Java to intermediate concepts like collections, exceptions, and file I/O, culminating in advanced topics such as concurrency, networking, reflection, annotations, and the module system. We've also covered the essential tooling and practices – build automation with Maven/Gradle, version control with Git, debugging techniques, unit testing with JUnit, and structured logging with SLF4j/Logback – that underpin professional Java development, particularly within the versatile Linux environment.

Java's strength lies in its vast ecosystem, platform independence (WORA), strong community support, and its applicability to a wide range of domains, from enterprise backend systems and web applications to Android mobile development and big data processing. The combination of Java's robust features and Linux's power and openness creates a formidable platform for building scalable, reliable software.

Mastering Java is an ongoing journey. The concepts covered here provide a solid foundation, but the landscape continues to evolve.

Next Steps and Further Learning:

  • Deep Dive into java.util.concurrent: Explore advanced concurrency utilities like CompletableFuture, advanced Lock implementations, and atomic operations in greater detail.
  • Java Streams API (Java 8+): Master functional-style operations on collections for more concise and expressive data processing.
  • Frameworks:
    • Spring Framework (Spring Boot): The dominant framework for building enterprise Java applications, simplifying web development, data access, security, microservices, and more.
    • Jakarta EE (formerly Java EE): The standard platform for enterprise Java, providing APIs for web services, persistence (JPA), messaging (JMS), etc. Frameworks like Quarkus and Helidon are modern implementations.
  • Databases and Persistence: Learn JDBC for direct database interaction and ORM (Object-Relational Mapping) frameworks like JPA (with implementations like Hibernate) or Jooq for easier data management.
  • Web Development: Explore Servlets, JSP, and modern web frameworks built on top (Spring MVC, JSF, etc.).
  • Testing Frameworks: Delve deeper into JUnit 5 features (parameterized tests, dynamic tests) and explore mocking frameworks like Mockito or EasyMock.
  • Build Tools Mastery: Learn advanced features of Maven or Gradle, such as multi-module projects, custom plugins, and profile management.
  • Containerization (Docker, Kubernetes): Understand how to package and deploy Java applications using containers, a standard practice in modern cloud environments, especially on Linux.
  • Cloud Platforms: Explore deploying and managing Java applications on cloud providers like AWS, Google Cloud, or Azure.
  • Android Development: If interested in mobile, Java (alongside Kotlin) is a primary language for native Android app development.

Continuously practice by building projects, contribute to open-source, and stay updated with the latest Java releases and ecosystem developments. The combination of theoretical knowledge and hands-on practice within the Linux environment will equip you well for a successful career in software development. Good luck!