관리 메뉴

나구리의 개발공부기록

동기화 - synchronized, 출금 예제, 동시성 문제, 임계 영역, synchronized 메서드, synchronized 코드 블럭 본문

인프런 - 실전 자바 로드맵/실전 자바 - 고급 1편, 멀티스레드와 동시성

동기화 - synchronized, 출금 예제, 동시성 문제, 임계 영역, synchronized 메서드, synchronized 코드 블럭

소소한나구리 2025. 2. 11. 18:04
728x90

출처 : 인프런 - 김영한의 실전 자바 - 고급1편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 동기화 - 출금 예제

1) 예제 시작

(1) 동기화

  • 멀티스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제임
  • 여러 스레드가 접근하는 자원을 공유 자원이라고 하는데 대표적인 공유 자원은 인스턴스의 필드(멤버 변수)임
  • 멀티스레드를 사용할 때는 이런 공유 자원에 대한 접근을 적절하게 동기화(synchronization)해서 동시성 문제가 발생하지 않도록 방지하는 것이 중요함

(2) BankAccount

  • 이 인터페이스의 구현체를 점진적으로 발전시키면서 문제를 해결할 예정
  • withdraw(amount): 출금할 금액을 매개변수로 받아서 계좌의 돈을 출금, 계좌의 잔액이 출금할 금액보다 적다면 출금에 실패하고 false를 반환
  • getBalance(): 계좌의 잔액을 반환
package thread.sync;

public interface BankAccount {
    boolean withdraw(int amount);
    int getBalance();
}

 

(2) BankAccountV1

  • BankAccount 인터페이스를 구현하고 생성자를 통해 계좌의 초기 잔액을 저장함
  • withdraw(amount): 검증과 출금 2가지 단계로 나누어짐
    • 검증 단계: 출금액과 잔액을 비교한 후 출금액이 잔액보다 많으면 검증에 실패하고 false를 반환
    • 출금 단계: 검증에 통과하면 잔액에서 출금액을 빼고 출금을 완료하면 true를 반환, 이때 출금에 걸리는 시간을 1초 정도 걸린다고 가정
package thread.sync;

public class BankAccountV1 implements BankAccount {

    private int balance;

    public BankAccountV1(int balance) {
        this.balance = balance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);

        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }

        log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
        sleep(1000);    // 출금에 걸리는 시간
        balance -= amount;
        log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

        log("거래 종료");
        return true;
    }

    @Override
    public int getBalance() {
        return balance;
    }
}

 

(3) WithdrawTask

  • 출금을 담당하는 Runnable 구현체
  • 생성 시 출금할 계좌와 출금할 금액을 저장해 두고 run()을 통해 스레드가 출금을 실행함
package thread.sync;

public class WithdrawTask implements Runnable {
    
    private BankAccount account;
    private int amount;

    public WithdrawTask(BankAccount account, int amount) {
        this.account = account;
        this.amount = amount;
    }

    @Override
    public void run() {
        account.withdraw(amount);
    }
}

 

(4) BankMain

  • 초기 금액을 1000으로 설정하고 스레드를 2개를 각 스레드에 800원을 동시에 출금하도록 시도하고 join()을 사용하여 t1, t2스레드가 완료한 이후에 최종 잔액을 확인
  • 실행 결과를 보면 t1, t2 스레드가 출금을 시도하여 검증로직이 모두 통과되고 결국 최종잔액이 -600원이 출력됨
  • 실행 환경에 따라서 t1, t2가 완전히 동시에 실행될 수 있는데 이 경우 출금액은 같고 잔액은 200원이 됨
package thread.sync;

public class BankMain {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccountV1(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "t1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "t2");

        t1.start();
        t2.start();

        sleep(500); // 검증 완료까지 잠시 대기
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

        t1.join();
        t2.join();

        log("최종 잔액: " + account.getBalance());
    }
}
/* 실행 결과
14:28:24.836 [       t2] 거래 시작: BankAccountV1
14:28:24.836 [       t1] 거래 시작: BankAccountV1
14:28:24.842 [       t2] [검증 시작] 출금액: 800, 잔액: 1000
14:28:24.842 [       t1] [검증 시작] 출금액: 800, 잔액: 1000
14:28:24.842 [       t2] [검증 완료] 출금액: 800, 잔액: 1000
14:28:24.842 [       t1] [검증 완료] 출금액: 800, 잔액: 1000
14:28:25.326 [     main] t1 state: TIMED_WAITING
14:28:25.326 [     main] t2 state: TIMED_WAITING
14:28:25.848 [       t2] [출금 완료] 출금액: 800, 잔액: 200
14:28:25.851 [       t1] [출금 완료] 출금액: 800, 잔액: -600
14:28:25.852 [       t2] 거래 종료
14:28:25.853 [       t1] 거래 종료
14:28:25.854 [     main] 최종 잔액: -600
*/

 

