자바 언어에서 동기화에 대한 질문입니다.

2013-08-16 21:53

자바 언어에서 멀티 스레드 상황에서 동기화를 위해 synchronized를 사용한다는 것은 대부분의 자바 개발자가 알고 있을 겁니다. 그런데 synchronized를 정확하게 이해하고 사용하는 개발자는 많지 않을 듯 합니다. 오늘 멀티 스레드에 대한 수업을 하다가 synchronized를 사용했을 때의 정확한 처리 메커니즘에 대해 나 또한 명확하게 알고 있지 못해 질문을 올립니다. 지금까지 특별한 고민 없이 알고 있었는데(하지만 개발하면서 synchronize 사용한 경험은 거의 없네요.) 학생들 가르치다 보니 더 정확하게 알아야겠다는 생각이 들더라고요.

요구사항은 멀티 스레드 상황에서 고유한 아이디 값을 구하는 상황을 재현해 봤습니다.

public class MultiThreadSynchronize { 
    private int index = 0;
    
    MultiThreadSynchronize() {
    }


    public int getMax() {
        index++;
        return index;
    }


    public static void main(String[] args) {
        MultiThreadSynchronize synchronize = new MultiThreadSynchronize();
        new SyncThread(synchronize).start();
        new SyncThread(synchronize).start();
        new SyncThread(synchronize).start();
    }
}


class SyncThread extends Thread {
    private MultiThreadSynchronize synchronize;


    public SyncThread(MultiThreadSynchronize synchronize) {
        this.synchronize = synchronize;
    }


    @Override
    public void run() {
        long start = System.currentTimeMillis();
        int max = synchronize.getMax();
        while (max < 1000) {
            System.out.println(max);
            max = synchronize.getMax();
        }
        long end = System.currentTimeMillis();
        System.out.println("execution time : " + (end - start));
    }
}

이 코드의 경우 멀티 스레드 상황에서 index++ 부분에서 중복된 값이 발생할 수 있기 때문에 synchronized를 통해 문제를 해결하는 방법을 찾았습니다. 여러 가지 해결 방법을 제시했죠.

1번은 가장 쉽게 메소드에 synchronized를 추가한다.

    public synchronized int getMax() {
        index++;
        return index;
    }

2번은 code block에 synchronized를 추가한다.

    public int getMax() {
        synchronized(this) {
            index++;
            return index;
        }
    }

3번은 Lock을 현재 인스턴스인 this로 하지 않고 별도의 클래스로 Lock을 잡는다.

    private static final Object countLock = new Object();
    
    public int getMax() {
        synchronized(countLock) {
            index++;
            return index;
        }
    }

물론 가장 좋은 해결책은 primitive type인 경우 다음 코드와 같이 concurrent api를 이용하는 것이 좋을 듯 합니다.

    private AtomicInteger atomicInt = new AtomicInteger();
    
    public int getMax() {
        return atomicInt.incrementAndGet();
    }

하지만 synchronized를 정확하게 이해하고 사용할 필요가 있다고 생각하는데요. method에 synchronized를 사용할 때와 코드 블록에 synchronized를 사용할 때의 차이점과 장,단점이 뭘까요? 또한 코드 블록에 synchronized를 사용할 때 Lock을 어느 클래스로 잡느냐는 어떤 기준으로 하는 것이 좋을까요?

이와 관련한 내용을 제대로 이해하려면 어떤 자료를 보는 것이 좋을지도 조언해 주시면 많은 도움이 될 듯 합니다. 역시 기초가 부실하다보니 깊이 있는 질문을 받으면 제대로 된 답변을 하지 못하네요. 자바 병렬 프로그래밍을 할 일이 그리 많지는 않지만 제대로 알고 사용해야 문제가 발생했을 때 제대로 된 해결책을 찾을 수 있을 듯 해서 질문 드립니다.

1개의 의견 from FB

BEST 의견 원본위치로↓
2013-08-19 05:28

앞의 분들이 말씀하신것처럼, 현재 구현으로는 1,2번은 같은 코드이고, 다른 방법으로 java.util.concurrent.locks 패키지를 이용하는 구현을 추가하는 편이 나을 것 같습니다.

