본문 바로가기
[스파르타코딩클럽]/Java 문법 종합반

Chapter 12. 스레드

by 진진리 2023. 10. 18.
728x90
  • 프로세스: 운영체제로부터 자원을 할당받는 작업의 단위. "실행 중인 프로그램"
    • OS가 프로세스를 할당할 때 프로그램 CodeData, 메모리 영역(Stack, Heap)을 함께 할당
    • 프로세스 구조
      1. Data: 프로그램이 실행  중 초기화된 데이터를 저장하는 공간(전역 변수, static 변수, 상수)
      2. Stack: 지역 변수, 매개변수 리턴 변수를 저장하는 공간
      3. Heap: 동적으로 필요한 변수를 저장하는 공간
  • 스레드: 프로세스가 할당받은 자원을 이용하는 실행의 단위
    • 프로세스가 작업 중인 프로그램에서 실행 요청이 들어오면 스레드를 만들어 명령 처리
    • 스레드는 프로세스 내 메모리공간(Heap)을 공유받음
    • 스레드는 자신만의 메모리공간(Stack)도 할당받음
  • Java 스레드: JVM 프로세스 안에서 실행되는 스레드
    • Java Main 스레드부터 실행되며 JVM에 의해 실행됨

싱글 스레드와 멀티 스레드

  • 싱글 스레드: 프로세스 안에서 하나의 스레드만 실행되는 것
    • Java 프로그램의 경우 main() 스레드만 실행시켰을 때
    • main() 메서드의 스레드를 '메인 스레드'라고 부름
    • 메인 스레드가 종료되면 JVM도 같이 종료됨
  • 멀티 스레드: 프로세스 안에서 여러개의 스레드가 실행되는 것
    • 장점
      1. 여러 개의 작업을 동시에 함으로써 성능이 좋아짐
      2. 자원을 효율적으로 사용 
      3. 응답 스레드와 작업 스레드를 분리하여 빠르게 응답 가능(비동기)
    • 단점
      1. 동기화 문제 발생: 자원 충돌
      2. 교착 상태 발생: 작업 진행 불가능

스레드 구현방법

1. Java에서 제공하는 Thread 클래스를 상속받아 구현

public class TestThread extends Thread {
	@Override
	public void run() {
		// 쓰레드 수행작업
	}
}

...

TestThread thread = new TestThread(); // 쓰레드 생성
thread.start() // 쓰레드 실행

 

2. Java에서 제공하는 Runnable 인터페이스를 사용하여 구현

public class TestRunnable implements Runnable {
	@Override
	public void run() {
		// 쓰레드 수행작업 
	}
}

...

Runnable run = new TestRunnable();
Thread thread = new Thread(run); // 쓰레드 생성

thread.start(); // 쓰레드 실행

인터페이스는 다중 상속을 지원 -> 다른 필요한 클래스를 상속받을 수 있으므로 확장성에 유리

 

 

3. Runnable 인터페이스 + 람다식

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            int sum = 0;
            for (int i = 0; i < 50; i++) {
                sum += i;
                System.out.println(sum);
            }
            System.out.println(Thread.currentThread().getName() + " 최종 합 : " + sum);
        };

        Thread thread1 = new Thread(task);
        thread1.setName("thread1");
        Thread thread2 = new Thread(task);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
    }
}

데몬 스레드와 사용자 스레드

  • 데몬 스레드: 보이지 않는 곳(background)에서 실행되는 낮은 우선순위를 가진 스레드
    • 대표적으로 메모리 영역을 정리해주는 가비지 컬렉터가 있음
    • 우선순위가 낮고 다른 스레드가 종료되면 강제 종료 당함
public class Main {
    public static void main(String[] args) {
        Runnable demon = () -> {
            for (int i = 0; i < 1000000; i++) {
                System.out.println("demon");
            }
        };

        Thread thread = new Thread(demon);
        thread.setDaemon(true); // true로 설정시 데몬스레드로 실행됨

        thread.start();

        for (int i = 0; i < 100; i++) {
            System.out.println("task");
        }
    }
}
  • 사용자 스레드: 보이는 곳(foreground)에서 실행되는 높은 우선순위를 가진 스레드
    • 대표적으로 메인 스레드가 있음
    • 프로그램 기능을 담당

  • 스레드 우선순위
    • 스레드가 생성될 때 우선순위가 정해짐: 우리가 직접 지정하거나 JVM에 의해 지정됨
    • 3가지 (최대/최소/보통) 우선순위로 나뉨
      1. 최대 우선순위 (MAX_PRIORITY) = 10
      2. 최소 우선순위 (MIN_PRIORITY) = 1
      3. 보통 우선순위 (NROM_PRIOTIY) = 5  -> 기본값
    • 우선순위의 범위는 OS가 아닌 JVM에서 설정함
    • setPriority() 메서드로 설정
    • getPriority() 메서드로 확인
  • 스레드 그룹
    • 서로 관련이 있는 스레드들을 그룹으로 묶어서 관리
    • JVM이 시작되면 system 그룹이 생성되고 기본적으로 스레드들은 system 그룹에 포함됨
    • 메인 스레드는 system 그룹 하위의 main 그룹에 포함됨
    • 모든 스레드들은 반드시 하나의 그룹에 포함되어 있어야 함
      • 그룹을 지정받지 못한 스레드는 자신을 생성한 부모 스레드의 그룹과 우선순위를 상속받음
  • 그룹 생성
