Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
Tags
- 스프링 고급 - 스프링 aop
- 스프링 mvc2 - 로그인 처리
- 자바의 정석 기초편 ch4
- 데이터 접근 기술
- 스프링 mvc1 - 스프링 mvc
- 스프링 입문(무료)
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch12
- 자바의 정석 기초편 ch7
- 자바의 정석 기초편 ch13
- 자바 중급2편 - 컬렉션 프레임워크
- 자바 기초
- 자바로 계산기 만들기
- 자바의 정석 기초편 ch2
- @Aspect
- 2024 정보처리기사 수제비 실기
- 람다
- 자바 중급1편 - 날짜와 시간
- 자바의 정석 기초편 ch14
- 스프링 트랜잭션
- 자바의 정석 기초편 ch6
- 자바로 키오스크 만들기
- 자바의 정석 기초편 ch5
- 자바 고급2편 - 네트워크 프로그램
- 자바의 정석 기초편 ch11
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch1
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch9
- 자바 고급2편 - io
Archives
- Today
- Total
개발공부기록
동기화 - synchronized, 출금 예제, 동시성 문제, 임계 영역, synchronized 메서드, synchronized 코드 블럭 본문
자바 로드맵 강의/고급 1 - 멀티스레드와 동시성
동기화 - synchronized, 출금 예제, 동시성 문제, 임계 영역, synchronized 메서드, synchronized 코드 블럭
소소한나구리 2025. 2. 11. 18:04728x90
출처 : 인프런 - 김영한의 실전 자바 - 고급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