เหตุใดโปรแกรม Java นี้จึงยุติลงแม้ว่าจะไม่ได้เป็นอย่างนั้นก็ตาม


205

การปฏิบัติที่ละเอียดอ่อนในห้องปฏิบัติการของฉันในวันนี้ผิดไปอย่างสิ้นเชิง แอคชูเอเตอร์ในกล้องจุลทรรศน์อิเล็กตรอนวิ่งข้ามขอบเขตของมันและหลังจากเหตุการณ์หลายอย่างฉันก็สูญเสียอุปกรณ์มูลค่า 12 ล้านเหรียญ ฉันแคบลงกว่า 40K บรรทัดในโมดูลที่ผิดพลาดดังนี้:

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);
    }
}

ตัวอย่างผลลัพธ์ที่ฉันได้รับ:

$ 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

เนื่องจากไม่มีเลขคณิตจุดลอยตัวที่นี่และเราทุกคนรู้ว่าจำนวนเต็มที่ลงนามทำงานได้ดีในล้นใน Java ฉันคิดว่าไม่มีอะไรผิดปกติกับรหัสนี้ อย่างไรก็ตามแม้จะมีเอาต์พุตที่ระบุว่าโปรแกรมไม่สามารถเข้าถึงเงื่อนไขการออกได้ แต่ก็มาถึงเงื่อนไขการออก (มันมาถึงแล้วและไม่ถึง?) ทำไม?


ฉันสังเกตว่าสิ่งนี้ไม่ได้เกิดขึ้นในบางสภาพแวดล้อม ฉันใช้OpenJDK 6 บน Linux 64 บิต


41
12 ล้านของอุปกรณ์? ฉันอยากรู้จริงๆว่าจะเกิดอะไรขึ้น ... ทำไมคุณใช้บล็อกการซิงโครไนซ์ที่ว่างเปล่า: ซิงโครไนซ์ (นี้) {}?
Martin V.

84
สิ่งนี้ไม่ได้ปลอดภัยแม้แต่เธรด
Matt Ball

8
สิ่งที่น่าสนใจที่จะต้องทราบ: การเพิ่มตัวระบุfinal(ซึ่งไม่มีผลกับรหัสไบต์ที่ผลิต) ลงในฟิลด์xและy"แก้ไข" ข้อบกพร่อง แม้ว่ามันจะไม่ส่งผลกระทบต่อ bytecode แต่ฟิลด์นั้นถูกตั้งค่าสถานะไว้ซึ่งทำให้ฉันคิดว่านี่เป็นผลข้างเคียงของการเพิ่มประสิทธิภาพ JVM
Niv Steingarten

9
@Eugene: มันไม่ควรจบ คำถามคือ "ทำไมมันถึงจบ" A Point pถูกสร้างขึ้นซึ่งเป็นที่น่าพอใจp.x+1 == p.yจากนั้นการอ้างอิงจะถูกส่งผ่านไปยังเธรดการสำรวจ ในที่สุดเธรดการโพลตัดสินใจที่จะออกเนื่องจากมันคิดว่าเงื่อนไขไม่เป็นไปตามที่ได้รับอย่างใดอย่างหนึ่งPointแต่จากนั้นเอาต์พุตคอนโซลจะแสดงว่ามันควรจะได้รับความพึงพอใจ การขาดvolatileที่นี่เพียงแค่หมายความว่าการสำรวจความคิดเห็นอาจติด แต่ที่ชัดเจนไม่ได้เป็นปัญหาที่นี่
Erma K. Pizarro

