Home 자바 쓰레드 복습 내용 정리
Post
Cancel

자바 쓰레드 복습 내용 정리

1. 글을 작성하게 된 계기


민지님과 오랜만에 자바를 복습하게 됐는데, 쓰레드에 대해 학습한 내용을 정리하기 위해 글을 작성하게 되었습니다.

오랜만에 공부하는데 꽤 많이 까먹었더라고요…? 😡





2. run과 start의 차이


run 메서드를 호출하면, 새로운 쓰레드를 생성하지 않고 현재 쓰레드 에서 해당 메서드를 호출 합니다. 반면, start 메서드는 새로운 쓰레드가 생성 하며, 이 쓰레드에서 run 메서드가 실행되어 병렬 처리 가 가능합니다.

If you just invoke run() directly, it’s executed on the calling thread, just like any other method call. Thread.start() is required to actually create a new thread so that the runnable’s run method is executed in parallel.





아래 코드를 실행하면 END가 항상 마지막에 출력되지는 않는데, 이는 main 메서드는 쓰레드를 시작하고 바로 종료되지만, 새로운 쓰레드가 생성되고 실행되기 때문입니다. 즉, 메인 메서드와 관계없이 실행됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {

    public static void main(String[] args) {
        for (int index = 1; index <= 100; index++) {
            final TestThread testThread = new TestThread();
            testThread.start();
        }
        System.out.println("END");
    }

    static class TestThread extends Thread {
        @Override
        public void run() {
            System.out.println("THREAD" + this.getName());
        }
    }
}
1
2
3
4
5
6
7
8
......

THREADThread-97
THREADThread-98
END
THREADThread-99
THREADThread-100







반면 아래 코드는 END가 항상 마지막에 실행되는데요, 새로운 쓰레드가 생성되지 않고 현재 쓰레드(main)에서 run 메서드가 실행되기 때문입니다. 정리하면 run 메서드는 한 쓰레드 내에서 다른 쓰레드를 호출하며, start 메서드는 새로운 쓰레드를 생성한 후 병렬로 작업을 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {

    public static void main(String[] args) {
        for (int index = 1; index <= 100; index++) {
            final TestThread testThread = new TestThread();
            testThread.run();
        }
        System.out.println("END");
    }

    static class TestThread extends Thread {
        @Override
        public void run() {
            System.out.println("THREAD" + this.getName());
        }
    }
}
1
2
3
4
5
6
7
8
......

THREADThread-97
THREADThread-98
THREADThread-99
THREADThread-100
END







3. 데몬 쓰레드


데몬 쓰레드는 백그라운드에서 돌아가는 쓰레드로, 쓰레드의 작업을 보조 하는 역할을 합니다. 이는 모든 사용자 쓰레드가 실행되면 자동으로 종료됩니다.

In multitasking computer operating systems, a daemon is a computer program that runs as a background process, rather than being under the direct control of an interactive user.





아래 코드를 실행하면 THREADThread-0이 출력되는 것을 볼 수 있는데, 이는 데몬 쓰레드 설정을 하지 않은 사용자 쓰레드 이기 때문입니다. 따라서 main 메서드가 종료되더라도 남은 쓰레드의 실행을 기다리므로, 쓰레드 이름이 출력됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {

    public static void main(String[] args) {
        final TestThread testThread = new TestThread();
        // testThread.setDaemon(true);
        System.out.println("START");
        testThread.start();
        System.out.println("END");
    }

    static class TestThread extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("THREAD" + this.getName());
        }
    }
}
1
2
3
START
END
THREADThread-0







반면 아래 코드는 THREADThread-0이 출력되지 않는데, 이는 데몬 쓰레드 설정을 했기 때문입니다. 즉, main 메서드가 종료되며 데몬 쓰레드도 함께 종료된 것이죠. 정리하면 사용자 쓰레드는 메인 쓰레드가 종료되더라도 남은 작업을 마무리하지만, 데몬 쓰레드는 메인 쓰레드가 종료되면 즉시 함께 종료됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {

    public static void main(String[] args) {
        final TestThread testThread = new TestThread();
        testThread.setDaemon(true);
        System.out.println("START");
        testThread.start();
        System.out.println("END");
    }

    static class TestThread extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("THREAD" + this.getName());
        }
    }
}
1
2
START
END







4. Happens Before


Happens Before는 동시성/병렬 프로그래밍에서 여러 쓰레드나 프로세스 간의 작업 순서를 이해 하고 동기화 문제를 해결 하는 데 사용되는 개념입니다. 이는 두 개의 사건 사이의 순서를 결정하는 규칙을 제공하며, A가 B보다 먼저 일어났다면 A happens before B 라고 합니다. 이는 쓰레드 실행 순서, 락(Lock) 순서, 변수 쓰기/읽기 순서 와 같이 자원의 실행 순서를 나타낼 때 사용할 수 있습니다.

In computer science, the happened-before relation is a relation between the result of two events, such that if one event should happen before another event, the result must reflect that, even if those events are in reality executed out of order.





