HSHimanshu Sharma
Basic Java

Java for beginners, part 3: streams, concurrency, JDBC, modules and JUnit

Java for beginners, part 3: streams, concurrency, JDBC, modules and JUnit Advertisement

The first two parts covered the basics and object-oriented Java. This part turns to the features used in real applications: functional-style data processing with lambdas and streams, running work on several threads, talking to a database through JDBC, splitting code into modules, and testing it. All programs were compiled with javac -Xlint:all on OpenJDK 21, with no warnings. The JDBC examples used the H2 in-memory database so that they run without a server, and the tests used JUnit 5.

Lambdas and functional interfaces

A lambda is a short, anonymous function: (parameters) -> expression. It can be used wherever a functional interface is expected, meaning an interface with a single abstract method. Java's java.util.function package provides the common ones:

Interface Method Meaning
Function<T, R> R apply(T) transform
Predicate<T> boolean test(T) yes/no test
Supplier<T> T get() produce a value
Consumer<T> void accept(T) use a value
BiFunction<T, U, R> R apply(T, U) two inputs

A method reference (String::isEmpty, Integer::sum, Employee::name) is a shorthand for a lambda that just calls a method. Lambdas can read local variables only if they are effectively final (never reassigned).

The Stream API

A stream is a pipeline: a source (a collection), zero or more intermediate operations (filter, map, sorted, distinct, limit), and one terminal operation (collect, forEach, reduce, count, sum, anyMatch) that runs the pipeline. Streams do not change the source, and nothing runs until the terminal operation is called.

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

public class Streams {
    record Employee(String name, String dept, double salary, int age) {}

    public static void main(String[] args) {
        List<Employee> staff = List.of(
            new Employee("Asha", "IT", 70000, 31), new Employee("Ravi", "HR", 45000, 45),
            new Employee("Meena", "IT", 82000, 38), new Employee("Kabir", "Sales", 52000, 27),
            new Employee("Zoya", "HR", 50000, 29), new Employee("Dev", "IT", 61000, 24));

        List<String> highPaid = staff.stream()
            .filter(e -> e.salary() >= 60000)
            .map(Employee::name)
            .sorted()
            .collect(Collectors.toList());
        System.out.println(highPaid);

        double total = staff.stream().mapToDouble(Employee::salary).sum();
        OptionalDouble avgAge = staff.stream().mapToInt(Employee::age).average();
        System.out.println(total + " " + avgAge.getAsDouble() + " " + staff.stream().anyMatch(e -> e.dept().equals("Sales")));

        Map<String, Long> countByDept = staff.stream().collect(Collectors.groupingBy(Employee::dept, TreeMap::new, Collectors.counting()));
        Map<String, Double> avgByDept = staff.stream().collect(Collectors.groupingBy(Employee::dept, TreeMap::new, Collectors.averagingDouble(Employee::salary)));
        System.out.println(countByDept + " " + avgByDept);

        Optional<Employee> oldest = staff.stream().max(Comparator.comparingInt(Employee::age));
        System.out.println(oldest.map(Employee::name).orElse("nobody"));
        System.out.println(staff.stream().map(Employee::name).collect(Collectors.joining(", ", "[", "]")));
        System.out.println(IntStream.rangeClosed(1, 5).map(n -> n * n).boxed().collect(Collectors.toList()));
        System.out.println(Stream.iterate(1, n -> n * 2).limit(8).reduce(0, Integer::sum));
        System.out.println(Stream.of("a", "bb", "cc", "ddd").collect(Collectors.partitioningBy(s -> s.length() > 1)));

        Function<Integer, Integer> twice = n -> n * 2;
        Function<Integer, Integer> plusOne = n -> n + 1;
        Predicate<String> empty = String::isEmpty;
        Supplier<String> sup = () -> "supplied";
        BiFunction<Integer, Integer, Integer> add = Integer::sum;
        System.out.println(twice.andThen(plusOne).apply(5) + " " + twice.compose(plusOne).apply(5) + " " + empty.negate().test("x") + " " + sup.get() + " " + add.apply(2, 3));

        Optional<String> maybe = Optional.of("java");
        System.out.println(maybe.map(String::toUpperCase).orElse("none") + " " + Optional.empty().isPresent() + " " + Optional.ofNullable((String) null).orElseGet(() -> "default"));
    }
}
[Asha, Dev, Meena]
360000.0 32.333333333333336 true
{HR=2, IT=3, Sales=1} {HR=47500.0, IT=71000.0, Sales=52000.0}
Ravi
[Asha, Ravi, Meena, Kabir, Zoya, Dev]
[1, 4, 9, 16, 25]
255
{false=[a], true=[bb, cc, ddd]}
11 12 true supplied 5
JAVA false default

