공부/whiteship-java

10주차: 멀티쓰레드 프로그래밍

chulphan 2021. 1. 22. 12:01

백기선님께서 주최하시는 자바 기초 스터디

 

github.com/whiteship/live-study

 

whiteship/live-study

온라인 스터디. Contribute to whiteship/live-study development by creating an account on GitHub.

github.com

 

이번주는 멀티쓰레드 프로그래밍에 관한 주제이다.

 

 

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

Java 에서 쓰레드를 만드는 방법은 Thread 클래스를 상속 받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다

 

먼저 Thread 클래스를 상속 받아 만드는 방법부터 살펴보자

♣ Thread 클래스

Thread 클래스는 java.lang 패키지에 속해 있는 클래스이다

 

Thread 는 프로그램 내에 실행 쓰레드로 JVM 은 애플리케이션이 다수의 실행 쓰레드를 가지고 병렬적으로 실행하는 것을 허용한다

 

뒤에서 알아볼 것이지만 모든 쓰레드는 우선 순위를 가진다. 높은 우선 순위를 가지는 쓰레드가 낮은 우선 순위를 가지는 쓰레드 보다 우선적으로 실행된다

 

각 쓰레드는 daemon 처럼 mark 되거나 mark 되지 않을 수도 있다

 

JVM이 실행을 시작할 때, main 이라 불리는 non-daemon 쓰레드가 존재하며 다음의 상황 중 하나가 발생할 때 까지 쓰레드의 실행을 계속한다

 

  • Runtime 클래스의 exit 메소드가 호출되고 security manager 가 exit 연산을 허락한다
  • daemon 이 아닌 모든 쓰레드가 죽고 run 메소드 호출로 부터 반환 되거나 (즉, run 메소드가 끝나거나) run 메소드 이후로 전파되는 예외를 던지거나

 

Thread 클래스를 상속 받아서 쓰레드를 생성하는 방법은 다음과 같다

  • Thread 를 상속 받는 클래스를 만든다
  • 위에서 만든 클래스에 run 메소드를 오버라이드 한다
  • 해당 클래스를 객체화 하여 변수에 할당하고 start 메소드를 호출한다

메인 쓰레드가 실행되고 그 child thread 로써 우리가 만든 쓰레드가 실행 됐음을 볼 수 있다

 

 

♣ Runnable 인터페이스

Thread 클래스와 마찬가지로 java.lang 패키지에 속해 있다

 

이 인터페이스를 통해 구현된 클래스의 인스턴스는 쓰레드에 의해서 실행될 수 있음을 의미한다

 

Runnable 인터페이스를 사용해서 새로운 쓰레드를 만드는 방법은 다음과 같다

  • Runnable 인터페이스의 구현체를 만들고 run 메소드를 오버라이드 하여 구현한다
  • 위에서 만든 클래스를 객체화 하고 Thread 클래스 생성자의 인자로 전달한다 (참고로, Thread 클래스 생성자 중에는 Runnable 인스턴스를 수용할 수 있는 생성자가 존재한다)
  • Thread 인스턴스의 start() 메소드를 실행하면 내부적으로 구현체의 run() 메소드를 호출하기 시작한다. start() 를 실행하는 것은 run() 메소드 내부에 쓰여진 코드를 실행하는 새로운 쓰레드를 생성하는 것이다

※ run() 메소드를 직접 실행하는 것은 새로운 쓰레드를 실행하는 것이 아니라, 현재 쓰레드에서 run() 메소드를 호출하는 것이다. 만약에 Main 쓰레드에서 run() 메소드를 실행하면 Main 쓰레드에서 해당 메소드의 내용이 실행된다

 

위의 코드를 실행하면 main 메소드를 제외한 각기 다른 10개의 쓰레드가 실행되는 것을 볼 수 있다

 

※ Runnable 이 예외를 만나면??

Runnable 인터페이스 자체에서는 Checked Exception 을 던져주지 않는다. 하지만 run() 메소드에서 RuntimeException 에러를 던져서 감지할 수 있다. 감지가 되지 않는 예외는 exception 핸들러에 의해서 처리할 수 있다. 만약에 JVM이 예외를 감지하지 못하거나 처리할 수 없으면 stack trace 를 출력하고 실행을 중단한다.

 