( 예제: http://git.springsource.org/s2gx-2010/concurrent-programming-distributed-applications/blobs/master/concurrency/src/main/java/counter/LockingCounter.java )

참고로 locks패키지에 있는 구현체들은 같은 기능을 synchronized로 잡는것보다 성능이 더 좋다고 알려져있는데요, Java concurrency in practice 책에 보면 그래도 대부분의 경우에 언어차원의 문법으로 지원하는 synchronized를 쓸 것을 권장하고 있고, 성능차이도 Java 최신버전으로 올수록 줄어들고 있다고 합니다. 하지만 ReadWriteLock처럼 synchronized로 대체하면 더 번거로워지는 경우도 있으니 그럴때는 대체로 쓰는게 좋겠죠.

그리고 synchronized 블록과 대상 객체를 잡는 기준은 '가능한 좁은 범위'가 우선 원칙이 될만합니다. 1,2번은 현재로서는 같은 구현이지만 나중에 코드가 메소드가 추가될때 synchronized의 범위를 좀 더 고민하게 된다는 점에서 2번이 저는 더 바람직한 습관이라고 생각합니다. Lock 분할, Lock 스트라이핑 등 대부분의 기법이 lock의 범위를 어떻게 줄일 것이냐에 대한 고민이니까요..

lock대상 객체를 this로 잡는것도 경우에 따라서는 부작용이 있는데요, Thread를 상속한 클래스에서 this로 잡은 lock은 Thread.join에서 의도하지 않게 풀려버릴수도 있는 등의 예가 있습니다. (Java Puzzler의 77번 퍼즐). 그래서 private Object lock; synchronized(lock) {} 과 같이 Lock전용 객체를 쓰거나, this보다 좁은 범위로 접근을 통제할 객체를 쓰는 것을 고민하는 습관을 들여야겠죠..

추천서적은 다른 분들도 이야기하신 java concurrency in practice이고, 분량 대비 내용은 Effective Java의 Concurrency 장이 액기스라고 느껴졌어요. devcafe에 가면 제가 사내에서 Java concurrency 강의할때 쓴 자료와 예제들이 있는데, 특별한 내용은 없지만 자료 탐색과 정리시간을 줄이시는데 도움이 되실지도 모르겠네요..

4개의 의견 from SLiPP

2013-08-16 22:09

1, 2번은 동일한 것이고 3번만 다른 형식. 즉, 메서드에 synchronized를 붙이는 것은 메서드 전체코드에 synchronized(this)를 한 것과 같다. 간단히 말 하자면 하나의 객체 내부 메서드에서 자기 자신을 이용하여 sync를 하는 경우에 해당하고, 대개의 경우 Thread 객체나 Runnable interface를 구현한 객체의 경우에 사용한다. sync 블럭이 전체에 설정되는 것은 비효율적이 될 수 있기 때문에 가장 좋은 것은 보호하고자 하는 값이 변경되는 부분을 중심으로 sync 블럭을 설정하는 것이 좋다. 3번의 경우는 동기화를 위한 객체를 따로 선언하고 사용하는 방법인데, 이 경우는 두 개 이상의 Thread에서 협력적인 작업을 하고자 하는 경우에 사용할 수 있다.

결론적으로 한 Thread 클래스의 메서드나 로직에서 공용변수를 보호하려면 this를 쓰던, static final로 별도의 lock 변수를 쓰던 상관이 없으나, 주로 this를 사용함이 충분하고 두 개 이상의 Thread 클래스들이 상호 협력작업을 하려면 외부에 객체를 생성하고 이를 이용하여 sync를 쓰는 것이 좋을 듯. 나중에 성능을 생각한다면 메서드에 sync를 쓰기보다는 메서드의 일 부분에 sync 블럭을 지정하는 것을 추천.

2013-08-16 22:45

자바는 객체마다 연관된 '모니터'를 갖고 있는데, synchronized 는 이 모니터를 획득/해제하는 방식을 통해서 한 쓰레드만 synchronized 영역을 실행할 수 있도록 함으로써 쓰레드의 동시 접근을 처리합니다.

쓰레드 접근 제어의 단위는 '모니터'인데, 이 모니터는 객체와 관련되어 있으므로, 자바에서 synchronized는 '객체'를 이용해서 접근 제어를 하게 됩니다. 이런 과점에서 1번과 2번은 효과가 (거의) 같다고 볼 수 있습니다. (메서드에 synchronized를 하면, 객체 자신-즉, this와 관련된 모니터를 사용하므로, synchronized(this)를 사용한 코드 블록과 동일한 모니터를 사용하게 됩니다.)

synchronized를 사용하면 아무래도 다중 쓰레드가 코드를 실행할 수 없기 때문에, 각 쓰레드들이 (순서를 알 수 없는 상태로) 순차적으로 synchronized 영역을 실행하게 되죠. 그래서, 한 메서드에서 일부부만 동기화가 필요하다면 이런 경우에는 메서드 전체를 synchronized로 하는 것보단, 특정 코드 영역만 synchronized로 하는 것이 다중 쓰레드 환경에서 쓰레드의 이점을 살릴 수 있게 됩니다.

    ... // someCode
    synchronized(this) {
        동기화영역을 최소화할 때에 synchronized 블록을 사용
    }
    ... // anyCode
}```


그런데, 멀티쓰레드 환경에 알맞은 코드를 작성하다보면 한 객체의 두 종류의 데이터는 동시에 두 쓰레드가 접근해도 문제가 없는 경우가 있습니다. 그런데, 각 데이터 군은 동시 접근을 막아야 하는 경우가 있죠. 예를 들어, 아래 코드를 보죠. (그냥 설명을 위해 억지로 만든 급조한 예제에요... 더 좋은 예제는 교수님이 만들어주셔요~)


private int width;
private int height;


private Object sizeLock = new Object();


private int revision;


public int getArea() {
    synchronized(sizeLock) {
        return width * height;
    }
}
public void setSize(int width, int height) {
    synchronized(sizeLock) {
        this.width = width;
        this.height = height;
    }
}


public synchronized int getRevision() {
    return revision;
}
public synchronized void increaseRevision() {
    revision++;
}

}```