Reading it: the first pipeline keeps the high earners, extracts their names, sorts them and collects a list. groupingBy is like SQL's GROUP BY (compare joins and grouping in SQL): it produced the head count and average salary of each department. reduce folded the powers of two 1 to 128 into 255. twice.andThen(plusOne) runs twice first (5 becomes 10, then 11) while compose runs the other function first (6, then 12).

Optional<T> represents "a value or nothing", and is the clean alternative to returning null. Use map, orElse, orElseGet and ifPresent instead of calling get() blindly (which throws if empty). Do not use Optional for fields or method parameters.

Stream cautions: a stream can be consumed once; keep lambdas free of side effects; use a plain loop when it is clearer; and use parallelStream() only after measuring, since it helps only with large, CPU-heavy, independent work.

Concurrency

A thread is an independent path of execution. Java programs always have a main thread; you can start more to do work at the same time, for example serving many requests, or running slow operations without freezing a user interface.

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class Conc {
    static int unsafe = 0;
    static int safe = 0;
    static final Object LOCK = new Object();

    public static void main(String[] args) throws Exception {
        Thread t = new Thread(() -> System.out.println("hello from " + Thread.currentThread().getName()), "worker-1");
        t.start();
        t.join();

        AtomicInteger atomic = new AtomicInteger();
        Thread[] threads = new Thread[4];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 100_000; j++) {
                    unsafe++;
                    synchronized (LOCK) { safe++; }
                    atomic.incrementAndGet();
                }
            });
            threads[i].start();
        }
        for (Thread th : threads) th.join();
        System.out.println("expected 400000; synchronized=" + safe + " atomic=" + atomic.get() + " unsafe<=400000: " + (unsafe <= 400_000));

        ExecutorService pool = Executors.newFixedThreadPool(3);
        List<Future<Integer>> futures = new ArrayList<>();
        for (int n = 1; n <= 5; n++) {
            final int k = n;
            futures.add(pool.submit(() -> { Thread.sleep(50); return k * k; }));
        }
        int sum = 0;
        for (Future<Integer> f : futures) sum += f.get();
        pool.shutdown();
        System.out.println("sum of squares = " + sum + ", terminated=" + pool.awaitTermination(2, TimeUnit.SECONDS));

        CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "data")
            .thenApply(String::toUpperCase)
            .thenCombine(CompletableFuture.supplyAsync(() -> 42), (s, n) -> s + "-" + n);
        System.out.println(cf.get());

        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 1000; i++) exec.submit(() -> map.merge("hits", 1, Integer::sum));
        }
        System.out.println("virtual threads hits = " + map.get("hits"));

        ExecutorService one = Executors.newSingleThreadExecutor();
        Future<Integer> bad = one.submit(() -> 1 / (args.length));
        try { bad.get(); } catch (ExecutionException e) { System.out.println("task failed: " + e.getCause()); }
        one.shutdown();
    }
}
hello from worker-1
expected 400000; synchronized=400000 atomic=400000 unsafe<=400000: true
sum of squares = 55, terminated=true
DATA-42
virtual threads hits = 1000
task failed: java.lang.ArithmeticException: / by zero

What each part shows:

  • Creating a thread: pass a Runnable (here a lambda) to new Thread, call start() (never run(), which would run it on the current thread), and join() to wait for it.
  • Race conditions: four threads each added 1 to a shared counter 100,000 times. The synchronized block and the AtomicInteger both gave exactly 400,000. The plain unsafe++ is not atomic (it is read, add, write), so threads overwrite each other's updates and the total is usually less than 400,000, differing on every run. My test only asserted that it was at most 400,000, because its exact value is unpredictable, which is the point. A synchronized block lets one thread at a time into the code; atomic classes use hardware instructions and are faster for simple counters.
  • Executors: creating a thread per task is wasteful. An ExecutorService keeps a thread pool and runs submitted tasks. submit returns a Future, and get() waits for the result. Always shutdown() the pool, or the program may not exit. A task's exception is wrapped in an ExecutionException when you call get().
  • CompletableFuture: composes asynchronous steps (thenApply, thenCombine, thenCompose, exceptionally) without blocking until you need the result. It printed DATA-42 from two independent computations.
  • Virtual threads (Java 21): very cheap threads managed by the JVM, which let you write simple blocking code and still handle thousands of concurrent tasks. The program started 1,000 and counted all their updates in a ConcurrentHashMap (a thread-safe map).