(5) 그림으로 설명

  • 각 스레드의 인스턴스에서 run()을 실행하면 각 스택 프레임의 this에는 호출한 메서드의 인스턴스 참조가 들어있고 두 스레드는 같은 계좌인 BankAccount에서 출금을 시도함
  • t1, t2의 스레드가 거의 동시에 run()에서 withdraw()를 실행하기 때문에 두 스레드는 같은 BankAccount의 balance 필드를 함께 사용하게 됨

(6) 동시성 문제

  • 악의적인 사용자가 2개의 PC에서 동시에 같은 계좌의 돈을 출금한다고 가정
  • t1, t2 스레드는 거의 동시에 실행되었지만 아주 약간의 차이로 t1 스레드가 먼저 실행되고, t2 스레드가 그다음에 실행되었다고 가정
  • 처음 계좌의 잔액은 1000원이고 t1 스레드가 800원을 출금하면 잔액은 200원이 남게 되고 t2 스레드가 800원을 출금하면 잔액보다 더 많은 돈을 출금하게 되므로 출금에 실패해야 함
  • 그러나 실행 결과를 보면 기대와는 다르게 t1, t2는 각각 800원씩 1600원 출금에 성공하게 되고 계좌의 잔액은 -600원이 되었음

** 참고

  • balance 값이 volatile을 도입하면 문제가 해결될 것처럼 보이지만 이 문제는 volatile로 메모리 가시성 문제를 해결해도 여전히 문제가 발생함

2. 동시성 문제

1) 문제 분석

(1) t1, t2 순서로 실행된다는 가정

  • t1이 약간 먼저 실행되면서 출금을 시도하면 출금 코드에 있는 검증 로직을 실행하면 잔액이 출금액보다 많기 때문에 검증로직을 통과함
  • t1 스레드가 출금 검증 로직을 통과해서 출금을 위해 잠시 대기하면 t2 스레드가 검증 로직을 실행하고 잔액이 출금액보다 많으므로 검증로직이 통과하게 됨
  • 바로 이 부분이 문제인데, t1이 아직 잔액(balance)을 줄이지 못했기 때문에 t2는 검증 로직에서 현재 잔액을 1000원으로 확인하고 검증로직을 통과하게 됨
  • t1이 검증 로직을 통과하고 바로 잔액을 줄였다면 이런 문제가 발생하기 않았겠지만 t1이 검증 로직을 통과하고 잔액을 줄이기도 전에 먼저 t2가 검증 로직을 확인한 것임
  • sleep(1000) 코드를 빼면 정상적으로 동작한다고 예상할 수 있지만 t1이 검증 로직을 통과하고 잔액을 계산하기 직전에 t2가 실행되면서 검증 로직을 통과할 수 있음
  • sleep(1000) 코드는 단지 이런 문제를 쉽게 확인하기 위해 넣었을 뿐이지 이 코드를 빼도 여전히 동일한 문제는 발생하게 됨
  • 결과적으로 t1, t2 모두 검증로직을 통과한 후 t1이 먼저 800원을 출금하면 계좌의 잔액이 200원이 되고 t2가 이 200원에서 800원을 출금하게 되고 잔액이 -600원이 됨

(2) t1, t2가 동시에 실행된다는 가정

  • t1, t2가 동시에 검증 로직을 실행하고 둘 다 잔액이 출금액보다 많으므로 통과하여 두 스레드 모두 출금 로직을 수행함
  • 두 스레드가 완전히 동시에 실행되기 때문에 잔액(balance)을 확인하는 시점의 잔액은 1000원임
  • 두 스레드가 출금로직을 실행하면 1000원 - 800원을 계산하고 계산결과를 balance에 반영하여 최종 잔액은 200원이 됨
  • 은행의 입장에서는 t1도 800원을 출금하고, t2도 800원을 출금하여 총 1600원이 출금되었지만 계좌의 원금이 1000원이었고 최종 잔액은 200원이 되어 800원만 줄어들었으므로 800원이 사라지는 결과가 됨