그리고 쓰레드 예외처리에 대한 좋은 글이 있어서 공유한다

hochulshin.com/java-multithreading-thread-exception-handling/

 

Java - (멀티쓰레딩 8) 쓰레드 Exception 처리하기

Content Similar Posts Comments

hochulshin.com

 

♣ Thread 클래스?? Runnable 인터페이스??

Thread 클래스를 사용하나 Runnable 인터페이스를 사용하나 둘 다 쓰레드를 생성할 수 있는데, 둘 중에 뭘 사용해야할까??

이거는 각자 코드를 구현하는 방법에 따라 다른 것 같다

 

Java 에서 클래스는 다중상속이 되지 않으므로 Thread 클래스를 상속 받아 사용하면 우리의 요구사항에 맞는 클래스를 작성하게 되지 못할수도 있다

 

그리고 Thread 클래스를 상속 받았지만 해당 클래스의 메소드를 사용하지 않는다면 낭비가 될 수도 있을 것이다

 

반면에 Runnable 인터페이스는 해당 구현체가 다른 유용한 혹은 우리 요구사항에 맞는 클래스를 상속 받아서 사용할 수 있고, run() 메소드만 구현하여 사용할 수 있으므로 Runnable 인터페이스를 사용해서 구현체 클래스를 구현하는게 좋을 것 같다 

▶ 쓰레드의 상태

쓰레드는 상황에 따라서 반드시 다음 중 하나의 상태를 가진다

  • New
  • Runnable
  • Blocked
  • Waiting
  • TimeWaiting
  • Terminated

 

위의 6가지 상태는 쓰레드의 생명주기와 관련 되어 있다

 

쓰레드의 생명주기를 알아보며 위의 상태들이 가지는 의미가 무엇인지 살펴보자

 

♣ 쓰레드의 생명주기(Lifecycle of thread)

  1. New Thread
    새로운 쓰레드가 만들어지면 그 쓰레드는 'new' 라는 상태를 갖는다. new 상태를 갖는 쓰레드는 아직 실행이 시작되지 않았음을 의미한다

  2. Runnable State
    쓰레드가 Runnable 상태로 변경되면 실행할 준비가 됐음을 뜻한다. 이 상태를 가진 쓰레드는 실제로 실행이 되고 있거나 객체화 시점에 실행할 준비가 됐음을 뜻한다

  3. Blocked/Waiting State

    이 상태는 쓰레드가 일시적으로 비활성화 된 상태를 의미한다

    쓰레드는 다른 쓰레드에 의해서 현재 잠긴(locked) 섹션에 접근하려고 시도할 때는 blocked state 를 가진다
    접근을 시도 했던 섹션에 잠김이 해제되면 스케줄러는 해당 섹션에 접근을 시도하는 blocked 된 쓰레드 중 하나를 택해서 Runnable state 로 옮긴다

    쓰레드가 Waiting 상태를 가진다는 것은 어떠한 조건 때문에 다른 쓰레드를 기다리고 있는 것을 뜻한다. 이러한 조건이 fulfilled(성공/실패) 되면 스케줄러는 이를 알아 차리고 이 상태에 있는 쓰레드를 Runnable state 로 옮긴다
  4. Timed Waiting

    쓰레드가 이 상태에 놓여있다는 것은 timeout 매개변수와 함께 메소드를 호출했다는 것을 의미한다 
    (Thread.sleep(), Object.wait() 또는 Thread.join() 메소드를 timeout 값과 같이 호출했을 때)
    timeout이 만료되거나 어떤 알림(notify) 를 받을때까지 이 상태에 놓인다 
  5. Terminated State

    쓰레드는 다음 두 가지 중 하나의 이유로 종료된다

    o 일반적인 이유 => 프로그램에 의해서 쓰레드의 모든 코드가 실행 완료 됐을 때
    o 비정상적인 이유 => segmentation fault 또는 unhandled exception 등에 의해서

    이 상태에 놓은 쓰레드는 더 이상 CPU의 사이클(cycle)을 점유하지 않는다

 

▶ 쓰레드의 우선순위

멀티 쓰레드 환경에서는 쓰레드 스케줄러가 프로세서에게 쓰레드의 우선순위에 기반하여 쓰레드를 할당한다

Java 내에서는 쓰레드를 생성할 때 마다 항상 우선순위를 쓰레드에 할당한다

 

