JAVA/Java

[Java] 스레드(Thread)와 동기화(Synchronization)

민트맛녹차 2022. 10. 11. 02:00

스레드(Thread)

스레드는 실행 중인 프로그램/프로세스 내에서 실행되는 흐름의 단위를 말한다. 일반적으로 한 프로그램은 하나의 스레드를 가지고 있지만, 프로그램 환경에 따라 둘 이상의 스레드를 동시에 실행할 수 있다. 이러한 실행 방식을 멀티스레드(multithread)라고 한다.

보통 스레드 하나에 CPU의 코어 하나가 할당되어 동시에 실행이된다. 스레드는 동시에 독립적으로 실행이 되므로 동일한 실행결과를 보장할 수 없다. 

 

public Thread(Runnable target) { ... }

Thread의 생성자는 매개변수 타입으로 java.lang.Runnable 인터페이스를 가진다. Runnable 인터페이스는 하나의 추상메서드인 void run() 을 가지는 함수형 인터페이스이다. Runnable 인터페이스를 구현한 인스턴스를 인자로 넣어 Thread 인스턴스를 생성할 수 있다.

public Thread(Runnable target, String name) { ... }

스레드의 이름은 기본적으로 Thread-N(N = 1, 2, 3, ...) 이다. 별도의 이름을 붙이고 싶다면 위와 같은 생성자를 사용해 Thread 인스턴스를 생성하면 된다.

public static native Thread currentThread();

currentThread 메서드를 통해 현재 실행되는 쓰레드 인스턴스의 참조를 얻을 수 있다.

public synchronized void start() { ... }

start 메서드를 사용해 쓰레드를 생성 및 실행할 수 있다.

public static native void sleep(long millis) throws InterruptedException;

sleep 메서드를 통해 쓰레드의 실행을 멈출 수 있다. 

public final void join() throws InterruptedException { ... }

join 메서드는 특정 스레드의 실행이 완료되기를 기다릴 때 호출하는 메서드이다.

참고로 native 는 자바가 아닌 언어(주로 C나 C++)로 구현한 후 사용할 때 쓰는 키워드이고 synchronized 는 스레드의 동기화를 보장하는 키워드이다. 

 

Runnable task = () -> {
    int n1 = 10;
    int n2 = 20;
    String name = Thread.currentThread().getName();
    System.out.println(name + " : " + (n1 + n2));
};

Thread thread = new Thread(task);
thread.start();
System.out.println("End " + Thread.currentThread().getName());

// End main
// Thread-0 : 30

스레드를 사용할 때 main 스레드가 끝났다고 해서 프로그램이 종료되지 않는다. 위의 코드를 보면 main 스레드가 먼저 끝났음에도 프로그램이 종료되지 않았다. 모든 스레드가 소멸되어야 종료된다. 참고로 스레드가 끝나면 스레드는 자동으로 소멸된다. 스레드의 소멸은 스레드의 생성을 위해 할당했던 모든 자원의 해제를 뜻한다.

 

class Task extends Thread {
    @Override
    public void run() {
        int n1 = 10;
        int n2 = 20;
        String name = Thread.currentThread().getName();
        System.out.println(name + " : " + (n1 + n2));
    }
}

Task t1 = new Task();
Task t2 = new Task();
t1.start();
t2.start();
System.out.println("End " + Thread.currentThread().getName());

//End main
//Thread-0 : 30
//Thread-1 : 30

Thread 를 상속하는 클래스를 정의하여 스레드를 생성하는 방법도 있다. Thread 상속 시 run 메서드를 오버라이딩 해야한다. 

 

 

동기화(Synchronization)

class Counter {
    int count=0;
    
    public void increment() {
        count++;
        System.out.println("count = " + count);
    }

    public void decrement() {
        count--;
        System.out.println("count = " + count);
    }

    int getCount() {
        return count;
    }
}

둘 이상의 쓰레드가 동일한 변수에 접근하는 것은 문제를 일으킨다. 경쟁 상태(Race Condition)가 발생하기 때문이다. 따라서 한 쓰레드만 변수에 접근하도록 제한하는 동기화(Synchronization)가 필요하다.

class Counter {
    int count=0;
    
    synchronized public void increment() {
        count++;
        System.out.println("count = " + count);
    }

    synchronized public void decrement() {
        count--;
        System.out.println("count = " + count);
    }

    public int getCount() {
        return count;
    }
}