21
@ JohnNicholas: รหัสจริง (ซึ่งเห็นได้ชัดว่าไม่ใช่) มีการทดสอบครอบคลุม 100% และการทดสอบหลายพันครั้งซึ่งหลาย ๆ สิ่งที่ทดสอบในการสั่งซื้อและการเรียงสับเปลี่ยนหลายพันรายการ ... การทดสอบไม่พบทุกกรณีที่เกิดจาก nondeterministic JIT / แคช / ตารางเวลา ปัญหาที่แท้จริงคือนักพัฒนาที่เขียนรหัสนี้ไม่ทราบว่าการก่อสร้างไม่ได้เกิดขึ้นก่อนที่จะใช้วัตถุ โปรดสังเกตว่าการลบสิ่งที่ว่างเปล่าsynchronizedทำให้ข้อผิดพลาดไม่เกิดขึ้นได้อย่างไร นั่นเป็นเพราะฉันต้องเขียนโค้ดแบบสุ่มจนกว่าฉันจะพบรหัสที่จะทำซ้ำพฤติกรรมนี้อย่างแน่นอน
สุนัข

คำตอบ:


140

เห็นได้ชัดว่าการเขียนไปที่ currentPos ไม่ได้เกิดขึ้นก่อนที่จะอ่าน แต่ฉันไม่เห็นว่ามันจะเป็นปัญหาได้อย่างไร

currentPos = new Point(currentPos.x+1, currentPos.y+1);ทำบางสิ่งรวมถึงการเขียนค่าเริ่มต้นไปที่xและy(0) แล้วเขียนค่าเริ่มต้นในตัวสร้าง เนื่องจากอ็อบเจ็กต์ของคุณไม่ได้รับการเผยแพร่อย่างปลอดภัยการดำเนินการเขียน 4 รายการเหล่านั้นสามารถจัดลำดับใหม่ได้อย่างอิสระโดยคอมไพเลอร์ / JVM

ดังนั้นจากมุมมองของเธรดการอ่านจึงเป็นการดำเนินการทางกฎหมายในการอ่านxด้วยค่าใหม่ แต่yมีค่าดีฟอลต์เป็น 0 เมื่อถึงprintlnคำสั่ง (ซึ่งจะมีการซิงโครไนซ์และมีผลต่อการดำเนินการอ่าน) ตัวแปรมีค่าเริ่มต้นและโปรแกรมจะพิมพ์ค่าที่คาดหวัง

การทำเครื่องหมายcurrentPosว่าvolatileจะช่วยให้มั่นใจว่าการตีพิมพ์อย่างปลอดภัยเนื่องจากวัตถุของคุณไม่เปลี่ยนรูปได้อย่างมีประสิทธิภาพ - ในกรณีที่ใช้งานจริงวัตถุจะกลายพันธุ์หลังการก่อสร้างvolatileการรับประกันจะไม่เพียงพอและคุณจะเห็นวัตถุที่ไม่สอดคล้องกันอีกครั้ง

หรือคุณสามารถสร้างสิ่งที่Pointไม่เปลี่ยนรูปซึ่งจะช่วยให้มั่นใจว่าสิ่งพิมพ์ที่ปลอดภัยแม้จะไม่ใช้volatileก็ตาม เพื่อให้บรรลุความไม่สามารถเปลี่ยนแปลงได้คุณเพียงแค่ต้องทำเครื่องหมายxและyสุดท้าย

ในฐานะที่เป็นบันทึกด้านข้างและตามที่ได้กล่าวมาแล้วsynchronized(this) {}สามารถถือได้ว่าเป็น JVM แบบไม่เลือกปฏิบัติ (ฉันเข้าใจว่าคุณรวมไว้ในการทำให้เกิดพฤติกรรม)


4
ฉันไม่แน่ใจ แต่จะไม่ทำให้ x และ y สุดท้ายมีผลเหมือนกันหลีกเลี่ยงอุปสรรคหน่วยความจำ?
Michael Böckling

3
การออกแบบที่ง่ายกว่าคือวัตถุจุดที่ไม่เปลี่ยนรูปซึ่งทดสอบค่าคงที่ในการก่อสร้าง ดังนั้นคุณจะไม่เสี่ยงที่จะเผยแพร่การกำหนดค่าที่เป็นอันตราย
Ron

