JavaRush /Blog Java /Random-VI /Bế tắc trong Java và các phương pháp khắc phục nó
articles
Mức độ

Bế tắc trong Java và các phương pháp khắc phục nó

Xuất bản trong nhóm
Khi phát triển các ứng dụng đa luồng, một vấn đề nan giải thường nảy sinh: điều quan trọng hơn là độ tin cậy hoặc hiệu suất của ứng dụng. Ví dụ: chúng tôi sử dụng đồng bộ hóa để đảm bảo an toàn cho luồng và trong trường hợp thứ tự đồng bộ hóa không chính xác, chúng tôi có thể gây ra bế tắc. Chúng tôi cũng sử dụng nhóm luồng và ngữ nghĩa để hạn chế mức tiêu thụ tài nguyên và một lỗi trong thiết kế này có thể dẫn đến bế tắc do thiếu tài nguyên. Trong bài viết này, chúng tôi sẽ nói về cách tránh bế tắc, cũng như các vấn đề khác về hiệu suất của ứng dụng. Chúng ta cũng sẽ xem xét cách viết một ứng dụng sao cho có thể phục hồi trong trường hợp bế tắc. Deadlock trong Java và cách khắc phục - 1Bế tắc là tình huống trong đó hai hoặc nhiều quy trình chiếm một số tài nguyên đang cố gắng lấy một số tài nguyên khác bị chiếm bởi các quy trình khác và không có quy trình nào có thể chiếm tài nguyên mà chúng cần và theo đó, giải phóng tài nguyên đã chiếm. Định nghĩa này quá chung chung và do đó khó hiểu; để hiểu rõ hơn, chúng ta sẽ xem xét các loại bế tắc bằng các ví dụ.

Thứ tự đồng bộ hóa Khóa lẫn nhau

Hãy xem xét nhiệm vụ sau: bạn cần viết một phương thức thực hiện giao dịch để chuyển một số tiền nhất định từ tài khoản này sang tài khoản khác. Giải pháp có thể trông như thế này:
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);
			}
		}
	}
}
Thoạt nhìn, mã này được đồng bộ hóa khá bình thường, chúng ta có thao tác nguyên tử là kiểm tra và thay đổi trạng thái của tài khoản nguồn và thay đổi tài khoản đích. Tuy nhiên, với chiến lược đồng bộ hóa này, tình trạng bế tắc có thể xảy ra. Hãy xem một ví dụ về cách điều này xảy ra. Cần thực hiện 2 giao dịch: chuyển tiền x từ tài khoản A sang tài khoản B và chuyển tiền y từ tài khoản B sang tài khoản A. Thông thường tình huống này sẽ không gây ra bế tắc, tuy nhiên, trong một số trường hợp không may, giao dịch 1 sẽ chiếm giám sát tài khoản A, giao dịch 2 sẽ chiếm giám sát tài khoản B. Kết quả là bế tắc: giao dịch 1 chờ giao dịch 2 giải phóng giám sát tài khoản B, nhưng giao dịch 2 phải truy cập vào màn hình A đang bị chiếm giữ bởi giao dịch 1. Một trong những vấn đề lớn với bế tắc là chúng không dễ tìm thấy trong quá trình thử nghiệm. Ngay cả trong tình huống được mô tả trong ví dụ, các luồng có thể không bị chặn, nghĩa là tình huống này sẽ không được tái tạo liên tục, điều này làm phức tạp đáng kể việc chẩn đoán. Nói chung, vấn đề được mô tả về tính không tất định là điển hình cho đa luồng (mặc dù điều này không làm cho nó dễ dàng hơn chút nào). Do đó, việc xem xét mã đóng một vai trò quan trọng trong việc cải thiện chất lượng của các ứng dụng đa luồng, vì nó cho phép bạn xác định các lỗi khó tái tạo trong quá trình thử nghiệm. Tất nhiên, điều này không có nghĩa là ứng dụng không cần phải được kiểm tra; chúng ta không nên quên việc xem xét mã. Tôi nên làm gì để ngăn mã này gây ra bế tắc? Việc chặn này xảy ra do việc đồng bộ hóa tài khoản có thể diễn ra theo một thứ tự khác. Theo đó, nếu bạn đưa ra một số thứ tự trên các tài khoản (đây là một số quy tắc cho phép bạn nói rằng tài khoản A nhỏ hơn tài khoản B), thì vấn đề sẽ được loại bỏ. Làm thế nào để làm nó? Thứ nhất, nếu tài khoản có một số loại mã định danh duy nhất (ví dụ: số tài khoản), số, chữ thường hoặc một số loại khác có khái niệm tự nhiên về thứ tự (các chuỗi có thể được so sánh theo thứ tự từ điển, thì chúng ta có thể coi mình là người may mắn và chúng ta sẽ luôn luôn Đầu tiên chúng ta có thể chiếm màn hình của tài khoản nhỏ hơn, sau đó là tài khoản lớn hơn (hoặc ngược lại).
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)}
			}
		}
	}
}
Tùy chọn thứ hai, nếu chúng tôi không có mã định danh như vậy, chúng tôi sẽ phải tự mình nghĩ ra. Chúng ta có thể, như một phép tính gần đúng đầu tiên, so sánh các đối tượng bằng mã băm. Rất có thể chúng sẽ khác nhau. Nhưng điều gì sẽ xảy ra nếu chúng giống nhau? Sau đó, bạn sẽ phải thêm một đối tượng khác để đồng bộ hóa. Nó có thể trông hơi phức tạp, nhưng bạn có thể làm gì? Và bên cạnh đó, đối tượng thứ ba sẽ khá hiếm khi được sử dụng. Kết quả sẽ như thế này:
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)
				}
			}
		}
	}
}