동기화 메서드는 메서드에 synchronized 선언을 추가하여 동기화를 하는 방법이다. synchronized 선언이 추가되면 메서드는 한순간에 한 스레드의 접근만을 허용한다. 두 스레드가 동시에 호출하면, 먼저 호출한 스레드가 메서드를 실행하고 다른 스레드는 대기하다 먼저 호출한 스레드가 종료되면 메서드를 실행한다.

class Counter {
    int count=0;
    
    public void increment() {
        synchronized (this) {
            count++;
        }
        System.out.println("count = " + count);
    }

    public void decrement() {
        synchronized (this) {
            count--;
        }
        System.out.println("count = " + count);
    }

    public int getCount() {
        return count;
    }
}

동기화 블록은 문장 단위로 동기화를 선언하여 동기화가 불필요한 부분의 스레드 접근을 막는 일을 방지한다.

 

스레드 풀(Thread Pool)

스레드의 생성과 소멸은 그 자체로 시스템에 부담을 준다. 매번 스레드를 생성하는 것은 성능의 저하로 이어진다. 따라서 스레드 풀(Thread Pool)이라는 것을 생성하여 그 안에 미리 제한된 수의 스레드를 생성해 이를 재활용하는 방법을 사용한다.

concurrent 패키지를 사용하면 스레드 풀을 구현 없이 사용할 수 있다. Executors 클래스에는 다음의 메서드들을 통해서 다양한 유형의 스레드 풀을 생성할 수 있다.

  • newSingleThreadExecutor : 풀 안에 하나의 스레드만 생성하고 유지 
  • newFixedThreadPool : 풀 안에 인자로 전달된 수의 스레드를 생성하고 유지
  • newCachedThreadPool : 풀 안의 스레드의 수를 작업의 수에 맞게 유동적으로 관리
Runnable task = () -> {
    int n1 = 10;
    int n2 = 20;
    String name = Thread.currentThread().getName();
    System.out.println(name + ": " + (n1 + n2));
};

ExecutorService exr = Executors.newSingleThreadExecutor();  // 스레드 풀 생성
exr.submit(task);   // 스레드 풀에 작업 전달

System.out.println("End " + Thread.currentThread().getName());
exr.shutdown();     // 스레드 풀과 그 안에 존재하는 스레드 소멸

newSingleThreadExecutor 가 생성하는 스레드 풀 안에는 하나의 스레드만 생성해 두고 이 스레드가 모든 작업을 처리하게 한다. 다만, 스레드가 shutdwon 이전에 실패로  종료된다면 새로운 스레드로 대체된다. 작업은 순차적 실행을 보장하고, 하나 이상의 작업은 동시에 실행될 수 없다.

Runnable task1 = () -> {
    String name = Thread.currentThread().getName();
    System.out.println(name + ": " + (5 + 7));
};

Runnable task2 = () -> {
    String name = Thread.currentThread().getName();
    System.out.println(name + ": " + (7 - 5));
};


ExecutorService exr = Executors.newFixedThreadPool(2);
exr.submit(task1);
exr.submit(task2);
exr.submit(() -> {
    String name = Thread.currentThread().getName();
    System.out.println(name + ": " + (5 * 7));
});	// 반복되는 작업이 아니라면 인자로 람다식을 작성해도 괜찮다.

exr.shutdown();

newFixedThreadPool 이 생성하는 풀은 입력받은 갯수의 스레드를 재사용하여 작업을 처리한다. 최대 n개의 스레드가 활성화 되며, 최대 스레드 갯수를 초과하는 작업이 들어오면 스레드를 사용할 수 있을 때까지 큐에서 기다린다. 만약 스레드가 shutdown 이전에 실패로 종료된다면 새로운 스레드가 대체된다.

newSingleThreadExecutor VS newFixedThreadPool(1)
하나의 스레드를 사용한다는 공통점이 있지만, 스레드 풀의 크기를 변경할 수 없는 newSingleThreadExecutor 와 달리 newFixedThreadPool(1)는 스레드 풀의 크기를 변경할 수 있다.

 

newCachedThreadPool 이 생성하는 풀은 전달된 작업 수에 근거하여 스레드의 수를 조절한다. 일반적으로 짧은 수명의 비동기 작업을 대량으로 처리하는 프로그램의 성능을 올린다. 가장 효율적으로 스레드를 관리하는 것처럼 보이지만, 빈번한 스레드의 생성과 소멸을 주의해야 한다.

 

Callable & Future

@FunctionalInterface
public interface Callable<V> {
    V call() throws Exceptions;
}

