Language/Java & Kotlin

[Java] 동기화란 무엇인가? (1) 상호배제

Joonfluence 2023. 10. 3. 13:36

동기화 방법 (자바에선 멀티 스레드 환경에서 어떻게 동기화를 할까?)

멀티 스레드 혹은 멀티 프로세스 환경에서 동기화를 보장하려면, 실행 순서 제어상호 배제가 필요합니다. 실행 순서 제어란 공유 자원에 대한 작업이 순서대로 작업되도록 보장하는 것입니다. 상호 배제란 동시에 접근해서는 안되는 자원(공유 자원)에 하나만 접근하도록 보장하는 것입니다. 자바에선 동기화를 보장하기 위해, 모니터 인터페이스를 제공합니다. 모니터는 프로그래밍 언어 수준에서 동기화를 지원합니다. 자바의 각 객체는 모니터 락을 소유하고 있으며, 각 스레드는 이 모니터 락을 잠그거나 해제할 수 있습니다. 한 번에 하나의 스레드만이 모니터의 잠금을 유지할 수 있습니다. 해당 모니터를 잠그려는 다른 스레드는 해당 모니터에서 잠금을 얻을 수 있을 때까지 차단됩니다. 스레드 t는 특정 모니터를 여러 번 잠글 수 있으며, 각 잠금 해제는 하나의 잠금 작업의 효과를 반전시킵니다.

코드 레벨에서 모니터 락을 획득할 수 있는 방법은 총 3가지입니다. 첫째, synchronized method을 사용하는 것과 둘째, static synchronized method를 사용하는 것, 마지막으로 synchronized 블럭을 사용하는 것입니다. 또한 모니터 락의 획득 대상은 객체와 클래스, 2개로 나뉩니다. 후술하겠지만, 전자는 synchronized method와 synchornized block에 this 인자를 전달하는 방법으로 가능합니다. 후자는 synchronized method에 static을 추가하여 가능합니다.

상호배제

1) 객체 락 획득

먼저 객체 락 획득 방법에 관하여 더욱 자세히 알아보겠습니다.

synchronized method

하나의 스레드가 synchronized가 붙은 메서드를 호출하면, 객체의 모니터 락을 획득합니다. 하나의 스레드에서 모니터 락을 획득하면 다른 스레드에서는 락을 획득할 때까지 기다립니다.

public class SynchronizationTest {
    public static void main(String[] args) {
        SynchronizationTest sync = new SynchronizationTest();
        Thread threadA = new Thread(() -> {
            System.out.println("스레드1 시작 " + LocalDateTime.now());
            sync.syncMethod1("스레드1");
            System.out.println("스레드1 종료 " + LocalDateTime.now());
        });
        Thread threadB = new Thread(() -> {
            System.out.println("스레드2 시작 " + LocalDateTime.now());
            sync.syncMethod2("스레드2");
            System.out.println("스레드2 종료 " + LocalDateTime.now());
        });
        thread1.start();
        thread2.start();
    }

    private synchronized void syncMethod1(String msg){
        System.out.println(msg + "의 syncMethod1 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e){
            e.printStackTrace();
        }
    }

    private synchronized void syncMethod2(String msg){
        System.out.println(msg + "의 syncMethod2 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e){
            e.printStackTrace();
        }
    }
}

위 예시 코드에 대한 실행 결과를 보면 알 수 있듯, threadA에서 락을 획득하니 threadB에서는 threadA가 종료될 때까지 기다립니다.

스레드1 시작 2023-10-03T12:10:59.405161
스레드2 시작 2023-10-03T12:10:59.407933
스레드1의 syncMethod1 실행중2023-10-03T12:10:59.539186
스레드2 종료 2023-10-03T12:11:04.587184
스레드1의 syncMethod2 실행중2023-10-03T12:11:04.591002
스레드2 종료 2023-10-03T12:11:09.607614

주의할 점이 있다면, 락은 synchronized 키워드가 붙은 메소드들에 대해서만 공유된다. 한 스레드가 synchronized 메소드를 호출했을 때, 메소드에 synchronized가 붙어 있지 않으면 lock이 걸리지 않는다.

public class SynchronizationTest {
    public static void main(String[] args) {
        SynchronizationTest sync = new SynchronizationTest();
        Thread threadA = new Thread(() -> {
            System.out.println("threadA 시작 " + LocalDateTime.now());
            sync.syncMethod1("스레드1");
            System.out.println("threadA 종료 " + LocalDateTime.now());
        });
        Thread threadB = new Thread(() -> {
            System.out.println("threadB 시작 " + LocalDateTime.now());
            sync.syncMethod2("스레드2");
            System.out.println("threadB 종료 " + LocalDateTime.now());
        });
        Thread threadC = new Thread(() -> {
            System.out.println("threadC 시작 " + LocalDateTime.now());
            sync.method3("스레드3");
            System.out.println("threadC 종료 " + LocalDateTime.now());
        });
        threadA.start();
        threadB.start();
        threadC.start();
    }

