Skip to main content
Dat 3. semester
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

Day 1 Exercise

Day 1 Exercise: Java Threads and Concurrent HTTP Requests

Scenario

You are building a small service-monitoring program. The program receives a list of URLs, sends an HTTP request to each server, and reports how long each request took. The first version will make the requests sequentially. You will then use Java threads to let independent requests run concurrently.

Learning objectives

By the end of the exercise, you should be able to:

  • explain what a thread is;
  • identify the main thread and worker threads;
  • create a task using Runnable;
  • start a worker thread with Thread.start();
  • explain the difference between start() and run();
  • wait for a thread using join();
  • run independent HTTP requests concurrently;
  • measure and compare sequential and concurrent execution;
  • recognize shared mutable state and possible race conditions; and
  • handle exceptions inside a worker thread.

Suggested project structure

You may use one class while experimenting, but the final program should have approximately this structure:

src/
└── main/
    └── java/
        ├── Main.java
        ├── HttpFetcher.java
        └── FetchResult.java

Data model

Use a record to represent the result of one request:

public record FetchResult(
        String url,
        int statusCode,
        int responseSize,
        long durationMs,
        String threadName
) {
}

You may modify the record if you want to store additional information.


Part 1: Observe the main thread

Create a new Java application and add the following statement to main:

System.out.println("Main is running on: "
        + Thread.currentThread().getName());

Run the program and inspect the output.

Questions

  1. What is the name of the thread executing main?
  2. Does a Java program already have a thread before you create one yourself?
  3. What does Thread.currentThread() return?

Part 2: Sequential HTTP requests

Create one reusable HttpClient:

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .build();

Create a method that sends one request and returns a FetchResult.

public static FetchResult fetch(HttpClient client, String url) {
    long start = System.currentTimeMillis();

    // 1. Build an HttpRequest for the URL.
    // 2. Add a request timeout.
    // 3. Send the request.
    // 4. Calculate the request duration.
    // 5. Return a FetchResult.

    throw new UnsupportedOperationException("Not implemented yet");
}

Use a request timeout so that the program does not wait forever:

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(url))
        .timeout(Duration.ofSeconds(10))
        .GET()
        .build();

Use a list containing at least five URLs supplied or approved by your instructor. The URLs should represent independent servers or endpoints.

Fetch them sequentially:

long totalStart = System.currentTimeMillis();

for (String url : urls) {
    FetchResult result = fetch(client, url);
    System.out.println(result);
}

long totalDuration = System.currentTimeMillis() - totalStart;
System.out.println("Sequential duration: " + totalDuration + " ms");

Run the program at least three times and record the durations.

RunTotal duration
1
2
3

Questions

  1. Which request finishes first?
  2. Can request number two start before request number one finishes?
  3. What is the program mostly doing while it waits for a server?
  4. Why may the duration change between runs?

Part 3: Create your first worker thread

Create a Runnable task:

Runnable task = () -> {
    System.out.println("Task is running on: "
            + Thread.currentThread().getName());
};

Create and start a thread:

Thread worker = new Thread(task);
worker.start();

System.out.println("Main continues on: "
        + Thread.currentThread().getName());

Run the program several times.

Experiment: start() compared with run()

Replace this:

worker.start();

with this:

worker.run();

Observe the thread names in both versions.

Questions

  1. Is the order of the output always identical?
  2. Which thread executes the task when you call start()?
  3. Which thread executes the task when you call run() directly?
  4. Why does calling run() directly not make the program concurrent?

Restore the call to start() before continuing.


Part 4: Fetch one URL on a worker thread

Move one HTTP request into a Runnable:

String url = urls.getFirst(); // Use urls.get(0) on older Java versions

Runnable fetchTask = () -> {
    FetchResult result = fetch(client, url);
    System.out.println(result);
};

Thread worker = new Thread(fetchTask);
worker.start();

You will probably encounter a problem with checked exceptions. A Runnable cannot declare checked exceptions in its run() method.

Decide where the exception should be handled. One option is to make fetch catch the HTTP-related exceptions and throw an unchecked application exception:

try {
    // Send request and create result
} catch (IOException ex) {
    throw new IllegalStateException("Request failed for " + url, ex);
} catch (InterruptedException ex) {
    Thread.currentThread().interrupt();
    throw new IllegalStateException("Request interrupted for " + url, ex);
}

Questions

  1. Why can a Runnable not simply return the FetchResult to main?
  2. Where is the result currently processed?
  3. Why should the interrupt status be restored after catching InterruptedException?

Part 5: Start one thread per URL

Create a thread for every URL. Store the threads so that you can access them later.

List<Thread> threads = new ArrayList<>();

for (String url : urls) {
    Runnable task = () -> {
        // Fetch the URL and print the result.
    };

    Thread thread = new Thread(task);
    threads.add(thread);
    thread.start();
}

Add log messages showing when each task starts and finishes:

START  https://example.org on Thread-2
FINISH https://example.org on Thread-2 after 438 ms

Measure the total duration around the code that starts the threads.

Initial observation

You may see the total duration printed before all requests finish. Explain why this measurement is incorrect before moving on.