Runnable 인터페이스의 run 메서드는 반환형이 void 이므로 결과를 return 할 수 없지만, Callable 인터페이스의 call 메서드는 작업의 끝에서 값을 반환할 수 있다.

<T> Future<T> submit(Callable<T> task) { ... }

스레드 풀에 Callable 인자를 가지는 작업을 전달하면 Future 인터페이스의 반환형을 가지는 인스턴스를 얻을 수 있다. Future의 타입 인자는 Callalbe의 타입 인자와 동일해야 한다.

Callable<Integer> task = () -> {
    int sum = 0;
    for (int i = 0; i < 10; i++) {
        sum += i;
    }
    return sum;
};

ExecutorService exr = Executors.newSingleThreadExecutor();
Future<Integer> fur = exr.submit(task);

Integer r = fur.get();
System.out.println("result: " + r);
exr.shutdown();

 

ReentrantLock

java 5 에서 동기화 블록과 동기화 메서드를 대신할 수 있는 ReentrantLock 클래스를 제공하였다.

한 스레드가 lock 메서드를 호출하고 다음 문장을 실행하기 시작하면, lock 메서드를 호출하는 다른 스레드는 lock 을 호출한 스레드가 unlock 을 호출할 때까지 대기한다. 따라서 여러 스레드가 동시에 두 메서드를 사용할 수 없게 된다.

동기화에서 선언한 Counter 클래스를 ReentrantLock 을 사용해 다음과 같이 나타낼 수 있다.

class Counter {
    int count=0;
    ReentrantLock criticObj = new ReentrantLock();

    public void increment() {
        criticObj.lock();

        try{
            count++;
            System.out.println("count = " + count);
        } finally {
            criticObj.unlock();
        }
    }

    public void decrement() {
        criticObj.lock();

        try {
            count--;
            System.out.println("count = " + count);
        } finally {
            criticObj.unlock();
        }
    }

    public int getCount() {
        return count;
    }
}

 

컬렉션 인스턴스 동기화

 

동기화는 성능의 저하가 불가피 하므로 컬렉션 프레임워크의 클래스 대부분도 동기화 처리가 되어 있지 않아 스레드의 동시 접근에 안전하지 않다. 대신 Collections 클래스에서 다음 메서드들을 통한 동기화 방법을 제공한다.

public static <T> Set<T> synchronizedSet(Set<T> s) { ... }
public static <T> List<T> synchronizedList(List<T> list) { ... }
public static <K, V> Map<K, V> synchronizedMap(Map<K, V> m) { ... }
public static <T> Collection<T> synchronizedCollection(Collection<T> c) { ... }

위의 메서드들을 사용해 생성된 컬렉션 인스턴스들은 스레드의 동시 접근에 안전한 상태가 된다.

 

public static List<Integer> lst = Collections.synchronizedList(new ArrayList<Integer>());

public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 16; i++) {
        lst.add(i);
    }
    System.out.println(lst);

    Runnable task = () -> {
        synchronized (lst) {
            ListIterator<Integer> itr = lst.listIterator();
            while (itr.hasNext()) {
                itr.set(itr.next() + 1);
            }
        }
    };

    ExecutorService exr = Executors.newFixedThreadPool(3);
    exr.submit(task);
    exr.submit(task);
    exr.submit(task);

    exr.shutdown();
    exr.awaitTermination(100, TimeUnit.SECONDS);
    System.out.println(lst);
}

// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
// [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]

참고로 위의 코드는 synchronizedList 를 사용했지만 잘못된 결과가 나온다. 컬렉션 인스턴스는 동기화 되었어도 반복자는 동기화가 이뤄지지 않았기 때문이다. 따라서 아래와 같이 synchronized 를 사용해 반복자에 대한 동기화를 추가해야 한다.

Runnable task = () -> {
    synchronized (lst) {
        ListIterator<Integer> itr = lst.listIterator();
        while (itr.hasNext()) {
            itr.set(itr.next() + 1);
        }
    }
};

// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
// [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]

 

 

 

참조
윤성우의 열혈 JAVA 프로그래밍
https://docs.oracle.com/javase/9/docs/api/index.html?overview-summary.html
https://fors.tistory.com/80
https://ko.wikipedia.org/wiki/%EC%8A%A4%EB%A0%88%EB%93%9C_(%EC%BB%B4%ED%93%A8%ED%8C%85)
https://richardworld.tistory.com/entry/Difference-Between-newSingleThreadExecutor-and-newFixedThreadPool