Java

[Java] synchronized 키워드

HGLee-- 2022. 8. 26. 22:25

Synchronized

Java는 크게 3가지 메모리 영역을 가지고 있다.

  • static 영역
  • heap 영역
  • stack 영역

자바 멀티 스레드 환경에서는 스레드끼리 static 영역과 heap 영역을 공유하므로 공유 자원에 대한 동기화 문제를 신경 써야 한다. 원자성 문제를 해결하기 위한 방법 중 하나인 synchronized 키워드에 대해 설명하려고 한다.

synchronized는 lock을 이용해 동기화를 수행하며 4가지의 사용 방법이 존재한다.

  • synchronized method
  • static synchronized method
  • synchronized block
  • static synchronized block

 

Synchronized method

public class Method {

    public static void main(String[] args) {

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

        Thread thread2 = 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();
        }
    }
}

Method 인스턴스를 한 개 생성하고, 두 개의 스레드를 만들어 각각 synchronized 키워드가 붙은 syncMethod1(), syncMethod2()를 호출하였다. 결과는 다음과 같다.

스레드1 시작 2022-08-25T19:52:39.878528300
스레드2 시작 2022-08-25T19:52:39.878528300
스레드1의 syncMethod1 실행중 2022-08-25T19:52:39.904530200
스레드1 종료 2022-08-25T19:52:44.937346700
스레드2의 syncMethod2 실행중 2022-08-25T19:52:44.937346700
스레드2 종료 2022-08-25T19:52:49.940427800

스레드 1이 syncMethod1()을 호출한 후 종료된 다음 스레드 2가 syncMethod2()를 호출한 것을 알 수 있다.

위 예시는 하나의 인스턴스를 서로 다른 스레드가 실행한 경우이다. 이제, 각각의 인스턴스를 만들고 스레드들이 메소드를 호출하도록 해 보자.

 

public class Method {

    public static void main(String[] args) {

        Method method1 = new Method();
        Method method2 = new Method();

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

        Thread thread2 = new Thread(() -> {
            System.out.println("스레드2 시작 " + LocalDateTime.now());
            method2.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();
        }
    }
}

결과는 다음과 같다.

스레드1 시작 2022-08-25T19:57:25.116962500
스레드2 시작 2022-08-25T19:57:25.116962500
스레드1의 syncMethod1 실행중 2022-08-25T19:57:25.151963800
스레드2의 syncMethod2 실행중 2022-08-25T19:57:25.151963800
스레드1 종료 2022-08-25T19:57:30.185566200
스레드2 종료 2022-08-25T19:57:30.185566200

 

위와 같이 인스턴스를 따로 만든 상황에서는 lock을 공유하지 않기 때문에 스레드 간의 동기화가 발생하지 않는다.

결과를 보면 알 수 있듯, synchronized method는 인스턴스에 lock을 건다. 인스턴스에 lock을 건다고 표현하여 인스턴스 접근 자체에 lock이 걸린다고 혼동할 수 있지만 그것은 아니다.

 

아래의 예제를 보자.

public class MethodEx {

    public static void main(String[] args) {

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

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

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

        thread1.start();
        thread2.start();
        thread3.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();
        }
    }
}

스레드3을 추가하고 synchronized 키워드가 붙지 않은 method3()를 호출하였다.

결과는 다음과 같다.

스레드3 시작 2022-08-25T20:01:53.693142700
스레드1 시작 2022-08-25T20:01:53.693142700
스레드2 시작 2022-08-25T20:01:53.692142600
스레드1의 syncMethod1 실행중2022-08-25T20:01:53.721140
스레드3의 method3 실행중2022-08-25T20:01:53.721140
스레드1 종료 2022-08-25T20:01:58.745621800
스레드3 종료 2022-08-25T20:01:58.745621800
스레드2의 syncMethod2 실행중2022-08-25T20:01:58.745621800
스레드2 종료 2022-08-25T20:02:03.750979400

위 결과에서 알 수 있듯, 스레드 3에는 동기화가 발생하지 않았음을 확인할 수 있다.