3. 임계 영역

1) 임계 영역

(1) 문제 원인

  • 이 문제가 발생한 근본 원인은 여러 스레드가 함께 사용하는 공유 자원을 여러 단계로 나누어서 사용하기 때문임
  • 출금 로직에서는 검증 단계에서 잔액이 출금액보다 많은지 확인하고 출금 단계에서는 잔액을 출금액만큼 줄이는데 이 로직에는 하나의 큰 가정이 있음
  • 스레드 하나의 관점에서 출금메서드를 보면 검증 단계에서 확인한 잔액은 출금 단계에서 계산을 끝마칠 때까지 같은 금액으로 유지되어야 검증 단계에서 확인한 금액으로 출금 단계에서 정확한 잔액을 계산할 수 있음
  • 결국 여기서는 내가 사용하는 값이 중간에 변경되지 않을 것이라는 가정이 있음
  • 그러나 중간에 다른 스레드가 잔액의 값을 변경하면 큰 혼란이 발생하게 됨
  • 1000원이라 생각한 잔액이 다른 값으로 변경되면 잔액이 전혀 다른 값으로 계산될 수 있음

(2) 공유 자원

  • 잔액(balance)은 여러 스레드가 함께 사용하는 공유 자원이므로 출금 로직을 수행하는 중간에 다른 스레드에서 이 값을 얼마든지 변경할 수 있음
  • 예제에서는 출금 메서드를 호출할 때 잔액의 값이 변경되는데 다른 스레드가 출금 메서드를 호출하면서 사용 중인 출금 값을 중간에 변경해 버릴 수 있음

(3) 한 번에 하나의 스레드만 실행

  • 만약 출금이라는 메서드를 한 번에 하나의 스레드만 실행할 수 있게 제한할 수 있다면 t1, t2 스레드가 함께 출금 메서드를 호출해도 t1 스레드가 먼저 처음부터 끝까지 메서드를 완료하고 그다음 t2 스레드가 처음부터 끝까지 메서드를 완료하게 될 것임
  • 이렇게 하면 공유 자원인 balance를 한 번에 하나의 스레드만 변경할 수 있게 되어 계산 중간에 다른 스레드가 balance의 값을 변경하는 부분을 걱정하지 않아도 됨
  • 즉, 출금을 진행할 때 검증하는 단계에서부터 출금을 완료하는 단계까지 잔액의 값은 중간에 변하면 안 됨
  • 이 두 단계는 한 번에 하나의 스레드만 실행해야 잔액이 중간에 변하지 않고 안전하게 계산을 수행할 수 있음

(4) 임계 영역(critical section)

  • 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 중요한 코드 부분을 뜻함
  • 여러 스레드가 동시에 접근해서는 안 되는 공유 자원을 접근하거나 수정하는 부분을 의미함
  • 예제에서의 balance 변수는 여러 스레드가 동시에 접근해서는 안되는 공유 자원이며 출금 메서드의 검증 코드에서부터 출금이 완료할 때까지가 임계 영역임
  • 임계 영역은 한 번에 하나의 스레드만 접근할 수 있도록 안전하게 보호해야 하는데 자바는 synchronized 키워드를 통해 아주 간단하게 임계 영역을 보호할 수 있음

4. synchronized 메서드

1) Synchronized 메서드

(1) BankAccountV1

  • 자바의 synchronized 키워드를 사용하면 한 번에 하나의 스레드만 실행할 수 있는 코드 구간을 만들 수 있음
  • withdraw()와 getBalance() 메서드의 코드에 synchronized 키워드를 추가
package thread.sync;

public class BankAccountV2 implements BankAccount {
    // ... 코드 동일 생략
    
    @Override
    public synchronized boolean withdraw(int amount) {
        // ... 코드 동일 생략
    }
    
    @Override
    public synchronized int getBalance() {
        return balance;
    }
}

 