Bế tắc giữa các đối tượng

Các điều kiện chặn được mô tả thể hiện trường hợp bế tắc dễ chẩn đoán nhất. Thông thường trong các ứng dụng đa luồng, các đối tượng khác nhau cố gắng truy cập vào cùng các khối được đồng bộ hóa. Điều này có thể gây ra bế tắc. Hãy xem xét ví dụ sau: một ứng dụng điều phối chuyến bay. Máy bay thông báo cho bộ điều khiển khi họ đã đến đích và xin phép hạ cánh. Người điều khiển lưu trữ tất cả thông tin về máy bay đang bay theo hướng của mình và có thể vẽ vị trí của chúng trên bản đồ.
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;
	}
}
Hiểu rằng có một lỗi trong mã này có thể dẫn đến bế tắc khó hơn so với mã trước. Thoạt nhìn, nó không có tính năng đồng bộ lại, nhưng thực tế không phải vậy. Có thể bạn đã nhận thấy rằng setLocationlớp Planevà các phương thức getMaplớp Dispatcherđược đồng bộ hóa và gọi các phương thức đồng bộ của các lớp khác trong chính chúng. Đây thường là thực hành xấu. Làm thế nào điều này có thể được sửa chữa sẽ được thảo luận trong phần tiếp theo. Kết quả là, nếu máy bay đến địa điểm, thời điểm có người quyết định lấy thẻ thì bế tắc có thể xảy ra. Nghĩa là, các phương thức getMapvà sẽ được gọi, setLocationsẽ chiếm các màn hình cá thể Dispatchertương Planeứng. Sau đó, phương thức này sẽ getMapgọi plane.getLocation(cụ thể là trên phiên bản Planehiện đang bận) và sẽ đợi màn hình rảnh cho từng phiên bản Plane. Đồng thời, phương thức này setLocationsẽ được gọi dispatcher.requestLandingtrong khi trình giám sát cá thể Dispatchervẫn đang bận vẽ bản đồ. Kết quả là bế tắc.

Mở cuộc gọi

Để tránh các tình huống như mô tả ở phần trước, nên sử dụng các lệnh gọi công khai tới các phương thức của các đối tượng khác. Nghĩa là gọi các phương thức của các đối tượng khác bên ngoài khối được đồng bộ hóa. Nếu các phương thức được viết lại theo nguyên tắc cuộc gọi mở setLocationthì getMapkhả năng bế tắc sẽ bị loại bỏ. Ví dụ, nó sẽ trông như thế này:
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;
}

Bế tắc tài nguyên

Bế tắc cũng có thể xảy ra khi cố gắng truy cập một số tài nguyên mà tại một thời điểm chỉ có một luồng có thể sử dụng. Một ví dụ sẽ là nhóm kết nối cơ sở dữ liệu. Nếu một số luồng cần truy cập hai kết nối cùng lúc và chúng truy cập chúng theo thứ tự khác nhau, điều này có thể dẫn đến bế tắc. Về cơ bản, loại khóa này không khác gì khóa thứ tự đồng bộ hóa, ngoại trừ việc nó xảy ra không phải khi cố gắng thực thi một số mã mà khi cố gắng truy cập tài nguyên.

