Java/STUDY HALLE

[Java] 쓰레드 Thread

무토(MUTO) 2021. 1. 20. 07:19

0. 학습목표

자바의 멀티쓰레드 프로그래밍에 대해서 학습한다.
어려워지려고 하면 너무 끝도 없이 어려워지니 정말 기본적으로 쓰레드를 이해할 수 있을 정도의 수준과 예제만 담도록 한다.

  • Thread 클래스
  • Runnable 인터페이스
  • 메인쓰레드
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • 동기화
  • 데드락
  • 싱글턴에서의 Thread-safe

1. Thread

인텔리제이의 기능을 이용하여 Thread클래스 명세를 읽어보며 공부를 해보자.

  • 스레드는 하나의 프로그램에서의 실행 흐름이다.
  • JVM은 병렬적으로 작동하는 여러개의 스레드 실행을 허용한다.
  • 모든 스레드는 우선순위가 있다. 우선순위가 높은 스레드는 우선순위가 낮은 스레드보다 먼저 실행된다.
  • 어떤 스레드는 데몬스레드가 되거나 되지 않을수 있다.
  • 일부 스레드에서 실행중인 코드가 새 스레드 객체를 생성할 때, 새 스레드는 처음에 생선된 스레드의 우선순위와 동일하게 설정된 우선순위를 가지며, 생성스레드가 데몬인 경우에만 데몬스레드가 된다.
  • JVM이 시작될 때 일반적으로 메인메서드의 호출로 발생한 단일 비데몬 스레드가 있다.
  • JVM은 다음과 같은 상황이 발생할 때 까지 지속된다.
    • Runtime 클래스의 exit() 메서드가 호출되고 security manager가 종료 조작을 허가한 경우.
    • 데몬 스레드가 아닌 모든 스레드가 run()메서드의 호출로 return되었거나, run()메서드를 넘어서 전파되는 예외를 throw하여 죽은경우.
  • 스레드는 두 가지의 실행방식이 있다. 첫 번째는 Thread 클래스의 서브클래스로 선언되는것이다. 이 서브클래스는 반드시 Thread클래스의 run()메서드를 오버라이딩 해야한다. 그런 다음에야 서브클래스의 인스턴스를 할당하고 시작할 수 있다.
  • 그 후 인스턴스의 start()메서드를 호출하면 스레드를 실행할 수 있다.

  • 또 다른 방법은 Runnable 인터페이스를 구현하는 클래스를 작성하는 것이다. 그 클래스는 run()메서드를 구현해야한다.
  • 새로운 스레드의 인수로 Runnable인스턴스를 인자로 넘긴 후, 해당 스레드를 실행하면 스레드를 실행할 수 있다.
  • 모든 스레드는 식별을 위한 이름이 있다.
  • 둘 이상의 스레드가 동일한 이름을 가질 수 있다.
  • 스레드가 생성될 때 이름이 지정되지 않으면 새 이름이 생성된다.
  • 달리 명시되지 않는 한, 이 클래스의 생성자, 또는 메서드에 null 인수를 전달하면 NullPointerException이 throw된다.

설명을 읽어봤으니 비슷하게 따라 쳐보자.

package study.moon.Test;

public class Main {

    public static void main(String[] args) {
        Data data = new Data(0);
        MyThread1 thread1 = new MyThread1("thread-1",data);
        MyThread1 thread2 = new MyThread1("thread-2",data);
        MyThread2 thread3 = new MyThread2("thread-3",data);
        MyThread2 thread4 = new MyThread2("thread-4",data);

        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();

    }
}

class Data {
    int number;

    public Data(int number) {
        this.number = number;
    }
}

class MyThread1 extends Thread{
    String name;
    Data data;
    int number;

    public MyThread1(String name, Data data) {
        this.name = name;
        this.data = data;
        this.number = 1;
    }

    public void run() {
        for (int i = 0; i < 5; i++) {
            data.number = number;
            number++;
            System.out.println(name+":"+data.number);
        }
    }
}