즉, synchronized 메소드는 인스턴스 단위로 lock을 걸지만, synchronized 키워드가 붙은 메소드들에 대해서만 lock을 공유한다. 쉽게 말해서, 한 스레드가 synchronized 메소드를 호출하는 순간, 모든 synchronized 메소드에 lock이 걸리므로 다른 스레드가 어떠한 synchronized 메소드를 호출할 수 없다. (단, 일반 메소드는 호출 가능)

 

 

static synchronized method

static 키워드가 포함된 synchronized 메소드는 인스턴스가 아닌 클래스 단위로 lock을 공유한다.

다음 예시를 보자.

public class StaticMethod {

    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 시작 2022-08-25T20:06:08.994112
스레드2 시작 2022-08-25T20:06:08.992110400
스레드1의 syncStaticMethod1 실행중2022-08-25T20:06:09.027108600
스레드1 종료 2022-08-25T20:06:14.056047200
스레드2의 syncStaticMethod2 실행중2022-08-25T20:06:14.056047200
스레드2 종료 2022-08-25T20:06:19.062216700

synchronized 메소드처럼 lock을 공유하여 메소드 간의 동기화가 이루어지고 있다. 다만, 여기서는 위의 예시와는 달리 인스턴스 단위가 아니라 클래스 단위로 lock을 공유하게 된다.

만약 이 상태에서 synchronized 메소드를 추가한다면 어떻게 될까?

 

public class StaticMethod {

    public static void main(String[] args) {
        StaticMethod staticMethod = new StaticMethod();

        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());
            staticMethod.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();
        }
    }
}

결과는 다음과 같다.

스레드3 시작 2022-08-25T20:08:27.723447500
스레드2 시작 2022-08-25T20:08:27.722448400
스레드1 시작 2022-08-25T20:08:27.722448400
스레드2의 syncStaticMethod2 실행중2022-08-25T20:08:27.774444400
스레드3의 syncMethod3 실행중2022-08-25T20:08:27.774444400
스레드2 종료 2022-08-25T20:08:32.805171700
스레드1의 syncStaticMethod1 실행중2022-08-25T20:08:32.805171700
스레드3 종료 2022-08-25T20:08:32.805171700
스레드1 종료 2022-08-25T20:08:37.806213500

static synchronized 메소드를 사용하는 스레드 1과 스레드 2 간에는 동기화가 잘 지켜지는 것을 확인할 수 있다.

그러나 synchronized 메소드를 사용한 스레드 3은 개발자가 의도한 동기화가 지켜지지 않았다.

정리하자면, 클래스 단위에 거는 lock과 인스턴스 단위에 거는 lock은 공유가 안 되기 때문에 혼용해서 쓰게 된다면 동기화 이슈가 발생하게 된다.

 

 

synchronized block

synchronized block은 인스턴스의 block 단위로 lock을 걸며, 2가지의 사용 방법이 있다.

  • synchronized(this)
  • synchronized(Object)

 

synchronized(this)

아래의 예시를 보자.

public class SynchronizedBlock {

    public static void main(String[] args) {

        SynchronizedBlock block = new SynchronizedBlock();

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

        Thread thread2 = new Thread(() -> {
            System.out.println("스레드2 시작 " + LocalDateTime.now());
            block.syncBlockMethod2("스레드2");
            System.out.println("스레드2 종료 " + LocalDateTime.now());
        });
        thread1.start();
        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();
            }
        }
    }
}

위와 같이, synchronized 인자 값으로 this를 사용하게 되면 모든 synchronized block에 lock이 걸리게 된다. 

여러 스레드가 들어와서 서로 다른 synchronized block을 호출하더라도 this를 사용해 자기 자신(인스턴스)에게 lock을 걸었기 때문에 기다려야 한다.

결과는 다음과 같다.

스레드1 시작 2022-08-26T20:27:13.850642100
스레드2 시작 2022-08-26T20:27:13.850642100
스레드1의 syncBlockMethod1 실행중2022-08-26T20:27:13.875640100
스레드2의 syncBlockMethod2 실행중2022-08-26T20:27:18.894391400
스레드1 종료 2022-08-26T20:27:18.894391400
스레드2 종료 2022-08-26T20:27:23.901822600

