Creating Threads
Understand how to create threads in Java by implementing Runnable or extending Thread. Learn to start threads properly, use lambdas for concise task definitions, and synchronize execution with join to build responsive, concurrent applications.
We now understand that threads are independent paths of execution that allow our programs to do multiple things at once. But knowing the theory doesn’t run code. To build responsive applications, we need to know exactly how to create these execution paths and control their lifecycles.
In this lesson, we will move from concept to implementation. We will write code to define tasks, hand them off to the JVM for execution, and ensure they complete exactly when we need them to.
Implementing the Runnable interface
The primary way to define a task in Java is by implementing the Runnable interface. By using Runnable, we separate the task (the logic) from the runner (the thread). This is considered best practice because it keeps the business logic separate from the threading infrastructure.
Java only allows a class to extend one superclass. If we were to define a thread by extending the Thread class directly, we would lose the ability to extend any other class. Implementing Runnable avoids this limitation, leaving our class inheritance open for other uses.
We define the work in a Runnable object and pass it to a generic Thread instance.
Line 1: We implement
Runnableto define a task.Lines 3–6: We place our logic inside the
run()method.Line 15: We wrap our
Runnableinstance inside aThreadobject.Line 16: We call
start(). This triggers the JVM to create a new execution thread and call therun()method of ourtask.Execution order: Once
start()is called, themainthread continues immediately to line 18. Because the new thread runs independently, “Main thread continues...” might print before or after “Task is running...” depending on how the JVM schedules them.
Using lambdas for concise tasks
Because Runnable is a functional interface (it has only one abstract method, run()), we can use lambda expressions to define threads without writing a separate class. This is a common way to write short, one-off tasks in Java.
Line 4: We pass a lambda
() -> { ... }directly to theThreadconstructor. The compiler treats this as aRunnable.Line 8: Just like the previous example,
start()launches the thread asynchronously.Line 10: The main thread continues immediately, retaining the same non-deterministic execution order (either message may print first).
Extending the Thread class
Alternatively, we can define a thread by directly extending the java.lang.Thread class. When we extend Thread, our class becomes a specialized thread that carries its own instructions.
To give the thread work to do, we must override the run() method. This method contains the code that will execute in the new call stack. However, simply creating the object isn’t enough; we must call the start() method to instruct the Java Virtual Machine (JVM) to create a new thread and execute run() inside it.
Lines 1–8: We define
NumberPrinteras a subclass ofThreadand overriderun()to define the task.Line 15: We create an instance of our custom thread.
Line 16: We call
start()to begin execution. Themainthread does not wait; it proceeds immediately to line 18. Consequently, “Main thread ends” will likely print before the worker thread finishes counting.
The critical difference: start() vs. run()
A common mistake when learning threads is calling run() directly instead of start().
start(): Tells the JVM to create a new thread and execute the code there.run(): Executes the method on the current thread, just like any regular method call.
If we call run(), our code runs sequentially, defeating the purpose of threading.
Lines 3–5: We define a thread inline using a lambda. This task simply prints the name of the thread executing it, which allows us to verify where the code is actually running.
Line 8: We call
run()directly. The output will show “main”, indicating no new thread was created.Line 11: We call
start(). The output will show “Thread-0” (or similar), indicating the code ran in a new execution context.
Waiting for completion with join()
When we start a thread, it runs asynchronously. This means the main thread continues executing without waiting for the new thread to finish. Sometimes, this is problematic. For example, if a worker thread calculates a value the main thread needs, the main thread might try to use the result before the worker finishes.
To solve this, we use the join() method. When we call thread.join(), the current thread pauses execution and waits until thread terminates.
Lines 5–16: We perform a CPU-intensive task (checking thousands of numbers for primality) instead of sleeping. This ensures the worker takes real time to complete.
Line 19: We call
start()to launch the worker thread.Line 23: The main thread calls
worker.join(). This halts the main thread's execution at this line, forcing it to wait until the worker thread completes its calculation.Lines 22–26: Because the main thread is blocked while waiting, the
join()method can throwInterruptedException, which the compiler requires us to handle.Line 28: This line executes only after the worker thread has finished, ensuring we don't print the final message prematurely.
We can now create concurrent applications by defining tasks and starting threads to execute them. We have learned to use Runnable for flexibility and join() to coordinate timing so that our program steps happen in the correct order. However, running threads is the easy part; managing how they share data without corruption is the challenge.