Tại sao dòng song song với lambda trong bộ khởi tạo tĩnh lại gây ra bế tắc?


86

Tôi đã gặp một tình huống kỳ lạ khi sử dụng một luồng song song với lambda trong trình khởi tạo tĩnh dường như vĩnh viễn mà không sử dụng CPU. Đây là mã:

class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(i -> i).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Đây dường như là một trường hợp thử nghiệm tái tạo tối thiểu cho hành vi này. Nếu tôi:

  • đặt khối trong phương thức chính thay vì một trình khởi tạo tĩnh,
  • loại bỏ song song hóa, hoặc
  • loại bỏ lambda,

mã hoàn thành ngay lập tức. Bất cứ ai có thể giải thích hành vi này? Nó là một lỗi hay là dự định này?

Tôi đang sử dụng OpenJDK phiên bản 1.8.0_66-nội bộ.


4
Với phạm vi (0, 1) chương trình kết thúc bình thường. Với (0, 2) hoặc cao hơn bị treo.
Laszlo Hirdi


2
Trên thực tế, nó giống hệt một câu hỏi / vấn đề, chỉ với một API khác.
Didier L

3
Bạn đang cố gắng sử dụng một lớp, trong một luồng nền, khi bạn chưa khởi tạo xong lớp, vì vậy nó không thể được sử dụng trong một luồng nền.
Peter Lawrey

4
@ Solomonoff'sSecret i -> ikhông phải là một tham chiếu phương thức, nó là một static methodthực thi trong lớp Deadlock. Nếu thay thế i -> ibằng Function.identity()mã này sẽ ổn.
Peter Lawrey

Câu trả lời:


71

Tôi đã tìm thấy một báo cáo lỗi về một trường hợp rất tương tự ( JDK-8143380 ) bị Stuart Marks đóng là "Không phải sự cố":

Đây là một bế tắc khởi tạo lớp. Luồng chính của chương trình thử nghiệm thực thi trình khởi tạo tĩnh của lớp, thiết lập cờ đang trong quá trình khởi tạo cho lớp; cờ này vẫn được đặt cho đến khi trình khởi tạo tĩnh hoàn thành. Trình khởi tạo tĩnh thực thi một luồng song song, điều này khiến các biểu thức lambda được đánh giá trong các luồng khác. Các khối luồng đó đang đợi lớp hoàn thành khởi tạo. Tuy nhiên, luồng chính bị chặn khi chờ các tác vụ song song hoàn thành, dẫn đến bế tắc.

Chương trình kiểm tra nên được thay đổi để di chuyển logic dòng song song ra bên ngoài bộ khởi tạo tĩnh của lớp. Đóng cửa không phải là một vấn đề.


Tôi có thể tìm thấy một báo cáo lỗi khác về vấn đề đó ( JDK-8136753 ), cũng bị đóng là "Không phải sự cố" của Stuart Marks:

Đây là một bế tắc đang xảy ra vì trình khởi tạo tĩnh của Fruit enum đang tương tác không tốt với việc khởi tạo lớp.

Xem Đặc tả ngôn ngữ Java, phần 12.4.2 để biết chi tiết về khởi tạo lớp.

http://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.2

Tóm lại, những gì đang xảy ra như sau.

  1. Luồng chính tham chiếu đến lớp Fruit và bắt đầu quá trình khởi tạo. Điều này đặt cờ đang trong quá trình khởi tạo và chạy trình khởi tạo tĩnh trên luồng chính.
  2. Trình khởi tạo tĩnh chạy một số mã trong một luồng khác và đợi nó kết thúc. Ví dụ này sử dụng các luồng song song, nhưng điều này không liên quan gì đến các luồng. Việc thực thi mã trong một luồng khác bằng bất kỳ phương tiện nào và đợi mã đó kết thúc, sẽ có tác dụng tương tự.
  3. Mã trong luồng khác tham chiếu đến lớp Fruit, lớp này kiểm tra cờ đang khởi tạo. Điều này làm cho luồng khác bị chặn cho đến khi cờ bị xóa. (Xem bước 2 của JLS 12.4.2.)
  4. Luồng chính bị chặn khi chờ luồng khác kết thúc, do đó, trình khởi tạo tĩnh không bao giờ hoàn thành. Vì cờ đang trong quá trình khởi tạo không được xóa cho đến khi trình khởi tạo tĩnh hoàn thành, các luồng bị khóa.

Để tránh vấn đề này, hãy đảm bảo rằng quá trình khởi tạo tĩnh của một lớp hoàn tất nhanh chóng, mà không khiến các luồng khác thực thi mã yêu cầu lớp này phải hoàn thành khởi tạo.

Đóng cửa không phải là một vấn đề.


Lưu ý rằng FindBugs có một vấn đề mở khi thêm cảnh báo cho tình huống này.


20
"Đây được coi là khi chúng tôi thiết kế các tính năng""Chúng tôi biết những gì gây ra lỗi này, nhưng không làm thế nào để sửa chữa nó" làm không có nghĩa là "đây không phải là một lỗi" . Đây hoàn toàn là một lỗi.
BlueRaja - Danny Pflughoeft,

13
@ bayou.io Vấn đề chính là sử dụng các luồng trong bộ khởi tạo tĩnh, không phải lambdas.
Stuart Marks

5
BTW Tunaki cảm ơn bạn đã tìm hiểu các báo cáo lỗi của tôi. :-)
Stuart Marks