synchronized(this) 블럭으로 감싼 부분끼리 동기화가 잘 지켜지는 것을 확인할 수 있다.

 

synchronized(Object)

synchronized(this) 방식은 이와 같은 모든 블럭에 lock이 걸리기 때문에 상황에 따라 비효율적일 수 있다. 따라서, synchronized(Object) 방식으로 블록마다 다른 lock이 걸리게 할 수 있다. 다음 예제를 보자.

public class SynchronizedBlockObject {

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

    public static void main(String[] args) {

        SynchronizedBlockObject block = new SynchronizedBlockObject();

        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();
        thread2.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();
            }
        }
    }
}

결과는 다음과 같다.

스레드1 시작 2022-08-26T20:33:28.286018200
스레드2 시작 2022-08-26T20:33:28.285016400
스레드2의 syncBlockMethod2 실행중2022-08-26T20:33:28.314023300
스레드1의 syncBlockMethod1 실행중2022-08-26T20:33:28.313022600
스레드1 종료 2022-08-26T20:33:33.353181500
스레드2 종료 2022-08-26T20:33:33.353181500

위의 synchronized(this)와 어떤 차이점이 있을까? 스레드1과 스레드2의 동기화가 따로 이루어지지 않은 것을 확인할 수 있다. 따라서, this가 아닌 서로 다른 Object를 인자로 넘겨주었을 경우 동시에 lock이 걸려야 하는 부분을 따로 지정할 수 있다.

 

static synchronized block

static method 안에 synchronized block을 지정할 수 있다. static의 특성상 this와 같이 현재 인스턴스를 가리키는 표현은 사용이 불가능하다.

static synchronized method 방식과의 차이점은 lock 객체를 지정하고 block으로 범위를 한정지을 수 있다는 점이다. 이외에 클래스 단위로 lock을 공유한다는 점은 동일하다.

 

 

가시성 문제의 해결

synchronized는 원자성 문제 이외에 가시성 문제도 해결해준다.

public class NotVolatile {

    private static boolean stopRequested;

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

        Thread.sleep(1000);
        stopRequested = true;
    }
}

위의 예제는 앞선 volatile 에 대한 포스팅에서 볼 수 있었듯 CPU cache memory와 RAM의 데이터의 불일치로 인해 메인 스레드가 stopRequested 값을 true로 바꿨음에도 backgroundThread에서 그 값을 읽어들여 업데이트한다는 보장이 없으므로 무한 루프에 빠질 수 있다.

이는, volatile 키워드로 RAM의 데이터를 직접 읽어들여 해결 가능하였지만 synchronized 키워드를 사용해도 해결 가능하다.

 

public class UseSynchronized {

    private static boolean stopRequested;

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

        Thread.sleep(1000);
        stopRequested = true;
    }
}

왜 이것이 가능한 것일까?

그 이유는 synchronized 블록을 들어가기에 앞서 CPU Cache Memory와 Main Memory를 즉시 동기화 해주기 때문이다.

 

 

Singleton 객체의 동기화

싱글톤 객체의 생성 방법은 다음과 같다.

public class BasicSingleton {
    
    private static BasicSingleton basicSingleton;
    
    public static BasicSingleton getInstance() {
        if (Objects.isNull(basicSingleton)) {
            basicSingleton = new BasicSingleton();
        }
        return basicSingleton;
    }
}

물론 필드에서 바로 new 키워드를 이용하여 생성하는 방법도 있지만, 그런 식으로는 실제 객체를 사용하기 전에 메모리에 객체가 올라가 버리므로 지연 초기화 방식으로 구현하였다.

이 방식은 싱글 스레드 환경에서는 문제가 없지만, 멀티 스레드 환경에서는 getInstance()가 동시에 호출될 수 있기 때문에 동기화 이슈가 발생한다.

이 동기화 이슈는 getInstance() 메소드에 synchronized 키워드를 붙임으로써 해결할 수 있다.

public class BasicSingleton {
    
    private static BasicSingleton basicSingleton;
    
    public static synchronized BasicSingleton getInstance() {
        if (Objects.isNull(basicSingleton)) {
            basicSingleton = new BasicSingleton();
        }
        return basicSingleton;
    }
}