Làm thế nào để tránh bế tắc?

Tất nhiên, nếu mã được viết mà không có bất kỳ lỗi nào (ví dụ mà chúng ta đã thấy trong các phần trước), thì sẽ không có bế tắc trong đó. Nhưng ai có thể đảm bảo rằng mã của mình được viết không có lỗi? Tất nhiên, kiểm tra giúp xác định một phần lỗi đáng kể, nhưng như chúng ta đã thấy trước đó, lỗi trong mã đa luồng không dễ chẩn đoán và ngay cả sau khi kiểm tra, bạn cũng không thể chắc chắn rằng không có tình huống bế tắc. Bằng cách nào đó chúng ta có thể tự bảo vệ mình khỏi bị chặn không? Câu trả lời là có. Các kỹ thuật tương tự được sử dụng trong các công cụ cơ sở dữ liệu, thường cần phục hồi từ các bế tắc (liên quan đến cơ chế giao dịch trong cơ sở dữ liệu). Giao diện Lockvà các triển khai của nó có sẵn trong gói java.util.concurrent.lockscho phép bạn cố gắng chiếm màn hình được liên kết với một phiên bản của lớp này bằng phương thức tryLock(trả về true nếu có thể chiếm màn hình). Giả sử chúng ta có một cặp đối tượng triển khai một giao diện Lockvà chúng ta cần chiếm màn hình của chúng theo cách tránh chặn lẫn nhau. Bạn có thể thực hiện nó như thế này:
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();
			}
		}
	}
}
Như bạn có thể thấy trong chương trình này, chúng tôi sử dụng hai màn hình, đồng thời loại bỏ khả năng chặn lẫn nhau. Xin lưu ý rằng việc chặn try- finallylà cần thiết vì các lớp trong gói java.util.concurrent.lockskhông tự động giải phóng màn hình và nếu một số ngoại lệ xảy ra trong quá trình thực hiện tác vụ của bạn, màn hình sẽ bị kẹt ở trạng thái khóa. Làm thế nào để chẩn đoán bế tắc? JVM cho phép bạn chẩn đoán các bế tắc bằng cách hiển thị chúng trong các kết xuất luồng. Các kết xuất như vậy bao gồm thông tin về trạng thái của luồng. Nếu nó bị chặn, kết xuất sẽ chứa thông tin về màn hình mà luồng đang chờ được giải phóng. Trước khi kết xuất các luồng, JVM xem xét biểu đồ các màn hình đang chờ (bận) và nếu tìm thấy các chu kỳ, nó sẽ thêm thông tin khóa chết, cho biết các màn hình và luồng tham gia. Một loạt các chủ đề bị bế tắc trông như thế này:
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)
Kết xuất ở trên cho thấy rõ rằng hai luồng làm việc với cơ sở dữ liệu đã chặn lẫn nhau. Để chẩn đoán các bế tắc khi sử dụng tính năng JVM này, cần thực hiện các lệnh gọi đến thao tác kết xuất luồng ở nhiều vị trí khác nhau trong chương trình và kiểm tra ứng dụng. Tiếp theo, bạn nên phân tích nhật ký kết quả. Nếu họ chỉ ra rằng bế tắc đã xảy ra, thông tin từ kết xuất sẽ giúp phát hiện các điều kiện khiến nó xảy ra. Nói chung, bạn nên tránh những tình huống như trong các ví dụ bế tắc. Trong những trường hợp như vậy, ứng dụng rất có thể sẽ hoạt động ổn định. Nhưng đừng quên kiểm tra và đánh giá mã. Điều này sẽ giúp xác định các vấn đề nếu chúng xảy ra. Trong trường hợp bạn đang phát triển một hệ thống mà việc khôi phục trường bế tắc là rất quan trọng, bạn có thể sử dụng phương pháp được mô tả trong phần “Làm thế nào để tránh bế tắc?”. Trong trường hợp này, phương thức lockInterruptiblygiao diện Locktừ tệp java.util.concurrent.locks. Nó cho phép bạn ngắt luồng đã chiếm màn hình bằng phương pháp này (và do đó giải phóng màn hình).
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION