[백기선님과 함께하는 Live-Study] 10주차 - 멀티쓰레드 프로그래밍

JoonYoung Maeng·2021년 1월 22일
0
post-thumbnail

✔️ 목표

자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.

✔️ 학습할 것 (필수)

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • Main 쓰레드
  • 동기화
  • 데드락

💡 Thread 클래스와 Runnable 인터페이스

Thread 란?

메모리를 할당받아 프로그램(프로세스)을 실행하는 단위로서 하나의 프로세스에 여러개의 쓰레드로 구성될 수 있다.

하나의 프로세스를 구성하는 여러 쓰레드는 스택 영역을 제외한 메모리 영역을 서로 공유한다.

스택 메모리는 메소드 호출 시 전달되는 매개변수, 되돌아갈 주소값 및 메소드 내에서 선언하는 변수 등을 저장하기 위해 사용되는 메모리 공간이기 때문에 쓰레드가 스택 메모리 영역을 독립적으로 가진다는 것은 독립적으로 메소드 호출이 가능하고 독립적인 실행이 가능하다는 뜻이다.

Java에서 Thread를 생성할 수 있는 방법은 2가지가 있다.

  1. Thread 클래스를 확장하는 방법
  2. Runnable 인터페이스를 구현해 사용하는 방법

📌 Thread 클래스 확장

java.lang.Thread 클래스를 상속받아 Thread를 생성해 사용할 수 있다. Thread 클래스 내의 여러 개 메소드중 run() 메소드를 오버라이딩해서 Thread를 사용한다.

package com.livestudy.tenth;

