JavaRush /Blog Java /Random-VI /Bạn không thể làm hỏng Java bằng một chủ đề: Phần III - T...
Viacheslav
Mức độ

Bạn không thể làm hỏng Java bằng một chủ đề: Phần III - Tương tác

Xuất bản trong nhóm
Tổng quan ngắn gọn về các tính năng của tương tác luồng. Trước đây, chúng ta đã xem xét cách các luồng đồng bộ hóa với nhau. Lần này chúng ta sẽ đi sâu vào các vấn đề có thể phát sinh khi các luồng tương tác và nói về cách có thể tránh được chúng. Chúng tôi cũng sẽ cung cấp một số liên kết hữu ích để nghiên cứu sâu hơn. Bạn không thể phá hỏng Java chỉ bằng một luồng: Phần III - tương tác - 1

Giới thiệu

Vì vậy, chúng tôi biết rằng có những luồng trong Java mà bạn có thể đọc trong bài đánh giá “ Thread Can’t Spoil Java: Part I - Threads ” và các luồng đó có thể được đồng bộ hóa với nhau mà chúng tôi đã đề cập trong bài đánh giá “ Chủ đề không thể làm hỏng Java ” Làm hỏng: Phần II - Đồng bộ hóa ." Đã đến lúc nói về cách các luồng tương tác với nhau. Họ chia sẻ tài nguyên chung như thế nào? Những vấn đề gì có thể xảy ra với điều này?

Bế tắc

Vấn đề tồi tệ nhất là Deadlock. Khi hai hoặc nhiều luồng chờ đợi lẫn nhau mãi mãi, điều này được gọi là Bế tắc. Hãy lấy một ví dụ từ trang web Oracle từ phần mô tả khái niệm " Bế tắc ":
public class Deadlock {
    static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s has bowed to me!%n",
                    this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s has bowed back to me!%n",
                    this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(() -> alphonse.bow(gaston)).start();
        new Thread(() -> gaston.bow(alphonse)).start();
    }
}
Bế tắc ở đây có thể không xuất hiện lần đầu tiên, nhưng nếu quá trình thực thi chương trình của bạn bị kẹt thì đã đến lúc chạy jvisualvm: Bạn không thể phá hỏng Java chỉ bằng một luồng: Phần III - tương tác - 2Nếu một plugin được cài đặt trong JVisualVM (thông qua Công cụ -> Plugin), chúng ta có thể thấy bế tắc xảy ra ở đâu:
"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
    at Deadlock$Friend.bowBack(Deadlock.java:16)
    - waiting to lock &lt33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
Chủ đề 1 đang chờ khóa từ chủ đề 0. Tại sao điều này lại xảy ra? Thread-1bắt đầu thực hiện và thực hiện phương thức Friend#bow. Nó được đánh dấu bằng từ khóa synchronized, tức là chúng ta nhấc màn hình lên this. Ở lối vào phương thức, chúng tôi nhận được một liên kết đến một tệp Friend. Bây giờ, luồng Thread-1muốn thực thi một phương thức trên một luồng khác Friend, do đó cũng nhận được khóa từ anh ta. Nhưng nếu một luồng khác (trong trường hợp này Thread-0) được quản lý để nhập phương thức bowthì khóa đã bận và Thread-1đang chờ Thread-0và ngược lại. Việc chặn không thể giải quyết được nên đã Chết, tức là đã chết. Vừa là một cái kẹp tử thần (không thể giải phóng) vừa là một khối chết mà người ta không thể thoát ra. Về chủ đề bế tắc, bạn có thể xem video: " Deadlock - Concurrency #1 - Advanced Java ".

khóa sống

Nếu có Deadlock thì có Livelock không? Có, có) Livelock là các chủ đề dường như vẫn còn sống ở bên ngoài, nhưng đồng thời chúng không thể làm gì cả, bởi vì... điều kiện mà họ đang cố gắng tiếp tục công việc của mình không thể được đáp ứng. Về bản chất, Livelock cũng tương tự như deadlock, nhưng các thread không “treo” trên hệ thống chờ màn hình mà luôn làm một việc gì đó. Ví dụ:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class App {
    public static final String ANSI_BLUE = "\u001B[34m";
    public static final String ANSI_PURPLE = "\u001B[35m";

    public static void log(String text) {
        String name = Thread.currentThread().getName(); //like Thread-1 or Thread-0
        String color = ANSI_BLUE;
        int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
        if (val != 0) {
            color = ANSI_PURPLE;
        }
        System.out.println(color + name + ": " + text + color);
        try {
            System.out.println(color + name + ": wait for " + val + " sec" + color);
            Thread.currentThread().sleep(val * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Lock first = new ReentrantLock();
        Lock second = new ReentrantLock();

        Runnable locker = () -> {
            boolean firstLocked = false;
            boolean secondLocked = false;
            try {
                while (!firstLocked || !secondLocked) {
                    firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
                    log("First Locked: " + firstLocked);
                    secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
                    log("Second Locked: " + secondLocked);
                }
                first.unlock();
                second.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        new Thread(locker).start();
        new Thread(locker).start();
    }
}
Sự thành công của mã này phụ thuộc vào thứ tự mà bộ lập lịch luồng Java bắt đầu các luồng. Nếu nó bắt đầu trước Thead-1, chúng ta sẽ nhận được Livelock:
Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
Như có thể thấy từ ví dụ, cả hai luồng lần lượt cố gắng nắm bắt cả hai khóa, nhưng chúng đều thất bại. Hơn nữa, họ không rơi vào tình trạng bế tắc, tức là về mặt trực quan thì mọi thứ đều ổn với họ và họ đang làm công việc của mình. Bạn không thể phá hỏng Java chỉ bằng một luồng: Phần III - tương tác - 3Theo JVisualVM, chúng ta thấy các khoảng thời gian ngủ và thời gian dừng (đây là khi một luồng cố gắng chiếm một khóa, nó sẽ chuyển sang trạng thái đỗ, như chúng ta đã thảo luận trước đó khi nói về đồng bộ hóa luồng ). Về chủ đề livelock, bạn có thể xem một ví dụ: " Java - Thread Livelock ".

Đói

Ngoài việc chặn (bế tắc và livelock), còn có một vấn đề khác khi làm việc với đa luồng - Đói, hay còn gọi là đói đói. Hiện tượng này khác với việc chặn ở chỗ các luồng không bị chặn mà đơn giản là chúng không có đủ tài nguyên cho mọi người. Do đó, trong khi một số luồng chiếm toàn bộ thời gian thực thi thì các luồng khác không thể được thực thi: Bạn không thể phá hỏng Java chỉ bằng một luồng: Phần III - tương tác - 4

https://www.logicbig.com/

Một ví dụ tuyệt vời có thể được tìm thấy ở đây: " Java - Thread Starvation and Fairness ". Ví dụ này cho thấy cách các luồng hoạt động trong Starvation và cách một thay đổi nhỏ từ Thread.sleep sang Thread.wait có thể phân phối tải đồng đều. Bạn không thể phá hỏng Java chỉ bằng một luồng: Phần III - tương tác - 5

Điều kiện của cuộc đua

Khi làm việc với đa luồng, có một thứ gọi là "điều kiện chạy đua". Hiện tượng này nằm ở chỗ các luồng chia sẻ một tài nguyên nhất định với nhau và mã được viết theo cách không đảm bảo hoạt động chính xác trong trường hợp này. Hãy xem một ví dụ:
public class App {
    public static int value = 0;

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                int oldValue = value;
                int newValue = ++value;
                if (oldValue + 1 != newValue) {
                    throw new IllegalStateException(oldValue + " + 1 = " + newValue);
                }
            }
        };
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
    }
}
Mã này có thể không tạo ra lỗi trong lần đầu tiên. Và nó có thể trông như thế này:
Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
    at App.lambda$main$0(App.java:13)
    at java.lang.Thread.run(Thread.java:745)
Như bạn có thể thấy, trong khi nó đang được chỉ định, newValueđã xảy ra sự cố và newValuecòn nhiều vấn đề khác. Một số chủ đề trong điều kiện cuộc đua đã thay đổi valuegiữa hai đội này. Như chúng ta có thể thấy, một cuộc đua giữa các chủ đề đã xuất hiện. Bây giờ hãy tưởng tượng tầm quan trọng của việc không mắc phải những sai lầm tương tự trong giao dịch tiền tệ... Bạn cũng có thể xem các ví dụ và sơ đồ ở đây: “ Mã mô phỏng điều kiện chủng tộc trong luồng Java ”.

Bay hơi

Nói về sự tương tác của các chủ đề, điều đáng chú ý đặc biệt là từ khóa volatile. Hãy xem một ví dụ đơn giản:
public class App {
    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Runnable whileFlagFalse = () -> {
            while(!flag) {
            }
            System.out.println("Flag is now TRUE");
        };

        new Thread(whileFlagFalse).start();
        Thread.sleep(1000);
        flag = true;
    }
}
Điều thú vị nhất là có khả năng cao nó sẽ không hoạt động. Chủ đề mới sẽ không thấy sự thay đổi flag. Để khắc phục điều này, flagbạn cần chỉ định từ khóa cho trường volatile. Như thế nào và tại sao? Tất cả các hành động được thực hiện bởi bộ xử lý. Nhưng kết quả tính toán cần được lưu trữ ở đâu đó. Với mục đích này, bộ xử lý có bộ nhớ chính và bộ đệm phần cứng. Các bộ nhớ đệm của bộ xử lý này giống như một phần bộ nhớ nhỏ để truy cập dữ liệu nhanh hơn truy cập vào bộ nhớ chính. Nhưng mọi thứ cũng có nhược điểm: dữ liệu trong bộ đệm có thể không cập nhật (như trong ví dụ trên, khi giá trị cờ không được cập nhật). Vì vậy, từ khóa volatilecho JVM biết rằng chúng ta không muốn lưu biến của mình vào bộ nhớ đệm. Điều này cho phép bạn xem kết quả thực tế trong tất cả các chủ đề. Đây là một công thức rất đơn giản. Về chủ đề này, volatilechúng tôi khuyên bạn nên đọc bản dịch " Các câu hỏi thường gặp về JSR 133 (Mô hình bộ nhớ Java) ". Tôi cũng khuyên bạn nên đọc thêm về các tài liệu “ Mô hình bộ nhớ Java ” và “ Từ khóa dễ biến động Java ”. Ngoài ra, điều quan trọng cần nhớ là volatileđây là về khả năng hiển thị chứ không phải về tính nguyên tử của các thay đổi. Nếu lấy mã từ "Điều kiện cuộc đua", chúng ta sẽ thấy gợi ý trong IntelliJ Idea: Bạn không thể phá hỏng Java chỉ bằng một luồng: Phần III - tương tác - 6Kiểm tra này (Kiểm tra) đã được thêm vào IntelliJ Idea như một phần của vấn đề IDEA-61117 , được liệt kê trong Ghi chú phát hành vào năm 2010.

Tính nguyên tử

Hoạt động nguyên tử là các hoạt động không thể phân chia. Ví dụ, hoạt động gán giá trị cho một biến là nguyên tử. Thật không may, tăng dần không phải là một hoạt động nguyên tử, bởi vì một mức tăng yêu cầu tối đa ba thao tác: lấy giá trị cũ, thêm một giá trị vào đó và lưu giá trị. Tại sao tính nguyên tử lại quan trọng? Trong ví dụ tăng dần, nếu điều kiện tương tranh xảy ra, tài nguyên được chia sẻ (tức là giá trị được chia sẻ) có thể đột ngột thay đổi bất cứ lúc nào. Ngoài ra, điều quan trọng là các cấu trúc 64-bit cũng không phải là cấu trúc nguyên tử, chẳng hạn như longdouble. Bạn có thể đọc thêm tại đây: " Đảm bảo tính nguyên tử khi đọc và ghi giá trị 64-bit ". Một ví dụ về các vấn đề với tính nguyên tử có thể được nhìn thấy trong ví dụ sau:
public class App {
    public static int value = 0;
    public static AtomicInteger atomic = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                value++;
                atomic.incrementAndGet();
            }
        };
        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
        Thread.sleep(300);
        System.out.println(value);
        System.out.println(atomic.get());
    }
}
Một lớp đặc biệt để làm việc với nguyên tử Integersẽ luôn hiển thị cho chúng ta 30000, nhưng valuenó sẽ thay đổi theo thời gian. Có một phần tổng quan ngắn về chủ đề này " Giới thiệu về các biến nguyên tử trong Java ". Atomic dựa trên thuật toán So sánh và Hoán đổi. Bạn có thể đọc thêm về nó trong bài viết trên Habré " So sánh các thuật toán Lock-free - CAS và FAA sử dụng ví dụ về JDK 7 và 8 " hoặc trên Wikipedia trong bài viết về " So sánh với trao đổi ". Bạn không thể phá hỏng Java chỉ bằng một luồng: Phần III - tương tác - 8

http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html

Xảy ra trước

Có một điều thú vị và bí ẩn - Xảy ra trước đó. Nói về dòng chảy, bạn cũng nên đọc về nó. Mối quan hệ xảy ra trước cho biết thứ tự các hành động giữa các luồng sẽ được nhìn thấy. Có nhiều cách giải thích và giải thích. Một trong những báo cáo gần đây nhất về chủ đề này là báo cáo này:
Có lẽ tốt hơn là video này không nói gì về nó. Vì vậy tôi sẽ chỉ để lại một liên kết đến video. Bạn có thể đọc " Java - Tìm hiểu các mối quan hệ xảy ra trước ".

Kết quả

Trong bài đánh giá này, chúng tôi đã xem xét các tính năng của tương tác luồng. Chúng tôi đã thảo luận về các vấn đề có thể phát sinh cũng như cách phát hiện và loại bỏ chúng. Danh sách các tài liệu bổ sung về chủ đề này: #Viacheslav
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION