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 시작 2025-02-10T12:15:02.597309100
스레드2 시작 2025-02-10T12:15:02.597309100
스레드1의 syncMethod1 실행중2025-02-10T12:15:02.607351800
스레드1 종료 2025-02-10T12:15:07.642867700
스레드2의 syncMethod2 실행중2025-02-10T12:15:07.643865400
스레드2 종료 2025-02-10T12:15:12.656470100
스레드 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 시작 2021-12-29T13:11:10.919
스레드2 시작 2021-12-29T13:11:10.919
스레드1의 syncMethod1 실행중2021-12-29T13:11:10.920
스레드2의 syncMethod2 실행중2021-12-29T13:11:10.920
스레드1 종료 2021-12-29T13:11:15.923
스레드2 종료 2021-12-29T13:11:15.923
이 상황에서는 lock을 공유하지 않기 때문에 스레드 간의 동기화가 발생하지 않습니다.
결과를 보면 알 수 있듯이, synchronized method는 인스턴스에 lock을 겁니다. 하지만, 인스턴스에 lock을 건다고 표현해서 인스턴스 접근 자체에 lock이 걸린다고 혼동할 수 있는데 그건 아닙니다.
아래 예제를 살펴 봅시다.
public class Method {
public static void main(String[] args) {
Method method = new Method();
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() 을 호출하였습니다.
결과는 다음과 같습니다.
스레드1 시작 2025-02-11T12:08:13.013162700
스레드2 시작 2025-02-11T12:08:13.013162700
스레드3 시작 2025-02-11T12:08:13.013162700
스레드3의 method3 실행중2025-02-11T12:08:13.019258400
스레드1의 syncMethod1 실행중2025-02-11T12:08:13.018258100
스레드3 종료 2025-02-11T12:08:18.034366800
스레드1 종료 2025-02-11T12:08:18.034366800
스레드2의 syncMethod2 실행중2025-02-11T12:08:18.034366800
스레드2 종료 2025-02-11T12:08:23.045738600
이 상황에서 스레드 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 시작 2021-12-29T13:17:04.025
스레드2 시작 2021-12-29T13:17:04.025
스레드1의 syncStaticMethod1 실행중2021-12-29T13:17:04.026
스레드1 종료 2021-12-29T13:17:09.033
스레드2의 syncStaticMethod2 실행중2021-12-29T13:17:09.033
스레드2 종료 2021-12-29T13:17:14.033
synchronized 메소드처럼 lock을 공유하여 메소드 간이 동기화가 지켜지고 있습니다. 다만, 여기서 중요한 점은 static 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();
}
}
}
위 예시에서 synchronized 메소드를 추가하였습니다.
결과를 확인해 보겠습니다.
스레드1 시작 2021-12-29T13:20:35.992
스레드2 시작 2021-12-29T13:20:35.992
스레드1의 syncStaticMethod1 실행중2021-12-29T13:20:35.993
스레드3 시작 2021-12-29T13:20:35.992
스레드3의 syncMethod3 실행중2021-12-29T13:20:35.993
스레드1 종료 2021-12-29T13:20:41.003
스레드3 종료 2021-12-29T13:20:41.003
스레드2의 syncStaticMethod2 실행중2021-12-29T13:20:41.003
스레드2 종료 2021-12-29T13:20:46.006
static synchronized 메소드를 사용하는 스레드 1과 스레드 2 간에는 동기화가 잘 지켜지는 것을 확인할 수 있다. 그러나 synchronized 메소드를 사용한 스레드 3은 개발자가 의도한 동기화가 지켜지지 않았습니다.
정리하자면, 클래스 단위에 거는 lock과 인스턴스 단위에 거는 lock은 공유가 안 되기 때문에 혼용해서 쓰게 된다면 동기화 이슈가 발생하게 됩니다.
synchronized block
synchronized block은 인스턴스의 block 단위로 lock을 걸며, 2가지의 사용 방법이 있습니다.
- synchronized(this)
- synchronized(Object)
synchronized(this)
public class Block1 {
public static void main(String[] args) {
Block1 block = new Block1();
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 (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 시작 2021-12-29T13:24:23.312
스레드2 시작 2021-12-29T13:24:23.312
스레드1의 syncBlockMethod1 실행중2021-12-29T13:24:23.312
스레드1 종료 2021-12-29T13:24:28.319
스레드2의 syncBlockMethod2 실행중2021-12-29T13:24:28.319
스레드2 종료 2021-12-29T13:24:33.324
synchronized(this) 블럭으로 감싸진 부분끼리 동기화가 잘 지켜지는 것을 확인할 수 있습니다.
synchronized(Object)
synchronized(this) 방식은 모든 블럭에 lock이 걸리므로 상황에 따라 비효율적일 수 있습니다.
따라서 synchronized(Object) 방식으로 블록마다 다른 lock이 걸리게 하여 훨씬 효율적으로 코드를 작성할 수 있습니다.
public class Block2 {
private final Object o1 = new Object();
private final Object o2 = new Object();
public static void main(String[] args) {
Block2 block = new Block2();
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();
}
}
}
}
결과를 확인해 보겠습니다.
스레드2 시작 2021-12-29T13:27:15.884
스레드1 시작 2021-12-29T13:27:15.884
스레드2의 syncBlockMethod2 실행중2021-12-29T13:27:15.884
스레드1의 syncBlockMethod1 실행중2021-12-29T13:27:15.884
스레드2 종료 2021-12-29T13:27:20.888
스레드1 종료 2021-12-29T13:27:20.888
스레드 1과 스레드 2간의 동기화가 지켜지지 않은 것을 확인할 수 있습니다.
따라서 this가 아닌 o1과 o2 객체를 만들어 인자로 넘겨주면 동시에 lock이 걸려야 하는 부분을 따로 지정해 줄 수 있습니다.
static synchronized block
static method 안에 synchronized block을 지정할 수 있습니다.
static의 특성상 this 같이 현재 객체를 가리키는 표현을 사용할 수 없습니다. static synchronized method 방식과 차이는 lock 객체를 지정하고 block으로 범위를 한정지을 수 있다는 점입니다.
이외에 클래스 단위로 lock을 공유한다는 점은 동일합니다.
가시성 문제를 해결해 주는 synchronized
synchronized는 원자성 문제 외에 가시성 문제도 해결해 줍니다.
public class Volatile {
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 실습에서 위 코드는 거의 영원히 루프를 돌 수 있다고 하였습니다. 하지만 위 케이스에서 synchronized 블록을 걸어주면, 루프에서 빠져나올 수 있습니다.
public class Volatile {
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를 즉시 동기화 해주기 때문입니다.
즉, volatile의 역할을 수행하는 것입니다.
Singleton 객체의 동기화
싱글톤 객체를 생성하는 쉬운 방법은 다음과 같습니다.
public class BasicSingleton {
private static BasicSingleton sBasicSingleton;
public static BasicSingleton getInstance() {
if (Objects.isNull(sBasicSingleton)) {
sBasicSingleton = new BasicSingleton();
}
return sBasicSingleton;
}
}
물론 필드에서 바로 new 키워드로 생성하는 방법도 있지만, 그러면 실제 객체를 사용하기도 전에 메모리에 다 올라가므로 지연 초기화 방식으로 구현하였습니다.
이 방식은 싱글 스레드 환경에서는 문제가 없지만, 멀티 스레드 환경에서는 getInstance() 가 동시에 호출될 수 있으므로 동기화 이슈가 발생합니다.
단순히 getInstance() 메소드에 synchronized 키워드를 붙여 동기화 이슈를 해결할 수 있습니다.
public class BasicSingleton {
private static BasicSingleton sBasicSingleton;
public static synchronized BasicSingleton getInstance() {
if (Objects.isNull(sBasicSingleton)) {
sBasicSingleton = new BasicSingleton();
}
return sBasicSingleton;
}
}
그러나 위 방식은 싱글톤에 synchronized 메소드가 많을수록 멀티 스레드는 병목 현상을 겪게 됩니다. 쉽게 말하자면, 기껏 멀티 스레드를 사용하는 데 싱글톤을 위해서 싱글 스레드처럼 동작해야 의미입니다.
이를 해결하기 위해 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 의 클래스 초기화 과정에 보장되는 thread-safe 특성을 이용해 Singleton 의 초기화 책임을 JVM 에게 넘겨 개발자가 직접 동기화 코드를 작성할 때 생기는 문제를 회피할 수 있습니다.
또한, 내부 static class 를 참조하기 전까지 클래스 로딩이 발생하지 않기 때문에 메모리를 효율적으로 사용할 수 있습니다.
즉, 클래스를 로딩하고 초기화하는 시점은 JVM의 영역이라 동기화를 보장하기 때문에 volatile이나 synchronized 키워드가 없어도 동기화를 보장하면서 성능도 좋은 방식입니다.
참고자료
https://steady-coding.tistory.com/556
[Java] synchronized 키워드란?
java-study에서 스터디를 진행하고 있습니다. Synchronized Java는 크게 3가지 메모리 영역을 가지고 있다. static 영역 heap 영역 stack 영역 자바 멀티 스레드 환경에서는 스레드끼리 static 영역과 heap 영역
steady-coding.tistory.com
'Java' 카테고리의 다른 글
[Java] 동시성 문제 해결을 위한 atomic과 CAS 알고리즘 (0) | 2025.02.11 |
---|---|
[Java] 가시성 문제 해결을 위한 volatile 키워드 (0) | 2025.02.11 |
[Java] 멀티스레드 환경에서의 동시성 이슈 (0) | 2025.02.11 |
[Java] 제네릭(Generic) 이란? (0) | 2025.01.31 |
[Java] 스레드 생명주기와 스케줄링 (0) | 2025.01.28 |