public class LiveStudyThread extends Thread{
    @Override
    public void run() {
				//쓰레드 이름 가져오기 
        String threadName = Thread.currentThread().getName();
        System.out.println(threadName+" 시작");
        try {
						//스레드 3초간 대기
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(threadName+" 끝");
    }
}

Runner클래스를 생성해서 위에서 생성한 LiveStudyThread 객체 스레드가 3초동안 대기하는지 확인해보는 코드이다. join()은 스레드가 죽는 것을 기다리는 메소드로, 메소드 매개변수로 밀리세컨드를 넣어주면 해당 시간만큼 기다린다.

package com.livestudy.tenth;

public class ThreadRunner {
    public static void main(String[] args) {
        LiveStudyThread thread1 = new LiveStudyThread();
        thread1.setName("라이브 스터디 스레드 #1");
        thread1.start();

        int second = 0;
        while(second < 3){
            try {
								//1초동안 스레드가 죽는것을 기다림
                thread1.join(1000);
                second++;
                System.out.println("second : "+second);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

따라서, 위의 코드를 실행하면 1초마다 초를 출력해주고 3초가 지난 후 스레드가 끝나는 것을 확인할 수 있다.

image

📌 Runnable 인터페이스 구현

Thread 클래스를 상속받아 구현한 것과 달리 Runnable 인터페이스를 구현해 동일한 스레드 기능을 가진 run() 메소드를 작성했다.

package com.livestudy.tenth;

public class LiveStudyRunnable implements Runnable{
    @Override
    public void run() {
        String threadName = Thread.currentThread().getName();
        System.out.println(threadName+" 시작");
        try {
            // 3초가 대기
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(threadName+" 끝");

    }
}

Runnable로 구현한 스레드는 스레드 생성 시 Runnable 타입의 객체로 받은 후 Thread 클래스의 생성자 매개변수로 Runnable 객체를 넣어서 사용한다.

Runnable runnable = new LiveStudyRunnable();
Thread thread2 = new Thread(runnable) ;
thread2.run();
thread2.setName("라이브 스터디 러너블 스레드 #1");

int second = 0;
while(second<3){
		try {
		    thread2.join(1000);
        second++;
        System.out.println("second : "+second);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

✔️ Runnable 인터페이스 구현 VS Thread 클래스 상속

스레드들 선언하는 2가지 방식은 공통적으로 run() 메소드를 오버라이딩하고 해당 메소드 내부의 기능들은 동일한 기능을 하도록 작성할 수 있기 때문에 둘 중 하나를 이용해 선언한다면 동일한 기능을 가진 스레드를 작성할 수 있다. 하지만, Java에서는 하나의 클래스는 한 개의 클래스밖에 상속을 받을 수 없기 때문에 Thread클래스를 상속받아 사용하는 클래스는 다른 클래스를 상속받아 사용하고자 할 때 사용할 수 없다. 반면에 인터페이스는 여러 개의 인터페이스를 구현해 사용할 수 있다.

따라서, 자바에서 스레드를 사용할 때에는 일반적으로 Runnable 인터페이스를 구현해 스레드를 사용하고, 해당 클래스에서 필요한 다른 클래스를 상속받아 사용한다고 한다.


💡 쓰레드의 상태

image

출처 : https://m.blog.naver.com/PostView.nhn?blogId=qbxlvnf11&logNo=220921178603&proxyReferer=https:%2F%2Fwww.google.com%2F

스레드의 상태는 크게 4가지로 구분할 수 있다.

  1. 객체 생성 상태 : 스레드 객체가 생성되고 start() 메소드가 실행되기 이전을 말한다. getState() 메소드의 Thread.State.NEW 열거상수 상태이다.
  2. 실행 대기 상태 : 스레드 스케줄링을 통해 실행되기 위해서 대기하는 것을 말한다. getState() 메소드의 Thread.State.RUNNABLE 열거상수 상태이다.
  3. 일시 정지 상태 : 스레드의 실행이 중지되는 상태이며 WAITING, TIMED_WAITING, BLOCKED 3가지 형태로 세분화된다.
    1. WAITING : 다른 스레드가 통지할 때 까지 기다리는 상태
    2. TIMED_WAITING : 주어진 시간동안 기다리는 상태
    3. BLOCKED : 사용하고자 하는 객체의 락이 풀리길 기다리는 상태
  4. 종료 상태 : 스레드가 실행을 마친 것을 말하며, getState() 메소드의 Thread.State.TERMINATED 열거상수 상태이다.

스레드의 상태를 확인하기 위해 작성한 예제 코드이다.

package com.livestudy.tenth;

public class LiveStudyRunnable implements Runnable{
    @Override
    public void run() {
        String threadName = Thread.currentThread().getName();
        for(int i=0; i<1000000; i++){
            for(int j=0; j<1000000; j++){
                for(int k=0;k<1000000;k++){}
            }
        }
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        for(int i=0; i<1000000; i++){
            for(int j=0; j<1000000; j++){
                for(int k=0;k<1000000;k++){}
            }
        }
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

////
package com.livestudy.tenth;

public class ThreadStateTest implements Runnable{

    private Thread thread;
    public ThreadStateTest(Thread thread) {
        this.thread = thread;
    }

    @Override
    public void run() {
        while(true){
            String threadName = thread.getName();
            Thread.State state = thread.getState();
            System.out.println(threadName+" 상태 : "+state);

            if(state == Thread.State.NEW) {
                thread.start();
            }

            if(state == Thread.State.TERMINATED) {
                System.out.println(threadName+" 상태 : "+state);
                break;
            }

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

        }
    }
}

처음 스레드가 생성되어 run() 메소드를 실행하면, 스레드 객체가 생성 상태이기 때문에 thread를 시작한다.

Runnable 인터페이스를 구현한 LiveStudyRunnable 클래스에서 3중 for문을 수행하는 동안은 스레드가 RUNNABLE 상태에 있는다. 이 후 Thread.sleep() 메소드를 통해 스레드가 중지되어 있는 경우에는 TIMED_WAITING 상태이다. 마지막으로, Thread가 모든 작업을 수행하면 Thread의 상태가 TERMINATED가 되고 종료한다.

image


💡 쓰레드의 우선순위

하나의 프로세스는 여러 개의 스레드를 가질 수 있다. 따라서, 스레드의 작업을 스케줄링이 필요하다. 자바에서는 스레드 스케줄링 방법으로 우선순위 방법Round-Robin 방법을 사용한다.

스레드의 우선순위는 1-10까지 높을수록 더 우선순위가 높은 것으로 인식한다. 스레드 생성시 주어지는 Default 우선순위는 5이고, 이 우선순위를 바꿔주기 위해서 setPriority() 메소드를 이용해 우선순위를 변경해준다.

package com.livestudy.tenth;

public class ThreadRunner {
    public static void main(String[] args) {
        Thread priority = new Thread(new ThreadPriority());
        priority.setName("가장 높은 우선순위 스레드 #1");
        priority.setPriority(10);

        Thread priority2 = new Thread(new ThreadPriority());
        priority2.setName("기본 스레드 #2");

        Thread priority3 = new Thread(new ThreadPriority());
        priority3.setName("가장 낮은 우선순위 스레드 #3");
        priority3.setPriority(1);

        priority3.start();
        priority2.start();
        priority.start();
    }
}

👉🏻 실행 결과

image

💡 Main 스레드

메인 스레드는 프로그램이 시작되면 가장 먼저 시작되는 스레드이다. 다른 스레드들은 메인 스레드에서 생성되며 다른 스레드가 생성되지 않고 메인 스레드만 실행한다면 싱글 스레드로 메인 스레드의 작업이 종료되는 순간 프로세스도 종료된다. 반대로, 메인 스레드가 여러 개의 스레드를 실행한다면 멀티 스레드로 메인 스레드가 종료되어도 다른 스레드들이 종료되기 전까지 프로세스가 종료되지 않는다.

메인 스레드는 기본적으로 우선순위 5를 가진다.

메인 스레드와 메인 스레드로부터 생성된 스레드 코드이다. 해당 코드를 실행하면 메인 스레드가 실행 되고 다른 스레드가 종료되어야 메인 스레드가 종료 될 것 같지만, 메인 스레드가 종료되고 다른 스레드가 종료되는 것을 확인할 수 있다. 이는, 메인 스레드가 종료되어도 다른 스레드가 종료되기 전까지 프로세스가 종료되지 않는 것과 스레드는 순서에 상관없이 동시에 실행됨을 확인할 수 있다.

package com.livestudy.tenth;

public class MainThreadTest {
    public static void main(String[] args) {
        Thread mainThread = Thread.currentThread();
        LiveStudyThread thread = new LiveStudyThread();
        thread.setName("라이브스터디 스레드");
        thread.start();

        System.out.println("메인 스레드 우선순위 : "+mainThread.getPriority());
        System.out.println("메인스레드 종료");

    }
}

👉🏻 실행 결과

image

다른 스레드가 종료되고 나서 메인 스레드가 종료되도록 하기 위해선 다른 스레드가 종료될 때까지 대기하는 메소드인 join() 메소드를 사용하면된다.

package com.livestudy.tenth;

public class MainThreadTest {
    public static void main(String[] args) {
        Thread mainThread = Thread.currentThread();
        LiveStudyThread thread = new LiveStudyThread();
        thread.setName("라이브스터디 스레드");
        thread.start();
        try {
            thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("메인 스레드 우선순위 : "+mainThread.getPriority());
        System.out.println("메인스레드 종료");

    }
}

👉🏻 실행 결과

image

📌 데몬 스레드

데몬 스레드는 메인 스레드의 작업을 보조하는 스레드로 메인 스레드가 종료되면 강제로 종료된다.

아래 코드의 경우 daemon 스레드 객체가 while(true)조건으로 인해 무한루프일 것 같지만 setDaemon(true)를 지정했기 때문에 메인 스레드가 종료되었을 때 강제로 종료되어 무한루프를 돌지 않고 종료되는 것을 확인할 수 있다.

package com.livestudy.tenth;

public class DaemonThreadTest {
    public static void main(String[] args) {
        Thread daemon = new Thread(() -> {
           while(true){
               System.out.println("데몬 스레드 실행 ");
               try {
                   Thread.sleep(30);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        daemon.setDaemon(true);
        daemon.start();

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("메인 스레드 종료");
    }
}

👉🏻 실행 결과

image


💡 동기화(Synchroize)

프로세스가 싱글 스레드로 작업하는 경우보다는 멀티 스레드로 작업하는 경우가 훨씬 많기 때문에 스레드들이 객체를 공유하는 경우에 문제가 생길 수가 있다.

예를 들어, A와 B 스레드가 C라는 객체를 공유하고 있을 때, A가 이미 C객체를 이용하고 있다고 가정하자. 이때, B 스레드도 C라는 객체가 필요해 접근하게 된다면, A와 B 스레드의 작업에 서로 영향을 끼칠 수 있다. 이를 방지하기 위해서 A 스레드가 C객체를 사용하고 있다면 해당 객체에 임계 영역(Critical Section)을 지정해 Lock을 걸어 B 스레드가 접근하지 못하도록 막게하는 것을 동기화라고 한다.

자바에서는 임계 영역을 지정하는 키워드로 synchronized를 사용하며 메소드에 synchronized 키워드를 사용하거나 synchronized 블록을 지정하는 방법이 있다.

1. Synchronized 메소드

아래와 같이 Bank라는 클래스가 있다고 하자.

package com.livestudy.tenth;

public class Bank {
    private int balance;

    public Bank(int balance) {
        this.balance = balance;
    }

    public void deposit(int money) {
        balance += money;
        System.out.println(money+"원 입금 완료");
        System.out.println("잔고 : "+balance);
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public void withdraw(int money) {
        balance -= money;
        System.out.println(money+"원 출금 완료");
        System.out.println("잔고 : "+balance);
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

입금을 하는 스레드(person1)와 출금을 하는 스레드(person2)가 실행된다면 동시에 Bank라는 객체를 접근하기 때문에 잔고가 5000원 4000원으로 각각 출력되지않고 잔고가 둘 다 4000원으로 나오는 경우가 발생한다.

package com.livestudy.tenth;

public class ATM {
    public static void main(String[] args) {
        Bank bank = new Bank(0);
        Thread person1 = new Thread(() -> {
            bank.deposit(5000);
        });

        Thread person2 = new Thread(() ->{
           bank.withdraw(1000);
        });

        person1.start();
        person2.start();;
    }

}

👉🏻 실행 결과

image

따라서 이러한 경우에 입금 작업을 하는 스레드가 Bank객체를 이용하고 있다면 출금 작업이 일어나지 않도록 해줘야한다. 따라서, 입금과 출금 메소드에 synchronized를 설정하면 스레드가 해당 메소드의 작업을 실행하고 있을 때 다른 스레드가 작업에 접근하지 못하기 때문에 문제를 해결할 수 있다.

public synchronized void deposit(int money) {
        balance += money;
        System.out.println(money+"원 입금 완료");
        System.out.println("잔고 : "+balance);
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public synchronized void withdraw(int money) {
        balance -= money;
        System.out.println(money+"원 출금 완료");
        System.out.println("잔고 : "+balance);
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

👉🏻 실행 결과

image

2. Synchronized 블록

동일한 작업을 하는 메소드를 synchronized 블록을 통해 표현하는 것도 가능하다.

package com.livestudy.tenth;

public class Bank {
    private int balance;

    public Bank(int balance) {
        this.balance = balance;
    }

    public  void deposit(int money) {
				//synchronized 블록
        synchronized (this) {
            balance += money;
            System.out.println(money + "원 입금 완료");
            System.out.println("잔고 : " + balance);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public void withdraw(int money) {
        synchronized (this) {
            balance -= money;
            System.out.println(money + "원 출금 완료");
            System.out.println("잔고 : " + balance);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

💡 데드락

데드락은 두 개 이상의 스레드가 Lock을 획득하기 위해 대기하는데, 해당 Lock을 점유하고 있는 스레드도 다른 Lock을 획득하기 위해 대기하는 경우 두 개의 스레드는 무한히 대기하는 현상이 발생하는데 이를 데드락이라 한다.

즉, A라는 스레드가 a를 점유하고 b를 대기하고 있고, B라는 스레드가 b를 점유하고 a를 대기하고 있다면, A와 B 스레드는 각각 b와 a를 획득하기 위해 무한히 대기하는 상황이 발생한다.

✏️ 데드락의 발생 조건

  • 상호 배제(Mutal Exclusion) : 자원은 한 번에 한 프로세스만 사용할 수 있어야 한다.
  • 점유 대기(Hold and Wait) : 최소 한 개의 자원을 점유하면서 다른 프로세스가 점유하고 있는 자원을 추가 점유하기 위해 대기하는 프로세스가 있어야 한다.
  • 비선점(No Preemption) : 다른 프로세스에 할당된 자원을 강제로 선점할 수 없어야 한다.
  • 순환 대기(Circular Wait) : 각 프로세스가 순환적으로 다음 프로세스의 자원을 가져야 한다.

위 네개의 조건이 모두 성립할 시에만 데드락이 발생한다.

✏️ 데드락 해결 방법

  1. 예방 (Prevention) : 데드락은 4개의 조건이 모두 충족하는 경우 발생하기 때문에 그 중 한개라도 해결하면 데드락이 해결된다.
  2. 회피 (Avoidance) : 데드락이 발생하지 않도록 알고리즘을 적용해 해결하는 방법이다. 대표적인 알고리즘으로는 은행원 알고리즘, 자원할당 그래프 알고리즘이 있다.
  3. 회복 (Recovery) : 데드락이 발생하면 해결하는 방법
  4. 무시 (Ignore) : 데드락을 해결할 때도 Context Switching이 발생하기 때문에 데드락으로 인한 성능 저하보다 이를 해결하는데 성능 저하가 심한 경우 무시하는 방법

📃 Reference

Thread,Runnable : https://www.daleseo.com/java-thread-runnable/

Thread 상태 : https://m.blog.naver.com/PostView.nhn?blogId=qbxlvnf11&logNo=220921178603&proxyReferer=https:%2F%2Fwww.google.com%2F

Thread 우선순위 : https://deftkang.tistory.com/56

메인 스레드 : https://honbabzone.com/java/java-thread/#step-6--스레드-그룹

synchronized : https://tourspace.tistory.com/54

데드락 : https://includestdio.tistory.com/12

profile
백엔드 개발자 지망생입니다!

0개의 댓글