class RunnableSample implements Runnable {
public void run() {
System.out.println("This is RunnableSample's run() method." );
}
}
class ThreadSample extends Thread{
public void run() {
System.out.println("This is ThreadSample's run() method.");
}
}
public class RunThreads{
public static void main(String[] args) {
RunThreads threads = new RunThreads();
threads.runBasic();
}
public void runBasic(){
RunnableSample runnable = new RunnableSample();
new Thread(runnable).start();
ThreadSample thread = new ThreadSample();
thread.start();
System.out.println("RunThreads.runBasic() method is ended.");
}
}
출력 결과
This is RunnableSample's run() method.
RunThreads.runBasic() method is ended.
This is ThreadSample's run() method.
위의 프로그램을 실행하면 위와 같은 결과가 나오는데, 중요한 점은 스레드가 수행되는 우리가 구현하는 메소드는 run() 메소드라는 점과 스레드는 start()가 호출되어야만 시작한다는 점이다.
스레드 클래스가 다른 클래스를 확장할 필요가 있을 경우에는 Runnable 인터페이스를 구현하면 되며, 그렇지 않은 경우에는 Thread 클래스를 사용하는 것이 편하다.
또한 출력결과가 항상 동일하게 나오지 않는데, 그 이유는 멀티 스레딩의 특성과 복잡성에 비롯된다.
자바 프로그램에서 멀티 스레드를 사용하면 각각의 스레드가 CPU 시간을 공유하면서 동시에 실행되는데, 이 과정에서 다음과 같은 요소들이 결과의 일관성에 영향을 줄 수 있다.
(1). 스케줄링의 비결정성(Non-deterministic Scheduling) : 자바에서 스레드의 실행 순서와 타이밍은 운영체제의 스레드 스케줄러에 의해 결정된다. 스케줄러는 실행 가능한 스레드 중에서 선택을 하는데, 이는 다양한 요인에 의해 영향을 받으므로 매번 실행할 때마다 다른 결과를 낼 수 있다.
(2). 공유 자원의 동시접근(Concurrent Access to Shared Resources) : 멀티 스레드 프로그램에서 여러 스레드가 동시에 같은 자원에 접근하려 할 때, 적절한 동기화 과정이 없이는 자원의 상태가 예측 불가능하게 변할 수 있다. 이로 인해 데이터 경쟁 조건(Race Condition)이 발생하고, 결과적으로 실행마다 다른 결과가 나올 수 있다.
(3). 동기화 문제(Synchronization Issues) : 스레드 간의 동기화를 관리하는 것은 매우 중요하다. 'synchronized' 블록, 락(lock), 세마포어(semaphore) 등을 사용해 자원에 대한 접근을 제어할 수 있지만, 동기화가 잘못 구현되면 데드락(deadlock)이나 라이브락(live lock) 같은 상황을 초래할 수 있고, 이 역시 실행 결과의 일관성을 해칠 수 있다.
(4). 스레드 간의 상호작용(Inter-thread Interaction) : 스레드들이 서로를 기다리거나, 특정 스레드의 결과에 기반하여 다음 작업을 수행할 필요가 있는 경우, 이들 스레드의 실행 순서와 시간에 따라 결과가 달라질 수 있다.
(5). 환경적 영향(Environmental Factors) : 실행 환경의 변화(CPU 사용률, 메모리 상태, 다른 프로세스의 영향.. 등등) 역시 스레드의 실행화 결과에 영향을 줄 수 있다.
이 와 같이 자바의 멀티스레딩 환경은 복잡하고 다양한 요소들에 의해서 영향을 받으므로, 프로그램을 실행할 때마다 결과가 일관되게 나오지 않는 것은 매우 흔한 현상이다. 이를 관리하고 예측 가능하게 만들기 위해 신중한 설계와 충분한 테스트가 요구된다.
스레드를 시작할 때 어떤 값을 전달하고 싶을 때는 아래 예제 소스와 같이 생성자에 값을 전달할 수 있도록 한다.
public class CounterThread extends Thread {
private static int counter = 0;
public CounterThread (String name) {
super(name);
}
public void run() {
synchronized (CounterThread.class) {
for( int i = 0; i < 1000; i++ ){
counter++;
}
}
}
public static void main(String[] args) {
int number = 1;
CounterThread[] threads = new CounterThread[10];
for( int i = 0; i < threads.length; i++ ){
threads[i] = new CounterThread("Thread-"+(i+1));
threads[i].start();
}
for(int i = 0; i < threads.length; i++ ){
try {
threads[i].join();
} catch (InterruptedException e){
e.printStackTrace();
}
}
System.out.println("Final counter value : " + counter );
System.out.println("CounterThread.main() method is ended.");
}
}
출력 결과
Final counter value : 10000
CounterThread.main() method is ended.
위 소스에는 CounterThread의 공유자원으로 counter 변수가 있고 이를 하나씩 증가하는 연산을 하는 스레드이다. 따라서 이 공유자원의 동기화를 위해서 증가연산자가 수행되는 곳을 synchronized 블록으로 감싸두었다.
그래서 마지막 값은 스레드의 개수만큼 증가한 counter가 출력된다. 여기서 synchronized 키워드를 빼고 실행하면, 실행할 때마다 counter value 값이 달라지게 된다. 이는 여러 스레드가 공유 자원인 counter 변수를 동시에 증가시키려고 시도하면서, 데이터 경쟁을 보여준다. 그리고 join 메소드가 있는데 이 메소드는 각각의 스레드가 종료될 때까지 기다리는 메소드이다.
데몬 스레드는 Thread클래스의 인스턴스에 대해 setDaemon(true)메소드를 호출함으로써 해당 스레드를 데몬 스레드로 설정할 수 있다. 프로그램의 보조적인 역할을 수행하며, 스레드의 작업을 돕는 배경 스레드로 활용된다.
public class DaemonThread extends Thread {
public void run() {
while(true){
for( int i = 0; i < 50; i++) {
System.out.println("[Daemon] counter : " + i );
try {
Thread.sleep(1000);
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
DaemonThread thread = new DaemonThread();
thread.setDaemon(true);
thread.start();
DummyThread dummyThread = new DummyThread();
dummyThread.start();
try {
dummyThread.join();
} catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("DaemonThread.main() method is ended.");
}
}
class DummyThread extends Thread {
public void run(){
for( int i = 0; i < 3; i++) {
System.out.println("[Dummy] counter : " + i );
i++;
try {
Thread.sleep(1000);
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
}
출력 결과
[Dummy] counter : 0
[Daemon] counter : 0
[Dummy] counter : 2
[Daemon] counter : 1
[Daemon] counter : 2
DaemonThread.main() method is ended.
출력 결과를 보면 DummyThread가 0부터 3이될 때까지 1초간격으로 counter를 출력하고 있고, DaemonThread는 무한하게 1초간격으로 counter를 출력하는 스레드이다. 여기서 DaemonThread는 setDaemon이 true로 설정되어있으므로, DummyThread가 종료되게 되면 해당 프로세스가 종료되면서 DaemonThread 또한 종료되게된다.
이러한 데몬 스레드는 주로 프로그램의 보조적인 역할을 수행하며, 주 스레드의 작업을 돕는 배경 스레드로 사용된다. 데몬 스레드의 주요 특징은 프로그램이 종료될 때 데몬 스레드가 실행 중이라도 자바 런타임이 프로그램의 종료를 막지 않는다는 점이다. 즉, 주 스레드(데몬 스레드가 아닌 스레드)가 모두 종료되면 데몬 스레드는 강제로 종료될 수 있다. 이는 데몬 스레드가 주로 프로그램의 생명 주기에 종속적인 보조적인 작업을 수행하기 때문이다.
백그라운드에서 지속적으로 실행되어야 하는 작업에 주로 사용되는데, 가비지 컬렉션, 자동 저장, 세션 모니터링 등의 작업이 데몬 스레드로 실행될 수 있다. 이러한 스레드들은 주요 비즈니스의 로직을 방해하지 않으면서도 필요한 지원 작업을 계속 수행할 수 있다.
데몬스레드를 사용할 때는 스레드가 강제 종료될 수 있다는 점을 명시해야한다. 따라서 중요한 작업을 처리하거나 데이터의 일관성을 유지해야하는 작업에는 사용하지 않는 것이 좋다.
그리고 추가적으로 알아야할 ExecutorService와 volatile 에 대해서 아래에 간단하게 정리한다.
ExecutorService는 스레드를 더 효율적으로 관리할 수 있는 고수준 API를 제공한다. 이 서비스를 사용하면 스레드의 생성과 생명 주기를 수동으로 관리하는 복잡성을 줄일 수 있다.
ExecutorService는 스레드 풀을 사용하여 스레드를 재 사용함으로써 시스템 리소스의 낭비를 줄이고, 작업 큐를 통해 작업을 효율적으로 관리할 수 있다.
volatile 키워드는 변수를 메인 메모리에 저장하도록 하여, 하나의 스레드에 의해 변경된 값이 다른 스레드에게 바로 보이도록 한다. 이는 메모리 가시성을 보장하며, 스레드 간의 변수 값의 일관성을 유지하는데 사용된다. volatile은 경량 동기화 옵션으로서, 값의 단순한 읽기 및 쓰기 작업에서 사용될 때 적합하다. 하지만 복잡한 상태 또는 여러 변수의 동기화가 필요한 경우에는 synchronized를 사용해야 한다.
이처럼 자바에서 스레드를 이용하기 위한 간단한 예제와 설명들을 알아봤는데, 이 부분에 대한 운영체제 내용도 공부해야겠다.
회사에서 C로 개발할 때는 Lock과 세마포어를 사용해서 공유자원에 대한 접근을 제한하는 매커니즘을 사용해서 멀티스레딩 환경을 개발하지만, JAVA에서는 synchronized 블럭과 키워드가 있는게 다른 점인거 같다.
물론 기본적으로 깔리는 베이스인 컴퓨터구조와 운영체제와 같은 이론은 그대로겠지만서도..