(2) BankMain 수정

  • BankMain에서 BankAccountV2를 실행하도록 코드를 변경하고 실행해 보면 먼저 실행된 t1 스레드는 출금이 정상적으로 완료되지만 t2 스레드의 결과는 검증이 실패되어 출금이 되지 않는 것을 확인할 수 있음
  • 로그 출력도 보면 t1 -> t2의 순서대로 차례대로 실행되고 있으며 t2 스레드의 상태가 BLOCKED인 부분도 확인할 수 있음
  • 물론 환경에 따라 t2가 먼저 실행될 수 있으며 이 경우에도 t2가 모두 실행하고 나서 t1이 실행됨
BankAccount account = new BankAccountV2(1000);

/* 적용 후 실행 결과
16:07:36.381 [       t1] 거래 시작: BankAccountV2
16:07:36.386 [       t1] [검증 시작] 출금액: 800, 잔액: 1000
16:07:36.386 [       t1] [검증 완료] 출금액: 800, 잔액: 1000
16:07:36.875 [     main] t1 state: TIMED_WAITING
16:07:36.875 [     main] t2 state: BLOCKED
16:07:37.392 [       t1] [출금 완료] 출금액: 800, 잔액: 200
16:07:37.392 [       t1] 거래 종료
16:07:37.395 [       t2] 거래 시작: BankAccountV2
16:07:37.396 [       t2] [검증 시작] 출금액: 800, 잔액: 200
16:07:37.396 [       t2] [검증 실패] 출금액: 800, 잔액: 200
16:07:37.397 [     main] 최종 잔액: 200
*/

 

(3) 실행 분석

  • 모든 객체(인스턴스)는 내부에 자신만의 락(lock)을 가지고 있음, 모니터 락(monitor lock)이라고도 부르며 직접 확인하기는 어려움
  • 스레드가 synchronized 키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락이 있어야 함
  • 스레드 t1이 먼저 실행된다고 가정하면, t1이 먼저 synchronized 키워드가 있는 withdraw() 메서드를 호출하기 때문에 해당 인스턴스의 락을 얻어야 함
  • 현재 BankAccount는 락을 가지고 있으므로 t1은 해당 인스턴스의 락을 획득하고 withdraw() 메서드에 진입할 수 있음
  • 스레드 t2도 withdraw() 메서드를 호출하려고 하면 락이 필요하므로 락 획득을 시도하지만 이미 해당 인스턴스의 락은 t1이 사용하고 있기 때문에 락이 없음
  • 이렇게 락이 없으면 t2 스레드는 락을 획득할 때까지 BLOCKED 상태로 대기하고 락을 획득할 때 까지 무한정 대기
  • BLOCKED 상태가 되면 락을 다시 획득하기 전까지 계속 대기하고 CPU 실행 스케줄링에 들어가지 않음
  • t1 스레드가 검증로직을 통과하고 출금로직을 실행하여 1000원에서 800원을 출금하고 계산 결과인 200원을 잔액에 반영한 뒤에 메서드 호출이 끝나면 락을 반납함
  • 인스턴스에 락이 반납되면 락 획득을 대기하는 스레드는 자동으로 락을 획득하므로 t2스레드는 BLOCKED -> RUNNABLE 상태가 되어 다시 코드를 실행함
  • t2 스레드가 withdraw() 메서드에 진입하여 검증로직을 수행하면 조건을 만족하지 않으므로 false를 반환하고 락을 반납하면서 return 함
  • 결과적으로 t1은 800원 출금이 완료되고 t2는 잔액 부족으로 출금실패하여 결과적으로 원금 1000원에 최종 잔액이 200원이 되어 기대하는 결과로 동작함
  • 이렇게 자바의 synchronized를 사용하면 한 번에 하나의 스레드만 실행하는 안전한 임계 영역 구간을 편리하게 만들 수 있음

** 참고 - 락을 획득하는 순서는 보장되지 않음

  • 만약 BankAccount 인스턴스의 withdraw()를 수많은 스레드가 동시에 호출한다면 1개의 스레드만 락을 획득하고 나머지는 모두 BLOCKED 상태가 됨
  • 그 이후 인스턴스에 락을 반납하면 해당 인스턴스의 락을 기다리는 수 많은 스레드 중 하나의 스레드만 락을 획득하고 락을 획득한 스레드만 BLOCKED -> RUNNABLE 상태가 됨
  • 이때 어떤 순서로 락을 획득하는지는 자바 표준에 정의되어있지 않으므로 순서를 보장하지 않고 환경에 따라서 순서가 달라질 수 있음
  • 참고로 volatile를 사용하지 않아도 synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 자동으로 해결됨

 