Part 6: Wait for all threads with join()

After all threads have been started, wait for each one:

for (Thread thread : threads) {
    try {
        thread.join();
    } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
        throw new IllegalStateException("Main thread was interrupted", ex);
    }
}

Only calculate the total duration after the join() loop.

System.out.println("All requests have finished");

Run the concurrent version at least three times.

RunSequential durationConcurrent durationDifference
1
2
3

Questions

  1. What does join() make the current thread do?
  2. Does calling join() start a thread?
  3. Why must all the threads be started before the join() loop?
  4. Is concurrent execution always faster? Why or why not?
  5. Is the concurrent duration equal to the duration of the slowest request? Explain any difference.

Part 7: A sequencing mistake

Temporarily change your program so that it starts and joins each thread in the same loop:

for (String url : urls) {
    Thread thread = new Thread(() -> {
        FetchResult result = fetch(client, url);
        System.out.println(result);
    });

    thread.start();
    thread.join();
}

Measure the duration.

Questions

  1. Is this version truly making multiple requests concurrently?
  2. Why is it approximately as slow as the sequential version?
  3. What general rule can you derive about starting work and waiting for it?

Restore the version that starts all threads before joining them.


Part 8: Shared state and race conditions

HTTP tasks are easiest to manage when each task works independently. To see why, perform the following small experiment.

Create an unsafe counter:

public class Counter {
    private int value;

    public void increment() {
        value++;
    }

    public int getValue() {
        return value;
    }
}

Create ten threads. Each thread should increment the same counter 100,000 times. Start all threads, join all threads, and print the result.

The expected result is:

1000000

Run the experiment several times.

Questions

  1. Do you always get the expected result?
  2. Why is value++ not safe when several threads use it concurrently?
  3. What is a race condition?
  4. Why does the error not necessarily happen in every run?
  5. How can avoiding shared mutable state simplify concurrent programs?

Optional correction

Replace int with AtomicInteger and test again:

private final AtomicInteger value = new AtomicInteger();

public void increment() {
    value.incrementAndGet();
}

public int getValue() {
    return value.get();
}

This is an introduction only. Synchronization and atomic operations will be explored separately if required.


Final assignment: Concurrent service monitor

Complete a program that monitors at least five URLs.

Functional requirements

Your program must:

  1. contain a collection of at least five URLs;
  2. reuse one HttpClient;
  3. first fetch every URL sequentially;
  4. then fetch every URL concurrently using ordinary Thread objects;
  5. use one Runnable per request;
  6. start all worker threads before waiting for them;
  7. use join() to wait for every worker thread;
  8. display each URL, status code, response size, duration and thread name;
  9. handle request failures without silently ignoring them; and
  10. print and compare the total sequential and concurrent durations.

Example output

SEQUENTIAL
200  1256 bytes   742 ms  main      https://server-a.example/data
200  3481 bytes   531 ms  main      https://server-b.example/data

CONCURRENT
200  3481 bytes   418 ms  Thread-1  https://server-b.example/data
200  1256 bytes   715 ms  Thread-0  https://server-a.example/data

Sequential total: 1273 ms
Concurrent total:  716 ms

The order of concurrent output may be different in each run.

Code-quality requirements

  • Use meaningful method and variable names.
  • Keep HTTP logic in a separate method or class.
  • Avoid duplicating the HTTP request code.
  • Do not create a separate HttpClient for every task.
  • Do not use unexplained empty catch blocks.
  • Restore the interrupt status when catching InterruptedException.

Submission

Submit:

  • your Java source code;
  • the completed timing table;
  • answers to the reflection questions below; and
  • a short conclusion of approximately 100–150 words.

Final reflection questions

  1. What is the difference between a task and a thread?
  2. What is the difference between Thread.start() and Thread.run()?
  3. Why are HTTP requests suitable for this concurrency exercise?
  4. Why does the main thread call join()?
  5. What happens if one worker thread fails?
  6. What problems could occur if 10,000 URLs each created their own thread?
  7. What responsibilities are currently handled manually by your program?
  8. What would you want a thread-management component to do for you?

The last two questions prepare you for Day 2, where the manually managed threads will be replaced by an ExecutorService.


Optional challenges

Challenge 1: Name the worker threads

Give every thread a descriptive name:

Thread thread = new Thread(task, "http-worker-" + number);

Challenge 2: Store successful results

Instead of printing directly inside each task, investigate how results could be collected safely. Be prepared to explain the risks of several threads modifying the same ArrayList.

You do not need to solve this perfectly today. Callable and Future will provide a cleaner solution on Day 2.

Challenge 3: Compare workloads

Compare concurrent execution for:

  • HTTP requests that mostly wait for external servers; and
  • a CPU-intensive calculation.

Consider why adding more threads may affect these workloads differently.

Challenge 4: Failed request

Add one invalid or unreachable URL. Make sure the other tasks still finish and that the failure is reported clearly.


Key takeaway

An HTTP request can be represented as an independent task. Threads allow several of these tasks to make progress during the same period, but manually creating, tracking, joining and handling errors from many threads quickly becomes difficult. Day 2 introduces ExecutorService to manage these responsibilities.