Concurrency rules of thumb: prefer immutable data, use the java.util.concurrent classes (ConcurrentHashMap, BlockingQueue, CountDownLatch) over hand-written wait/notify, keep lock scopes small, always acquire multiple locks in the same order (see deadlocks in the transactions post, the same problem in a database), and never ignore InterruptedException. The same ideas appear in Python multithreading, which has its own global interpreter lock; in Java, threads genuinely run in parallel on several cores.

JDBC: working with a database

JDBC (Java Database Connectivity) is the standard API for relational databases. You add the database's driver (a JAR) to the class path (for MySQL, mysql-connector-j; for PostgreSQL, postgresql), open a Connection with a URL, and run SQL through Statement and PreparedStatement. The following program uses the H2 database in memory so it runs anywhere; with a real MySQL server only the URL, user and password change (for example jdbc:mysql://localhost:3306/shop).

import java.sql.*;

public class Db {
    public static void main(String[] args) throws SQLException {
        try (Connection con = DriverManager.getConnection("jdbc:h2:mem:shop", "sa", "")) {
            try (Statement st = con.createStatement()) {
                st.execute("CREATE TABLE products (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, price DECIMAL(8,2), stock INT)");
            }
            String insert = "INSERT INTO products (name, price, stock) VALUES (?, ?, ?)";
            try (PreparedStatement ps = con.prepareStatement(insert)) {
                Object[][] rows = {{"Pen", 10.5, 100}, {"Notebook", 45.0, 40}, {"Bag", 799.99, 5}};
                for (Object[] r : rows) {
                    ps.setString(1, (String) r[0]);
                    ps.setBigDecimal(2, java.math.BigDecimal.valueOf((Double) r[1]));
                    ps.setInt(3, (Integer) r[2]);
                    ps.addBatch();
                }
                System.out.println("batch inserted: " + java.util.Arrays.toString(ps.executeBatch()));
            }
            try (PreparedStatement ps = con.prepareStatement("SELECT id, name, price FROM products WHERE price > ? ORDER BY price")) {
                ps.setDouble(1, 20);
                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) System.out.println(rs.getInt("id") + " " + rs.getString("name") + " " + rs.getBigDecimal("price"));
                }
            }
            String evil = "x' OR '1'='1";
            try (PreparedStatement ps = con.prepareStatement("SELECT COUNT(*) FROM products WHERE name = ?")) {
                ps.setString(1, evil);
                try (ResultSet rs = ps.executeQuery()) { rs.next(); System.out.println("injection attempt matched: " + rs.getInt(1)); }
            }
            con.setAutoCommit(false);
            try (PreparedStatement ps = con.prepareStatement("UPDATE products SET stock = stock - ? WHERE name = ?")) {
                ps.setInt(1, 10); ps.setString(2, "Bag");
                ps.executeUpdate();
                con.rollback();
            }
            try (Statement st = con.createStatement(); ResultSet rs = st.executeQuery("SELECT stock FROM products WHERE name = 'Bag'")) {
                rs.next();
                System.out.println("stock after rollback: " + rs.getInt(1));
            }
            con.setAutoCommit(true);
            try (Statement st = con.createStatement()) {
                st.execute("INSERT INTO products (id, name) VALUES (1, 'Dup')");
            } catch (SQLException e) {
                System.out.println("SQLException: " + e.getErrorCode() + " " + e.getSQLState());
            }
        }
    }
}

I compiled it normally and ran it with java -cp h2.jar:. Db:

batch inserted: [1, 1, 1]
2 Notebook 45.00
3 Bag 799.99
injection attempt matched: 0
stock after rollback: 5
SQLException: 23505 23505

The parts to learn:

  1. DriverManager.getConnection opens the connection. Connections are expensive, so real applications use a connection pool (HikariCP) instead of opening one per request.
  2. try-with-resources closes the connection, statements and result sets in the right order, even on errors.
  3. PreparedStatement with ? placeholders is the answer to SQL injection. The attack string x' OR '1'='1 matched 0 rows because it was treated as plain data, not SQL. Never build SQL by joining strings with user input.
  4. Batching (addBatch/executeBatch) sends many inserts efficiently; the result [1, 1, 1] is the number of rows affected by each.
  5. Transactions: setAutoCommit(false) starts a manual transaction; commit() saves and rollback() undoes. The stock update was rolled back, so Bag still had 5. The theory is in database transactions.
  6. Errors: a duplicate key raised a SQLException; its SQLState (here the standard class 23, integrity-constraint violation) identifies the kind of failure.
  7. Use BigDecimal for money, as above, not double.

Most projects use a higher-level layer on top of JDBC: JPA/Hibernate or Spring Data for object mapping, and jOOQ or MyBatis for SQL-centred code. They all rest on JDBC.

