JavaRush /Java Blog /Random-KO /Java의 교착상태 및 이를 해결하는 방법
articles
레벨 15

Java의 교착상태 및 이를 해결하는 방법

Random-KO 그룹에 게시되었습니다
멀티 스레드 애플리케이션을 개발할 때 종종 딜레마가 발생합니다. 더 중요한 것은 애플리케이션의 안정성이나 성능입니다. 예를 들어 스레드 안전을 위해 동기화를 사용하는데, 동기화 순서가 잘못된 경우 교착 상태가 발생할 수 있습니다. 또한 스레드 풀과 세마포어를 사용하여 리소스 소비를 제한하며 이 디자인의 오류로 인해 리소스 부족으로 인해 교착 상태가 발생할 수 있습니다. 이 기사에서는 교착 상태를 피하는 방법과 응용 프로그램 성능의 다른 문제에 대해 설명합니다. 또한 교착 상태가 발생한 경우 복구할 수 있는 방식으로 애플리케이션을 작성하는 방법도 살펴보겠습니다. Java의 교착상태 및 이를 해결하는 방법 - 1교착 상태는 일부 리소스를 점유하고 있는 두 개 이상의 프로세스가 다른 프로세스가 점유하고 있는 다른 리소스를 획득하려고 시도하고 어느 프로세스도 필요한 리소스를 점유할 수 없어 점유된 리소스를 해제하는 상황입니다. 이 정의는 너무 일반적이어서 이해하기 어려우므로, 더 나은 이해를 위해 교착상태의 유형을 예시를 통해 살펴보겠습니다.

동기화 순서 상호 잠금

다음 작업을 고려해보세요. 특정 금액을 한 계좌에서 다른 계좌로 이체하기 위해 거래를 수행하는 메서드를 작성해야 합니다. 해결책은 다음과 같습니다.
public void transferMoney(Account fromAccount, Account toAccount, Amount amount) throws InsufficientFundsException {
	synchronized (fromAccount) {
		synchronized (toAccount) {
			if (fromAccount.getBalance().compareTo(amount) < 0)
				throw new InsufficientFundsException();
			else {
				fromAccount.debit(amount);
				toAccount.credit(amount);
			}
		}
	}
}
언뜻 보기에 이 코드는 매우 정상적으로 동기화되어 있으며 원본 계정의 상태를 확인 및 변경하고 대상 계정을 변경하는 원자적 작업을 수행합니다. 그러나 이 동기화 전략을 사용하면 교착 상태 상황이 발생할 수 있습니다. 이것이 어떻게 일어나는지에 대한 예를 살펴 보겠습니다. 두 가지 거래를 해야 합니다: x 돈을 A 계좌에서 B 계좌로 이체하고, y 돈을 B 계좌에서 A 계좌로 이체합니다. 종종 이 상황은 교착 상태를 일으키지 않지만 불행한 상황에서는 트랜잭션 1이 계정 모니터 A를 차지하고 트랜잭션 2가 계정 모니터 B를 차지합니다. 결과는 교착 상태입니다. 트랜잭션 1은 트랜잭션 2가 계정 모니터를 해제할 때까지 기다립니다. 그러나 트랜잭션 2는 트랜잭션 1이 차지하는 모니터 A에 액세스해야 합니다. 교착 상태의 가장 큰 문제 중 하나는 테스트에서 찾기가 쉽지 않다는 것입니다. 예제에 설명된 상황에서도 스레드가 차단되지 않을 수 있습니다. 즉, 이 상황은 지속적으로 재현되지 않으므로 진단이 상당히 복잡해집니다. 일반적으로 설명된 비결정성 문제는 멀티스레딩에서 일반적입니다(비록 이것이 더 쉬워지지는 않지만). 따라서 코드 리뷰는 테스트 중에 재현하기 어려운 오류를 식별할 수 있게 해주기 때문에 멀티스레드 애플리케이션의 품질을 향상시키는 데 중요한 역할을 합니다. 물론 이는 애플리케이션을 테스트할 필요가 없다는 의미는 아니며 코드 검토만 잊어서는 안 됩니다. 이 코드로 인해 교착 상태가 발생하지 않도록 하려면 어떻게 해야 합니까? 이러한 차단은 계정 동기화가 다른 순서로 발생할 수 있기 때문에 발생합니다. 따라서 계정에 일부 주문을 도입하면(이것은 계정 A가 계정 B보다 작다고 말할 수 있는 규칙임) 문제가 해결됩니다. 어떻게 하나요? 첫째, 계정에 숫자, 소문자 또는 자연적인 순서 개념(문자열은 사전식 순서로 비교될 수 ​​있음)이 있는 일종의 고유 식별자(예: 계정 번호)가 있는 경우 운이 좋다고 생각할 수 있습니다. 항상 작은 계정의 모니터를 먼저 차지한 다음 큰 계정의 모니터를 차지할 수 있습니다(또는 그 반대).
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromId= fromAcct.getId();
	int toId = fromAcct.getId();
	if (fromId < toId) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	} else  {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	}
}
두 번째 옵션은 그러한 식별자가 없다면 우리가 직접 찾아내야 한다는 것입니다. 첫 번째 근사치로 객체를 해시 코드로 비교할 수 있습니다. 아마도 그들은 다를 것입니다. 하지만 만약 그들이 똑같은 것으로 판명된다면 어떨까요? 그런 다음 동기화를 위해 다른 개체를 추가해야 합니다. 조금 정교해 보일 수도 있지만 무엇을 할 수 있습니까? 게다가 세 번째 개체는 거의 사용되지 않습니다. 결과는 다음과 같습니다:
private static final Object tieLock = new Object();
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromHash = System.identityHashCode(fromAcct);
	int toHash = System.identityHashCode(toAcct);
	if (fromHash < toHash) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else if (fromHash > toHash) {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else {
		synchronized (tieLock) {
			synchronized (fromAcct) {
				synchronized (toAcct) {
					doTransfer(fromAcct, toAcct, amount)
				}
			}
		}
	}
}

