Study/java

[백기선님의 자바 라이브 스터디] 10주차 - 멀티쓰레드 프로그래밍

에디개발자 2021. 3. 2. 07:00
반응형

www.youtube.com/watch?v=HLnMuEZpDwU 

나를 닮았다고 한다...

목표

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

학습할 것 (필수)

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

 

추천도서

www.yes24.com/Product/Goods/3015162

 

자바 병렬 프로그래밍

스레드는 자바 플랫폼에서 가장 기본적으로 제공되는 기능 중 하나다. 멀티코어 프로세서가 대중화되면서 고성능 애플리케이션을 작성할 때 병렬 처리 능력을 효과적으로 활용하는 일의 중요

www.yes24.com

 

Process

  • 의미 그대로 Process입니다. 프로그램을 실행하여 실행 중인 것
  • 예를 들어 OS에서 크롬을 킨다던가, intellij를 실행한다던가 엑셀을 실행하는 것 모든 것
  • OS에서 Resource를 할당받아 구동되는 것

Thread

  • Prcess에서 무엇인가 실행을 하려면 Memory, Resource를 할당받아 Thread를 통해 실행합니다.
  • Process는 최소 1개 이상의 Thread로 구성되어있습니다. 
@SpringBootApplication
public class PracticeApplication {

	public static void main(String[] args) {
		SpringApplication.run(PracticeApplication.class, args);
	}
    
}

우리가 가장 흔흐 보는 코드입니다. Project를 생성하면 IDE가 기본적으로 생성해주는 main 메서드입니다. 이 메서드를 구동시키면 tomcat을 통해 서비스가 실행됩니다. 이 떄 main 메서드를 실행하면 main Thread가 생기게 됩니다. 우리는 이 Thread안에서 여러가지 Action을 취하고 다른 Thread를 생성하면 이것을 Multi Thread라고 합니다.

 

Thread 클래스와 Runnable 인터페이스

두 가지의 공통점은 Thread를 사용하는 것입니다. 차이점은 구현하는 방법에 차이가 있습니다. 

자세히 살펴보겠습니다.

Runnable 인터페이스

public class RunnablePractice implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
        System.out.println("Runnable.");
    }
}

구현방법

Runnable 인터페이스를 implements하여 사용할 수 있습니다. 위 코드에서 작성한 run 메서드는 Runnable 인터페이스내에 추상화 메서드입니다. 

 

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

Runnable 인터페이스는 단순하게 run() 추상화 메서드 하나만 가지고 함수형 인터페이스입니다.

 

실행방법

public class Practice {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        runRunnable();
    }

    private static void runRunnable() {
        Thread thread = new Thread(new RunnablePractice());
        thread.start();
    }
}

위처럼 Thread 클래스 생성자에 Runnable 인터페이스를 파라미터로 넘기고 start 메서드를 통해 실행합니다. 결과는 아래와 같습니다.

main
Thread-0
Runnable.

 

Thread 클래스

구현방법

구현하는 방법은 Thread 클래스를 상속받아 Thread를 실행시키는 Method인 run 메서드를 오버라이드합니다. 그리고 run 메서드 블록내에 로직을 구현하는 방법입니다.

public class ThreadPractice extends Thread {

    @Override    // Thread 클래스 함수
    public void run() {
        System.out.println(Thread.currentThread().getName());
        System.out.println("Thread");
    }
}

실행방법

public class Practice {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        runThread();
    }
    
    private static void runThread() {
        ThreadPractice threadPractice = new ThreadPractice();
        threadPractice.start();
    }
}

위 처럼 Thread를 생성하여 start를 시켜주면 실행이 됩니다. 결과는 아래와 같습니다.

main
Thread-0
Thread

 

public
class Thread implements Runnable {
    // ...
    
    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }
    
    // ...
}

Thread 클래스를 살펴보면 Runnable 인터페이스를 implements 하는것을 알 수 있습니다. 그리하여 run 메서드를 오버라이드하여 사용하고 있습니다. Thread 클래스를 상속받아 사용하려면 run 메서드를 다시 오버라이드하여 재정의할 수 있습니다.

 