여기서, width/height와 revision은 서로 동시 접근을 막을 필요가 없다고 한다고 해 보죠. 이 경우 한 쓰레드가 width/height의 값을 변경하는 동안에 다른 쓰레드가 revision의 값을 접근하는 것을 막을 필요가 없을 것입니다. 따라서, 하나의 모니터(즉, 객체)를 사용하기 보단, 두 데이터 군에 대해 서로 다른 모니터를 사용하도록 함으로써 쓰레드에 대한 동시 접근을 알맞게 제어할 수 있게 됩니다.

그런데, 메서드에 synchronized를 붙일 때에는 조심할 게 있는데, 그것은 바로 this를 사용한다는 점입니다. 아래 코드를 보죠.

        final Rectangle rec = new Rectangle();
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (rec) {
                    System.out.println("synchronized(rec) begin");
                    try {
                        Thread.sleep(10000);
                    } catch (InterruptedException e) {
                    }
                    System.out.println("synchronized(rec) end");
                }
            }
        });
        t1.start();


        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        System.out.println("rec.increaseRevision() before");
        rec.increaseRevision();
        System.out.println("rec.increaseRevision() after");
    }```


이 코드에서 t1 쓰레드는 rec 객체를 이용해서 구성된 synchronized 블록을 실행합니다.
메인 쓰레드는 rec.increaseRevision()을 실행하구요.
여기서 t1 쓰레드의 synchronized 블록과 rec.increaseRevision() 메서드는 동일한 모니터(객체)를 사용하기 때문에 한 쓰레드가 먼저 코드를 실행하게 되면, 다른 쓰레드는 대기하게 됩니다.
실제 실행 결과는 아래와 같아요.


rec.increaseRevision() before ---> 메인 쓰레드는 대기 synchronized(rec) end --> t1 쓰레드가 모니터 해제 rec.increaseRevision() after ---> 메인 쓰레드가 비로서 실행```

이 예만 보더라도, 메서드에 synchronized를 적용하는 것은 다소 위험이 따를 수 있습니다. 따라서, 실제 코드에서는 synchronized를 메서드에 적용하기 보다는 자바 5부터 추가된 Lock을 이용해서 동시 접근을 제어하는 것이 더 안전한 코드를 만들 가능성을 높여준다고 할 수 있습니다.

2013-08-19 05:28

앞의 분들이 말씀하신것처럼, 현재 구현으로는 1,2번은 같은 코드이고, 다른 방법으로 java.util.concurrent.locks 패키지를 이용하는 구현을 추가하는 편이 나을 것 같습니다.

( 예제: http://git.springsource.org/s2gx-2010/concurrent-programming-distributed-applications/blobs/master/concurrency/src/main/java/counter/LockingCounter.java )

참고로 locks패키지에 있는 구현체들은 같은 기능을 synchronized로 잡는것보다 성능이 더 좋다고 알려져있는데요, Java concurrency in practice 책에 보면 그래도 대부분의 경우에 언어차원의 문법으로 지원하는 synchronized를 쓸 것을 권장하고 있고, 성능차이도 Java 최신버전으로 올수록 줄어들고 있다고 합니다. 하지만 ReadWriteLock처럼 synchronized로 대체하면 더 번거로워지는 경우도 있으니 그럴때는 대체로 쓰는게 좋겠죠.

그리고 synchronized 블록과 대상 객체를 잡는 기준은 '가능한 좁은 범위'가 우선 원칙이 될만합니다. 1,2번은 현재로서는 같은 구현이지만 나중에 코드가 메소드가 추가될때 synchronized의 범위를 좀 더 고민하게 된다는 점에서 2번이 저는 더 바람직한 습관이라고 생각합니다. Lock 분할, Lock 스트라이핑 등 대부분의 기법이 lock의 범위를 어떻게 줄일 것이냐에 대한 고민이니까요..

lock대상 객체를 this로 잡는것도 경우에 따라서는 부작용이 있는데요, Thread를 상속한 클래스에서 this로 잡은 lock은 Thread.join에서 의도하지 않게 풀려버릴수도 있는 등의 예가 있습니다. (Java Puzzler의 77번 퍼즐). 그래서 private Object lock; synchronized(lock) {} 과 같이 Lock전용 객체를 쓰거나, this보다 좁은 범위로 접근을 통제할 객체를 쓰는 것을 고민하는 습관을 들여야겠죠..

추천서적은 다른 분들도 이야기하신 java concurrency in practice이고, 분량 대비 내용은 Effective Java의 Concurrency 장이 액기스라고 느껴졌어요. devcafe에 가면 제가 사내에서 Java concurrency 강의할때 쓴 자료와 예제들이 있는데, 특별한 내용은 없지만 자료 탐색과 정리시간을 줄이시는데 도움이 되실지도 모르겠네요..

의견 추가하기

연관태그

← 목록으로