@BuddyCasino ใช่แน่นอน - ฉันได้เพิ่มสิ่งนั้นแล้ว ความจริงแล้วฉันจำการสนทนาทั้งหมดไม่ได้เมื่อ 3 เดือนที่แล้ว (การใช้ขั้นสุดท้ายถูกเสนอในความคิดเห็นดังนั้นไม่แน่ใจว่าทำไมฉันจึงไม่รวมเป็นตัวเลือก)
assylias

2
การเปลี่ยนไม่ได้เองไม่ได้รับประกันสิ่งพิมพ์ที่ปลอดภัย (ถ้า x an y เป็นส่วนตัว แต่สัมผัสกับผู้ให้บริการเท่านั้นปัญหาสิ่งพิมพ์เดียวกันจะยังคงมีอยู่) ขั้นสุดท้ายหรือความผันผวนนั้นรับประกันได้ ฉันต้องการความผันผวนมากกว่าครั้งสุดท้าย
Steve Kuo

@SteveKuo Immutability ต้องใช้ขั้นสุดท้าย - โดยไม่ต้องปิดท้ายสิ่งที่ดีที่สุดที่คุณจะได้รับคือ immutability ที่มีประสิทธิภาพซึ่งไม่มีความหมายเหมือนกัน
assylias

29

เนื่องจากcurrentPosมีการเปลี่ยนแปลงภายนอกเธรดจึงควรทำเครื่องหมายเป็นvolatile:

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

เธรดจะไม่รับประกันว่าจะอ่านในอัพเดตเป็น currentPos ที่ทำในเธรดหลัก ดังนั้นค่าใหม่จะยังคงถูกเขียนสำหรับ currentPos แต่เธรดยังคงใช้เวอร์ชันแคชก่อนหน้าเพื่อเหตุผลด้านประสิทธิภาพ เนื่องจากมีเพียงเธรดเดียวที่ปรับเปลี่ยน currentPos คุณสามารถหนีไปได้โดยไม่ต้องล็อคซึ่งจะปรับปรุงประสิทธิภาพ

ผลลัพธ์จะดูแตกต่างกันมากหากคุณอ่านค่าเพียงครั้งเดียวภายในเธรดเพื่อใช้ในการเปรียบเทียบและการแสดงผลในภายหลัง เมื่อฉันทำต่อไปนี้xจะแสดงเป็น1และyแตกต่างกันระหว่าง0และจำนวนเต็มขนาดใหญ่ ฉันคิดว่าพฤติกรรมของมัน ณ จุดนี้ค่อนข้างไม่ได้กำหนดโดยไม่มีvolatileคำหลักและเป็นไปได้ว่าการรวบรวม JIT ของรหัสนั้นมีส่วนทำให้มันทำหน้าที่เช่นนี้ นอกจากนี้ถ้าฉันใส่ความคิดเห็นsynchronized(this) {}บล็อกว่างเปล่าแล้วรหัสทำงานเช่นกันและฉันสงสัยว่ามันเป็นเพราะการล็อคทำให้เกิดความล่าช้าเพียงพอที่currentPosและเขตข้อมูลของมันจะถูกอ่านซ้ำมากกว่าที่ใช้จากแคช

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

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

2
ใช่แล้วฉันก็สามารถล็อคทุกอย่างได้ ประเด็นของคุณคืออะไร?
สุนัข

volatileผมเพิ่มคำอธิบายเพิ่มเติมบางอย่างสำหรับการใช้งานของ
Ed Plese

19

คุณมีหน่วยความจำธรรมดาการอ้างอิง 'currentpos' และวัตถุ Point และฟิลด์ที่อยู่ด้านหลังใช้ร่วมกันระหว่าง 2 เธรดโดยไม่มีการซิงโครไนซ์ ดังนั้นจึงไม่มีการเรียงลำดับที่กำหนดไว้ระหว่างการเขียนที่เกิดขึ้นกับหน่วยความจำนี้ในเธรดหลักและการอ่านในเธรดที่สร้างขึ้น (เรียกว่า T)