class MyThread2 extends Thread{
    String name;
    Data data;
    int number;

    public MyThread2(String name, Data data) {
        this.name = name;
        this.data = data;
        this.number = 1;
    }

    public void run() {
        for (int i = 0; i < 5; i++) {
            data.number = number;
            number--;
            System.out.println(name+":"+data.number);
        }
    }
}

싱글스레드 환경에서는 정답이 0이 나와야 한다.

  • 값이 출력되는것을 보아하니 스레드의 실행순서가 제멋대로이다. 날뛰는 이녀석들을 제어하기는 요원해보인다.

메서드 구성

이번 주요 내용과 관련이 있는 일부 메서드들을 찾아보자

  • sleep()
    • 시스템 타이머 및 스케줄러의 정밀도에 따라 현재 실행중인 스레드를 지정된 밀리초동안 휴면한다. 스레드는 모니터의 소유권을 잃지 않는다.
  • yield()
    • 현재 스레드가 프로세서의 현재 사용을 양보할 의사가 있다는 스케줄러에 대한 힌트이다. 보통 디버깅 또는 테스트 목적에 유용하다.
  • clone() 불가능
    • 클론이 불가능하다. 호출시 예외를 던진다.
  • start()
    • 스레드를 실행한다. 스레드를 두번 시작할 수 없고, 스레드가 실행 완료된 후에도 다시 시작할 수 없다.
  • run()
    • 스레드에 할당된 runnable을 실행한다.
  • exit()
    • 스레드가 실제로 종료되기 전에 정리할 기회를 주기 위해 시스템에 의해 호출된다.
  • interrupt()
    • 이 스레드를 중단한다.
  • join()
    • 해당 메서드가 죽을때까지 최대 파라미터ms만큼 기다린다.
  • set/getPriority()
    • 스레드의 우선순위 get/set한다.
  • getState()
    • 스레드의 상태를 get/set 한다.

2. Runnable

run()메서드 하나만을 가지고있는 인터페이스이다.

package study.moon.Test;

public class Main {

    public static void main(String[] args) {
        Data data = new Data(0);
        MyThread3 thread1 = new MyThread3(data,0,"Thread-1");
        MyThread3 thread2 = new MyThread3(data,0,"Thread-2");
        MyThread3 thread3 = new MyThread3(data,0,"Thread-3");
        MyThread3 thread4 = new MyThread3(data,0,"Thread-4");
        new Thread(thread1).start();
        new Thread(thread2).start();
        new Thread(thread3).start();
        new Thread(thread4).start();

    }
}

class Data {
    int number;

    public Data(int number) {
        this.number = number;
    }
}

class MyThread3 implements Runnable {

    public MyThread3(Data data, int number, String name) {
        this.data = data;
        this.number = number;
        this.name = name;
    }

    Data data;
    int number;
    String name;
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            data.number = number;
            number++;
            System.out.println(name+":"+data.number);
        }
    }
}

실행결과는 역시 전과 같이 제멋대로 순서없이 돌아가고 있다.

3. 메인쓰레드

main() 메서드를 실행하는 쓰레드이다. 모든 자바 어플리케이션은 메인쓰레드가 메인메소드를 호출하면서 시작한다.

4. 스레드의 상태

스레드는 다음과 같은 상태중 하나를 가진다.

  • new
    • 아직 시작하지 않은 상태이다.
  • runnable
    • jvm에서 실행중인 상태이다.
  • blocked
    • 모니터락을 기다리면서 블럭된 상태이다.
  • waiting
    • 다른 스레드가 특정 작업을 수행할 때까지 무기한 대기중인 상태.
  • timed-waiting
    • 다른 스레드가 지정된 대기시간까지 작업을 수행하기를 기다리는 상태.
  • terminated
    • 종료된 상태.

https://www.javamadesoeasy.com/2015/03/thread-states-thread-life-cycle-in-java.html

5. 스레드의 우선순위