우선순위는 쓰레드를 생성하는 동안 JVM에 의해 주어지거나 프로그래머에 의해서 명시적으로 주어질 수 있다

Java 에서 우선순위로 주어질 수 있는 값은 정수이며 1부터 10까지의 값을 가질 수 있다

 

Thread 클래스에는 우선순위를 지정해주기 위한 3개의 static 변수가 정의 되어 있다

  • public static int MIN_PRIORITY: 최소 우선순위. 1을 가진다
  • public static int NORM_PRIORITY: 기본 우선순위. 5를 가진다
  • public static int MAX_PRIORITY: 최대 우선순위. 10을 가진다

getPriority() 메소드를 통해서 쓰레드에 주어진 우선순위 값을 확인할 수 있다

setPriority(int newPriority) 메소드를 통해서 쓰레드의 우선순위를 변경할 수 있다

 

만약에 1~10 이외의 값을 setPriority 인자로 전달할 경우 IllegalArgumentException 예외를 던진다

 

참고

  • 가장 높은 우선순위를 가지는 쓰레드는 다른 쓰레드들 보다 우선적으로 실행된다
  • Main 쓰레드의 우선순위 값은 5이다. 이후에 바꿀 수 있다
  • 쓰레드들의 기본 우선순위는 부모 쓰레드의 우선순위에 의존한다
  • 만약 두 개의 쓰레드가 같은 우선순위를 갖는다면 어떤 쓰레드가 먼저 실행될 지 예측할 수 없다
    이건 쓰레드 스케줄러의 알고리즘에 의존한다(Round-Robin, First Come First Serve 등)

 

▶ Main 쓰레드

Java 프로그램이 실행 될 때 하나의 쓰레드가 즉시 실행된다. 일반적으로 이 쓰레드를 프로그램의 main 쓰레드라고 불린다

main 쓰레드는 프로그램 실행의 진입점이다

 

JVM은 자동적으로 main 쓰레드를 생성하지만 우리가 직접 이 쓰레드를 확인할 수 있고 제어할 수 있다

main 쓰레드를 제어하기 위해서는 이에 대한 참조가 필요한데, Thread 클래스에 정의된 currentThread() 메소드를 호출하면 된다

즉, main 쓰레드 내부에서 currentThread() 를 호출하면 main 쓰레드에 대한 참조를 얻을 수 있다

 

※ 메인 쓰레드의 중요한 점

  • JVM 에 의해서 자동으로 만들어진다
    JVM 은 프로그램을 실행할 때 자동으로 main 쓰레드를 생성한다 그래서 개발자가 어떠한 쓰레드를 만들든 말든 프로그램 내에는 main 쓰레드가 존재한다

    JVM 이 실행이 시작되면 main 쓰레드를 위한 스택을 생성하고 데이터를 특정한 스택 내부에 저장한다

    모든 메소드 호출은 스택 내부에 저장 되며 각 스택 내부의 공간을 stack frame 이라 부른다
    stack 은 여러 stack frame 을 가질 수 있는데 stack 크기에 의존한다. 만약에 쓰레드가 허용된 stack 크기 이상의 아이템을 저장하려고 시도하면 stack overflow 에러를 던진다(throw)

  • 다른 쓰레드들을 제공한다
    main 쓰레드는 다른 하위 쓰레드들이 생성될 쓰레드이다. 왜냐하면 이미 JVM 에 의해서 main 쓰레드가 생성됐기 때문에 우리가 다른 쓰레드를 생성할 때 마다 main 쓰레드의 하위 쓰레드로 여겨진다.

    main 쓰레드의 기본 우선 순위는 5 이므로 main 쓰레드로 부터 생성된 모든 하위 쓰레드의 기본 우선 순위도 5이다. (기본적으로 부모 쓰레드의 우선 순위를 물려 받기 때문에)

▶ 동기화

멀티 쓰레드 프로그래밍에서는 다수의 쓰레드가 같은 자원에 접근하는 것을 시도해서 오류나 예상하지 못한 결과를 얻게되는 상황을 자주 마주하게 된다

 

먼저 아래와 같은 코드를 실행하면 실행 결과가 어떻게 될까??

Runnable 인터페이스 구현체는 같은 Counter 객체의 인스턴스 필드인 count 를 하나씩 증가시키는 작업을