    private synchronized void syncMethod1(String msg){
        System.out.println(msg + "의 syncMethod1 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e){
            e.printStackTrace();
        }
    }

    private synchronized void syncMethod2(String msg){
        System.out.println(msg + "의 syncMethod2 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e){
            e.printStackTrace();
        }
    }
    private void method3(String msg) {
        System.out.println(msg + "의 method3 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

아래와 같이 말이다.

스레드1 시작 2023-10-03T12:38:18.989203
스레드2 시작 2023-10-03T12:38:18.990913
스레드3 시작 2023-10-03T12:38:18.990610
스레드1의 syncMethod1 실행중2023-10-03T12:38:19.157825
스레드3의 method3 실행중2023-10-03T12:38:19.158088
스레드2의 syncMethod2 실행중2023-10-03T12:38:24.253210
스레드1 종료 2023-10-03T12:38:24.253210
스레드3 종료 2023-10-03T12:38:24.254144
스레드2 종료 2023-10-03T12:38:29.256871

2) 클래스 락 획득

Synchronized를 사용하면 객체의 모니터 락을 획득한다고 했습니다. 그렇다면 static method에 synchronized를 사용하면 어떨까요? static method의 경우, 객체 생성 없이도 호출하여 사용할 수 있습니다. 이는 JVM 내 런타임 데이터 영역 중 method 영역에 적재되어 사용되기 때문입니다. synchronized는 기본적으로 java object의 monitor lock을 활용합니다. static method에는 연결된 객체가 없으므로 synchronized를 사용하면 스레드가 객체 대신 클래스에 대한 락을 획득합니다.

static synchronized method

public class SyncStaticTest {

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            System.out.println("스레드1 시작 " + LocalDateTime.now());
            syncStaticMethod1("스레드1");
            System.out.println("스레드1 종료 " + LocalDateTime.now());
        });

        Thread thread2 = new Thread(() -> {
            System.out.println("스레드2 시작 " + LocalDateTime.now());
            syncStaticMethod2("스레드2");
            System.out.println("스레드2 종료 " + LocalDateTime.now());
        });

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