스레드의 우선순위는 1이 가장 낮고 10이 가장 높다. setPriority() 메서드를 사용해서 우선순위를 지정할 수 있다.

이것을 잘 사용하면 프로그램의 우선순위를 정하여 스레드를 제어할 수 있을 것 같다 한번 코드를 작성해보자.

package study.moon.Test;

public class Main {

    public static void main(String[] args) {
        Data data = new Data(0);
        MyThread1 thread1 = new MyThread1(data,10);
        MyThread1 thread2 = new MyThread1(data,7);
        MyThread2 thread3 = new MyThread2(data,4);
        MyThread2 thread4 = new MyThread2(data,1);

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

class Data {
    int number;

    public Data(int number) {
        this.number = number;
    }
}

class MyThread1 extends Thread{
    Data data;
    int number;

    public MyThread1(Data data, int priority) {
        this.data = data;
        this.number = 1;
        setPriority(priority);
    }

    public void run() {
        for (int i = 0; i < 5; i++) {
            data.number = number;
            number++;
            System.out.println(this.getName()+":"+data.number);
        }
    }

}

class MyThread2 extends Thread{
    Data data;
    int number;

    public MyThread2(Data data, int priority) {
        this.data = data;
        this.number = 1;
        setPriority(priority);
    }

    public void run() {
        for (int i = 0; i < 5; i++) {
            data.number = number;
            number--;
            System.out.println(this.getName()+":"+data.number);
        }
    }
}

하지만 여전히 제멋대로이다.

Why?

프로그램의 해당 내용을 들여다 보았을 때, cpu의 할당이 필요하지 않은 데이터의 입출력에 대한 비율이 높은 것을 알 수 있다. 이러한 상황에서는 쓰레드는 무리하게 cpu를 차지하려고 하지 않는다고 한다. 오히려 이런 상황에서는 자신에게 할당된 cpu를 다른 쓰레드에게 넘겨 우선순위가 낮은 쓰레드가 실행의 기회를 얻는다고 한다.

6. 동기화

하나의 공유자원을 여러 쓰레드가 각각의 작업을 진행한다면 필연적으로 문제가 발생한다.

다음 결과를 보자.

package study.moon.Test;

import static java.lang.Thread.sleep;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Data data = new Data(0);
        MyThread1 thread1 = new MyThread1(data,1);
        MyThread1 thread2 = new MyThread1(data,1);
        MyThread1 thread3 = new MyThread1(data,1);

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

        sleep(1000);
        System.out.println(data.number);//10098396

    }
}

class Data {
    int number;

    public Data(int number) {
        this.number = number;
    }

    public void plus() {
        number++;
    }
}

class MyThread1 extends Thread{
    Data data;
    int number;
    long time;

    public MyThread1(Data data, int priority) {
        this.data = data;
        this.number = 0;
        setPriority(priority);
    }

    public void run() {
        for (int i = 0; i < 10_000_000; i++) {
            data.plus();
        }
    }
}

원래대로라면 10,000,000을 세번 더했으니 30,000,000 이 결괏값으로 나와야 정상이다.
하지만 전혀다른 이상한 값이 나왔다. 서로가 데이터의 값을 읽어들였을 때 이미 다른 쓰레드가 해당 데이터를 변경한 뒤이기 때문에 데이터의 일관성이 보장되지 않는 상황인 것이다.
이 상황을 해결하기 위해 자바는 synchronized 라는 키워드가 있다. 해당 키워드를 사용하면 해당 값을 변경할 때 다른 실행흐름이 해당 자원에 접근하지 못하도록 락을 건다.

package study.moon.Test;

import static java.lang.Thread.sleep;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Data data = new Data(0);
        MyThread1 thread1 = new MyThread1(data,1);
        MyThread1 thread2 = new MyThread1(data,1);
        MyThread1 thread3 = new MyThread1(data,1);

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

        sleep(1000);
        System.out.println(data.number);//30,000,000
    }
}

class Data {
    int number;