13
@ bayou.io: ở cấp độ lớp cũng giống như ở cấp độ một hàm tạo, cho phép thisthoát ra trong quá trình xây dựng đối tượng. Quy tắc cơ bản là, không sử dụng các hoạt động đa luồng trong trình khởi tạo. Tôi không nghĩ rằng điều này là khó hiểu. Ví dụ của bạn về việc đăng ký một chức năng được triển khai lambda vào một sổ đăng ký là một điều khác biệt, nó không tạo ra các deadlock trừ khi bạn định đợi một chuỗi nền bị chặn này. Tuy nhiên, tôi thực sự không khuyến khích thực hiện các thao tác như vậy trong trình khởi tạo lớp. Đó không phải là mục đích của chúng.
Holger

9
Tôi đoán bài học về phong cách lập trình là: giữ cho static initalizers đơn giản.
Raedwald

16

Đối với những người đang tự hỏi đâu là các luồng khác tham chiếu đến Deadlockchính lớp đó, các lambdas Java hoạt động như bạn đã viết sau:

public class Deadlock {
    public static int lambda1(int i) {
        return i;
    }
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return lambda1(operand);
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Với các lớp ẩn danh thông thường không có bế tắc:

public class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return operand;
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

5
@ Solomonoff'sSecret Đó là một lựa chọn triển khai. Mã trong lambda phải đi đâu đó. Javac biên dịch nó thành một phương thức tĩnh trong lớp chứa (tương tự với lambda1tôi trong ví dụ này). Đặt mỗi lambda vào lớp riêng của nó sẽ đắt hơn đáng kể.
Stuart Marks

1
@StuartMarks Cho rằng lambda tạo ra một lớp triển khai giao diện chức năng, sẽ không hiệu quả nếu đưa việc triển khai lambda vào việc triển khai lambda của giao diện chức năng như trong ví dụ thứ hai của bài đăng này? Đó chắc chắn là cách rõ ràng để làm mọi thứ nhưng tôi chắc chắn rằng có một lý do tại sao họ làm theo cách của họ.
Phục hồi Monica

6
@ Solomonoff'sSecret Lambda có thể tạo một lớp trong thời gian chạy (thông qua java.lang.invoke.LambdaMetafactory ), nhưng phần thân lambda phải được đặt ở đâu đó trong thời gian biên dịch. Do đó, các lớp lambda có thể tận dụng một số phép thuật của VM để ít tốn kém hơn so với các lớp bình thường được tải từ các tệp .class.
Jeffrey Bosboom

1
@ Solomonoff'sSecret Có, câu trả lời của Jeffrey Bosboom là đúng. Nếu trong tương lai JVM có thể thêm một phương thức vào một lớp hiện có, thì metafactory có thể làm điều đó thay vì quay một lớp mới. (Suy đoán thuần túy.)
Stuart Marks

3
@ Solomonoff's Secret: đừng đánh giá bằng cách nhìn vào những biểu thức lambda tầm thường như của bạn i -> i; chúng sẽ không phải là tiêu chuẩn. Các biểu thức Lambda có thể sử dụng tất cả các thành viên của lớp xung quanh chúng, bao gồm cả các thành viên privatevà điều đó làm cho lớp xác định chính nó trở thành vị trí tự nhiên của chúng. Để cho tất cả các trường hợp sử dụng này phải chịu sự triển khai được tối ưu hóa cho trường hợp đặc biệt của trình khởi tạo lớp với việc sử dụng đa luồng các biểu thức lambda tầm thường, không sử dụng các thành viên của lớp xác định của chúng, không phải là một lựa chọn khả thi.
Holger

14

Có một lời giải thích tuyệt vời về vấn đề này bởi Andrei Pangin , ngày 07 tháng 4 năm 2015. Nó có sẵn ở đây , nhưng nó được viết bằng tiếng Nga (tôi khuyên bạn nên xem lại các mẫu mã - chúng là quốc tế). Vấn đề chung là một khóa trong quá trình khởi tạo lớp.

Dưới đây là một số trích dẫn từ bài báo:


Theo JLS , mọi lớp đều có một khóa khởi tạo duy nhất được bắt trong quá trình khởi tạo. Khi luồng khác cố gắng truy cập lớp này trong quá trình khởi tạo, nó sẽ bị chặn trên khóa cho đến khi quá trình khởi tạo hoàn tất. Khi các lớp được khởi tạo đồng thời, có thể xảy ra tình trạng bế tắc.

Tôi đã viết một chương trình đơn giản tính tổng các số nguyên, nó sẽ in ra những gì?

public class StreamSum {
    static final int SUM = IntStream.range(0, 100).parallel().reduce((n, m) -> n + m).getAsInt();

    public static void main(String[] args) {
        System.out.println(SUM);
    }
} 

Bây giờ xóa parallel()hoặc thay thế lambda bằng Integer::sumlệnh gọi - điều gì sẽ thay đổi?

Ở đây chúng ta lại thấy deadlock [đã có một số ví dụ về deadlock trong trình khởi tạo lớp trước đây trong bài viết]. Do các parallel()hoạt động luồng chạy trong một nhóm luồng riêng biệt. Các luồng này cố gắng thực thi lambda body, được viết bằng bytecode như một private staticphương thức bên trong StreamSumlớp. Nhưng phương thức này không thể được thực thi trước khi hoàn thành trình khởi tạo tĩnh của lớp, phương thức này sẽ đợi kết quả hoàn thành luồng.

Suy nghĩ nhiều hơn là gì: mã này hoạt động khác nhau trong các môi trường khác nhau. Nó sẽ hoạt động chính xác trên một máy CPU và rất có thể sẽ bị treo trên máy nhiều CPU. Sự khác biệt này đến từ việc triển khai nhóm Fork-Join. Bạn có thể tự xác minh việc thay đổi thông số-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.