참고자료
- https://docs.oracle.com/javase/tutorial/essential/concurrency/simple.html
- https://www.geeksforgeeks.org/lifecycle-and-states-of-a-thread-in-java/
- https://www.baeldung.com/java-thread-priority
- https://javagoal.com/thread-creation-in-java/
- https://javagoal.com/main-thread-in-java/
- https://charles098.tistory.com/99
Thread
클래스의 인스턴스를 생성하는 어플리케이션은 반드시 해당 쓰레드에서 실행할 코드를 제공해야 한다. 이를 위한 두 가지 방법이 존재한다.
Runnable
인터페이스는 run
이라는 메소드를 정의한다. 해당 메소드는 쓰레드에서 실행시킬 코드를 의미한다. Runnable
객체는 Thread
생성자의 파라미터로 전달된다. public class RunnableImpl implements Runnable{
@Override
public void run() {
System.out.println("Thread run!");
}
public static void main(String[] args) {
(new Thread(new RunnableImpl())).start();
}
}
Thread
클래스는 자체적으로 Runnable
인터페이스를 구현한다. 그러나 Thread
의 run
메소드는 비어있는 상태이다. 어플리케이션에서는 run
메소드를 오버라이딩 함으로써 Thread
클래스를 상속할 수 있다.public class ThreadInherit extends Thread{
@Override
public void run() {
super.run();
}
public static void main(String[] args) {
(new ThreadInherit()).start();
}
}
Thread
클래스를 사용하는 것이 더 쉬운 방법이긴 하다. 하지만, 커스텀한 쓰레드 클래스를 만들려면, Thread
클래스를 상속해야 하기 때문에, 다중 상속 문제로 다른 클래스를 상속할 수 없다는 단점이 있다. Runnable
인터페이스를 사용하면 implements
만 하면 되기 때문에, 다형성의 측면에서 더 이점이 있다.
Thread.sleep
은 메소드를 호출한 쓰레드가 특정 기간동안 잠들도록 한다. 위 메소드를 통해 다른 쓰레드가 프로세서의 시간을 사용할 수 있도록 하는 효과적인 방법이다. 또한 sleep
메소드는 다른 쓰레드를 기다리고 해당 쓰레드의 실행과정에 맞춰가는데 사용될 수 있다.
sleep
메소드를 오버라이딩한 두 가지 버전이 존재한다. 하나는 밀리세컨드 단위, 다른 하나는 나노세컨드 단위로 기다리도록 한다. 하지만, 이러한 sleep time
은 정확하게 그 시간 동안 기다린 다는 것을 보장하지 못한다. 왜냐하면, 잠자는 기간동안 interrupts
가 발생할 수 있기 때문이다. 따라서, sleep
메소드가 명시된 시간만큼 정확하게 기다릴 거라고 기대해서는 안된다.
sleep 메소드 사용 예제
public class SleepMethod {
public static void main(String[] args){
Thread thread1 = new Thread(() -> {
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread1!");
});
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread2!");
});
thread1.start();
thread2.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("main thread!");
}
}
// 출력결과
// main thread!
// Thread2!
// Thread1!
sleep이 없을 때의 출력문의 실행 순서는 thread1 → thread2 → main 이지만, sleep으로 인해 실제 출력문은 main → thread2 → thread1 순서로 진행된다.
인터럽트는 스레드에게 멈추라고 지시를 내리는 것이다. 쓰레드가 인터럽트에 실제 어떻게 반응하는지는 프로그래머의 선택이지만, 쓰레드가 종료되는 것이 보편적이다.
쓰레드는 Thread 클래스에 있는 interrupt
를 호출하여 인터럽트를 보낸다. 인터럽트 매커니즘이 올바르게 동작하려면, 인터럽트을 받는 쓰레드는 반드시 interruption
을 지원해야 한다.
쓰레드는 어떻게 interruption을 지원할까? InterruptedException
을 catch함으로써 가능하다. 위에서 언급했듯이 다른 쓰레드가 현재 쓰레드에게 interrupt를 발생시킬 수 있다.
// Thread.java
public void interrupt() {
if (this != Thread.currentThread())
checkAccess();
synchronized (blockerLock) {
Interruptible b = blocker;
if (b != null) {
interrupt0(); // Just to set the interrupt flag
b.interrupt(this);
return;
}
}
interrupt0();
}
}
interrupt를 발생시킬 쓰레드의 interrupt 플래그를 변경시켜 해당 쓰레드에 interrupt가 발생했다는 표시를 한다.
실제 interrupt를 발생시키는 메소드인 interrupt0
은 native 메소드이다. 이를 보아 해당 플랫폼에서 네이티브 코드를 사용하여 쓰레드의 플래그를 변경시킴을 유추할 수 있다.
public class InterruptTest{
public static void main(String[] args) {
InterruptedThread interruptTest = new InterruptedThread();
interruptTest.start();
InterruptThread interruptThread = new InterruptThread(interruptTest);
interruptThread.start();
System.out.println("main is finished!");
}
}
class InterruptedThread extends Thread{
@Override
public void run() {
try{
Thread.sleep(4000);
boolean isInterrupted = Thread.interrupted();
System.out.println("Is interrupted? " + isInterrupted);
System.out.println("This Thread is finished!");
} catch (InterruptedException e) {
System.out.println("Interrupt occur!");
return;
}
}
}
class InterruptThread extends Thread{
Thread otherThread;
InterruptThread(Thread thread){
this.otherThread = thread;
}
@Override
public void run() {
interruptOtherThread(otherThread);
}
public void interruptOtherThread(Thread thread){
try {
thread.interrupt();
}catch (SecurityException e){
e.printStackTrace();
}
}
}
// 출력
// main is finished!
// Interrupt occur!
join
메소드는 쓰레드가 다른 쓰레드가 종료될 때까지 기다리도록 한다. 특정 시간을 넘기지 않게 기다리도록 파라미터를 설정할 수도 있다.
public class JoinTest extends Thread{
@Override
public void run() {
try {
sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Child Thread finished!");
}
public static void main(String[] args) {
JoinTest joinTest = new JoinTest();
joinTest.start();
try {
joinTest.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Main Thread finished!");
}
}
// 출력
// Child Thread finished!
// Main Thread finished!
NEW
: Thread 객체가 만들어졌지만, start되지 않은 상태.RUNNABLE
: 실제 쓰레드가 JVM에서 동작하는 상태.BLOCKED
: lock이 걸려있는 block 코드에 들어가지 못하고 대기하고 있는 상태.WAITING
: wait
or join
or park
메소드가 호출될 때 이 상태로 전환된다. 다른 쓰레드가 특정한 행동을 하는 것을 기다리는 상태.TIME_WAITING
: WAITING과 유사하지만, 특정 시간을 기다린다는 차이점 존재. sleep
or parkNanos
or parkUntil
메소드가 추가.TERMINATED
: 쓰레드가 실행을 종료한 상태.public class StatusTest {
public static void main(String[] args) throws InterruptedException {
// state : NEW
Thread newStateThread = new Thread(new NewStateThread());
System.out.println("State : " + newStateThread.getState());
// state : RUNNABLE
newStateThread.start();
System.out.println("State : " + newStateThread.getState());
// state : TIME_WAITING
Thread timeWaitingStateThread = new SleepThread();
timeWaitingStateThread.start();
timeWaitingStateThread.join(1000);
System.out.println("State : " + timeWaitingStateThread.getState());
// state : WAITING
Thread waitStateThread = new WaitStateThread();
waitStateThread.start();
waitStateThread.join(1000);
System.out.println("State : " + waitStateThread.getState());
// state : TERMINATED
Thread.currentThread().sleep(2000);
System.out.println("State : " + timeWaitingStateThread.getState());
}
}
class NewStateThread implements Runnable {
@Override
public void run() {
for(int i = 0 ; i < 1000000; i++){
for(int j = 0 ; j < 1000000; j++){}
}
}
}
class SleepThread extends Thread{
@Override
public void run() {
try {
sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class WaitStateThread extends Thread{
@Override
public void run() {
Thread thread = new SleepThread();
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 출력
// State : NEW
// State : RUNNABLE
// State : TIMED_WAITING
// State : WAITING
// State : TERMINATED
자바에서 쓰레드의 우선순위는 1부터 10의 Integer 변수로 표현된다. 클 수록 높은 우선순위를 의미한다. 쓰레드 스케줄러는 우선순위 값을 보고 어떤 쓰레드를 먼저 실행시킬 지 결정한다.
Thread 클래스에서는 우선순위와 관련한 세 가지 타입을 정의한다.
JVM은 여러 쓰레드가 실행 가능 상태일 때, Runnable
상태이고 우선순위가 높은 순서대로 실행시킨다. 실행 중인 쓰레드가 멈추거나 Not Runnable
한 상태가 되면, 다음 우선순위를 갖는 쓰레드가 실행된다. 만약, 우선순위가 동일한 두 쓰레드가 존재할 때, JVM은 FIFO 순서에 맞춰 실행시킬 것이다.
그러나 종종, 쓰레드 스케줄러는 starvation
을 피하기 위해 우선순위가 낮은 쓰레드를 실행시키는 경우도 존재한다.
// Main Thread's priority : 5
// child Thread1's priority : 1
// child Thread2's priority : 10
public class PriorityTest {
public static void main(String[] args) throws InterruptedException {
Thread currentThread = Thread.currentThread();
Thread childThread1 = new ChildThread(currentThread,"child thread 1");
Thread childThread2 = new ChildThread(currentThread,"child thread 2");
childThread1.setPriority(1);
childThread2.setPriority(10);
childThread1.start();
childThread2.start();
currentThread.sleep(4000);
}
}
class ChildThread extends Thread{
private Thread parentThread;
private String name;
ChildThread(Thread parentThread, String name){
this.parentThread = parentThread;
this.name = name;
}
@Override
public void run() {
try {
System.out.println(name + " start!");
sleep(1000);
parentThread.join();
System.out.println(name + " end!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 출력1
// child thread 2 start!
// child thread 1 start!
// child thread 1 end!
// child thread 2 end!
// 출력2
// child thread 2 start!
// child thread 1 start!
// child thread 2 end!
// child thread 1 end!
여러 번 실행했을 때, 대체로 우선순위가 높은 2번 쓰레드가 먼저 실행되었다. 하지만, 1번 쓰레드가 먼저 실행되는 경우도 종종 있었다. 따라서, 우선순위 만으로 쓰레드들의 실행 순서를 예상하고 개발하는 것은 매우 위험하다는 결론을 얻었다.
모든 자바 프로그램은 main()
메소드를 갖으며, main()
메소드는 프로그램을 실행시키는 엔트리 포인트이다. 따라서, JVM이 프로그램을 실행시킬 때, 해당 프로그램을 실행시키는 main thread
를 생성한다.
JVM이 main thread
를 생성할 때, main thread
를 위한 스택을 생성한다. 해당 스택에는 main thread
가 호출하는 메소드들이 스택 프레임의 형태로 저장된다. 만약 지정된 스택의 사이즈를 넘어서서 메소드를 호출한 다면 stack overflow error
를 throw
할 것이다.
그렇다면, thread
가 생성될 때 마다 stack
이 생성되는 것일까? 아니면 main thread
만의 특별한 경우일까?
JVM은 쓰레드를 생성할 때 각각의 쓰레드를 위한 stack
을 할당해준다. 각각의 쓰레드는 자신만의 고유한 stack
을 갖게 되며, main thread
에서와 마찬가지로 메소드 호출시 각각의 stack
에 스택 프레임 형태로 적재된다.
그런데, main thread
는 되도록이면 마지막으로 실행되는 thread
이어야 한다. 왜냐하면, main thread
가 프로그램이 종료될 때의 로직들을 수행하기 때문이다.
그렇다면, main thread가 종료된다면, 자식 스레드들도 종료될까?
// 메인 스레드가 종료될 때, 자식 스레드들도 자동으로 종료되는 지 확인합니다.
public class MainThreadStopTest {
public static void main(String[] args) throws InterruptedException {
Thread childThread = new ChildTestThread();
childThread.run();
Thread.currentThread().sleep(1000);
return;
}
}
class ChildTestThread extends Thread{
@Override
public void run() {
for(int i = 0 ;i < 5; i++){
System.out.println("child thread run! count " + i + "!");
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 출력
// child thread run! count 0!
// child thread run! count 1!
// child thread run! count 2!
// child thread run! count 3!
// child thread run! count 4!
결론은, 종료되지 않았다. main thread
가 종료되더라도, child thread
들은 정상적으로 동작하였다. 따라서, 만약 main()
메소드에서 프로그램이 종료될 때 반드시 수행되어야 할 로직들을 담고 있다면, main thread
가 child thread
들이 종료될 때까지 기다린 이후에 종료되도록, 코드를 작성해야 한다.
쓰레드들은 주로 필드나 객체에 대한 접근을 공유함으로서 다른 쓰레드들과 소통한다. 이러한 소통 방식은 매우 효과적이지만, 두 가지 에러 상황을 만든다. thread interference
& memory consistency errors
. 이러한 에러들을 방지하기 위한 도구가 synchronization
이다.
public class Counter {
private int c = 0;
public void increment(){
c++;
}
public void decrement(){
c--;
}
public int value(){
return c;
}
}
위 Counter
클래스는 간단하게 더하기, 빼기를 수행하는 메소드를 갖는 클래스이다. 해당 메소드들이 JVM에서 동작하는 순서를 자세하게 살펴보자.
increment()
메소드를 보겠다.
즉, c++;
의 코드는 실제 JVM에서 동작할 때, 위와같이 세 단계로 수행된다. 만약 두 개의 쓰레드에서 Counter
객체를 공유하고 하나의 쓰레드는 increment() 메소드를, 다른 하나는 decrement() 메소드를 수행한다고 가정하자.(c는 0이라고 가정한다.)
최종적으로 변수 c
에는 -1
이 저장된다. incrementThread에서 변수 c
에 저장했던 것은 decrementThread에 의해 덮혀지게 된다. 이처럼, 자바코드가 바이트코드로 변환되어, JVM에서 실제 수행될 때는 위와 같이 실행들이 짬뽕될 수 있다. 이러한 에러 상황을 Thread Interference
라고 말한다.
public class ThreadInterference {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread incrementThread = new CounterThread(counter,"increment");
Thread decrementThread = new CounterThread(counter,"decrement");
incrementThread.start();
decrementThread.start();
incrementThread.join();
decrementThread.join();
System.out.println(counter.value());
}
}
class CounterThread extends Thread{
Counter counter;
String methodName;
CounterThread(Counter counter, String methodName){
this.counter = counter;
this.methodName = methodName;
}
@Override
public void run() {
if(methodName == "increment") for (int i = 0; i < 1000000; i++) counter.increment();
else for (int i = 0; i < 1000000; i++) counter.decrement();
}
}
// 예상 출력
// 0
// 실제 출력
// -4715
각각의 쓰레드가 메소드들이 온전히 수행되는 것을 보장했다면, 실제 출력은 “0”일 것이다. 하지만, 메소드가 완전히 실행되고 종료되는 것을 보장하지 않기 때문에 위 결과 처럼, 예상과는 다른 값이 도출되게 된다.
이 에러는 Thread Inference와 매우 유사해 보인다. 하지만, Thread Inference
는 JVM에서 자바 소스코드가 atomically
하게 실행된는 것을 보장해주지 않는다는 것이며, Memory Consistency Errors
는 cpu level
에서 multi core
사용시 발생할 수 있는 에러를 말한다. H/W 레벨에서 발생하는 concurrency 문제를 해결하기 위해, 각 코드가 실행되는 관계를 명시함으로서 해결 가능하다.
메소드를 synchronized하게 만들기 위해 synchronized
키워드를 사용하면 된다.
public class SynchronizedCounter {
private int c = 0;
public synchronized void increment(){
c++;
}
public synchronized void decrement(){
c--;
}
public synchronized int value(){
return c;
}
}
기존 Counter
클래스의 메소드에 synchronized
키워드를 추가하였다. 이를 통해 다음과 같은 효과를 얻을 수 있다.
synchronized
메소드를 실행시키면, 다른 쓰레드는 해당 메소드를 실행시키지 못하고 블락된다.synchronized
메소드가 종료되면, 자동으로 해당 메소드를 실행하려고 했던 쓰레드들에게 이 사실을 알린다. 이때 다른 쓰레드들은 해당 메소드를 실행시킬 수 있는 상태로 변경된다.자바 공식문서에서는, 공유하는 객체로의 접근에 대해 절차적 수행을 보장하는 것 처럼 말한다. 그래서, 공유 객체를 다루는 코드에 대해서만 블락되는 것으로 오해하였다. 하지만, 실제 테스트 결과 synchronized 키워드가 붙은 메소드에 대해 메소드 레벨에서 블락되는 것을 확인하였다.
public class SynchronizedMethodTest {
public static void main(String[] args) throws InterruptedException {
SynchronizedCounter counter = new SynchronizedCounter();
Thread incrementThread = new SynchronizedCounterThread(counter,"increment");
Thread decrementThread = new SynchronizedCounterThread(counter,"decrement");
incrementThread.start();
decrementThread.start();
incrementThread.join();
decrementThread.join();
System.out.println(counter.value());
}
}
class SynchronizedCounterThread extends Thread{
SynchronizedCounter counter;
String methodName;
SynchronizedCounterThread(SynchronizedCounter counter, String methodName){
this.counter = counter;
this.methodName = methodName;
}
@Override
public void run() {
if(methodName == "increment") for (int i = 0; i < 1000000; i++) counter.increment();
else for (int i = 0; i < 1000000; i++) counter.decrement();
}
}
// 예상 출력
// 0
// 실제 출력
// 0
추가적으로, sysnchronized
키워드는 생성자에 붙을 수 없다. 왜냐하면, 생성자가 실행되는 동안에 해당 객체에 접근할 수 있는 쓰레드는 오직 생성자를 호출한 쓰레드이기 때문이다. 생성자에는 synchronized
가 적용되지 않기 때문에, 생성자에서 다른 synchronized method
를 호출해서는 안된다.
데드락은 여러 쓰레드가 서로를 기다리느라 영원히 block되는 상황을 말한다. 다음 4가지 조건이 충족되면 데드락이 발생한다.
상호배제(Mutual Exclusion)
점유와 대기(Hold and Wait)
비선점(No Preemption)
순환대기(Circular wait)