5. synchronized 코드 블럭

1) synchronized 코드 블럭

(1) 특정 부분에 synchronized 적용

  • synchronized의 가장 큰 장점이자 단점은 한 번에 하나의 스레드만 실행할 수 있다는 점인데 여러 스레드가 동시에 실행하지 못하므로 전체로 보면 성능이 떨어질 수 있음
  • 따라서 synchronized를 통해 여러 스레드를 동시에 실행할 수 없는 코드 구간은 꼭! 필요한 곳으로 한정해서 설정해야 함
  • withdraw() 메서드를 보면 처음 로그를 출력하는 거래 시작 부분과 마지막 로그를 출력하는 거래 종료 부분은 공유 자원을 전혀 사용하지 않으므로 이런 부분은 동시에 실행해도 아무런 문제가 발생하지 않음
  • 즉, 실제 임계 영역이 필요한 아래 코드의 주석으로 표시한 부분이지만 메서드 앞에 적용하면 synchronized의 적용 범위가 메서드 전체에 적용됨
  • 자바는 이런 문제를 해결하기 위해서 synchronized를 메서드 단위가 아니라 특정 코드 블럭에 최적화할 수 있는 기능을 제공함
public synchronized boolean withdraw(int amount) {
    log("거래 시작: " + getClass().getSimpleName());

    //  == 임계 영역 시작 ==
    log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
    if (balance < amount) {
        log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
        return false;
    }

    log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
    sleep(1000);    // 출금에 걸리는 시간
    balance -= amount;
    log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

    //  == 임계 영역 끝 ==

    log("거래 종료");
    return true;
}

 

(2) BankAccountV3

  • withdraw() 메서드 앞에 사용하던 synchronized를 제거하고 임계영역을 지정하고 싶은 곳에 synchronized (this) { }로 코드 블럭을 지정하면 꼭 필요한 코드만 안전한 임계 영역으로 만들 수 있음
  • synchronized (this)에서 괄호 안에 들어가는 값은 획득할 인스턴스의 참조임
  • getBalance()의 경우에는 코드가 한 줄이므로 메서드에 설정하나 코드 블럭으로 설정하나 같음
package thread.sync;

public class BankAccountV3 implements BankAccount {

    // ... 코드 동일 생략

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        synchronized (this) {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000);
            balance -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        }

        log("거래 종료");
        return true;
    }
    // ... 코드 동일 생략
}

 

(3) BankMain

  • BankAccountV3로 변경하고 실행해 보면 거래 시작은 동시에 시작하고 검증은 먼저 락을 얻은 스레드부터 시작되는 것을 확인할 수 있음
  • synchronized 블럭 기능을 사용한 덕분에 딱 필요한 부분에 임계 영역을 지정할 수 있어 아주 약간이지만 여러 스레드가 동시에 수행되는 부분을 더 늘려서 전체적으로 성능을 향상시킬 수 있게 됨
  • 지금의 예제는 단순히 로그 몇 줄 출력하는 정도이지만 만약 작업이 오래 수행된다면 큰 성능 차이가 발생함
  • 핵심은 하나의 스레드만 실행할 수 있는 안전한 임계 영역은 가능한 최소한의 범위에 적용해야 한다는 점이며 그래야 동시에 여러 스레드가 실행할 수 있는 부분을 늘려서 전체적인 처리 성능을 높일 수 있음
/* 실행 결과
17:07:56.122 [       t1] 거래 시작: BankAccountV3
17:07:56.122 [       t2] 거래 시작: BankAccountV3
17:07:56.127 [       t1] [검증 시작] 출금액: 800, 잔액: 1000
17:07:56.128 [       t1] [검증 완료] 출금액: 800, 잔액: 1000
17:07:56.615 [     main] t1 state: TIMED_WAITING
17:07:56.616 [     main] t2 state: BLOCKED
17:07:57.133 [       t1] [출금 완료] 출금액: 800, 잔액: 200
17:07:57.134 [       t1] 거래 종료
17:07:57.135 [       t2] [검증 시작] 출금액: 800, 잔액: 200
17:07:57.135 [       t2] [검증 실패] 출금액: 800, 잔액: 200
17:07:57.136 [     main] 최종 잔액: 200
*/

 