// ThreadGroup 클래스로 객체를 만듭니다.
ThreadGroup group1 = new ThreadGroup("Group1");

// Thread 객체 생성시 첫번째 매개변수로 넣어줍니다.
// Thread(ThreadGroup group, Runnable target, String name)
Thread thread1 = new Thread(group1, task, "Thread 1");

// Thread에 ThreadGroup 이 할당된것을 확인할 수 있습니다.
System.out.println("Group of thread1 : " + thread1.getThreadGroup().getName());
  • 그룹 관리
// ThreadGroup 클래스로 객체를 만듭니다.
ThreadGroup group1 = new ThreadGroup("Group1");

// Thread 객체 생성시 첫번째 매개변수로 넣어줍니다.
// Thread(ThreadGroup group, Runnable target, String name)
Thread thread1 = new Thread(group1, task, "Thread 1");
Thread thread2 = new Thread(group1, task, "Thread 2");

// interrupt()는 일시정지 상태인 쓰레드를 실행대기 상태로 만듭니다.
group1.interrupt();

  • 스레드 상태

스레드는 실행과 대기를 반복하며 run() 메서드를 수행

일시정지 상태에서는 스레드가 실행을 할 수 없는 상태가 됨

상태 Enum 설명
객체생성 NEW 쓰레드 객체 생성, 아직 start() 메서드 호출 전의 상태
실행대기 RUNNABLE 실행 상태로 언제든지 갈 수 있는 상태
일시정지 WAITING 다른 쓰레드가 통지(notify) 할 때까지 기다리는 상태
일시정지 TIMED_WAITING 주어진 시간 동안 기다리는 상태
일시정지 BLOCKED 사용하고자 하는 객체의 Lock이 풀릴 때까지 기다리는 상태
종료 TERMINATED 쓰레드의 작업이 종료된 상태

 

  • 스레드 제어

  • sleep(): 현재 스레드를 지정된 시간동안 멈추게 함

ms(밀리초) 단위로 설정됨

예외처리를 해야함 -> 메서드가 throws 하고 있음

특정 스레드를 지목하는 것은 불가능

try {
    Thread.sleep(2000); // 2초
} catch (InterruptedException e) {
    e.printStackTrace();
}

 

  • interrupt(): 일시정지 상태인 스레드를 실행대기 상태로 만듦

스레드가 start()된 후 동작하다 interrupt()를 만나면 Thread 클래스 내부의 interrupted 변수가 true가 됨

sleep() 실행 중 interrupt()가 실행되면 예외가 발생

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    break;
                }
            }
            System.out.println("task : " + Thread.currentThread().getName());
        };

        Thread thread = new Thread(task, "Thread");
        thread.start();

        thread.interrupt();

        System.out.println("thread.isInterrupted() = " + thread.isInterrupted());
        
    }
}

 

  • join(): 정해진 시간동안 지정한 스레드가 작업하는 것을 기다림

시간을 지정하지 않으면 지정한 스레드의 작업이 끝날 때까지 기다림

//...

Thread thread = new Thread(task, "thread");

thread.start();

try {
    thread.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}

thread가 작업을 끝날 때까지 main 스레드가 기다림

 

  • yield(): 남은 시간을 다음 스레드에게 양보하고 자신은 실행대기 상태가 됨
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                for (int i = 0; i < 10; i++) {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                }
            } catch (InterruptedException e) {
                Thread.yield();
            }
        };

        Thread thread1 = new Thread(task, "thread1");
        Thread thread2 = new Thread(task, "thread2");

        thread1.start();
        thread2.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        thread1.interrupt();

    }
}

 

  • synchronized

스레드 동기화(Synchronization): 스레드가 진행중인 작업을 다른 스레드가 침범하지 못하도록 막는 것

임계영역: 다른 스레드의 침범을 막아야하는 코드들

임계영역에는 Lock을 가진 단 하나의 스레드만 출입가능

 

임계영역 지정 - synchronized 사용

1. 메서드 전체

public synchronized void asyncSum() {
	  ...침범을 막아야하는 코드...
}

2. 특정 영역

synchronized(해당 객체의 참조변수) {
	...침범을 막아야하는 코드...
}

사용 예시:

    public void eatApple() {
        synchronized (this) {
            if(storedApple > 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                storedApple -= 1;
            }
        }
    }

 

 

  • wait()과 notify()

- wait(): 임계영역에서 작업을 수행하던 스레드를 Lock을 반납하고 기다리게 함(waiting pool로)

다른 스레드가 Lock을 얻어 해당 객체에 대한 작업을 수행할 수 있게 됨

- notify(): waiting pool에 있는 스레드 중 임의 스레드가 다시 Lock을 얻어 진행할 수 있게 함

public class Main {
    public static String[] itemList = {
            "MacBook", "IPhone", "AirPods", "iMac", "Mac mini"
    };
    public static AppleStore appleStore = new AppleStore();
    public static final int MAX_ITEM = 5;

    public static void main(String[] args) {

        // 가게 점원
        Runnable StoreClerk = () -> {
                while (true) {
                    int randomItem = (int) (Math.random() * MAX_ITEM);
                    appleStore.restock(itemList[randomItem]);
                    try {
                        Thread.sleep(50);
                    } catch (InterruptedException ignored) {
                    }
                }
        };

        // 고객
        Runnable Customer = () -> {
                while (true) {
                    try {
                        Thread.sleep(77);
                    } catch (InterruptedException ignored) {
                    }

                    int randomItem = (int) (Math.random() * MAX_ITEM);
                    appleStore.sale(itemList[randomItem]);
                    System.out.println(Thread.currentThread().getName() + " Purchase Item " + itemList[randomItem]);
                }
        };


        new Thread(StoreClerk, "StoreClerk").start();
        new Thread(Customer, "Customer1").start();
        new Thread(Customer, "Customer2").start();

    }
}

class AppleStore {
    private List<String> inventory = new ArrayList<>();

    public void restock(String item) {
        synchronized (this) {
            while (inventory.size() >= Main.MAX_ITEM) {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                try {
                    wait(); // 재고가 꽉 차있어서 재입고하지 않고 기다리는 중!
                    Thread.sleep(333);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            // 재입고
            inventory.add(item);
            notify(); // 재입고 되었음을 고객에게 알려주기
            System.out.println("Inventory 현황: " + inventory.toString());
        }
    }

    public synchronized void sale(String itemName) {
        while (inventory.size() == 0) {
            System.out.println(Thread.currentThread().getName() + " Waiting!");
            try {
                wait(); // 재고가 없기 때문에 고객 대기중
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        while (true) {
            // 고객이 주문한 제품이 있는지 확인
            for (int i = 0; i < inventory.size(); i++) {
                if (itemName.equals(inventory.get(i))) {
                    inventory.remove(itemName);
                    notify(); // 제품 하나 팔렸으니 재입고 하라고 알려주기
                    return; // 메서드 종료
                }
            }

            // 고객이 찾는 제품이 없을 경우
            try {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                wait();
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }
}

 

  • Lock

synchronized의 제약: 같은 메서드 내에서만 Lock을 걸 수 있음

  • ReentrantLock
    • 재진입 가능한 Lock, 가장 일반적인 배타 Lock
    • 특정 조건에서 Lock을 풀고, 나중에 다시 Lock을 얻어 임계영역으로 진입이 가능
  • ReentrantReadWriteLock
    • 읽기를 위한 Lock(공유적), 쓰기를 위한 Lock(배타적)을 따로 제공
    • 읽기 Lock이 걸려있는 상태에서 쓰기 Lock을 거는 것은 허용되지 않음 (데이터 변경 방지)
  • StampedLock
    • ReentrantReadWriteLock + 낙관적인 Lock 기능
    • 낙관적인 Lock: 데이터를 변경하기 전에 락을 걸지 않음
    • 데이터 변경을 할 때 충돌이 일어날 가능성이 적은 상황에서 사용
    • 읽기와 쓰기 작업 모두 빠르게 처리됨
    • 낙관적인 읽기 Lock은 쓰기 Lock에 의해 바로 해제 가능
    • 쓰기와 읽기가 충돌할 때만 쓰기 후 읽기 Lock을 사용

 

  • Condition

wait() & notify()의 문제점: waiting pool 내 스레드를 구분하지 못함

Condition: 특정 조건을 만족하는 스레드만 깨울 수 있으며 ReentrantLock 클래스가 함께 사용됨

awit() & signal() 을 사용

public static final int MAX_TASK = 5;

private ReentrantLock lock = new ReentrantLock();

// lock으로 condition 생성
private Condition condition1 = lock.newCondition();
private Condition condition2 = lock.newCondition();

private ArrayList<String> tasks = new ArrayList<>();

// 작업 메서드
public void addMethod(String task) {
	lock.lock(); // 임계영역 시작

	try {
		while(tasks.size() >= MAX_TASK) {
			String name = Thread.currentThread().getName();
			System.out.println(name+" is waiting.");
			try {
				condition1.await(); // wait(); condition1 쓰레드를 기다리게 합니다.
				Thread.sleep(500);
			} catch(InterruptedException e) {}	
		}

		tasks.add(task);
		condition2.signal(); // notify();  기다리고 있는 condition2를 깨워줍니다.
		System.out.println("Tasks:" + tasks.toString());
	} finally {
		lock.unlock(); // 임계영역 끝
	}
}