객체 간 교착 상태

설명된 차단 조건은 진단하기 가장 쉬운 교착 상태 사례를 나타냅니다. 다중 스레드 애플리케이션에서는 종종 서로 다른 객체가 동일한 동기화된 블록에 액세스하려고 시도합니다. 이로 인해 교착 상태가 발생할 수 있습니다. 다음 예제를 고려해보세요: 비행기 배차 애플리케이션. 비행기는 목적지에 도착하면 관제사에게 이를 알리고 착륙 허가를 요청합니다. 관제사는 자신의 방향으로 비행하는 항공기에 대한 모든 정보를 저장하고 지도에 항공기 위치를 표시할 수 있습니다.
class Plane {
	private Point location, destination;
	private final Dispatcher dispatcher;

	public Plane(Dispatcher dispatcher) {
		this.dispatcher = dispatcher;
	}
	public synchronized Point getLocation() {
		return location;
	}
	public synchronized void setLocation(Point location) {
		this.location = location;
		if (location.equals(destination))
		dispatcher.requestLanding(this);
	}
}

class Dispatcher {
	private final Set<Plane> planes;
	private final Set<Plane> planesPendingLanding;

	public Dispatcher() {
		planes = new HashSet<Plane>();
		planesPendingLanding = new HashSet<Plane>();
	}
	public synchronized void requestLanding(Plane plane) {
		planesPendingLanding.add(plane);
	}
	public synchronized Image getMap() {
		Image image = new Image();
		for (Plane plane : planes)
			image.drawMarker(plane.getLocation());
		return image;
	}
}
이 코드에 교착 상태를 일으킬 수 있는 버그가 있다는 것을 이해하는 것은 이전 코드보다 더 어렵습니다. 언뜻보기에는 재동기화 기능이 없지만 그렇지 않습니다. 아마도 setLocation클래스 PlanegetMap클래스 메서드가 Dispatcher동기화되고 그 자체 내에서 다른 클래스의 동기화된 메서드를 호출한다는 사실을 이미 알아차렸을 것입니다. 이는 일반적으로 나쁜 습관입니다. 이를 수정하는 방법은 다음 섹션에서 설명합니다. 결과적으로 비행기가 해당 위치에 도착하면 누군가가 카드를 받기로 결정하는 순간 교착 상태가 발생할 수 있습니다. 즉, getMap및 메소드가 호출되어 각각 setLocation인스턴스 모니터를 차지합니다 . 그런 다음 메소드는 모니터가 각 인스턴스에 대해 사용 가능해질 때까지 기다리는 호출 (특히 현재 사용 중인 인스턴스에서 )을 수행합니다 . 동시에 인스턴스 모니터가 지도를 그리는 동안 메소드가 호출됩니다 . 결과는 교착 상태입니다. DispatcherPlanegetMapplane.getLocationPlanePlanesetLocationdispatcher.requestLandingDispatcher

공개 통화

이전 섹션에서 설명한 것과 같은 상황을 방지하려면 다른 객체의 메서드에 대한 공개 호출을 사용하는 것이 좋습니다. 즉, 동기화된 블록 외부의 다른 개체의 메서드를 호출합니다. 공개 호출 원칙을 사용하여 메소드를 다시 작성하면 setLocation교착 getMap상태 가능성이 제거됩니다. 예를 들어 다음과 같습니다.
public void setLocation(Point location) {
	boolean reachedDestination;
	synchronized(this){
		this.location = location;
		reachedDestination = location.equals(destination);
	}
	if (reachedDestination)
		dispatcher.requestLanding(this);
}
………………………………………………………………………………
public Image getMap() {
	Set<Plane> copy;
	synchronized(this){
		copy = new HashSet<Plane>( planes);
	}
	Image image = new Image();
	for (Plane plane : copy)
		image.drawMarker(plane.getLocation());
	return image;
}

자원 교착상태

한 번에 하나의 스레드만 사용할 수 있는 일부 리소스에 액세스하려고 할 때도 교착 상태가 발생할 수 있습니다. 예를 들면 데이터베이스 연결 풀이 있습니다. 일부 스레드가 동시에 두 개의 연결에 액세스해야 하고 서로 다른 순서로 액세스하는 경우 교착 상태가 발생할 수 있습니다. 기본적으로 이러한 종류의 잠금은 일부 코드를 실행하려고 할 때가 아니라 리소스에 액세스하려고 할 때 발생한다는 점을 제외하면 동기화 순서 잠금과 다르지 않습니다.

