Day 1 Exercise
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.
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()andrun(); - 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.
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
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.
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.
- What is the name of the thread executing
main? - Does a Java program already have a thread before you create one yourself?
- What does
Thread.currentThread()return?
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.
| Run | Total duration |
|---|---|
| 1 | |
| 2 | |
| 3 |
- Which request finishes first?
- Can request number two start before request number one finishes?
- What is the program mostly doing while it waits for a server?
- Why may the duration change between runs?
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.
Replace this:
worker.start();
with this:
worker.run();
Observe the thread names in both versions.
- Is the order of the output always identical?
- Which thread executes the task when you call
start()? - Which thread executes the task when you call
run()directly? - Why does calling
run()directly not make the program concurrent?
Restore the call to start() before continuing.
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);
}
- Why can a
Runnablenot simply return theFetchResulttomain? - Where is the result currently processed?
- Why should the interrupt status be restored after catching
InterruptedException?
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.
You may see the total duration printed before all requests finish. Explain why this measurement is incorrect before moving on.
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.
| Run | Sequential duration | Concurrent duration | Difference |
|---|---|---|---|
| 1 | |||
| 2 | |||
| 3 |
- What does
join()make the current thread do? - Does calling
join()start a thread? - Why must all the threads be started before the
join()loop? - Is concurrent execution always faster? Why or why not?
- Is the concurrent duration equal to the duration of the slowest request? Explain any difference.
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.
- Is this version truly making multiple requests concurrently?
- Why is it approximately as slow as the sequential version?
- What general rule can you derive about starting work and waiting for it?
Restore the version that starts all threads before joining them.
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.
- Do you always get the expected result?
- Why is
value++not safe when several threads use it concurrently? - What is a race condition?
- Why does the error not necessarily happen in every run?
- How can avoiding shared mutable state simplify concurrent programs?
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.
Complete a program that monitors at least five URLs.
Your program must:
- contain a collection of at least five URLs;
- reuse one
HttpClient; - first fetch every URL sequentially;
- then fetch every URL concurrently using ordinary
Threadobjects; - use one
Runnableper request; - start all worker threads before waiting for them;
- use
join()to wait for every worker thread; - display each URL, status code, response size, duration and thread name;
- handle request failures without silently ignoring them; and
- print and compare the total sequential and concurrent durations.
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.
- 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
HttpClientfor every task. - Do not use unexplained empty
catchblocks. - Restore the interrupt status when catching
InterruptedException.
Submit:
- your Java source code;
- the completed timing table;
- answers to the reflection questions below; and
- a short conclusion of approximately 100–150 words.
- What is the difference between a task and a thread?
- What is the difference between
Thread.start()andThread.run()? - Why are HTTP requests suitable for this concurrency exercise?
- Why does the main thread call
join()? - What happens if one worker thread fails?
- What problems could occur if 10,000 URLs each created their own thread?
- What responsibilities are currently handled manually by your program?
- 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.
Give every thread a descriptive name:
Thread thread = new Thread(task, "http-worker-" + number);
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.
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.
Add one invalid or unreachable URL. Make sure the other tasks still finish and that the failure is reported clearly.
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.