-
[Java]멀티태스킹과 스레드[Java] 2024. 11. 20. 01:13
멀티태스킹이 반드시 효율적인 것은 아니다
나는 멀티태스킹을 들었을 때, 여러 가지 일을 동시에 처리하면 무조건 생산성이 높아지고 효율적일 것이라는 생각을 가지고 있었다. 하지만 멀티태스킹이 항상 긍정적인 결과만을 가져오는 것은 아니라는 것을 알게 되었고, 여기서는 프로세스와 스레드에 대해서 적어볼까한다.
컨텍스트 스위칭의 비용
컨텍스트 스위칭은 CPU가 한 작업에서 다른 작업으로 전환할 때 발생하는 과정입니다. 이때 CPU는 현재 작업의 상태(레지스터 값, 프로그램 카운터 등)를 메모리에 저장하고, 전환할 작업의 상태를 메모리에서 불러옵니다. 이 과정은 시간이 소요되며, 빈번한 컨텍스트 스위칭은 시스템의 전체적인 성능을 저하시킬 수 있습니다.
예제: 컨텍스트 스위칭의 비용 측정
public class ContextSwitchingDemo { public static void main(String[] args) { long startTime = System.nanoTime(); Thread thread1 = new Thread(() -> { for (int i = 0; i < 1_000_000; i++) { // 간단한 연산 int a = i * i; } }); Thread thread2 = new Thread(() -> { for (int i = 0; i < 1_000_000; i++) { // 간단한 연산 int b = i + i; } }); thread1.start(); thread2.start(); try { thread1.join(); thread2.join(); long endTime = System.nanoTime(); System.out.println("Total time: " + (endTime - startTime) + " ns"); } catch (InterruptedException e) { e.printStackTrace(); } } }
위 예제에서 두 개의 스레드가 동시에 실행되지만, 컨텍스트 스위칭으로 인해 오히려 단일 스레드로 실행하는 것보다 시간이 더 걸릴 수 있습니다.
CPU 바운드 작업 vs I/O 바운드 작업
프로그램의 작업 유형을 이해하는 것은 성능 최적화에 중요합니다. 작업은 크게 CPU 바운드와 I/O 바운드로 구분됩니다.
CPU 바운드 작업
- 정의: CPU의 연산 능력을 많이 요구하는 작업.
- 특징: CPU의 처리 속도가 작업 완료 시간을 결정.
- 예시:
- 복잡한 수학 연산
- 데이터 분석
- 비디오 인코딩
- 과학적 시뮬레이션
예제: CPU 바운드 작업
public class CpuBoundTask { public static void main(String[] args) { // 소수(prime number) 계산 for (int number = 2; number <= 10_000; number++) { if (isPrime(number)) { System.out.println(number + " is a prime number."); } } } public static boolean isPrime(int number) { for (int i = 2; i <= Math.sqrt(number); i++) { if (number % i == 0) { return false; } } return true; } }
위 코드는 2부터 10,000까지의 숫자 중 소수를 찾는 CPU 바운드 작업입니다.
I/O 바운드 작업
- 정의: 디스크, 네트워크, 파일 시스템 등과 같은 입출력 작업을 많이 요구하는 작업.
- 특징: I/O 작업 완료 대기 시간 동안 CPU는 유휴 상태.
- 예시:
- 데이터베이스 쿼리 처리
- 파일 읽기/쓰기
- 네트워크 통신
- 사용자 입력 처리
예제: I/O 바운드 작업
import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; public class IoBoundTask { public static void main(String[] args) { // 대용량 파일 읽기 try (BufferedReader reader = new BufferedReader(new FileReader("largefile.txt"))) { String line; while ((line = reader.readLine()) != null) { // 읽은 내용 처리 System.out.println(line); } } catch (IOException e) { e.printStackTrace(); } } }
위 코드는 큰 파일을 읽는 I/O 바운드 작업으로, 파일에서 데이터를 읽어들이는 동안 CPU는 대기 상태가 됩니다.
웹 애플리케이션 서버에서는 일반적으로 I/O 바운드 작업이 CPU 바운드 작업보다 더 많이 발생합니다. 따라서 이러한 특성을 고려하여 스레드 풀의 크기나 비동기 처리를 설계해야 합니다.
스레드의 실행 순서와 기간은 보장되지 않는다
스레드는 운영체제의 스케줄러에 의해 관리되며, 실행 순서와 실행 기간이 보장되지 않습니다. 이는 동시성 문제를 야기할 수 있으며, 이를 해결하기 위해서는 적절한 동기화 메커니즘이 필요합니다.
예제: 스레드 실행 순서 확인
public class ThreadOrderDemo { public static void main(String[] args) { Thread threadA = new Thread(() -> System.out.println("Thread A 실행")); Thread threadB = new Thread(() -> System.out.println("Thread B 실행")); Thread threadC = new Thread(() -> System.out.println("Thread C 실행")); threadA.start(); threadB.start(); threadC.start(); } }
위 코드의 실행 결과는 실행할 때마다 출력 순서가 달라질 수 있습니다.
start() 메서드 vs run() 메서드
스레드를 시작할 때 start()와 run() 메서드의 차이를 이해하는 것이 중요합니다.
run() 메서드 직접 호출
- 동작: run() 메서드를 직접 호출하면 별도의 스레드가 아닌 현재 스레드에서 메서드가 실행됩니다.
- 결과: 멀티스레딩의 효과를 얻지 못하고, 일반 메서드 호출과 동일하게 동작합니다.
예제: run() 메서드 직접 호출
public class RunMethodDemo { public static void main(String[] args) { Thread thread = new Thread(() -> System.out.println("별도의 스레드에서 실행")); thread.run(); // run() 직접 호출 System.out.println("메인 스레드에서 실행"); } }
출력 결과:
별도의 스레드에서 실행 메인 스레드에서 실행
위 결과에서 run() 메서드를 직접 호출했기 때문에 "별도의 스레드에서 실행"과 "메인 스레드에서 실행"이 순차적으로 실행됩니다.
start() 메서드 사용
- 동작: start() 메서드를 호출하면 새로운 스레드가 생성되어 run() 메서드가 실행됩니다.
- 결과: 실제로 멀티스레딩이 구현되어 병렬 처리가 가능합니다.
예제: start() 메서드 사용
public class StartMethodDemo { public static void main(String[] args) { Thread thread = new Thread(() -> System.out.println("별도의 스레드에서 실행")); thread.start(); // start() 메서드 호출 System.out.println("메인 스레드에서 실행"); } }
출력 결과:
메인 스레드에서 실행 별도의 스레드에서 실행
또는
별도의 스레드에서 실행 메인 스레드에서 실행
출력 순서는 실행할 때마다 달라질 수 있으며, 이는 두 스레드가 병렬로 실행되기 때문입니다.
사용자 스레드와 데몬 스레드
자바에서는 스레드를 사용자 스레드와 데몬 스레드로 구분합니다.
사용자 스레드 (Non-Daemon Thread)
- 역할: 프로그램의 주요 작업을 수행.
- 특징:
- 모든 사용자 스레드가 종료될 때까지 JVM이 종료되지 않음.
- 메인 로직을 담당하는 스레드들로 구성.
예제: 사용자 스레드
public class UserThreadDemo { public static void main(String[] args) { Thread userThread = new Thread(() -> { try { Thread.sleep(3000); System.out.println("사용자 스레드 종료"); } catch (InterruptedException e) { e.printStackTrace(); } }); userThread.start(); System.out.println("메인 메서드 종료"); } }
위 코드에서 메인 메서드가 종료되더라도 사용자 스레드가 종료될 때까지 프로그램은 종료되지 않습니다.
데몬 스레드 (Daemon Thread)
- 역할: 백그라운드에서 보조적인 작업을 수행.
- 특징:
- 모든 사용자 스레드가 종료되면 자동으로 종료됨.
- 예시로는 가비지 컬렉터, 메모리 관리 등이 있음.
예제: 데몬 스레드
public class DaemonThreadDemo { public static void main(String[] args) { Thread daemonThread = new Thread(() -> { while (true) { System.out.println("데몬 스레드 실행 중"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); daemonThread.setDaemon(true); // 데몬 스레드로 설정 daemonThread.start(); try { Thread.sleep(3000); // 메인 스레드 3초 대기 System.out.println("메인 메서드 종료"); } catch (InterruptedException e) { e.printStackTrace(); } } }
출력 결과:
데몬 스레드 실행 중 데몬 스레드 실행 중 데몬 스레드 실행 중 메인 메서드 종료
메인 스레드가 종료되면 데몬 스레드도 자동으로 종료됩니다.
Thread 상속 vs Runnable 인터페이스 구현
스레드를 구현할 때는 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있습니다. 일반적으로 Runnable 인터페이스를 구현하는 것이 권장됩니다.
Thread 클래스 상속
- 장점:
- 구현이 간단하며, run() 메서드만 재정의하면 됨.
- 단점:
- 상속의 제한: 자바는 단일 상속만 지원하므로 이미 다른 클래스를 상속받고 있으면 사용할 수 없음.
- 유연성 부족: 인터페이스 구현 방식에 비해 유연성이 떨어짐.
예제: Thread 클래스 상속
public class ThreadInheritanceDemo extends Thread { @Override public void run() { System.out.println("Thread 클래스를 상속받은 스레드 실행"); } public static void main(String[] args) { ThreadInheritanceDemo thread = new ThreadInheritanceDemo(); thread.start(); } }
Runnable 인터페이스 구현
- 장점:
- 상속의 자유로움: 다른 클래스를 상속받으면서 스레드 기능을 구현할 수 있음.
- 코드의 분리: 스레드 동작과 실행할 작업을 분리하여 코드의 가독성이 높아짐.
- 자원 공유의 효율성: 여러 스레드가 동일한 Runnable 객체를 공유할 수 있어 자원 관리가 효율적.
- 단점:
- 구현의 복잡성 증가: Runnable 객체를 생성하고 이를 Thread에 전달하는 과정이 추가됨.
예제: Runnable 인터페이스 구현
public class RunnableImplementationDemo implements Runnable { @Override public void run() { System.out.println("Runnable 인터페이스를 구현한 스레드 실행"); } public static void main(String[] args) { Thread thread = new Thread(new RunnableImplementationDemo()); thread.start(); } }
예제: 다른 클래스 상속과 Runnable 구현
class BaseClass { public void baseMethod() { System.out.println("BaseClass 메서드 실행"); } } public class MultiInheritanceDemo extends BaseClass implements Runnable { @Override public void run() { System.out.println("Runnable 인터페이스를 구현한 스레드 실행"); } public static void main(String[] args) { MultiInheritanceDemo demo = new MultiInheritanceDemo(); demo.baseMethod(); Thread thread = new Thread(demo); thread.start(); } }
위 예제에서는 BaseClass를 상속받으면서 Runnable 인터페이스를 구현하여 스레드를 실행할 수 있습니다.
참고 자료
- 인프런 김영한 자바 고급1
- 오라클 자바 공식 문서
'[Java]' 카테고리의 다른 글
[Java] 결합도 / 응집도 (0) 2024.08.02 [Java] 스택 / 큐 / 덱 (0) 2024.05.12 [Java] 명명규칙 / 형변환 / Steak / Heap (0) 2024.03.15 [Java] 접근 제한자 / 연산자 (0) 2024.03.15 [Java] 자바 언어 특징 (0) 2024.03.15