Java의 Collections.synchronized 메소드는기본 컬렉션 객체를 스레드 안전한 버전으로 래핑하는 데 사용된다. 이러한 래핑된 컬렉션은 내부 동기화를 통해 멀티 스레드 환경에서 안전성을 보장하지만,일부 동작에 대해서는 아니다.(ex. Iterator, Spliterator, Stream)
Collections.synchronizedList 주석을 보면 Iterator, Spliterator, Stream은 동기화가 필요하다고 작성돼있다. 내부 구현을 보면 Iterator은 따로 동기화 처리하지 않았다는 점을 알 수 있다.
✍ 조건부 스레드 안전한 클래스는 주의해서 문서화해야 한다.
어떤 순서로 호출할 때 외부 동기화가 필요한지, 그 순서로 호출하려면 어떤 락 혹은 락들을 얻어야 하는지 알려줘야 한다.
일반적으로 인스턴스 자체를 락으로 얻지만 예외도 있다. (ex. Collections.synchronizedMap)
public class SharedResource {
public final Object lock = new Object(); // 공개된 락
public void doSomething() {
synchronized(lock) {
// 어떤 작업 수행
}
}
}
public class ExternalClient {
public void manipulateResource(SharedResource resource) {
synchronized(resource.lock) {
// resource에 대한 원자적 연산
}
}
}
장점
클라이언트는 여러 메소드 호출을 원자적으로 수행할 수 있다.
단점
성능
내부에서 처리하는 고성능의 동시성 제어 메커니즘(ex. CocurrentHashMap)을 외부에서 제공되는 락과 혼용할 수 없다.
ex. ShareResource에서 ConcurrentHashMap같은 고성능 동시성 제어 메커니즘을 사용하려고 할 때, 외부에서 이미 lock을 사용하고 있기 때문에 해당 메커니즘을 적용하기 어렵다.
서비스 거부 공격
클라이언트가 의도적으로 락을 오래 쥐고 놓지 않으면, 다른 클라이언트나 스레드가 해당 리소스에 접근할 수 없데 되어 서비스가 중단될 수 있다.
ex. 아래 코드를 보면, MaliciousClient 코드는 SharedResource의 락을 계속 쥐고 있어서, 다른 클라이언트나 스레드가 SharedResource에 접근하는 것을 방해한다.
public class MaliciousClient {
public void denialOfServiceAttack(SharedResource resource) {
synchronized(resource.lock) {
while(true) {
// 무한 루프로 락을 영원히 쥐고 있음
}
}
}
}
비공개 락 객체
이러한 문제를 막으려면 클래스 내부에서만 사용되는 비공개 락 객체를 사용하자.
private final Object lock = new Object();
public void foo() {
synchronized(lock) {
// Some operations
}
}
외부에서 락 객체에 접근할 수 없으므로, 클라이언트가 동기화와 관련된 문제를 야기시키거나 락을 오래 쥐고 놓지 않는 공격을 수행할 수 없다.
동일한 이유로 성능 문제도 해결된다.
🌈 락 필드는 항상 final로 선언하라.
final로 선언하여 락 객체가 교체되는 일을 방지한다. (item 15, 78)
private로 선언해 클래스 내부에서만 접근할 수 있다. (item 17)
어느 상황에서 사용할까?
무조건적 스레드 안전 클래스
조건부 스레드 안전의 경우, 비공개 락 객체를 사용하면 외부에서 동기화 블록을 설정하는 것이 불가능하므로 사용할 수 없다.
상속용 클래스(item 19)
하위 클래스와 상위 클래스가 동일한 락(자신의 인스턴스)을 사용하면 동기화 문제가 발생할 수 있다.
이는 인스턴스 대신 비공개 락 객체를 사용하면 문제를 해결할 수 있다.
ex. Thread 클래스
Thread 클래스 메소드 중 일부는 synchronized로 동기화되어 있는데, 하위 클래스에서 이러한 메소드를 확장하거나 오버라이드할 떄 동기화 문제가 발생할 수 있다.
따라서 Thread 클래스를 확장하는 것은 권장되지 않으며, Runnable 인터페이스를 구현하는 것이 더 안전하고 유연한 방법이다.
class Parent {
synchronized void syncMethod() {
System.out.println("Parent's syncMethod");
// ... 기타 작업
try {
Thread.sleep(1000); // 이 락은 1초 동안 유지된다.
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class Child extends Parent {
void childMethod() {
super.syncMethod(); // 여기에서 부모 클래스의 synchronized 메소드를 호출하면서 락을 획득한다.
}
synchronized void anotherSyncMethod() {
System.out.println("Child's anotherSyncMethod");
// ... 기타 작업
}
}
public class Main {
public static void main(String[] args) {
Child child = new Child();
// 첫 번째 스레드: 부모 클래스의 synchronized 메소드 호출
new Thread(child::childMethod).start();
// 잠깐의 딜레이를 주어 첫 번째 스레드가 락을 획득하도록 한다.
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 두 번째 스레드: 자식 클래스의 synchronized 메소드 호출
new Thread(child::anotherSyncMethod).start();
}
}
첫 번째 스레드는 chileMethod를 통해 Parent 클래스의 syncMethod를 호출하며 락을 획득한다. 두 번째 스레드는 Chile 클래스의 anotherSyncMethod를 호출하려고 시도한다. 그러나 첫 번째 스레드가 아직 syncMethod의 락을 획득한 상태이므로, 두 번째 스레드는 락을 대기하게 한다.