JAVA

[JAVA] 동기화 - synchronized

경딩 2024. 11. 13. 21:33

동시성 문제 발생 테스트

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);
    }
}
package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV1 implements BankAccount {

    private int balance;

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

    @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 = balance - amount;
        log("[출금 완료] 출금액 : " + amount + " , 잔액: " + balance);

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

    @Override
    public int getBalance() {
        return balance;
    }
}
package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

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());


    }
}

실행 결과 1

  • 실행 결과에서 시간이 완전히 같다는 사실을 통해 두 스레드가 같이 실행된 것을 대략 확인할 수 있다
  • 출금된 돈은 1600 원인데 잔액이 200원이 되는 문제가 발생하였다..

 

실행 결과 2

  • t2 가 약간 빠르게 실행되어 t1 이 그 이후 출금하여 -600원이 되었다.
  • 잔액이 마이너스가 되는 문제가 발생하였다.

같은 코드지만 운영체제 스레드 시간 순서에 따라 실행결과 1 또는 2의 결과를 가진다.

 

공유자원 

잔액 balance  은 여러 스레드가 함께 사용하는 공유 자원이다.

 

Critical Section(임계 영역)

멀티 스레드 환경에서 하나의 스레드만 접근할 수 있도록 보호해야 하는 코드 영역

  1. 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 또 중요한 코드 부분을 뜻한다.
  2. 여러 스레드가 동시에 접근해서는 안 되는 공유자원을 접근하거나 수정하는 부분을 의미한다
    1. 예) 공유 변수나 공유 객체 수정
  3. 출금 로직이 바로 임계 영역이다.
    1. 더 자세히는 출금을 진행할 때 balance을 검증하는 단계부터 잔액의 계산을 완료할 때 까지가 임계영역이다.
    2. 여기서 balance는 여러 스레드가 동시에 접근해서는 안 되는 공유자원이다.

synchronized 메서드

자바의 synchronized 키워드를 사용하면 한 번에 하나의 스레드만 실행할 수 있는 코드 구간을 만들 수 있다.