Modules

Before Java 9, all classes on the class path could see each other. The Java Platform Module System adds a stronger unit: a module declares what it exports (its public API) and what it requires. Packages that are not exported are hidden even from public classes. The descriptor is a file called module-info.java. I built a tiny two-module application:

com.shop.util/module-info.java        exports com.shop.util
com.shop.util/com/shop/util/Money.java
com.shop.util/com/shop/util/internal/Rounding.java      (not exported)
com.shop.app/module-info.java         requires com.shop.util
com.shop.app/com/shop/app/Main.java
module com.shop.util {
    exports com.shop.util;
}
module com.shop.app {
    requires com.shop.util;
}

Compile both with javac -d out --module-source-path src $(find src -name '*.java'), then run with java --module-path out -m com.shop.app/com.shop.app.Main. It printed:

Rs 1234.50
com.shop.app requires [com.shop.util, java.base]

Every module implicitly requires java.base. When another class tried to import the unexported package com.shop.util.internal, the compiler rejected it:

error: package com.shop.util.internal is not visible

That is strong encapsulation: internal helpers can be changed freely without breaking users. The JDK itself is split into modules (my JDK lists 71: java.base, java.sql, java.net.http and so on), and jlink can build a minimal runtime containing only the modules your application needs. Many applications still use the plain class path with Maven or Gradle, and modules matter most for libraries and large systems, but you will meet module-info.java in the wild.

Testing with JUnit 5

A test is code that checks that other code works. JUnit is the standard Java testing framework; Maven and Gradle run it for you, and IDEs show green and red marks. Here is a class to test and its tests:

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

    public double divide(int a, int b) {
        if (b == 0) throw new IllegalArgumentException("divisor must not be zero");
        return (double) a / b;
    }
}
import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class CalculatorTest {
    private Calculator calc;

    @BeforeEach
    void setUp() { calc = new Calculator(); }

    @Test
    void addsTwoNumbers() { assertEquals(5, calc.add(2, 3)); }

    @Test
    void dividesWithDecimals() { assertEquals(2.5, calc.divide(5, 2), 0.0001); }

    @Test
    void rejectsDivisionByZero() {
        IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> calc.divide(1, 0));
        assertEquals("divisor must not be zero", e.getMessage());
    }

    @ParameterizedTest
    @CsvSource({"1,1,2", "10,-4,6", "0,0,0"})
    void addsMany(int a, int b, int expected) { assertEquals(expected, calc.add(a, b)); }
}

I ran the tests with the JUnit console launcher, and also added one deliberately wrong test (assertEquals(6, calc.add(2, 3), "2+3 should be 5, this test is wrong on purpose")) to see a failure. The run reported 7 tests found and 6 successful, and the failing one printed:

deliberatelyFailing() ✘ 2+3 should be 5, this test is wrong on purpose ==> expected: <6> but was: <5>

The tree also showed the parameterized test running three times, once per row of the @CsvSource. Notes on good tests:

  • @Test marks a test method; @BeforeEach runs before each, giving every test a fresh Calculator.
  • Assertions (assertEquals, assertTrue, assertThrows, assertAll) state the expectation. For double, always give a tolerance (the 0.0001).
  • Test the failure cases, like division by zero, not only the happy path.
  • One behaviour per test, with a descriptive name. A failing test name should tell you what broke.
  • Keep tests independent and fast. Use test doubles (fake or mock versions, for example with Mockito) to replace databases and network calls in unit tests, and reserve real databases for a few integration tests.
  • In a Maven project, tests live in src/test/java, and mvn test runs them; in Gradle, gradle test.

Where to go from here

You now have the shape of the whole language. A sensible path: build a small project (a command-line library manager, then a REST API with Spring Boot), learn a build tool (Maven or Gradle), learn Git and a framework for your area (Spring for back ends, Android for mobile), and read Effective Java by Joshua Bloch. Keep practising algorithms; the prime number programs and external sort are good exercises to port to Java. If you work with web APIs in another language, see the Laravel API tutorial for the equivalent design in PHP, and revisit part 1, part 2 or the introduction whenever a concept feels shaky.

Common mistakes

  • Calling run() instead of start() on a thread.
  • Sharing mutable state between threads without synchronisation.
  • Forgetting to shutdown() an executor.
  • Building SQL with string concatenation instead of PreparedStatement.
  • Not closing connections and result sets (use try-with-resources).
  • Using streams for everything, including cases where a loop is clearer.
  • Calling Optional.get() without checking.
  • Writing tests that depend on each other or on the current time.
Advertisement