그러나 위 방식은 싱글톤에 synchronized 메소드가 많을수록 멀티 스레드는 병목 현상을 겪게 된다. 쉽게 말하자면, 기껏 멀티 스레드를 사용하는데 싱글톤을 위해서 싱글 스레드처럼 동작한다는 의미이다.

 

 

Double Checked Locking

DCL이라고 불리는 이 방식으로 동기화 이슈를 해결할 수 있으나, 현재 사용하는 기법은 아니다.

public class LazySingleton {

    private volatile static LazySingleton lazySingleton;

    private LazySingleton() {
    }

    public static LazySingleton getInstance() {
        if (lazySingleton == null) {
            synchronized (LazySingleton.class) {
                if (lazySingleton == null) {
                    lazySingleton = new LazySingleton();
                }
            }
        }
        return lazySingleton;
    }
}

 

메소드에서 synchronized를 빼면서 동기화 오버헤드를 줄여보고자 하는 의도로 설계된 방식이다. 최초 인스턴스가 생성된 이후로는 동기화 블럭에 진입하지 않기 때문에 효율적인 방식이라고 생각할 수 있지만, 특정 상황에서는 정상 동작하지 않을 수 있다.

예를 들어, Thread A와 Thread B가 있다고 하자. Thread A가 인스턴스의 생성을 완료하기 전에 메모리 공간에 할당을 한다. Thread B는 이미 LazySingleton 객체가 할당된 것을 보고, 이 인스턴스를 사용하려고 하지만, 아직 LazySingleton 자체의 생성 과정이 끝난 것은 아니므로 오동작할 수 있다는 것이다. 물론 이러한 확률은 적겠지만, 혹시 모를 문제를 생각하여 쓰지 않는 것이 좋다.

 

LazyHolder

현재 사용되고 있는 방식이다.

public class Singleton {

    private Singleton() {
    }

    public static Singleton getInstance() {
        return Holder.instance;
    }

    private static class Holder {
        public static final Singleton instance = new Singleton();
    }
}

 

개발자가 직접 동기화 문제에 대해 코드를 작성하고 문제를 회피하려 한다면, 프로그램 구조가 그만큼 복잡해지고 비용 문제가 생길 수 있으며 정확한 코드가 아니게 될 수 있다.

위 방법은 JVM의 클래스 초기화 과정에서 보장되는 원자적 특성을 이용해 싱글톤의 초기화 책임을 JVM에게 넘긴다. Singleton 클래스에는 LazyHolder 클래스의 변수가 없기 때문에 Singleton 클래스 로딩시 Holder 클래스를 초기화하지 않는다. Holder 클래스는 Singleton 클래스의 getInstance() 메소드에서 Holder.instance 를 참조하는 순간 클래스가 로딩되며 초기화가 진행된다.

클래스를 로딩하고 초기화하는 시점은 JVM의 영역이라 동기화를 보장하기 때문에 volatile이나 synchronized 키워드가 없어도 동기화를 보장하면서 성능도 좋은 방식이다.

 

 

Vector의 동기화

Vector는 기본적으로 모든 메소드에 synchronized 동기화 처리가 되어 있다. 하지만 아래와 같은 예제를 보자.

Vector vector = new Vector();
if (vector.size() > 0) {
    System.out.println(vector.get(0));
}

Vector의 사이즈는 1이라고 가정하자.

Thread A가 if문 안에서 size() > 0이 만족해서 lock을 푸는 순간, Thread B가 Vector에 접근해서 remove() 메소드를 호출해 버리는 순간 Vector의 size는 0이 되어 첫 번째 요소를 호출하는 순간 NullPointerException이 발생하게 된다. 즉, Vector는 스레드 안전하지 않다.

 

 

[출처]

[Java] synchronized 키워드란? (tistory.com)

 

[Java] synchronized 키워드란?

java-study에서 스터디를 진행하고 있습니다. Synchronized Java는 크게 3가지 메모리 영역을 가지고 있다. static 영역 heap 영역 stack 영역 자바 멀티 스레드 환경에서는 스레드끼리 static 영역과 heap 영역

steady-coding.tistory.com