    public Data(int number) {
        this.number = number;
    }

    public synchronized void plus() {
        number++;
    }
}

class MyThread1 extends Thread{
    Data data;
    int number;
    long time;

    public MyThread1(Data data, int priority) {
        this.data = data;
        this.number = 0;
        setPriority(priority);
    }

    public void run() {
        for (int i = 0; i < 10_000_000; i++) {
            data.plus();
        }
    }
}
  • 쓰레드 문제로부터 안전해졌지만 성능이 안좋아졌다. 다음의 두 코드를 보고 비교해보자.

그냥 메서드

package study.moon.Test;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Data data = new Data(0);
        MyThread1 thread1 = new MyThread1(data,1);
        MyThread1 thread2 = new MyThread1(data,1);
        MyThread1 thread3 = new MyThread1(data,1);

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

class Data {
    int number;

    public Data(int number) {
        this.number = number;
    }

    public void plus() {
        number++;
    }
}

class MyThread1 extends Thread{
    Data data;
    int number;
    long time;

    public MyThread1(Data data, int priority) {
        this.data = data;
        this.number = 0;
        setPriority(priority);
    }

    public void run() {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10_000_000; i++) {
            data.plus();
        }
        long end = System.currentTimeMillis();
        time = end-start;
        System.out.println(time);
    }
}

결괏값 : 11 11 11

synchronized

package study.moon.Test;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Data data = new Data(0);
        MyThread1 thread1 = new MyThread1(data,1);
        MyThread1 thread2 = new MyThread1(data,1);
        MyThread1 thread3 = new MyThread1(data,1);

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

class Data {
    int number;

    public Data(int number) {
        this.number = number;
    }

    public void plus() {
        number++;
    }
}

class MyThread1 extends Thread{
    Data data;
    int number;
    long time;

    public MyThread1(Data data, int priority) {
        this.data = data;
        this.number = 0;
        setPriority(priority);
    }

    public void run() {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10_000_000; i++) {
            data.plus();
        }
        long end = System.currentTimeMillis();
        time = end-start;
        System.out.println(time);
    }
}

결괏값 : 813 818 819

해당 연산의 경우 대략 80배의 성능차이가 발생한다.

7. 데드락(교착상태)

http://denninginstitute.com/itcore/processes/Dead.html

위의 그림과 같이 두개 이상의 작업이 서로 상대방의 작업이 끝나기만을 기다리고있기 때문에 프로세스가 자원을 얻지 못해 다음 처리를 진행할 수 없는 상태를 말한다.

데드락이 발생하기 위해서는 다음 네가지의 조건을 모두 만족시켜야한다.

  1. 상호배제(Mutual exclusion)Permalink
    자원을 한 번에 한 프로세스만이 사용하는 경우.
  2. 점유대기(Hold and wait)Permalink
    최소한 하나의 자원을 점유하고 있으면서 다른 프로세스에 할당되어 사용하고 있는 자원을 추가로 점유하기 위해 대기하는 프로세스가 있는 경우.
  3. 비선점(No preemption)Permalink
    다른 프로세스에 할당된 자원은 사용이 끝날 때까지 강제로 빼앗을 수 없는 경우.
  4. 순환대기(Circular wait)Permalink
    각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있는 경우.

다음 조건을 확인하여 예제를 작성해보자.

package study.moon.Test;

public class Main {

