fragile and resilient

Java

[Java] 스레드 동기화 - Synchronized

Green Lawn 2022. 9. 11. 17:20

얼마 전 팀 프로젝트에서 모집 인원이 초과하였는데도 스터디 참여가 가능한 일이 발생했다.
상황에 대한 설명을 덧붙이면 모집 인원이 3명이고, 현재 2명이 참여한 상태에서 동시에 추가로 2명이 참여 버튼을 누를 경우 가입이 성공하여 총 4명이 되는 문제였다.

'스터디 참여자 수’라는 공유 자원이 존재하여 동시성 문제라고 생각하여 스터디 참여를 처리하는 메서드에 synchronized 키워드를 붙여주니까 발생하지 않았다. 

해당 문제를 팀원들과 인지한 후 동시성 문제에 대한 이야기를 많이 했는데, 현재 방법이 비효율적인 방식일 수도 있으니 해당 방법 이외의 더 좋은 방식으로 풀 수는 없는지 고민하기로 했다. 

자바는 스레드 동기화를 지원하는 방법들을 제공하는데, 그중 위에서 언급한 synchronized에 4가지 방법이 존재하고 있어 이에 대해 정리해 보고자 한다.


[Synchronized]

멀티 스레드인 경우 여러 스레드가 프로세스의 자원을 공유(Code, Data, Heap 영역)하기 때문에 서로의 작업에 영향을 줄 수 있습니다.
따라서 공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정하고, 공유 데이터가 가지고 있는 lock을 획득한 하나의 스레드만 해당 영역 내의 코드를 수행할 수 있도록 해야 합니다.
이처럼 하나의 스레드가 진행 중인 작업을 다른 스레드가 간섭하지 못하도록 막는 것을 스레드 동기화(Synchronization)라고 합니다.

자바에서 스레드 동기화를 지원하는 방법들 중 하나인 synchronized를 여러 예시를 통해 확인해보겠습니다.


1) synchronized method
2) static synchronized method
3) synchronized block

 

1) synchronized method

인스턴스의 메서드 영역 전체를 임계 영역으로 지정하는 방법입니다.
1.1)

public class Test {

    public static void main(String[] args) {
        final Target1 target1 = new Target1();

        final Thread thread1 = new Thread(() ->
                target1.run("Thread1")
        );

        final Thread thread2 = new Thread(() ->
                target1.run("Thread2")
        );

        thread1.start();
        thread2.start();
    }
}
public class Target1 {

    public synchronized void run(String threadName) {
        System.out.println("START " + threadName);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("END " + threadName);
    }
}​

실행 결과를 보면 Thead1이 먼저 임계 영역에 들어가서 lock을 획득하고 반납한 후,
Thread2가 lock을 획득하여 반납하는 것을 알 수 있다.
즉, 동기화된 것을 알 수 있습니다.

1.2)
이번에는 Target2라는 새로운 인스턴스를 만들어서 실행해 보겠습니다.

public class Test {

    public static void main(String[] args) {
        final Target1 target1 = new Target1();
        final Target1 target2 = new Target1();

        final Thread thread1 = new Thread(() ->
                target1.run("Thread1")
        );

        final Thread thread2 = new Thread(() ->
                target2.run("Thread2")
        );

        thread1.start();
        thread2.start();
    }
}
public class Target1 {

    public synchronized void run(String threadName) {
        System.out.println("START " + threadName);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("END " + threadName);
    }
}

위 결과처럼 각각의 인스턴스에서는 Lock을 공유하지 않습니다.

2) static synchronized method

인스턴스가 아닌, 클래스의 메서드 영역 전체를 임계 영역으로 지정하는 방법입니다.
2.1)

public class Test {

    public static void main(String[] args) {
        final Target1 target1 = new Target1();
        final Target1 target2 = new Target1();

        final Thread thread1 = new Thread(() ->
                target1.run("Thread1")
        );

        final Thread thread2 = new Thread(() ->
                target2.run("Thread2")
        );

        thread1.start();
        thread2.start();
    }
}
public class Target1 {

    public static synchronized void run(String threadName) {
        System.out.println("START " + threadName);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("END " + threadName);
    }
}

각각의 인스턴스임에도 불구하고 동기화된 것을 알 수 있습니다.
1.2 예제와 함께 봐주시면 이해에 도움이 될 것 같습니다.

3) synchronized block

인스턴스의 메서드 특정 영역을 임계 영역으로 지정하는 방법입니다.
3.1)

public class Test {

    public static void main(String[] args) {
        final Target1 target1 = new Target1();

        final Thread thread1 = new Thread(() ->
                target1.run("Thread1")
        );

        final Thread thread2 = new Thread(() ->
                target1.run("Thread2")
        );

        thread1.start();
        thread2.start();
    }
}
public class Target1 {

    public void run(String threadName) {
        synchronized (this) {
            System.out.println("START " + threadName);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("END " + threadName);
        }
    }
}

위의 예제는 lock을 전체를 걸어 놓았기 때문에 결과를 쉽게 예상할 수 있습니다.

메서드 전체가 아닌 일부분에 lock을 걸고 실행해 보겠습니다.
3.2)

public class Test {

    public static void main(String[] args) {
        final Target1 target1 = new Target1();

        final Thread thread1 = new Thread(() ->
                target1.run("Thread1")
        );

        final Thread thread2 = new Thread(() ->
                target1.run("Thread2")
        );

        thread1.start();
        thread2.start();
    }
}
public class Target1 {

    public void run(String threadName) {
        System.out.println("START " + threadName);

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        synchronized (this) {
            System.out.println("END " + threadName);
        }
    }
}

임계 영역인 END 출력 부분에서만 동기화된 것을 확인할 수 있었습니다.

 


++) 스레드의 동기화 순서는 보장되지 않습니다.
이해를 돕기 위해 올바른 순서가 나오는 경우의 사진만 첨부한 것이며, 아래처럼 결과가 나오기도 했습니다.


 

위에서 사용하는 lock 기반 방식이 아닌 CAS를 활용해서 구현할 수도 있다.

 

References

https://jgrammer.tistory.com/entry/Java-%ED%98%BC%EB%8F%99%EB%90%98%EB%8A%94-synchronized-%EB%8F%99%EA%B8%B0%ED%99%94-%EC%A0%95%EB%A6%AC
https://brunch.co.kr/@kd4/156
남궁성, 자바의 정석 3판, 2016