(4) 동기화 정리

  • 자바에서 동기화(synchronization)는 여러 스레드가 동시에 접근할 수 있는 자원(객체, 메서드 등)에 대해 일관성 있고 안전한 접근을 보장하기 위한 메커니즘임
  • 동기화는 주로 멀티스레드 환경에서 발생할 수 있는 문제(데이터 손상, 예기치 않은 결과)를 방지하기 위해 사용됨
  • 메서드 동기화: 메서드를 synchronized로 선언해서 메서드에 접근하는 스레드가 하나뿐이도록 보장함
  • 블록 동기화: 코드 블록을 synchronized로 감싸서 동기화를 구현할 수 있음
  • 경합 조건(Race condition): 두 개 이상의 스레드가 경쟁적으로 동일한 자원을 수정할 때 발생하는 문제
  • 데이터 일관성: 여러 스레드가 동시에 읽고 쓰는 데이터의 일관성을 유지
  • 동기화는 멀티 스레드 환경에서 필수적인 기능이지만 과도하게 사용할 경우 성능 저하를 초래할 수 있으므로 꼭 필요한 곳에 적절히 사용해야 함

6. 문제와 풀이

1) 공유 자원

(1) 문제 설명

  • 다음 코드의 결과는 20000이 되어야 함
  • 이 코드의 문제점을 찾아서 해결하되 다른 부분은 변경하면 안 되고 Counter 클래스 내부만 수정해야 함
더보기
package thread.sync.test;

public class SyncTest1BadMain {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Runnable task = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10000; i++) {
                    counter.increment();
                }
            }
        };
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("결과: " + counter.getCount());
    }

    static class Counter {
        private int count = 0;

        public void increment() {
            count = count + 1;
        }

        public int getCount() {
            return count;
        }
    }
}

 

기대하는 실행 결과

결과: 20000

 

(2) 정답

더보기
package thread.sync.test;

public class SyncTest1BadMain {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Runnable task = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10000; i++) {
                    counter.increment();
                }
            }
        };
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("결과: " + counter.getCount());
    }

    static class Counter {
        private int count = 0;

        public synchronized void increment() {
            count = count + 1;
        }

        public synchronized int getCount() {
            return count;
        }
    }
}

 

설명

  • count = count + 1의 코드는 count의 값을 읽고, 읽은 count의 값에 1을 더하고 더한 결과를 count에 다시 저장하는 3단계로 나누어져 있음
  • 스레드1과 스레드2가 동시에 increment() 메서드를 호출하면 둘 다 count의 값을 0으로 읽었기 때문에 읽은 카운드의 값에 1을 더해서 두 스레드의 count의 값은 각각 1이 됨
  • 즉, 스레드 2개가 increment()를 호출하기 때문에 기대하는 결과는 count의 결과는 2가 되어야 하지만 동기화가 되지 않아 값이 증발되었음
  • count는 여러 스레드가 함께 사용하는 공유 자원이므로 멀티스레드 상황에서는 synchronized 키워드를 사용해서 한 번에 하나의 스레드만 실행할 수 있도록 안전한 임계 영역을 만들어야 함

2) 지역 변수의 공유

(1) 문제 설명

  • 다음 코드에서 MyTask의 run() 메서드는 두 스레드에서 동시에 실행함
  • 다음 코드의 실행결과를 예측하고 localValue 지역 변수에 동시성 문제가 발생하는지 하지 않는지 생각해 보기
더보기
package thread.sync.test;

public class SyncTest2Main {
    public static void main(String[] args) {
        MyCounter myCounter = new MyCounter();
        Runnable task = new Runnable() {
            @Override
            public void run() {
                myCounter.count();
            }
        };
        Thread thread1 = new Thread(task, "Thread-1");
        Thread thread2 = new Thread(task, "Thread-2");
        thread1.start();
        thread2.start();
    }

    static class MyCounter {
        public void count() {
            int localValue = 0;
            for (int i = 0; i < 1000; i++) {
                localValue = localValue + 1;
            }
            log("결과: " + localValue);
        }
    }
}

 