    public static void main(String[] args) {
        final Object car1 = "car1";
        final Object car2 = "car2";
        Thread t1 = new Thread(() -> {
            synchronized (car1) {
                System.out.println("Thread 1: locked car 1");
                synchronized (car2) {
                    System.out.println("Thread 1: locked car 2");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (car2) {
                System.out.println("Thread 2: locked car 2");
                synchronized (car1) {
                    System.out.println("Thread 2: locked car 1");
                }
            }
        });

        t1.start();
        t2.start();
    }
}

데드락이 발생했다.

다음과 같이 서비스 운영중에 데드락이 발생했을 경우 회사의 큰 손해로 이어질 수도 있기 때문에 항상 서비스가 데드락에서 빠져나올 수 있는 해결방안을 미리 고민해두어야 한다. 이 이후의 내용은 자바의 내용이라기보다는 운영체제의 내용에 가깝기 때문에 추후에 운영체제에 대한 포스팅을 할 기회가 있으면 작성하도록 할것이다.

8. 싱글턴 패턴에서의 Thread-safe (Option)

  • 디자인 패턴 중, 단 하나의 공유 객체를 만들어 재사용하는 패턴을 싱글턴패턴이라고 한다.
  • 싱글턴 패턴은 Thread-safe를 고려하지 않으면 싱글턴 패턴이 아니게될 가능성이 있다.
  • 따라서 싱글턴패턴을 Thread-safe하게 만드는 방법을 추가적으로 정리해보았다.

8-1.synchronized 사용

public class Singleton {
    private static Singleton instance;

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

문제점

  • 7장에서 말한 것과 같이 스레드 문제로부터 안전해졌지만 불필요한 락이 많아 성능상의 이슈가 발생할 확률이 있다.

8-2.Double-Checkd Locking 사용

package study.moon.Test;

public class Singleton {
    private static volatile Singleton instance;

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

volatile 키워드

  • 사실 우리는 메인 메모리에 항상 직접 접근하여 연산을 하는것이 아니라 성능상의 이익을 얻기 위해 cpu 캐시에 저장된 값으로 연산을 진행한다. 그래서 멀티쓰레드 환경에서는 특정 쓰레드에서 값을 변경하여도 cpu캐시에서만 값이 변경되어, 메인 메모리에는 반영이 되지 않아 다른 쓰레드에서 문제가 발생할 수 있다. 이 때, volatile 키워드를 사용하면 해당 값을 cpu캐시에 갱신하지 않고 직접 메인 메모리에 갱신하게된다.

문제점

  • 역시 아직 성능상의 문제가 있다.
  • 코드 가독성이 떨어진다.
  • 1.4 이하의 버전에서는 적용할 수 없다.

8-3.Eager Initialization

package study.moon.Test;

public class Singleton {
    /*
    static -> 인스턴스화와 상관없이 접근 가능
    private -> 외부에서 instance 바로 접근 방지
     */
    private static Singleton instance = new Singleton();

    /*
    private -> new Singleton() 방지
     */
    private Singleton() {}

    //오로지 해당 메서드를 통해서만 인스턴스 접근 가능
    public static Singleton getInstance() {
        return instance;
    }
}

문제점

  • 싱글턴 객체 사용 유무에 관계없이 클래스가 로딩되는 시점에 객체가 생성되어 메모리를 잡아먹을 수 있다.

8-4.Lazy Initialization

package study.moon.Test;

public class Singleton {

    private static Singleton instance;
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { // 처음으로 호출될 때 메모리에 할당한다.
            instance = new Singleton();
        }
        return instance;
    }
}

문제점

  • Thread-safe하지 않다.

8-5.Holder

package study.moon.Test;

public class Singleton {

    private Singleton() {}

    private static class Holder{//getInstance()가 호출되기 전에는 참조되지 않는다.
        private static final Singleton instance = new Singleton();//클래스 로딩 시점에 한번만 호출, 재할당 금지
    }

    public static Singleton getInstance() {
        return Holder.instance;
    }
}
  • 많이 사용되는 방법이라고 한다.

8-5.Enum

package study.moon.Test;

public enum Singleton {
    instance
}
  • 간단하게 구현할 수 있다. enum의 특성을 이용하여 싱글턴 구현이 가능하다.

'Java > STUDY HALLE' 카테고리의 다른 글

[Java] 람다  (0) 2021.03.05
[Java] enum  (0) 2021.01.30
[Java] 예외처리  (0) 2021.01.15
[Java] 인터페이스  (0) 2021.01.08
[Java] 패키지  (0) 2020.12.29