교착상태를 피하는 방법은 무엇입니까?

물론 코드가 오류(이전 섹션에서 본 예) 없이 작성되면 교착 상태가 발생하지 않습니다. 하지만 그의 코드가 오류 없이 작성되었다고 누가 보장할 수 있습니까? 물론 테스트는 오류의 상당 부분을 식별하는 데 도움이 되지만 앞서 살펴보았듯이 멀티 스레드 코드의 오류는 진단하기 쉽지 않으며 테스트 후에도 교착 상태 상황이 없다고 확신할 수 없습니다. 어떻게든 차단으로부터 자신을 보호할 수 있나요? 대답은 '예'입니다. 유사한 기술이 교착 상태(데이터베이스의 트랜잭션 메커니즘과 관련됨)에서 복구해야 하는 데이터베이스 엔진에 사용됩니다. Lock패키지에서 사용할 수 있는 인터페이스 와 해당 구현을 java.util.concurrent.locks사용하면 메서드를 사용하여 이 클래스의 인스턴스와 연결된 모니터를 점유할 수 있습니다 tryLock(모니터를 점유할 수 있는 경우 true를 반환). 인터페이스를 구현하는 한 쌍의 객체가 있고 Lock상호 차단을 방지하는 방식으로 모니터를 점유해야 한다고 가정해 보겠습니다. 다음과 같이 구현할 수 있습니다.
public void twoLocks(Lock A,  Lock B){
	while(true){
		if(A.tryLock()){
			if(B.tryLock())
			{
				try{
					//do something
				} finally{
					B.unlock();
					A.unlock();
				}
			} else{
				A.unlock();
			}
		}
	}
}
이 프로그램에서 볼 수 있듯이 우리는 두 개의 모니터를 사용하면서 상호 차단 가능성을 제거합니다. try- finally패키지의 클래스는 자동으로 모니터를 해제하지 않으므로 블록이 필요하며 java.util.concurrent.locks, 작업 실행 중에 예외가 발생하면 모니터가 잠긴 상태에 갇히게 됩니다. 교착 상태를 진단하는 방법은 무엇입니까? JVM을 사용하면 교착 상태를 스레드 덤프에 표시하여 진단할 수 있습니다. 이러한 덤프에는 스레드의 상태에 대한 정보가 포함됩니다. 차단되면 덤프에는 스레드가 해제되기를 기다리고 있는 모니터에 대한 정보가 포함됩니다. JVM은 스레드를 덤프하기 전에 대기 중인(busy) 모니터의 그래프를 보고, 주기를 찾으면 참여하는 모니터와 스레드를 나타내는 교착 상태 정보를 추가합니다. 교착 상태 스레드 덤프는 다음과 같습니다.
Found one Java-level deadlock:
=============================
"ApplicationServerThread":
waiting to lock monitor 0x0f0d80cc (a MyDBConnection),
which is held by "ApplicationServerThread"
"ApplicationServerThread":
waiting to lock monitor 0x0f0d8fed (a MyDBCallableStatement),
which is held by "ApplicationServerThread"
Java stack information for the threads listed above:
"ApplicationServerThread":
at MyDBConnection.remove_statement
- waiting to lock <0x6f50f730> (a MyDBConnection)
at MyDBStatement.close
- locked <0x604ffbb0> (a MyDBCallableStatement)
...
"ApplicationServerThread":
at MyDBCallableStatement.sendBatch
- waiting to lock <0x604ffbb0> (a MyDBCallableStatement)
at MyDBConnection.commit
- locked <0x6f50f730> (a MyDBConnection)
위의 덤프는 데이터베이스와 함께 작동하는 두 스레드가 서로를 차단했음을 명확하게 보여줍니다. 이 JVM 기능을 사용하여 교착 상태를 진단하려면 프로그램의 다양한 위치에서 스레드 덤프 작업을 호출하고 애플리케이션을 테스트해야 합니다. 다음으로 결과 로그를 분석해야 합니다. 교착 상태가 발생했음을 나타내는 경우 덤프의 정보는 교착 상태가 발생한 조건을 검색하는 데 도움이 됩니다. 일반적으로 교착 상태 예제와 같은 상황은 피해야 합니다. 이러한 경우 애플리케이션은 안정적으로 작동할 가능성이 높습니다. 하지만 테스트와 코드 검토를 잊지 마세요. 이는 문제가 발생할 경우 이를 식별하는 데 도움이 됩니다. 교착 상태 필드의 복구가 중요한 시스템을 개발하는 경우 "교착 상태를 방지하는 방법" 섹션에 설명된 방법을 사용할 수 있습니다. 이 경우 lockInterruptibly. _ 이 방법을 사용하면 모니터를 점유하고 있는 스레드를 중단할 수 있습니다(따라서 모니터를 해제할 수 있습니다). Lockjava.util.concurrent.locks
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION