Tại sao chương trình Java này chấm dứt mặc dù rõ ràng là nó không nên (và không)?


205

Một hoạt động nhạy cảm trong phòng thí nghiệm của tôi hôm nay đã hoàn toàn sai. Một thiết bị truyền động trên kính hiển vi điện tử đã đi qua ranh giới của nó, và sau một chuỗi sự kiện tôi đã mất 12 triệu đô la thiết bị. Tôi đã thu hẹp hơn 40K dòng trong mô-đun bị lỗi này:

import java.util.*;

class A {
    static Point currentPos = new Point(1,2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x+1 != p.y) {
                    System.out.println(p.x+" "+p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Một số mẫu đầu ra tôi nhận được:

$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

Vì không có bất kỳ số học dấu phẩy động nào ở đây và tất cả chúng ta đều biết các số nguyên đã ký hoạt động tốt trên tràn trong Java, tôi nghĩ rằng không có gì sai với mã này. Tuy nhiên, mặc dù đầu ra chỉ ra rằng chương trình không đạt được điều kiện thoát, nhưng nó đã đạt đến điều kiện thoát (cả hai đều đạt không đạt?). Tại sao?


Tôi đã nhận thấy điều này không xảy ra trong một số môi trường. Tôi đang dùng OpenJDK 6 trên Linux 64 bit.


41
12 triệu thiết bị? tôi thực sự tò mò làm thế nào điều đó có thể xảy ra ... tại sao bạn lại sử dụng khối đồng bộ hóa trống: đã đồng bộ hóa (cái này) {}?
Martin V.

84
Điều này thậm chí không phải là chủ đề an toàn từ xa.
Matt Ball

8
Điều thú vị cần lưu ý: việc thêm finalvòng loại (không ảnh hưởng đến mã byte được sản xuất) vào các trường xy"giải quyết" lỗi. Mặc dù nó không ảnh hưởng đến mã byte, nhưng các trường được gắn cờ với nó, điều này khiến tôi nghĩ rằng đây là tác dụng phụ của tối ưu hóa JVM.
Niv Steingarten

9
@Eugene: Nó không nên kết thúc. Câu hỏi là "tại sao nó kết thúc?". A Point pđược xây dựng thỏa mãn p.x+1 == p.y, sau đó một tham chiếu được chuyển đến chuỗi bỏ phiếu. Cuối cùng, chuỗi bỏ phiếu quyết định thoát vì nó nghĩ rằng điều kiện không được thỏa mãn đối với một trong những Pointđiều nó nhận được, nhưng sau đó đầu ra giao diện điều khiển cho thấy rằng nó nên được thỏa mãn. Việc thiếu volatileở đây chỉ đơn giản có nghĩa là chuỗi bỏ phiếu có thể bị kẹt, nhưng rõ ràng đó không phải là vấn đề ở đây.
Erma K. Pizarro

21
@ John John JIT / bộ đệm / lịch trình. Vấn đề thực sự là nhà phát triển đã viết mã này không biết rằng việc xây dựng không xảy ra trước khi sử dụng đối tượng. Chú ý cách xóa trống synchronizedkhiến lỗi không xảy ra? Đó là bởi vì tôi phải viết ngẫu nhiên mã cho đến khi tôi tìm thấy một mã sẽ tái tạo hành vi này một cách xác định.
Chó

Câu trả lời:


140

Rõ ràng việc ghi vào currentPos không xảy ra - trước khi đọc nó, nhưng tôi không thấy vấn đề đó có thể là vấn đề như thế nào.

currentPos = new Point(currentPos.x+1, currentPos.y+1);thực hiện một số điều, bao gồm ghi các giá trị mặc định vào xy(0) và sau đó viết các giá trị ban đầu của chúng vào hàm tạo. Vì đối tượng của bạn không được xuất bản một cách an toàn, 4 thao tác ghi có thể được sắp xếp lại một cách tự do bởi trình biên dịch / JVM.

Vì vậy, từ góc độ của luồng đọc, đây là một thực thi pháp lý để đọc xvới giá trị mới của nó nhưng yvới giá trị mặc định là 0 chẳng hạn. Vào thời điểm bạn đạt được printlncâu lệnh (bằng cách này được đồng bộ hóa và do đó ảnh hưởng đến các hoạt động đọc), các biến có giá trị ban đầu của chúng và chương trình in các giá trị dự kiến.

Đánh dấu currentPosvolatilesẽ đảm bảo xuất bản an toàn vì đối tượng của bạn thực sự không thay đổi - nếu trong trường hợp sử dụng thực sự của bạn, đối tượng bị đột biến sau khi xây dựng, volatileđảm bảo sẽ không đủ và bạn có thể gặp lại một đối tượng không nhất quán.

Ngoài ra, bạn có thể làm cho Pointbất biến cũng sẽ đảm bảo xuất bản an toàn, ngay cả khi không sử dụng volatile. Để đạt được sự bất biến, bạn chỉ cần đánh dấu xycuối cùng.

Là một ghi chú bên lề và như đã đề cập, synchronized(this) {}có thể được JVM coi là không hoạt động (Tôi hiểu rằng bạn đã đưa nó vào để tái tạo hành vi).


4
Tôi không chắc chắn, nhưng sẽ không làm cho x và y cuối cùng có tác dụng như nhau, tránh rào cản bộ nhớ?
Michael Böckling

3
Một thiết kế đơn giản hơn là một đối tượng điểm bất biến kiểm tra các bất biến khi xây dựng. Vì vậy, bạn không bao giờ có nguy cơ xuất bản một cấu hình nguy hiểm.
Ron

@BuddyCasino Có thực sự - Tôi đã thêm điều đó. Thành thật mà nói, tôi không nhớ toàn bộ cuộc thảo luận 3 tháng trước (sử dụng cuối cùng đã được đề xuất trong các bình luận nên không chắc tại sao tôi không đưa nó vào như một tùy chọn).
assylias

2
Bản thân tính không thay đổi không đảm bảo xuất bản an toàn (nếu x an y là riêng tư nhưng chỉ tiếp xúc với getters, vấn đề xuất bản tương tự vẫn tồn tại). cuối cùng hoặc không ổn định đảm bảo nó. Tôi muốn cuối cùng hơn biến động.
Steve Kuo

@SteveKuo Tính bất biến đòi hỏi cuối cùng - không có cuối cùng, điều tốt nhất bạn có thể nhận được là tính bất biến hiệu quả không có cùng ngữ nghĩa.
assylias

29

currentPosđang được thay đổi bên ngoài chuỗi, nên được đánh dấu là volatile:

static volatile Point currentPos = new Point(1,2);

Không có biến động, luồng không được đảm bảo để đọc trong các bản cập nhật cho currentPos đang được tạo trong luồng chính. Vì vậy, các giá trị mới tiếp tục được ghi cho currentPos nhưng luồng vẫn tiếp tục sử dụng các phiên bản được lưu trong bộ nhớ cache trước đó vì lý do hiệu suất. Vì chỉ có một luồng sửa đổi currentPos, bạn có thể thoát khỏi mà không cần khóa sẽ cải thiện hiệu suất.

Các kết quả trông khác nhau nhiều nếu bạn chỉ đọc các giá trị một lần trong luồng để sử dụng trong so sánh và hiển thị tiếp theo của chúng. Khi tôi làm như sau xluôn hiển thị dưới dạng 1ythay đổi giữa 0và một số nguyên lớn. Tôi nghĩ rằng hành vi của nó tại thời điểm này có phần không xác định nếu không có volatiletừ khóa và có thể việc biên dịch mã JIT đang góp phần làm cho nó hoạt động như thế này. Ngoài ra nếu tôi nhận xét synchronized(this) {}khối trống thì mã cũng hoạt động và tôi nghi ngờ đó là do khóa gây ra độ trễ đủ currentPosvà các trường của nó được đọc lại thay vì được sử dụng từ bộ đệm.

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}

2
Vâng, và tôi cũng có thể đặt một cái khóa xung quanh mọi thứ. Ý bạn là sao?
Chó

Tôi đã thêm một số giải thích bổ sung cho việc sử dụng volatile.
Ed Plese

19

Bạn có bộ nhớ thông thường, tham chiếu 'currentpose' và đối tượng Point và các trường của nó phía sau nó, được chia sẻ giữa 2 luồng, không đồng bộ hóa. Do đó, không có thứ tự được xác định giữa các lần ghi xảy ra với bộ nhớ này trong luồng chính và các lần đọc trong luồng đã tạo (gọi nó là T).

Chủ đề chính đang thực hiện ghi sau đây (bỏ qua thiết lập ban đầu của điểm, sẽ dẫn đến px và py có giá trị mặc định):

  • để px
  • để py
  • để hiện tại

Do không có gì đặc biệt về các lần ghi này về mặt đồng bộ hóa / rào cản, nên thời gian chạy tự do cho phép luồng T nhìn thấy chúng xảy ra theo bất kỳ thứ tự nào (luồng chính tất nhiên luôn thấy ghi và đọc theo thứ tự chương trình), và xảy ra tại bất kỳ điểm nào giữa các lần đọc trong T.

Vậy là T đang làm:

  1. đọc hiện tại để p
  2. đọc px và py (theo thứ tự)
  3. so sánh và lấy chi nhánh
  4. đọc px và py (theo thứ tự) và gọi System.out.println

Do không có mối quan hệ sắp xếp nào giữa ghi trong chính và các lần đọc trong T, rõ ràng có một số cách có thể tạo ra kết quả của bạn, vì T có thể thấy ghi chính của bạn vào hiện tại trước khi ghi vào currentpose.y hoặc currentpose.x:

  1. Nó đọc currentpose.x trước, trước khi x write xảy ra - được 0, sau đó đọc currentpose.y trước khi y write xảy ra - được 0. So sánh evals với true. Việc ghi trở nên hiển thị với T. System.out.println được gọi.
  2. Nó đọc currentpose.x trước, sau khi x write xảy ra, sau đó đọc currentpose.y trước khi y write xảy ra - được 0. So sánh evals với true. Các bài viết trở nên hiển thị với T ...
  3. Nó đọc currentpose.y trước, trước khi y write xảy ra (0), sau đó đọc currentpose.x sau khi ghi x, evals thành true. Vân vân.

và v.v ... Có một số cuộc đua dữ liệu ở đây.

Tôi nghi ngờ giả định thiếu sót ở đây đang nghĩ rằng việc ghi kết quả từ dòng này được hiển thị trên tất cả các luồng theo thứ tự chương trình của luồng thực thi nó:

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java không đảm bảo như vậy (nó sẽ rất tệ cho hiệu năng). Một cái gì đó nhiều hơn phải được thêm vào nếu chương trình của bạn cần một thứ tự được bảo đảm của ghi tương đối để đọc trong các luồng khác. Những người khác đã đề xuất làm cho các trường x, y cuối cùng hoặc thay vào đó làm cho hiện tại biến động.

  • Nếu bạn làm cho các trường x, y cuối cùng, thì Java đảm bảo rằng việc ghi các giá trị của chúng sẽ được nhìn thấy xảy ra trước khi hàm tạo trả về, trong tất cả các luồng. Do đó, vì việc gán cho currentpose nằm sau hàm tạo, luồng T được đảm bảo để xem ghi theo đúng thứ tự.
  • Nếu bạn làm cho hiện tại không ổn định, thì Java đảm bảo rằng đây là điểm đồng bộ hóa sẽ được tổng các điểm đồng bộ hóa khác theo thứ tự. Vì trong chính việc ghi vào x và y phải xảy ra trước khi ghi vào hiện tại, sau đó bất kỳ lần đọc nào của hiện tại trong một luồng khác cũng phải xem ghi của x, y đã xảy ra trước đó.

Sử dụng cuối cùng có lợi thế là nó làm cho các trường không thay đổi và do đó cho phép các giá trị được lưu trữ. Sử dụng dễ bay hơi dẫn đến đồng bộ hóa trên mỗi lần ghi và đọc hiện tại, điều này có thể ảnh hưởng đến hiệu suất.

Xem chương 17 của Thông số ngôn ngữ Java để biết chi tiết về tin đồn: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(Câu trả lời ban đầu giả định mô hình bộ nhớ yếu hơn, vì tôi không chắc rằng JLS được đảm bảo là không ổn định. Trả lời được chỉnh sửa để phản ánh nhận xét từ các assylias, chỉ ra mô hình Java mạnh hơn - xảy ra trước đó là quá độ - và vì vậy cũng dễ bay hơi ).


2
Đây là lời giải thích tốt nhất theo ý kiến ​​của tôi. Cảm ơn rất nhiều!
skyde

1
@skyde nhưng sai về ngữ nghĩa của biến động. đảm bảo dễ bay hơi rằng các lần đọc của một biến dễ bay hơi sẽ thấy ghi mới nhất có sẵn của một biến dễ bay hơi cũng như bất kỳ ghi trước nào . Trong trường hợp này, nếu currentPosđược thực hiện biến động, việc chuyển nhượng đảm bảo xuất bản an toàn currentPosđối tượng cũng như các thành viên của nó, ngay cả khi bản thân chúng không dễ bay hơi.
assylias

Chà, tôi đã nói rằng bản thân tôi không thể nhìn thấy chính xác làm thế nào JLS đảm bảo rằng sự dễ bay hơi đã tạo thành một rào cản với việc đọc và viết bình thường khác. Về mặt kỹ thuật, tôi không thể sai về điều đó;). Khi nói đến các mô hình bộ nhớ, sẽ là khôn ngoan khi cho rằng một đơn đặt hàng không được đảm bảo và sai (bạn vẫn an toàn) so với cách khác và sai và không an toàn. Thật tuyệt nếu biến động cung cấp sự đảm bảo đó. Bạn có thể giải thích cách ch 17 của JLS cung cấp nó không?
paulj

2
Tóm lại, trong Point currentPos = new Point(x, y), bạn có 3 ghi: (w1) this.x = x, (w2) this.y = yvà (w3) currentPos = the new point. Thứ tự chương trình đảm bảo rằng hb (w1, w3) và hb (w2, w3). Sau đó trong chương trình bạn đọc (r1) currentPos. Nếu currentPoskhông dễ bay hơi, không có hb giữa r1 và w1, w2, w3, vì vậy r1 có thể quan sát bất kỳ (hoặc không có) nào trong số chúng. Với tính dễ bay hơi, bạn giới thiệu hb (w3, r1). Và mối quan hệ hb là bắc cầu nên bạn cũng giới thiệu hb (w1, r1) và hb (w2, r1). Điều này được tóm tắt trong Java Đồng thời trong thực tiễn (3.5.3. Thành ngữ xuất bản an toàn).
assylias

2
À, nếu hb là bắc cầu theo cách đó, thì đó là một 'rào cản' đủ mạnh, đúng vậy. Tôi phải nói rằng, không dễ để xác định rằng 17.4.5 của JLS định nghĩa hb để có thuộc tính đó. Nó chắc chắn không có trong danh sách các tài sản được đưa ra gần đầu ngày 17.4.5. Đóng cửa bắc cầu chỉ được đề cập sâu hơn sau khi một số ghi chú giải thích! Dù sao, tốt để biết, cảm ơn cho câu trả lời! :). Lưu ý: Tôi sẽ cập nhật câu trả lời của mình để phản ánh nhận xét của assylias.
paulj

-2

Bạn có thể sử dụng một đối tượng để đồng bộ hóa ghi và đọc. Mặt khác, như những người khác đã nói trước đây, việc ghi vào currentPos sẽ xảy ra ở giữa hai lần đọc p.x + 1 và py

new Thread() {
    void f(Point p) {
        if (p.x+1 != p.y) {
            System.out.println(p.x+" "+p.y);
            System.exit(1);
        }
    }
    @Override
    public void run() {
        while (currentPos == null);
        while (true)
            f(currentPos);
    }
}.start();
Object sem = new Object();
while (true) {
    synchronized(sem) {
        currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Trên thực tế điều này làm công việc. Trong nỗ lực đầu tiên của tôi, tôi đã đặt phần đọc bên trong khối được đồng bộ hóa, nhưng sau đó tôi nhận ra rằng điều đó không thực sự cần thiết.
Germano Fronza

1
-1 JVM có thể chứng minh rằng semnó không được chia sẻ và coi câu lệnh được đồng bộ hóa là không có ... Thực tế là nó giải quyết vấn đề là may mắn thuần túy.
assylias

4
Tôi ghét lập trình đa luồng, quá nhiều thứ hoạt động vì may mắn.
Jonathan Allen

-3

Bạn đang truy cập currentPos hai lần và không đảm bảo rằng nó không được cập nhật ở giữa hai lần truy cập đó.

Ví dụ:

  1. x = 10, y = 11
  2. công nhân chủ đề đánh giá px là 10
  3. chủ đề chính thực hiện cập nhật, bây giờ x = 11 và y = 12
  4. công nhân chủ đề đánh giá py là 12
  5. worker worker thông báo rằng 10 + 1! = 12, do đó in và thoát.

Về cơ bản, bạn đang so sánh hai điểm khác nhau .

Lưu ý rằng ngay cả việc làm cho currentPos biến động sẽ không bảo vệ bạn khỏi điều này, vì đó là hai lần đọc riêng biệt của luồng công nhân.

Thêm một

boolean IsValid() { return x+1 == y; }

phương pháp cho lớp điểm của bạn. Điều này sẽ đảm bảo rằng chỉ có một giá trị của currentPos được sử dụng khi kiểm tra x + 1 == y.


currentPos chỉ được đọc một lần, giá trị của nó được sao chép vào p. p được đọc hai lần, nhưng nó sẽ luôn được chỉ cùng một vị trí.
Jonathan Allen
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.