เธรดหลักกำลังทำการเขียนต่อไปนี้ (ไม่สนใจการตั้งค่าเริ่มต้นของจุดจะส่งผลให้ px และ py มีค่าเริ่มต้น):

  • ถึง px
  • เพื่อ py
  • เพื่อ currentpos

เนื่องจากไม่มีสิ่งใดเป็นพิเศษเกี่ยวกับการเขียนเหล่านี้ในแง่ของการซิงโครไนซ์ / สิ่งกีดขวางรันไทม์มีอิสระที่จะอนุญาตให้เธรด T เห็นพวกเขาเกิดขึ้นในลำดับใด ๆ (เธรดหลักของหลักสูตรจะเห็นการเขียนและอ่าน ที่จุดใดก็ได้ระหว่างการอ่านใน T

ดังนั้น T กำลังทำอะไรอยู่:

  1. อ่าน currentpos ถึง p
  2. อ่าน px และ py (ตามลำดับ)
  3. เปรียบเทียบและนำสาขา
  4. อ่าน px และ py (ทั้งคำสั่งซื้อ) และเรียก System.out.println

เนื่องจากไม่มีความสัมพันธ์ในการสั่งซื้อระหว่างการเขียนใน main และการอ่านใน T มีหลายวิธีที่สามารถสร้างผลลัพธ์ของคุณได้อย่างชัดเจนเนื่องจาก T อาจเห็นการเขียนไปยัง currentpos หลักก่อนที่จะเขียนไปยัง currentpos.y หรือ currentpos.x:

  1. มันอ่าน currentpos.x ก่อนที่จะเกิดการเขียน x - ได้รับ 0 จากนั้นอ่าน currentpos.y ก่อนที่การเขียน y จะเกิดขึ้น - รับ 0 เปรียบเทียบ evals เป็นจริง การเขียนจะมองเห็นได้โดย T. System.out.println ถูกเรียก
  2. มันอ่าน currentpos.x ก่อนจากหลังจากที่มีการเขียน x เกิดขึ้นแล้วอ่าน currentpos.y ก่อนที่จะเกิดการเขียน y - ได้รับ 0 เปรียบเทียบ evals เป็นจริง การเขียนจะปรากฏต่อ T ... ฯลฯ
  3. มันอ่าน currentpos.y ก่อนที่การเขียน y จะเกิดขึ้น (0) จากนั้นอ่าน currentpos.x หลังจาก x write จะกลายเป็นจริง เป็นต้น

เป็นต้น ... มีการแข่งขันของข้อมูลจำนวนมากที่นี่

ฉันสงสัยว่าข้อสันนิษฐานที่ผิดพลาดที่นี่คือการคิดว่าการเขียนผลลัพธ์ที่ได้จากบรรทัดนี้จะปรากฏให้เห็นทั่วเธรดทั้งหมดในลำดับโปรแกรมของเธรดที่ดำเนินการ:

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

Java ไม่รับประกันเช่นนั้น (มันน่ากลัวสำหรับประสิทธิภาพ) จะต้องมีอะไรเพิ่มเติมถ้าโปรแกรมของคุณต้องการการรับประกันการเรียงลำดับของการเขียนที่สัมพันธ์กับการอ่านในเธรดอื่น คนอื่น ๆ แนะนำให้ทำฟิลด์ x, y เป็นครั้งสุดท้าย

  • หากคุณทำให้ฟิลด์ x, y สิ้นสุดลงดังนั้น Java รับประกันว่าการเขียนค่าของพวกเขาจะเกิดขึ้นก่อนที่ตัวสร้างจะส่งคืนในทุกเธรด ดังนั้นเนื่องจากการกำหนดให้ currentpos อยู่หลัง Constructor T thread จึงรับประกันว่าจะเห็นการเขียนในลำดับที่ถูกต้อง
  • หากคุณทำให้ความผันผวนของ currentpos นั้น Java รับประกันได้ว่านี่เป็นจุดประสานซึ่งจะเป็นจุดประสานทั้งหมดอื่น ๆ เช่นเดียวกับในหลักการเขียนไปยัง x และ y ต้องเกิดขึ้นก่อนการเขียนไปยัง currentpos ดังนั้นการอ่าน currentpos ใด ๆ ในเธรดอื่นจะต้องเห็นการเขียนของ x, y ที่เกิดขึ้นก่อน

การใช้ขั้นสุดท้ายมีข้อได้เปรียบที่ทำให้เขตข้อมูลไม่เปลี่ยนรูปและทำให้สามารถเก็บค่าได้ การใช้ความผันผวนนำไปสู่การประสานในทุกการเขียนและการอ่านของข้อเสนอปัจจุบันซึ่งอาจส่งผลกระทบต่อประสิทธิภาพการทำงาน

ดูบทที่ 17 ของ Java Language Spec สำหรับรายละเอียดเต็มไปด้วยเลือด: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(คำตอบเบื้องต้นสันนิษฐานว่าเป็นหน่วยความจำที่อ่อนแอกว่าเนื่องจากฉันไม่แน่ใจว่า JLS รับประกันความผันผวนเพียงพอคำตอบที่ถูกแก้ไขเพื่อสะท้อนความคิดเห็นจาก assylias ชี้ให้เห็นว่ารูปแบบ Java แข็งแกร่งขึ้น - เกิดขึ้นก่อน - เป็นสกรรมกริยา )


2
นี่คือคำอธิบายที่ดีที่สุดในความคิดของฉัน ขอบคุณมาก!
skyde

1
@skyde แต่ผิดเกี่ยวกับความหมายของความผันผวน การค้ำประกันระเหยที่อ่านของตัวแปรที่ผันผวนจะเห็นการเขียนที่มีอยู่ล่าสุดของตัวแปรที่มีความผันผวนเช่นเดียวกับการเขียนก่อนหน้าใดในกรณีนี้หากcurrentPosมีการระเหยงานที่มอบหมายจะทำให้มั่นใจว่าการตีพิมพ์currentPosวัตถุและสมาชิกนั้นปลอดภัยแม้ว่าพวกเขาจะไม่ระเหยตัวเองก็ตาม
assylias

ฉันกำลังบอกว่าฉันทำไม่ได้สำหรับตัวฉันเองดูว่า JLS รับประกันได้อย่างไรว่าสารระเหยกลายเป็นสิ่งกีดขวางกับคนอื่น ๆ การอ่านและการเขียนปกติ ในทางเทคนิคแล้วฉันไม่สามารถผิดพลาดได้;) เมื่อพูดถึงรุ่นของหน่วยความจำก็ควรระมัดระวังที่จะถือว่าการสั่งซื้อนั้นไม่ได้รับประกันและผิด (คุณยังปลอดภัย) กว่าวิธีอื่น ๆ และผิดและไม่ปลอดภัย มันยอดเยี่ยมถ้าความผันผวนนั้นให้การรับประกันนั้น คุณช่วยอธิบายได้ว่า chs 17 ของ JLS ให้มันได้อย่างไร
paulj