두 개의 쓰레드를 통해서 실행하고 있다.

 

결과는 당연히 10000 이 찍힐 것이라고 생각하겠지만, 그렇지 않다

심지어 코드를 실행할 때 마다 결과도 계속 변하게 된다.

 

이런 일이 일어나는 이유는 두 개 이상의 쓰레드에서 공유하는 객체의 내용을 변경할 때, 쓰레드 간의 간섭과 메모리 일관성 오류 등이 발생하기 때문이다

 

Java 에서는 이런 현상을 방지하기 위해서 synchronized 키워드를 제공함으로써 한 번에 하나의 쓰레드에서만 자원에 접근하도록 할 수 있다

 

synchronized 키워드는 다음과 같은 기능을 한다

  • 공유된 자원에 상호 배타적으로 접근하는 것을 보장하고 데이터에 대한 경쟁을 방지하는 '잠금' 을 제공한다
  • 컴파일러에 의해서 감지하기 힘든 동시성 이슈를 유발하는 코드 재배열을 방지한다
  • locking 과 unlocking 을 포함하는 의미이다. synchronized 메소드 또는 블록에 진입하기 전에 쓰레드는 이 블록에 대한 lock을 습득하는 것이 요구된다. 이 시점에서 데이터를 캐시가 아니라 메인 메모리에서 읽어 들이며, lock 이 풀리면 한꺼번에 메인 메모리에 쓰기 연산을 해서 메모리 일관성 에러를 제거한다

 

이제 위의 예제의 increment 메소드를 synchronized 를 포함해서 선언하고 다시 시도하면 우리가 원하는 결과를 얻을 수 있다

 

※ 데이터를 읽기만 하거나, 데이터가 Immutable 이면 synchronized 를 적용할 필요가 없다

 

▶ 데드락

데드락 (Dead-lock) 이란 두 개 이상의 쓰레드가 영원히 블록(blocked) 되어 서로의 lock 을 점유하기 위해 기다리는 상태를 말한다

데드락은 다수의 쓰레드들이 동일한 lock 을 점유하려고 하지만 서로 다른 순서로 수요를 얻을 때 발생한다

 

Java 도 멀티 쓰레드 프로그래밍 언어이기 때문에 데드락이 발생할 수 있는데, 발생하는 이유는 다음과 같다

synchronized 키워드를 사용하면  실행 중인 쓰레드가 lock 또는 monitor 를 기다리는 객체를 block 하기 때문이다

 

예제를 보자

 

두 개의 쓰레드가 하나의 자원에 대해 접근을 시도하려고 할 때 dead-lock 이 발생하는 코드를 작성했다

왼쪽의 클래스에서는 obj1 에 대해 lock 을 점유한 뒤에 obj2 에 대한 자원을 lock 하려고 시도한다

반면에 오른쪽 클래스에서는 obj2 에 대해 lock 을 점유한 뒤에 obj1에 대한 자원을 lock 하려고 시도한다

 

 

하지만 서로 lock 이 풀리지 않은 자원에 대해 (ThreadLockDemo1 에서는 obj2 에, ThreadLockDemo2 에서는 obj1 에) lock 을 접근하려고 하기 때문에 데드락이 발생하는 것이다

 

이 이상 진행되지 않는다

프로그램은 더 이상의 작업을 진행하지 못하고 계속 멈춰있다.

 

어떠한 이유 때문에 데드락이 걸린건지 알고 싶다면 jcmd <pid> Thread.print 명령어를 사용할 수 있다

 

¿ 그럼 데드락을 어떻게 피해갈 수 있을까??

  • 먼저 위의 예제에서는 두 클래스 모두 lock 을 걸 자원에 대한 순서를 통일해주면 된다
    즉, ThreadLockDemo1 에서 obj1 에 대한 lock을 먼저 점유하는 순으로 코드를 짰다면 ThreadLockDemo2 에서도 obj1 에 대한 lock 을 먼저 점유하도록 해주면 된다
  • Lock은 꼭 필요한 멤버들에 대해서만 사용하도록 한다.
  • 위의 예제처럼 synchronized 를 중첩해서 사용하지 말자
  • Thread.join() 메소드를 사용하자

+) 데몬 쓰레드

Java 에는 Main Thread 와 Daemon Thread 가 존재하며 JVM 에 의해 실행된다

 

데몬 쓰레드는 유저 쓰레드에게 서비스를 제공하는 서비스 제공자 쓰레드이다. 여기서 서비스라는 건 백그라운드 태스크를 지원하기 위한 것이다 (e.g., Garbage Collection, Finalizer 등등)

그리고 모든 유저 쓰레드가 종료되면 JVM 이 데몬 쓰레드를 종료시킨다 즉, 유저 쓰레드에 의해 생명주기가 결정된다

또, 쓰레드들 중에 낮은 우선 순위를 가진다

 

 

+) Thread 클래스의 유용한 메소드들

● getId()

long 타입의 쓰레드가 가진 ID 넘버를 반환한다. 이 ID 는 쓰레드가 죽으면 같이 없어진다

 

● getPriority() 와 setPriority()

쓰레드의 우선 순위를 제어하기 위해서 사용한다. 스케줄러는 쓰레드 우선 순위를 어떻게 처리할지 결정한다

쓰레드 우선 순위는 1~10 사이의 정수이며 10이 제일 높다 (이미 위에서 다루긴 했다)

 

● setName() 과 getName()

이 메소드는 각 쓰레드들에 대해 이름을 부여할 수 있도록 한다. 쓰레드에 이름을 붙여주는 건 좋은 습관이며 디버깅을 용이하게 해준다

 

● getState()

쓰레드의 상태를 표시하는 Thread.State 객체를 반환한다. (상태에 대해선 이미 위에서 자세하게 알아봤다)

 

● isAlive()

쓰레드가 여전히 살아있는지 체크하기 위해서 사용한다

 

● start()

새로운 애플리케이션 쓰레드를 생성하고 그 쓰레드를 스케줄링하고, run() 메소드와 함께 실행의 진입점을 지정하는 용도로 사용된다

쓰레드는 일반적으로 run() 코드의 끝에 도달하거나 return 문을 실행하면 종료된다.

 

● interrupt()

만약에 쓰레드가 sleep(), wait() 또는 join() 메소드를 호출하는 것에 의해서 block 되면 Thread 객체 상에 interrupt() 메소드를 호출하여 쓰레드가 InterruptException 을 보낼 것임을 표현한다.

 

● join()

이 메소드는 다른 쓰레드가 소멸하기 전까지 진행하지 말라는 일종의 mark 역할을 한다

 

● setDaemon()

유저 쓰레드를 데몬 쓰레드로 지정할 때 사용한다.

인자로 boolean (true or false) 를 전달한다. 그리고 isDaemon() 메소를 통해 쓰레드가 데몬 쓰레드인지 아닌지의 여부를 판단할 수 있다

 

● setUncaughtExceptionHandler()

쓰레드가 예외를 던지는 것에 의해서 종료되면 기본 행동은 쓰레드의 이름, 예외의 타입, 예외 메세지 그리고 stack trace 를 출력한다

만약에 이 정도의 정보로 충분하지 않다면 쓰레드 내에 감지하지 못하는 예외들을 위한 커스텀 핸들러를 작성할 수 있다

 

 

 

[참고사이트]

https://www.geeksforgeeks.org/runnable-interface-in-java/

https://www.geeksforgeeks.org/lifecycle-and-states-of-a-thread-in-java/ 

https://www.geeksforgeeks.org/java-thread-priority-multithreading/ 

https://www.geeksforgeeks.org/main-thread-java/ 

https://www.geeksforgeeks.org/synchronized-in-java/ 

https://docs.oracle.com/javase/7/docs/api/java/lang/Thread.html 

https://javagoal.com/main-thread-in-java/ 

https://javarevisited.blogspot.com/2011/04/synchronization-in-java-synchronized.html#axzz6k3wlIJZL 

https://www.tutorialspoint.com/java/java_thread_deadlock.htm

dzone.com/articles/jvm-tuning-using-jcmd

www.javatpoint.com/daemon-thread

 

 

'공부 > whiteship-java' 카테고리의 다른 글

12주차: 애노테이션  (0) 2021.02.06
11주차: enum  (0) 2021.01.30
9주차: 예외 처리  (0) 2021.01.16
8주차: 인터페이스  (0) 2021.01.08
7주차: 패키지  (0) 2021.01.01