(2) 정답

더보기

실행 결과

[ Thread-2] 결과: 1000

[ Thread-2] 결과: 1000

 

지역 변수는 스택 프레임에 값이 저장되기 때문에 공유를 하지 않고 메서드가 종료되면 사라짐

 

풀이

  • localValue는 지역 변수임
  • 스택 영역은 각각의 스레드가 가지는 별도의 메모리 공간이며 이 메모리 공간은 다른 스레드와 공유하지 않음
  • 지역 변수는 스레드의 개별 저장 공간인 스택 영역에 생성되므로 지역 변수는 절대로 다른 스레드와 공유되지 않음
  • 이런 이유로 지역 변수는 동기화에 대한 걱정을 하지 않아도 되며 여기에 synchronized를 사용하면 아무 이득도 없이 성능만 느려짐
  • 지역 변수를 제외한 인스턴스 멤버 변수(필드), 클래스 변수 등은 공유 될 수 있음

3) final 필드

(1) 문제 설명

  • 다음 value필드(멤버 변수)는 공유되는 값인데 멀티스레드 상황에서 문제가 될 수 있는지 생각해 보기
public class Immutable {
    private final int value;

    public Immutable(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}

 

(2) 정답

더보기

값이 변하지 않는 상수이기 때문에 멀티 스레드 상황에서도 값을 읽는 부분은 문제가 되지 않음

 

풀이

  • 여러 스레드가 공유 자원에 접근하는 것 자체는 문제가 되지 않으며 진짜 문제는 공유 자원을 사용하는 중간에 다른 스레드가 공유 자원의 값을 변경해버리기 때문에 발생함
  • 결국 변경이 문제가 되는 것이므로 여러 스레드가 접근 가능한 공유 자원이라도 그 값을 아무도 변경할 수 없다면 모든 스레드가 항상 같은 값을 읽기 때문에 문제가 되지 않음
  • 필드에 final이 붙으면 어떤 스레드도 값을 변경할 수 없으므로 멀티스레드 상황에 문제 없는 안전한 공유 자원이 됨

4) 정리

  • 자바는 처음부터 멀티스레드를 고려하고 나온 언어이므로 자바 1.0부터 synchronized 같은 동기화 방법을 프로그래밍 언어의 문법에 포함해서 제공함

(1) synchronized 장점

  • 프로그래밍 언어에 문법으로 제공
  • 편리한 사용
  • 자동 잠금 해제: synchronized 메서드나 블록이 완료되면 자동으로 락을 대기 중인 다른 스레드의 잠금이 해제되기 때문에 개발자가 직접 특정 스레드를 깨우도록 관리할 필요가 없음

(2) synchronized 단점

  • synchronized는 매우 편리하지만 제공하는 기능이 너무 단순하다는 단점이 있음
  • 시간이 점점 지나면서 멀티스레드가 더 중요해지고 점점 더 복잡한 동시성 개발 방법들이 필요해졌음
  • 무한 대기: BLOCKED 상태의 스레드는 락이 풀릴 때까지 무한 대기하여 중간에 인터럽트도 할 수 없고 일정 시간만큼만 기다리게도 할 수 없음
  • 공정성: 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없음, 최악의 경우 특정 스레드가 너무 오랜 기간 락을 획득하지 못할 수 있음
  • 가장 치명적인 단점은 락을 얻기 위해 무한 대기한다는 점인데, 웹 애플리케이션의 경우 고객이 요청을 했을 때 화면에 계속 요청 중만 뜨고 응답을 받을 수 없게 됨
  • 차라리 너무 오랜 시간이 지나면 시스템에 사용자가 너무 많아서 다음에 다시 시도해 달라고 하는 식의 응답이 더 나은 선택일 것임
  • 결국 더 유연하고 더 세밀한 제어가 가능한 방법들이 필요하게 되었고 이런 문제를 해결하기 위해 자바 1.5부터 java.util.concurrent라는 동시성 문제를 해결하기 위한 패키지가 추가됨
  • 다만 단순하고 편리하게 사용하기에는 synchronized도 여전히 좋은 선택지가 될 수 있기 때문에 목적에 부합한다면 사용하면 됨
728x90