2
ในระยะสั้นในPoint currentPos = new Point(x, y)คุณมี 3 เขียน: (W1) this.x = x(W2) this.y = yและ currentPos = the new point(W3) ใบสั่งของโปรแกรมรับประกันได้ว่า hb (w1, w3) และ hb (w2, w3) ต่อมาในโปรแกรมที่คุณอ่าน currentPos(r1) หากcurrentPosไม่ระเหยจะไม่มี hb ระหว่าง r1 และ w1, w2, w3 ดังนั้น r1 จึงสามารถสังเกตได้ (หรือไม่มี) ด้วยความผันผวนคุณจะแนะนำ hb (w3, r1) และความสัมพันธ์ hb คือสกรรมกริยาดังนั้นคุณยังแนะนำ hb (w1, r1) และ hb (w2, r1) สรุปได้ใน Java Concurrency ในทางปฏิบัติ (3.5.3. Safe Publication Idioms)
assylias

2
อ๊ะถ้า hb เป็นสกรรมกริยาในวิธีนั้นนั่นคือ 'สิ่งกีดขวาง' ที่แข็งแกร่งเพียงพอใช่ ฉันต้องบอกว่ามันไม่ง่ายเลยที่จะตัดสินว่า 17.4.5 ของ JLS กำหนด hb ให้มีคุณสมบัตินั้น ไม่แน่นอนอยู่ในรายการคุณสมบัติที่กำหนดไว้ใกล้กับจุดเริ่มต้นของ 17.4.5 การปิดสถาปัตยกรรมจะกล่าวถึงต่อไปหลังจากที่บางบันทึกอธิบาย! อย่างไรก็ตามรู้ดีขอบคุณสำหรับคำตอบ! :) หมายเหตุ: ฉันจะอัปเดตคำตอบของฉันเพื่อสะท้อนความคิดเห็นของ assylias
paulj

-2

คุณสามารถใช้วัตถุเพื่อซิงโครไนซ์การเขียนและการอ่าน มิฉะนั้นอย่างที่คนอื่นพูดก่อนหน้านี้การเขียนไปที่ currentPos จะเกิดขึ้นในช่วงกลางของทั้งสองอ่าน p.x + 1 และ 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);
    }
}

จริงๆแล้วมันทำงานได้ ในความพยายามครั้งแรกของฉันฉันวางการอ่านไว้ในบล็อกที่ซิงโครไนซ์ แต่ต่อมาฉันรู้ว่าไม่จำเป็นจริงๆ
Germano Fronza

1
-1 JVM สามารถพิสูจน์semได้ว่าไม่ได้ใช้ร่วมกันและปฏิบัติตามคำสั่งที่ซิงโครไนซ์ว่าไม่ต้องใช้ ... ความจริงที่ว่าการแก้ปัญหานี้เป็นโชคที่บริสุทธิ์
assylias

4
ฉันเกลียดการเขียนโปรแกรมแบบมัลติเธรดการทำงานหลายอย่างเกินไปเพราะโชคดี
Jonathan Allen

-3

คุณกำลังเข้าถึง currentPos สองครั้งและไม่รับประกันว่าจะไม่มีการอัปเดตระหว่างการเข้าถึงทั้งสอง

ตัวอย่างเช่น:

  1. x = 10, y = 11
  2. เธรดผู้ปฏิบัติงานประเมิน px เป็น 10
  3. เธรดหลักดำเนินการอัปเดตตอนนี้ x = 11 และ y = 12
  4. เธรดผู้ปฏิบัติงานประเมิน py เป็น 12
  5. เธรดผู้ปฏิบัติงานสังเกตว่า 10 + 1! = 12 ดังนั้นพิมพ์และออก

คุณกำลังเปรียบเทียบสองจุดต่างกัน

โปรดทราบว่าแม้กระทั่งการทำให้ความผันผวนในปัจจุบันของโปจะไม่ปกป้องคุณจากสิ่งนี้

เพิ่ม

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

วิธีการเรียนคะแนนของคุณ สิ่งนี้จะช่วยให้มั่นใจได้ว่าจะใช้ค่า currentPos เพียงหนึ่งค่าเมื่อตรวจสอบ x + 1 == y


currentPos อ่านได้เพียงครั้งเดียวค่าของมันจะถูกคัดลอกไปยัง p p อ่านสองครั้ง แต่มันจะชี้ตำแหน่งเดิมเสมอ
Jonathan Allen
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.