package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV2 implements BankAccount {

    private int balance;

    public BankAccountV2(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    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 = balance - amount;
        log("[출금 완료] 출금액 : " + amount + " , 잔액: " + balance);

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

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

 

package thread.sync;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankMain {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccountV2(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());


    }
}

 

t1 이 withdraw() 메서드를 시작부터 끝까지 완료하고나서, 그 다음에 t2 withdraw() 메서드를 수행하였다. t2 가 먼저 실행 될 수 도 있다.

synchronized 분석

모든 객체(인스턴스)는 내부에 자신만의 락(lock)을 가지고 있다.

  • 모니터 락(monitor lock)이라고도 부른다.
  • 객체 내부에 있고 우리가 확인하기 어렵다.

스레드가 synchronized 키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락이 있어야 한다.

  • 여기서 BankAccount (x001) 인스턴스의 synchronized withdraw() 메서드를 호출하므로 이 인스턴스 락이 필요하다.

스레드 t1, t2는 withdraw()를 실행하기 직전이다.

  • t1 이 먼저 실행된다고 가정하겠다.
  • 스레드 t1 이 먼저 synchronized 키워드가 있는 withdraw() 메서드를 호출한다.
  • synchronized 메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.
  • 락이 있으므로 스레드 t1 은 BankAccount(x001) 인스턴스에 있는 락을 획득한다.

  • 스레드 t1 은 해당 인스턴스의 락을 획득했기 때문에 withdraw() 메서드에 진입할 수 있다.
  • 스레드 t2 도 withdrwa() 메서드 호출을 시도한다. synchronized 메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.
  • 스레드 t2는 BankAccount(x001) 인스턴스에 있는 락 획득을 시도한다. 하지만 락이 없으면 t2 스레드는 락을 획득할 때까지 BLOCKED  상태로 대기한다.
    • t2 스레드는 상태는 BLOCKED -> BLOCKED 상태로 변하고, 락을 획득할 때까지 무한정 대기한다.

 

참고로 BLOCKED 상태가 되면 락을 다시 획득하기 전까지는 계속 대기하고, CPU 실행 스케줄링에 들어가지 않는다.

 

  • t1 잔액(1000)이 출금액(800) 보다 많으므로 검증 로직을 통과한다.

 

 

t1 : 잔액 1000원에서 800원을 출금하고 계산 결과인 200원을 잔액( balance )에 반영한다.

  • t2 인스턴스에 락이 반납되면 락 획득을 대기하는 스레드는 자동으로 락을 획득한다.
  • 이때 락을 획득한 스레드는 BLOCKED-> RUNNABLE 상태가 되고, 다시 코드를 실행한다.

t2 : 락을 반납하면서 return 한다.

 

t1 은 800원 출금에 성공하지만, t2는 잔액 부족으로 출금에 실패한다. 그리고 최종 잔액은 1000원에서 200원이 돼 프로 정확하게 맞다.

이렇게 자바의 synchronized를 사용하면 한 번에 하나의 스레드만 실행하는 안전한 임계 영역 구간을 편리하게 만 들 수 있다.

 

참고: 락을 획득하는 순서는 보장되지 않는다.

  • 만약 BankAccount(x001) 인스턴스의 withdraw()를 수많은 스레드가 동시에 호출한다면, 1개의 스레드만 락 을 획득하고 나머지는 모두 BLOCKED 상태가 된다.
  • 그리고 이후에 BankAccount(x001) 인스턴스에 락을 반납하면, 해당 인스턴스의 락을 기다리는 수많은 스레드 중에 하나의 스레드만 락을 획득하고, 락을 획득한 스레드만 BLOCKED RUNNABLE 상태가 된다.
  • 이때 어떤 순서로 락을 획득하는지는 자바 표준에 정의되어 있지 않다. 따라서 순서를 보장하지 않고, 환경에 따라서 순서가 달라질 수 있다.

 

 

참고: volatile를 사용하지 않아도 synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 해결된다. 

 

synchronized 동기화 정리

자바에서 동기화(synchronization)는 여러 스레드가 동시에 접근할 수 있는 자원(예: 객체, 메서드)에 대해 일관성 있고 안 전한 접근을 보장하기 위한 메커니즘이다. 동기화는 주로 멀티스레드 환경에서 발생할 수 있는 문제, 예를 들어 데이터 손상이나 예기치 않은 결과를 방지하기 위해 사용된다.

 

메서드 동기화 : 메서드는 synchronized로 선언해서, 메서드에 접근하는 스레드가 하나뿐이도록 보장한다.

public synchronized void synchronizedMethod() {
 // 코드
 }

블록 동기화 : 코드 블록을 synchronized로 감싸서, 동기화를 구현할 수 있다.

public void method() {
 synchronized(this) {
 // 동기화된 코드
 }
}

 

이런 동기화를 사용하면 다음 문제들을 해결할 수 있다.

  • 경합 조건 (Race condition) : 두 개 이상의 스레드가 경쟁적으로 동일한 자원을 수정할 때 발생하는 문제
  • 데이터 일관성 : 여러 스레드가 동시에 읽고 쓰는 데이터의 일관성을 유지

 

동기화는 멀티스레드 환경에서 필수적인 기능이지만, 과도하게 사용할 경우 성능 저하를 초래할 수 있으므로 꼭 필요한 곳에 적절히 사용해야 한다.

 

 

지역변수는 스레드의 개별 저장 공간인 스택영역에 저장되기 때문에 동기화문제를 고려하지 않아도 된다.

package thread.sync;

import static util.MyLogger.log;

public class SyncTest2Main {
    public static void main(String[] args) throws InterruptedException {
        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);
        }
    }
}

'JAVA' 카테고리의 다른 글

유니코드, UTF-8, 직렬화  (1) 2024.11.18
[JAVA] String intern()  (1) 2024.11.15
[JAVA] 스레드 Interrupted  (0) 2024.11.13
[JAVA] 스레드 Join  (0) 2024.11.12
[JAVA] Runnable 을 만드는 다양한 방법  (0) 2024.11.11