    public static synchronized void syncStaticMethod1(String msg) {
        System.out.println(msg + "의 syncStaticMethod1 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static synchronized void syncStaticMethod2(String msg) {
        System.out.println(msg + "의 syncStaticMethod2 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

마찬가지로 실행 순서가 보장되는 것을 알 수 있습니다.

스레드1 시작 2023-10-03T12:48:25.732295
스레드2 시작 2023-10-03T12:48:25.732132
스레드1의 syncStaticMethod1 실행중2023-10-03T12:48:25.868206
스레드1 종료 2023-10-03T12:48:30.915236
스레드2의 syncStaticMethod2 실행중2023-10-03T12:48:30.915236
스레드2 종료 2023-10-03T12:48:35.922537

그렇지만 앞서, synchronized method를 동시에 실행하면, 동기화가 보장되지 않습니다. 이는 synchronized static method를 실행하는 스레드와 synchronized method를 실행하는 스레드 간 획득한 락이 서로 다르기 때문입니다. 락이 공유되지 않기 때문에 동기화 역시 보장되지 않게 됩니다.

public class SyncStaticTest {

    public static void main(String[] args) {
        SyncStaticTest sync = new SyncStaticTest();

        Thread thread1 = new Thread(() -> {
            System.out.println("스레드1 시작 " + LocalDateTime.now());
            syncStaticMethod1("스레드1");
            System.out.println("스레드1 종료 " + LocalDateTime.now());
        });

        Thread thread2 = new Thread(() -> {
            System.out.println("스레드2 시작 " + LocalDateTime.now());
            syncStaticMethod2("스레드2");
            System.out.println("스레드2 종료 " + LocalDateTime.now());
        });
        Thread thread3 = new Thread(() -> {
            System.out.println("스레드3 시작 " + LocalDateTime.now());
            sync.syncMethod3("스레드3");
            System.out.println("스레드3 종료 " + LocalDateTime.now());
        });

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

    public static synchronized void syncStaticMethod1(String msg) {
        System.out.println(msg + "의 syncStaticMethod1 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static synchronized void syncStaticMethod2(String msg) {
        System.out.println(msg + "의 syncStaticMethod2 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private synchronized void syncMethod3(String msg) {
        System.out.println(msg + "의 syncMethod3 실행중" + LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
스레드1 시작 2023-10-03T12:54:37.435351
스레드3 시작 2023-10-03T12:54:37.437883
스레드2 시작 2023-10-03T12:54:37.438601
스레드1의 syncStaticMethod1 실행중2023-10-03T12:54:37.649475
스레드3의 syncMethod3 실행중2023-10-03T12:54:37.655082
스레드2의 syncStaticMethod2 실행중2023-10-03T12:54:42.730617
스레드3 종료 2023-10-03T12:54:42.730526
스레드1 종료 2023-10-03T12:54:42.730598

기타 : Synchronized block

synchronized block을 사용하여 동기화하는 방법에는 block의 인자로 this와 object를 전달하여 락을 거는 방법, 총 두 가지가 있습니다.

synchronized(this)

자바의 this는 현재 객체를 가리키기 때문에, 두 쓰레드 간 동기화가 보장됩니다.

public class SyncBlockTest {
    public static void main(String[] args) throws InterruptedException {

        SyncBlockTest block = new SyncBlockTest();

        Thread thread1 = new Thread(() -> {
            System.out.println("스레드1 시작 " + LocalDateTime.now());
            block.syncBlockMethod1("스레드1");
            System.out.println("스레드1 종료 " + LocalDateTime.now());
        });

        Thread thread2 = new Thread(() -> {
            System.out.println("스레드2 시작 " + LocalDateTime.now());
            block.syncBlockMethod2("스레드2");
            System.out.println("스레드2 종료 " + LocalDateTime.now());
        });

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

    private void syncBlockMethod1(String msg) {
        synchronized (this) {
            System.out.println(msg + "의 syncBlockMethod1 실행중" + LocalDateTime.now());
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void syncBlockMethod2(String msg) {
        synchronized (this) {
            System.out.println(msg + "의 syncBlockMethod2 실행중" + LocalDateTime.now());
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
스레드1 시작 2023-10-03T13:33:10.649740
스레드1의 syncBlockMethod1 실행중2023-10-03T13:33:10.719720
스레드2 시작 2023-10-03T13:33:11.563358
스레드1 종료 2023-10-03T13:33:15.794734
스레드2의 syncBlockMethod2 실행중2023-10-03T13:33:15.794734
스레드2 종료 2023-10-03T13:33:20.801018

synchronized(Object)

object의 경우에는 동일한 객체를 전달 받았을 경우, 동기화가 보장됩니다.

public class SyncBlockObjTest {

    private final Object o1 = new Object();
    private final Object o2 = new Object();

    public static void main(String[] args) throws InterruptedException {

        SyncBlockObjTest block = new SyncBlockObjTest();

        Thread thread1 = new Thread(() -> {
            System.out.println("스레드1 시작 " + LocalDateTime.now());
            block.syncBlockMethod1("스레드1");
            System.out.println("스레드1 종료 " + LocalDateTime.now());
        });

        Thread thread2 = new Thread(() -> {
            System.out.println("스레드2 시작 " + LocalDateTime.now());
            block.syncBlockMethod2("스레드2");
            System.out.println("스레드2 종료 " + LocalDateTime.now());
        });

        Thread thread3 = new Thread(() -> {
            System.out.println("스레드3 시작 " + LocalDateTime.now());
            block.syncBlockMethod3("스레드3");
            System.out.println("스레드3 종료 " + LocalDateTime.now());
        });

        Thread thread4 = new Thread(() -> {
            System.out.println("스레드4 시작 " + LocalDateTime.now());
            block.syncBlockMethod4("스레드4");
            System.out.println("스레드4 종료 " + LocalDateTime.now());
        });

        thread1.start();
        sleep(1000);
        thread3.start();

        thread2.start();
        sleep(1000);
        thread4.start();
    }

    private void syncBlockMethod1(String msg) {
        synchronized (o1) {
            System.out.println(msg + "의 syncBlockMethod1 실행중" + LocalDateTime.now());
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void syncBlockMethod2(String msg) {
        synchronized (o2) {
            System.out.println(msg + "의 syncBlockMethod2 실행중" + LocalDateTime.now());
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void syncBlockMethod3(String msg) {
        synchronized (o1) {
            System.out.println(msg + "의 syncBlockMethod3 실행중" + LocalDateTime.now());
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void syncBlockMethod4(String msg) {
        synchronized (o2) {
            System.out.println(msg + "의 syncBlockMethod4 실행중" + LocalDateTime.now());
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

1과 3, 그리고 2와 4는 동일한 객체를 전달 받았기 때문에 각각 동기화가 보장되는 것을 알 수 있습니다.

스레드1 시작 2023-10-03T13:30:31.644834
스레드1의 syncBlockMethod1 실행중2023-10-03T13:30:31.704465
스레드3 시작 2023-10-03T13:30:32.545810
스레드2 시작 2023-10-03T13:30:32.545849
스레드2의 syncBlockMethod2 실행중2023-10-03T13:30:32.552129
스레드4 시작 2023-10-03T13:30:33.552814
스레드1 종료 2023-10-03T13:30:36.788321
스레드3의 syncBlockMethod3 실행중2023-10-03T13:30:36.788686
스레드2 종료 2023-10-03T13:30:37.554968
스레드4의 syncBlockMethod4 실행중2023-10-03T13:30:37.554990
스레드3 종료 2023-10-03T13:30:41.795053
스레드4 종료 2023-10-03T13:30:42.565842

가시성 문제와 Memory Flush

아래와 같이, 한 스레드에서 무한 루프를 유발하는 코드가 있습니다. stopRequested 변수 값이 true로 바뀌었음에도, 해당 루프를 탈출하지 못합니다. 성능 상의 이유로 CPU는 프로그램을 실행할 때, 레지스터 안에 캐시 메모리(L1 혹은 L2 캐시) 안에 존재하는 로컬 변수로부터 공유 자원을 불러와 사용합니다. main 쓰레드에서 stopRequested 값이 바뀌며 메모리 상에선 값이 변경되게 됩니다. 동시에 캐시 메모리 상에는 반영되지 않아, 값이 서로 차이가 나게 되죠. 이처럼 CPU Cache Memory와 RAM의 데이터가 서로 일치하지 않아 생기는 문제가시성 문제라고 합니다. 아래와 같은, 멀티 스레드 환경에서 발생하게 됩니다.

public class VolatileWithSyncTest {

    private static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        System.out.println("start = " + stopRequested + LocalDateTime.now());
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) {
                i++;
            }
        });
        backgroundThread.start();

        Thread.sleep(1000);
        stopRequested = true;
        System.out.println("end = " + stopRequested + LocalDateTime.now());
    }
}

synchronized을 통한 가시성 확보

가시성 문제를 해결하기 위해선, volatile을 사용하거나 synchronized 블록을 사용하여, 다수의 스레드에서 바라보는 변수를 동기화해줘야 합니다. synchronized 블록을 사용하면, 해당 쓰레드가 블록 진입 전에 CPU Cache Memory와 Main Memory를 즉시 동기화 해줍니다. 이를 Memory Flush라고 합니다. 그러면 main 쓰레드에서 true로 바뀐 stopRequested 변수 값이 Main Memory 상에 동기화되며, 루프를 실행하는 쓰레드에서 참조하고 있던 stopRequested도 true로 바뀌면서 무한루프를 탈출하게 됩니다.

public class VolatileWithSyncTest {

    private static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        System.out.println("start = " + stopRequested + LocalDateTime.now());
        Thread backgroundThread = new Thread(() -> {
            Integer i = 0;
            while (!stopRequested) {
                synchronized (i){
                    i++;
                }
            }
        });
        backgroundThread.start();

        Thread.sleep(1000);
        stopRequested = true;
        System.out.println("end = " + stopRequested + LocalDateTime.now());
    }
}

volatile을 통한 가시성 확보

아래와 같이, 캐시 메모리와 Main Memory를 동기화하려는 변수에 volatile이란 키워드를 붙여줍니다.

private volatile static boolean stopRequested;

전체 코드와 이에 대한 실행 결과는 다음과 같습니다.

public class VolatileTest {
    private volatile static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        sync();
    }

    private static void sync() throws InterruptedException {
        System.out.println("start = " + stopRequested + LocalDateTime.now());
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) {
                i++;
            }
        });
        backgroundThread.start();

        Thread.sleep(1000);
        stopRequested = true;
        System.out.println("end = " + stopRequested + LocalDateTime.now());
    }
}

stopRequested 값이 변경되며, mainThread에서 변경된 stopRequested 값이 mainMemory에 반영되고 이를 backgroundThread에서도 이를 읽어들여 성공적으로 루프를 탈출할 수 있게 됩니다.

start = false2023-10-04T21:00:19.540309
end = true2023-10-04T21:00:20.584254

실행 순서 제어

동기화를 위한 작업을 위해선 상호 배제 뿐 아니라, 실행 순서 제어도 보장되어야 합니다. 이는 추후 포스팅을 통해 더욱 자세히 알아보도록 하겠습니다.

참고한 사이트

https://steady-coding.tistory.com/556
https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.1
https://jenkov.com/tutorials/java-concurrency/java-happens-before-guarantee.html#the-java-synchronized-visibility-guarantee

반응형