Happens Before는 메모리 가시성 과도 연관이 있는데, A가 B보다 먼저 발생했다면, A의 모든 메모리 변경 사항은 B에서 볼 수 있어야 합니다. 이를 통해 멀티쓰레드 프로그램에서 데이터 경합이나 불일치가 발생하지 않도록 보장할 수 있습니다. 예를 들어 다음과 같은 코드로, volatile 키워드로 flag의 변경 내역이 바로 보이기 때문에 다음 쓰레드의 작업이 실행되지 않고 종료되는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Main {
    
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        System.out.println("START");
        final Thread thread = new Thread(() -> {
            flag = true;
        });

        final Thread readerThread = new Thread(() -> {
            while (!flag) {
            }
            System.out.println("END");
        });

        // 1. flag를 TRUE로 변경.
        thread.start();
        
        // 2. flag가 TRUE 이므로 바로 종료.
        readerThread.start();

        try {
            thread.join();
            readerThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
1
2
START
END







여깃 추가로 알아두면 좋은 내용이 있는데, 전이 규칙(Transitivity Rule) 입니다. 이는 특정 조건이 만족될 때, 한 요소에서 다른 요소로 간접적으로 연결되는 관계를 말합니다.

In mathematics, a binary relation R on a set X is transitive if, for all elements a, b, c in X, whenever R relates a to b and b to c, then R also relates a to c.





예를 들어, 어떤 관계 R이 집합 X의 요소들 사이에서 전이적이라고 할 때, 이는 다음과 같은 의미를 가집니다. 즉, 집합 X의 모든 a, b, c ∈ X에 대해, R( a,b)와 R(b,c)가 성립한다면, R(a,c)도 성립 하는 것이죠.

  1. 집합 X: X 요소들의 집합. ex) X={1, 2, 3}
  2. 관계 𝑅: 집합의 요소들 사이의 관계. ex) R 이상(≥)
  3. 여기서 3>=2, 2>=1 이라면 3>=1도 성립하며, 이를 전이적이라고 합니다.





Happens Before 관계의 전이성은 논리적으로 성립하지만, 동시성 프로그램에서 쓰레드의 실행 순서는 보장되지 않기 때문에, 실제 실행 결과가 기대한 대로 나오지 않을 수 있습니다. 예를 들어 아래와 같은 경우, start( ) 메서드로 인해 쓰레드의 실행 순서가 보장되지 않을 수 있습니다. 이런 현상이 발생할 수 있다는 것도 알고 있으면 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Main {
    private static volatile int x = 0;
    private static volatile int y = 0;

    public static void main(String[] args) {
        final Thread threadA = new Thread(() -> {
            x = 1;  // A: x를 1로 설정
        });

        final Thread threadB = new Thread(() -> {
            if (x == 1) { // B: x가 1인지 확인
                y = 1;
            }
        });

        final Thread threadC = new Thread(() -> {
            if (y == 1) { // C: y가 1인지 확인
                System.out.println("y is 1, x is " + x);
            }
        });

        startThread(threadA);
        startThread(threadB);
        startThread(threadC);

        try {
            threadA.join();
            threadB.join();
            threadC.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private synchronized static void startThread(final Thread thread) {
        thread.start();
        Thread.sleep(1_000);
    }
}
1
y is 1, x is 1







Happens Before와 전이 규칙은 모두 프로그램의 일관성 을 유지하는 데 중요한 역할을 합니다. Happens Before는 이벤트 순서의 일관성 을, 전이 규칙은 관계의 일관성 을 유지하기 때문에 이를 활용해 멀티쓰레드 프로그램에서 데이터 경합이나 불일치가 발생하지 않도록 보장할 때 사용할 수 있습니다.

쉽게 말해, 두 개념을 통해 프로그램이 예측 범위 내에서 동작할 수 있도록 하는 것입니다. 하지만 동시성 프로그래밍에서는 프로세스/실행 순서를 보장할 수 없으므로, 추가적인 동기화를 할 수도 있다는 것을 반드시 인지합니다.







5. yield


yield 메서드는 자신의 차례에 다른 쓰레드에 차례를 양보 합니다. 예를 들어, 아래와 같이 1부터 100까지 수를 카운트하는 코드가 존재할 경우, yield 메서드를 실행한 쪽이 작업 시간이 더 걸립니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@DisplayName("[UnitTest] Thread 단위 테스트")
class ThreadUnitTest {

    private static final Logger log = LoggerFactory.getLogger(ThreadUnitTest.class);

    @Test
    @DisplayName("쓰레드가 작업을 양보하며, 작업시간이 더 오래걸릴 수 있다.")
    void whenYieldCalledThenThreadShouldYield() throws InterruptedException {
        final YieldingThread yieldingThread = new YieldingThread();
        final BusyThread busyThread = new BusyThread();

        final long yieldStartTime = System.nanoTime();
        yieldingThread.start();
        yieldingThread.join();
        final long yieldEndTime = System.nanoTime();

        final long busyStartTime = System.nanoTime();
        busyThread.start();
        busyThread.join();
        final long busyEndTime = System.nanoTime();

        assertTrue((yieldEndTime - yieldStartTime) > busyEndTime - busyStartTime);
    }

    static class YieldingThread extends Thread {
        private int executionCount = 0;

        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                executionCount++;
                log.info("YieldingThread execution count: {}", executionCount);
                Thread.yield();
            }
        }
    }

    static class BusyThread extends Thread {
        private int executionCount = 0;

        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                executionCount++;
                log.info("BusyThread execution count: {}", executionCount);
            }
        }
    }
}







Thread.yield( ) 메서드를 호출하면 현재 쓰레드가 실행을 양보하므로, 다른 쓰레드가 실행 될 수 있기 때문입니다. 이 과정에서 컨텍스트 스위칭도 발생하고요. 현재 쓰레드는 재실행을 위해 대기하며, 이로 인해 총 실행 시간이 더 길어질 수 있습니다.

BusyThread는 CPU를 양보하지 않고 연속으로 실행하기 때문에 작업 시간이 더 짧습니다.







6. 정리


오랜만에 쓰레드의 기본에 대해 복습했는데, 정말 많이 까먹었네요? 다시 공부합시다. 😡😡


This post is licensed under CC BY 4.0 by the author.