Thread 클래스와 Runnable 인터페이스 사용 용도

Runnable 인터페이스

  • 쓰레드에서 run메서드만 사용하고 싶은 경우 사용
  • 다른 클래스를 상속받아야 하는 경우 사용

Thread 클래스

  • Thread 클래스는 Runnable 인터페이스를 Implements하고 다른 메서드도 존재합니다. run 메서드말고 다른 메서드를 사용하고 싶을 때 사용합니다.
  • 이미 Thread 클래스를 상속받아 사용하기 때문에 다른 클래스를 상속받아서 사용하고 싶은 경우는 사용이 불가능합니다. ( 자바는 다중 상속이 불가 )

쓰레드의 상태

객체 생성 NEW 쓰레드 객체가 생성되고 start 메서드가 호출 되지 않은 상태
실행 대기 RUNNABLE 실행 상태
일시 정지 WATING 쓰레드가 대기 상태
  TIMED_WAITING 시간동안 대기 상태
  BLOCKED 락이 풀릴때까지 대기
종료 TERMINATED 쓰레드 종료

 

예제 코드로 알아보자!

 

먼저 SimpleThread 클래스가 있습니다. 이 클래스는 Thread 클래스를 상속받고 run() 메서드에 아래와 같이 구현되어 있습니다.

public class SimpleThread extends Thread {

    @Override
    public void run() {
        System.out.println("state : " + this.getState());    // 3)
        try {
            Thread.sleep(500L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("state : " + this.getState());    // 4)
    }
}

 

다음으로 SimpleThread 클래스를 생성자에서 주입받아 SimpleThread를 start합니다.

public class ThreadPractice extends Thread {

    private Thread simpleThread;

    public ThreadPractice(Thread simpleThread) {
        this.simpleThread = simpleThread;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
        System.out.println("Thread");

        while (true) {
            State state = simpleThread.getState();
            System.out.println("state :: " + state);    // 1), 5)

            if (State.NEW.equals(state)) {
                simpleThread.start();       // 2)
            }

            if (State.TERMINATED.equals(state)) {
                break;    // 6)
            }

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

 

그리고 Thread 클래스를 테스트할 클래스입니다.

public class Practice {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        runThread();
    }

    private static void runThread() {
        SimpleThread simpleThread = new SimpleThread();

        ThreadPractice threadPractice = new ThreadPractice(simpleThread);
        threadPractice.start();
    }
}

 

main 메서드를 실행하면 아래와 같은 결과를 얻을 수 있습니다.

main
Thread-1
Thread
state :: NEW
state : RUNNABLE
state : RUNNABLE
state :: RUNNABLE
state :: TERMINATED

 

이렇게 출력하게 된 이유는 위 소스에 주석을 달아놓았습니다. 

  1. main 메서드에서 ThreadPractice 클래스를 실행
  2. ThreadPractice 클래스에서 SimpleThread 클래스에 run 메서드 실행
  3. SimpleThread 클래스에 run 메서드 실행하고 종료
  4. ThreadPractice 클래스에 run 메서드 종료
  5. main 메서드 종료

쓰레드의 우선순위

다른 설정을 하지 않은 쓰레드의 우선 순위는 실행 상태를 많이 가지고 있는 쓰레드가 우선 순위로 선정됩니다. 간단한 코드를 작성 후 결과값으로 살펴보겠습니다.

 

public class OrderThread extends Thread {

    private String order;

    public OrderThread(String order) {
        this.order = order;
    }

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(order);
            try {
                Thread.sleep(500L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

OrderThread는 Thread 클래스를 상속받고 loop를 돌면서 print하는 run() 메서드를 구현했습니다.

 

public class Practice {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
         checkThreadOrder();
    }

    private static void checkThreadOrder() {
        OrderThread orderThread1 = new OrderThread("1");
        OrderThread orderThread2 = new OrderThread("2");
        OrderThread orderThread3 = new OrderThread("3");
        OrderThread orderThread4 = new OrderThread("4");

        orderThread1.start();
        orderThread2.start();
        orderThread3.start();
        orderThread4.start();
    }
}

main 메서드에서 OrderThread 클래스를 다수 생성하고 실행합니다. 결과는 아래와 같이 뒤죽박죽 나오게 됩니다.

main
1
2
3
4
1
2
4
3
2
4
3
1
2
3
1
4
3
2
1
4

 

여기서 priority 설정값을 사용하여 쓰레드의 우선순위를 부여할 수 있습니다. 설정 값이 높을 수록 우선순위가 높아집니다.

Thread 클래스 내에 상수값은 아래와 같습니다.

public final static int MIN_PRIORITY = 1;

public final static int NORM_PRIORITY = 5;

public final static int MAX_PRIORITY = 10;

 

그리고 main 메서드의 checkThreadOrder 메서드 블록 내 로직을 수정하고 다시 실행해보겠습니다.

public class Practice {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
         checkThreadOrder();
    }

    private static void checkThreadOrder() {
        List<OrderThread> orderThreadList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            OrderThread orderThread = new OrderThread(String.valueOf(i));
            if (i <= 10) {
                orderThread.setPriority(Thread.MIN_PRIORITY);
            } else {
                orderThread.setPriority(Thread.MAX_PRIORITY);
            }

            orderThreadList.add(orderThread);
        }

        for (OrderThread orderThread : orderThreadList) {
            orderThread.start();
        }
    }
}
0
2
6
1
5
4
8
7
3
11
12
13
15
16
19
9
10
18
17
14

결과값이 내가 예상하는 값과 다르게 나왔습니다. 이유는 우선 순위는 절대적이 아니라는 것을 알 수 있습니다.

 

Main 쓰레드

main 쓰레드는 개발자라면 정말 많이 보았던 그 쓰레드가 맞습니다.

public class PracticeApplication {
	public static void main(String[] args) {
		SpringApplication.run(PracticeApplication.class, args);
	}
}

 

어플리케이션을 톰캣으로 실행하면 main 메서드가 실행되는 것입니다. 그렇다는 것은 애플리케이션은 main 메서드 thread 안에서 모든 동작을 실행하는 것입니다. 여기서 새로운 Thread를 생성해서 사용하면 이것이 데몬 쓰레드가 되는 것입니다.

 

데몬 쓰레드

일반적인 쓰레드는 모든 작업이 마무리되고 작동이 끝나면 쓰레드가 종료됩니다. 하지만 데몬 쓰레드는 작업이 마무리 되지 않았음에도 데몬 쓰레드가 돌고 있는 쓰레드가 종료되면 강제로 종료되는 것을 말합니다. 

 

단편적인 예로 크롬을 켜놓고 유투브로 음악을 듣고 있다고 가정합니다. 그럼 크롬이 main 쓰레드가 되고 유투브가 데몬 쓰레드가 됩니다. 크롬은 사용자가 종료하기 전까지 자동으로 종료되지 않지만 유투브는 크롬을 종료하면 종료되는 것을 알 수 있습니다. 이처럼 내가 종료하지 않았음에도 종료되는 쓰레드를 데몬 쓰레드라고 합니다.

 

동기화

일반적으로 Single Thread 환경에서는 메서드를 호출하면 내가 예상하는 리턴값을 얻을 수 있습니다. 하지만 MultiThread라면 일반적인 메서드라면 예상하는 리턴값이 아닌 다른 값이 나올 수도 있습니다.

 

먼저 Simgle Thread일 경우 예제코드를 살펴보겠습니다

public class Bag {

    private Integer itemCount;

    public Bag (Integer itemCount) {
        this.itemCount = itemCount;
    }

    public void pullItem() {
        if (itemCount > 0) {
            itemCount--;
        }
    }

    public void pushItem() {
        itemCount++;
    }

    public int getItemCount() {
        return itemCount;
    }
}
public class Test {
    public static void main(String[] args) {
        notMultiThread();
    }

    private static void notMultiThread() {
        Bag bag = new Bag(100);

        for (int i = 0; i < 4000; i++) {
            bag.pushItem();
        }

        System.out.println(bag.getItemCount());

        for (int i = 0; i < 4000; i++) {
             bag.pushItem();
        }

        System.out.println(bag.getItemCount());
    }
}    

위 코드의 결과값은 아래와 같이 예상한 대로 나옵니다.

4100
8100

 

다음으론 멀티 쓰레드환경에서 살펴보겠습니다.

public class BagThread implements Runnable {

    private final Bag bag;

    public BagThread(Bag bag) {
        this.bag = bag;
    }

    @Override
    public void run() {
        for (int i = 0; i < 4000; i++) {
            bag.pushItem();
        }

        System.out.println(bag.getItemCount());
    }
}
public class Test {
    public static void main(String[] args) {
        notMultiThread();
//        multiThread();
    }

    private static void multiThread() {
        Bag bag = new Bag(100);
        Thread thread1 = new Thread(new BagThread(bag));
        Thread thread2 = new Thread(new BagThread(bag));

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

내용은 똑같습니다. 동일하게 4000번씩 pushItem을 실행합니다. 하지만 결과값은 완전히 다르게 나타납니다.

5061
5061

 

왜 이렇게 된 걸까??

이유는 Single 쓰레드는 첫 번쨰 loop에서 Bag 클래스에 접근하여 count를 순차적 4000번 증가시키고 다음으로 똑같은 액션을 취하므로 예상된 값이 나타납니다. 하지만 멀티쓰레드 환경에서는 순차적이 아니고 동시다발적으로 접근하여 마구 증가시킵니다. 그래서 결과값이 완전히 다르게 나옵니다.

 

그럼 내가 원하는 값을 얻으려면 어떻게 해야하는가??

간단합니다. 메서드에 synchronized 를 작성합니다. synchronized는여러쓰레드에서 동시에 접근하는 것을 막습니다. 한개의 쓰레드에서 접근 중이면 락을 걸어 다른 쓰레드의 접근을 막습니다. 

package com.example.practice.multis;

public class Bag {

    private Integer itemCount;

    public Bag (Integer itemCount) {
        this.itemCount = itemCount;
    }

    public synchronized void pullItem() {  // synchronized 추가
        if (itemCount > 0) {
            itemCount--;
        }
    }

    public synchronized void pushItem() {
        itemCount++;
    }

    public int getItemCount() {
        return itemCount;
    }
}

 

데드락

데드락은 멀티 쓰레드를 사용할 때 lock을 획득하기 위해 대기하는 데 대기를 무한정하는 것을 말합니다. 

package com.example.practice.multis;

public class DeadLockPractice {
    private static Object threadOneLockObject = new Object();
    private static Object threadTwoLockObject = new Object();

    public static void main(String[] args) {
        ThreadOne thread1 = new ThreadOne();
        ThreadTwo thread2 = new ThreadTwo();

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

    static class ThreadOne extends Thread {

        @Override
        public void run() {
            synchronized (threadOneLockObject) {
                try {
                    System.out.println("ThreadOne synchronized start.");
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                System.out.println("ThreadOne waiting");
                synchronized (threadTwoLockObject) {
                    System.out.println("ThreadOne synchronized start.");
                }
            }
        }
    }

    static class ThreadTwo extends Thread {

        @Override
        public void run() {
            synchronized (threadTwoLockObject) {
                try {
                    System.out.println("ThreadOne synchronized start.");
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                System.out.println("ThreadOne waiting");
                synchronized (threadOneLockObject) {
                    System.out.println("ThreadOne synchronized start.");
                }
            }
        }
    }
}

위 코드를 살펴보면 2개의 Thread에서 서로가 사용하는 파라미터를 사용하기 위해 무한정 대기하는 예제입니다. 결과값은 아래를 참조해주세요.

 

ThreadOne synchronized start.
ThreadOne synchronized start.
ThreadOne waiting
ThreadOne waiting

이 상태로 Thread가 끝나지 못하고 무한정 기다리고 있는 상태입니다.

반응형