[JAVA] Thread

JHJeong·2024년 4월 22일
0

스레드

  • 어떠한 프로그램 내에서, 특히 프로세스 내에서 실행되는 흐름의 단위를 말함( 위키백과, 스레드 )
  • 일반적으로 하나의 프로그램은 하나의 스레드를 가지고 있지만, 프로그램 환경에 따라서 둘 이상의 스레드를 동시에 실행할 수 도 있다. 이러한 실행 방식을 멀티 스레드라고 함
  • 자바에서는 자바 프로그램을 만든 후 java 명령어를 사용하여 클래스를 실행하게 되면 자바 프로세스가 실행되고, main() 메소드가 수행되면서 하나의 스레드가 시작된다. 다른 스레드가 필요하다면 main() 메소드에서 스레드를 생성해주면 된다. ( 아무런 스레드를 생성하지 않아도 JVM을 관리하기 위한 여러 스레드가 존재한다. 예를 들면 자바의 GC관련 스레드. )
  • 프로세스가 하나 시작하려면 많은 자원이 필요한데, 하나의 작업을 동시에 수행하려고 할 때 여러 개의 프로세스를 띄우는 것보다 하나의 프로세스 내에서 스레드를 여러 개 두는게 메모리를 적게 점유한다. (하나의 프로세스는 대략 32MB~64MB를 점유, 하나의 스레드는 대락 1MB 이내의 메모리를 점유)
  • 자바에서 Thread를 생성하는 방법은 2가지가 있다. Runnable 인터페이스와 Thread 클래스이다.

Runnable 인터페이스와 Thread 클래스

  • 둘 다 java.lang 패키지에 있음
  • Thread 클래스는 Runnable 인터페이스를 구현한 클래스이다.
    Runnable 인터페이스에 선언되어 있는 메소드는 스레드가 사작되면 수행되는 메소드인 run() 메소드만 있다. 그에 반해 Thread 클래스는 매우 많은 생성자와 메소드(start, run, join 등등)를 제공한다. Runnable 인터페이스를 사용하면 더 많은 유연성을 얻을 수 있으며, 컴포지션을 통해 다른 목적으로 클래스를 확장하는 것이 가능합니다.
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 메소드가 있는데 이 메소드는 각각의 스레드가 종료될 때까지 기다리는 메소드이다.

Daemon Thread

데몬 스레드는 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와 volatile 에 대해서 아래에 간단하게 정리한다.

ExecutorService는 스레드를 더 효율적으로 관리할 수 있는 고수준 API를 제공한다. 이 서비스를 사용하면 스레드의 생성과 생명 주기를 수동으로 관리하는 복잡성을 줄일 수 있다.
ExecutorService는 스레드 풀을 사용하여 스레드를 재 사용함으로써 시스템 리소스의 낭비를 줄이고, 작업 큐를 통해 작업을 효율적으로 관리할 수 있다.

volatile 키워드는 변수를 메인 메모리에 저장하도록 하여, 하나의 스레드에 의해 변경된 값이 다른 스레드에게 바로 보이도록 한다. 이는 메모리 가시성을 보장하며, 스레드 간의 변수 값의 일관성을 유지하는데 사용된다. volatile은 경량 동기화 옵션으로서, 값의 단순한 읽기 및 쓰기 작업에서 사용될 때 적합하다. 하지만 복잡한 상태 또는 여러 변수의 동기화가 필요한 경우에는 synchronized를 사용해야 한다.

이처럼 자바에서 스레드를 이용하기 위한 간단한 예제와 설명들을 알아봤는데, 이 부분에 대한 운영체제 내용도 공부해야겠다.
회사에서 C로 개발할 때는 Lock과 세마포어를 사용해서 공유자원에 대한 접근을 제한하는 매커니즘을 사용해서 멀티스레딩 환경을 개발하지만, JAVA에서는 synchronized 블럭과 키워드가 있는게 다른 점인거 같다.
물론 기본적으로 깔리는 베이스인 컴퓨터구조와 운영체제와 같은 이론은 그대로겠지만서도..

profile
이것저것하고 싶은 개발자

0개의 댓글