การเขียนโปรแกรมพร้อมกันเป็นหนึ่งในทักษะที่สำคัญที่สุดสำหรับโปรแกรมเมอร์ Java และเป็นหนึ่งในทักษะที่ยากที่สุดในการฝึกฝน มันต้องการให้โปรแกรมเมอร์มีความเข้าใจอย่างลึกซึ้งเกี่ยวกับหลักการปฏิบัติการต่ำสุดของคอมพิวเตอร์และในเวลาเดียวกันก็ต้องมีโปรแกรมเมอร์ที่จะมีตรรกะที่ชัดเจนและการคิดอย่างพิถีพิถันเพื่อให้พวกเขาสามารถเขียนโปรแกรมพร้อมกันหลายเธรดที่มีประสิทธิภาพปลอดภัยและเชื่อถือได้ ซีรีส์นี้จะเริ่มต้นจากลักษณะของการประสานงานระหว่างเธรด (รอแจ้งแจ้งแจ้งเตือน), ซิงโครไนซ์และผันผวนและอธิบายรายละเอียดเครื่องมือพร้อมกันและกลไกการใช้งานพื้นฐานที่ JDK จัดทำขึ้น บนพื้นฐานนี้เราจะวิเคราะห์คลาสเครื่องมือของแพ็คเกจ java.util.concurrent เพิ่มเติมรวมถึงการใช้งานการใช้งานซอร์สโค้ดและหลักการที่อยู่เบื้องหลัง บทความนี้เป็นบทความแรกในซีรี่ส์นี้และเป็นส่วนสำคัญทางทฤษฎีมากที่สุดของซีรี่ส์นี้ บทความที่ตามมาจะถูกวิเคราะห์และอธิบายตามสิ่งนี้
1. การแบ่งปัน
การแบ่งปันข้อมูลเป็นหนึ่งในเหตุผลหลักสำหรับความปลอดภัยของเธรด หากข้อมูลทั้งหมดใช้ได้เฉพาะในเธรดไม่มีปัญหาด้านความปลอดภัยของเธรดซึ่งเป็นหนึ่งในเหตุผลหลักที่เรามักจะไม่ต้องพิจารณาความปลอดภัยของเธรดเมื่อเขียนโปรแกรม อย่างไรก็ตามในการเขียนโปรแกรมแบบมัลติเธรดการแบ่งปันข้อมูลเป็นสิ่งที่หลีกเลี่ยงไม่ได้ สถานการณ์ทั่วไปที่สุดคือข้อมูลในฐานข้อมูล เพื่อให้แน่ใจว่าข้อมูลที่สอดคล้องกันเรามักจะต้องแบ่งปันข้อมูลในฐานข้อมูลเดียวกัน แม้ในกรณีของ Master และ Slave ข้อมูลเดียวกันก็สามารถเข้าถึงได้ ต้นแบบและทาสเป็นเพียงการคัดลอกข้อมูลเดียวกันเพื่อประสิทธิภาพของการเข้าถึงและความปลอดภัยของข้อมูล ตอนนี้เราแสดงให้เห็นถึงปัญหาที่เกิดจากการแบ่งปันข้อมูลภายใต้หลายเธรดผ่านตัวอย่างง่ายๆ:
รหัสตัวอย่าง 1:
แพ็คเกจ com.paddx.test.concurrent; คลาสสาธารณะ Sharedata {นับ int คงที่สาธารณะ = 0; โมฆะคงที่สาธารณะหลัก (String [] args) {สุดท้าย Sharedata data = new Sharedata (); สำหรับ (int i = 0; i <10; i ++) {เธรดใหม่ (ใหม่ runnable () {@Override โมฆะสาธารณะเรียกใช้ () {ลอง {// หยุดชั่วคราวสำหรับ 1 มิลลิวินาทีเมื่อเข้าสู่การเพิ่มโอกาสของปัญหาการเกิดขึ้นพร้อมกัน data.addcount ();} system.out.print (count + ""); } ลอง {// โปรแกรมหลักหยุดชั่วคราวเป็นเวลา 3 วินาทีเพื่อให้แน่ใจว่าการดำเนินการโปรแกรมข้างต้นเสร็จสิ้น thread.sleep (3000); } catch (interruptedException e) {e.printStackTrace (); } system.out.println ("count =" + นับ); } โมฆะสาธารณะ addCount () {count ++; -วัตถุประสงค์ของรหัสข้างต้นคือการเพิ่มหนึ่งการดำเนินการเพื่อนับและดำเนินการ 1,000 ครั้ง แต่ที่นี่จะถูกนำไปใช้ผ่าน 10 เธรดแต่ละเธรดจะดำเนินการ 100 ครั้งและภายใต้สถานการณ์ปกติ 1,000 ควรเป็นเอาต์พุต อย่างไรก็ตามหากคุณเรียกใช้โปรแกรมข้างต้นคุณจะพบว่าผลลัพธ์นั้นไม่ใช่กรณี นี่คือผลการดำเนินการของเวลาที่กำหนด (ผลลัพธ์ของการรันแต่ละครั้งอาจไม่เหมือนกันและบางครั้งอาจได้ผลลัพธ์ที่ถูกต้อง):
จะเห็นได้ว่าสำหรับการดำเนินการของตัวแปรที่ใช้ร่วมกันผลลัพธ์ที่ไม่คาดคิดต่าง ๆ สามารถมองเห็นได้ง่ายในสภาพแวดล้อมแบบมัลติเธรด
2. การยกเว้นซึ่งกันและกัน
การแยกทรัพยากรร่วมกันหมายความว่ามีผู้เข้าชมเพียงคนเดียวเท่านั้นที่ได้รับอนุญาตให้เข้าถึงได้ในเวลาเดียวกันซึ่งไม่ซ้ำกันและพิเศษ เรามักจะอนุญาตให้หลายเธรดอ่านข้อมูลในเวลาเดียวกัน แต่มีเพียงเธรดเดียวเท่านั้นที่สามารถเขียนข้อมูลได้ในเวลาเดียวกัน ดังนั้นเรามักจะแบ่งล็อคเป็นล็อคที่ใช้ร่วมกันและล็อคพิเศษหรือไม่เรียกว่าการอ่านล็อคและเขียนล็อค หากทรัพยากรไม่ได้เกิดร่วมกันเราไม่จำเป็นต้องกังวลเกี่ยวกับความปลอดภัยของเธรดแม้ว่าจะเป็นทรัพยากรที่ใช้ร่วมกันก็ตาม ตัวอย่างเช่นสำหรับการแบ่งปันข้อมูลที่ไม่เปลี่ยนรูปเธรดทั้งหมดสามารถอ่านได้เท่านั้นดังนั้นปัญหาความปลอดภัยของเธรดจึงไม่จำเป็น อย่างไรก็ตามการเขียนการดำเนินการสำหรับข้อมูลที่ใช้ร่วมกันโดยทั่วไปจะต้องมีการยกเว้นซึ่งกันและกัน ในตัวอย่างข้างต้นปัญหาการปรับเปลี่ยนข้อมูลเกิดขึ้นเนื่องจากขาดการยกเว้นซึ่งกันและกัน Java มีกลไกหลายอย่างเพื่อให้แน่ใจว่าการยกเว้นซึ่งกันและกันวิธีที่ง่ายที่สุดคือการใช้ซิงโครไนซ์ ตอนนี้เราเพิ่มการซิงโครไนซ์กับโปรแกรมด้านบนและดำเนินการ:
รหัสตัวอย่างสอง:
แพ็คเกจ com.paddx.test.concurrent; คลาสสาธารณะ Sharedata {นับ int คงที่สาธารณะ = 0; โมฆะคงที่สาธารณะหลัก (String [] args) {สุดท้าย Sharedata data = new Sharedata (); สำหรับ (int i = 0; i <10; i ++) {เธรดใหม่ (ใหม่ runnable () {@Override โมฆะสาธารณะเรียกใช้ () {ลอง {// หยุดชั่วคราวสำหรับ 1 มิลลิวินาทีเมื่อเข้าสู่การเพิ่มโอกาสของปัญหาการเกิดขึ้นพร้อมกัน data.addcount ();} system.out.print (count + ""); } ลอง {// โปรแกรมหลักหยุดชั่วคราวเป็นเวลา 3 วินาทีเพื่อให้แน่ใจว่าการดำเนินการโปรแกรมข้างต้นเสร็จสิ้น thread.sleep (3000); } catch (interruptedException e) {e.printStackTrace (); } system.out.println ("count =" + นับ); } / *** เพิ่มคำหลักที่ซิงโครไนซ์* / โมฆะที่ซิงโครไนซ์สาธารณะ addCount () {count ++; -ตอนนี้รหัสข้างต้นถูกเรียกใช้งานคุณจะพบว่าไม่ว่าคุณจะดำเนินการกี่ครั้งผลลัพธ์สุดท้ายจะเป็น 1,000
iii. อะตอม
Atomicity หมายถึงการดำเนินการของข้อมูลว่าเป็นอิสระและแบ่งแยกไม่ได้ กล่าวอีกนัยหนึ่งมันเป็นการดำเนินการที่ต่อเนื่องและไม่สามารถหยุดยั้งได้ ครึ่งหนึ่งของการดำเนินการข้อมูลไม่ได้ถูกแก้ไขโดยเธรดอื่น วิธีที่ง่ายที่สุดในการตรวจสอบให้แน่ใจว่าอะตอมคือคำแนะนำระบบปฏิบัติการนั่นคือถ้าการดำเนินการหนึ่งสอดคล้องกับคำแนะนำระบบปฏิบัติการหนึ่งครั้งในแต่ละครั้งมันจะทำให้มั่นใจได้ว่าเป็นอะตอม อย่างไรก็ตามการดำเนินการจำนวนมากไม่สามารถเสร็จสิ้นได้ด้วยคำแนะนำเดียว ตัวอย่างเช่นสำหรับการดำเนินการประเภทนานระบบจำนวนมากจำเป็นต้องแบ่งออกเป็นคำแนะนำหลายคำเพื่อทำงานในตำแหน่งสูงและต่ำตามลำดับ ตัวอย่างเช่นการดำเนินการของจำนวนเต็ม i ++ ที่เรามักจะใช้จริง ๆ จะต้องแบ่งออกเป็นสามขั้นตอน: (1) อ่านค่าของจำนวนเต็ม i; (2) เพิ่มการดำเนินการหนึ่งครั้งให้กับ I; (3) เขียนผลลัพธ์กลับไปที่หน่วยความจำ กระบวนการนี้อาจเกิดขึ้นในมัลติเธรด:
นี่คือเหตุผลที่ผลลัพธ์ของการดำเนินการเซ็กเมนต์โค้ดไม่ถูกต้อง สำหรับการดำเนินการชุดค่าผสมนี้วิธีที่พบบ่อยที่สุดในการตรวจสอบให้แน่ใจว่าอะตอมคือการล็อคเช่นการซิงโครไนซ์หรือล็อคใน Java สามารถนำไปใช้งานได้และส่วนโค้ด 2 จะถูกนำไปใช้ผ่านการซิงโครไนซ์ นอกเหนือจากการล็อคแล้วยังมีอีกวิธีหนึ่งในการเปรียบเทียบและการแลกเปลี่ยน) นั่นคือก่อนที่จะแก้ไขข้อมูลให้เปรียบเทียบว่าค่าที่อ่านก่อนที่ก่อนหน้านี้จะสอดคล้องกันหรือไม่ หากพวกเขามีความสอดคล้องแก้ไขพวกเขาและหากพวกเขาไม่สอดคล้องกันพวกเขาจะถูกดำเนินการอีกครั้ง นี่คือหลักการของการปรับใช้การล็อคให้เหมาะสม อย่างไรก็ตาม CAS อาจไม่ได้ผลในบางสถานการณ์ ตัวอย่างเช่นเธรดอื่นแรกจะแก้ไขค่าที่แน่นอนจากนั้นเปลี่ยนกลับเป็นค่าดั้งเดิม ในกรณีนี้ CAS ไม่สามารถตัดสินได้
4. ทัศนวิสัย
เพื่อให้เข้าใจการมองเห็นคุณต้องมีความเข้าใจบางอย่างเกี่ยวกับโมเดลหน่วยความจำของ JVM โมเดลหน่วยความจำของ JVM นั้นคล้ายกับระบบปฏิบัติการดังที่แสดงในรูป:
จากรูปนี้เราจะเห็นว่าแต่ละเธรดมีหน่วยความจำในการทำงานของตัวเอง (เทียบเท่ากับบัฟเฟอร์ขั้นสูง CPU จุดประสงค์ของสิ่งนี้คือเพื่อลดความแตกต่างของความเร็วระหว่างระบบจัดเก็บและซีพียูและปรับปรุงประสิทธิภาพ) สำหรับตัวแปรที่ใช้ร่วมกันทุกครั้งที่เธรดอ่านสำเนาของตัวแปรที่ใช้ร่วมกันในหน่วยความจำที่ใช้งานได้ เมื่อเขียนมันจะปรับเปลี่ยนค่าของสำเนาโดยตรงในหน่วยความจำที่ทำงานแล้วซิงโครไนซ์หน่วยความจำที่ทำงานกับค่าในหน่วยความจำหลัก ณ เวลาหนึ่งเวลา ปัญหาที่เกิดขึ้นคือถ้าเธรด 1 ปรับเปลี่ยนตัวแปรบางอย่างเธรด 2 อาจไม่เห็นการแก้ไขที่ทำโดยเธรด 1 เป็นตัวแปรที่ใช้ร่วมกัน ผ่านโปรแกรมต่อไปนี้เราสามารถแสดงให้เห็นถึงปัญหาที่มองไม่เห็น:
แพ็คเกจ com.paddx.test.concurrent; การมองเห็นระดับสาธารณะ {บูลีนคงที่ส่วนตัวพร้อม; หมายเลข int คงที่ส่วนตัว; Private Static Class ReaderThread ขยายเธรด {public void run () {ลอง {thread.sleep (10); } catch (interruptedException e) {e.printStackTrace (); } if (! พร้อม) {system.out.println (พร้อม); } system.out.println (หมายเลข); }} คลาสคงที่คลาสคงที่ WriterThread ขยายเธรด {โมฆะสาธารณะเรียกใช้ () {ลอง {thread.sleep (10); } catch (interruptedException e) {e.printStackTrace (); } number = 100; พร้อม = จริง; }} โมฆะคงที่สาธารณะหลัก (สตริง [] args) {new WriterThread (). start (); ใหม่ readerThread (). start (); -โดยสังหรณ์ใจโปรแกรมนี้ควรส่งออก 100 เท่านั้นและค่าพร้อมจะไม่ถูกพิมพ์ ในความเป็นจริงถ้าคุณเรียกใช้รหัสข้างต้นหลายครั้งอาจมีผลลัพธ์ที่แตกต่างกันมากมาย นี่คือผลลัพธ์ของการวิ่งสองครั้ง:
แน่นอนว่าผลลัพธ์นี้สามารถกล่าวได้ว่าเป็นไปได้เนื่องจากการมองเห็น เมื่อตั้งเธรดการเขียน (WriterThread) ready = true readerThread ไม่สามารถดูผลลัพธ์ที่แก้ไขได้ดังนั้นจะพิมพ์เท็จ สำหรับผลลัพธ์ที่สองนั่นคือผลลัพธ์ของเธรดการเขียนยังไม่ได้รับการอ่านเมื่อดำเนินการหาก (พร้อม) แต่ผลลัพธ์ของการดำเนินการเธรดการเขียนจะถูกอ่านเมื่อดำเนินการ System.out.println (พร้อม) อย่างไรก็ตามผลลัพธ์นี้อาจเกิดจากการดำเนินการทางเลือกของเธรด การมองเห็นสามารถรับรองได้ผ่านการซิงโครไนซ์หรือผันผวนใน Java และรายละเอียดเฉพาะจะถูกวิเคราะห์ในบทความที่ตามมา
5. ลำดับ
เพื่อปรับปรุงประสิทธิภาพคอมไพเลอร์และโปรเซสเซอร์อาจจัดลำดับคำแนะนำใหม่ การจัดลำดับใหม่มีสามประเภท:
(1) การจัดลำดับการสั่งซื้อใหม่แบบคอมไพเลอร์ คอมไพเลอร์สามารถกำหนดตารางเวลาการดำเนินการของคำสั่งโดยไม่ต้องเปลี่ยนความหมายของโปรแกรมเธรดเดี่ยว
(2) การจัดลำดับใหม่ของการเรียนการสอนระดับคำสั่ง โปรเซสเซอร์ที่ทันสมัยใช้เทคโนโลยีคู่ขนานระดับคำสั่ง (ICP) เพื่อทับซ้อนการดำเนินการของคำแนะนำหลายคำสั่ง หากไม่มีการพึ่งพาข้อมูลโปรเซสเซอร์สามารถเปลี่ยนลำดับการดำเนินการของคำสั่งที่สอดคล้องกับคำแนะนำของเครื่อง
(3) การจัดลำดับระบบหน่วยความจำใหม่ เนื่องจากโปรเซสเซอร์ใช้แคชและบัฟเฟอร์การอ่าน/เขียนสิ่งนี้ทำให้การดำเนินการโหลดและการจัดเก็บข้อมูลดูเหมือนจะถูกดำเนินการตามลำดับ
เราสามารถอ้างถึงคำอธิบายของปัญหาการเรียงลำดับใหม่ใน JSR 133:
(1) (2)
ก่อนอื่นให้ดูที่ส่วนซอร์สโค้ด (1) ในภาพด้านบน จากซอร์สโค้ดคำสั่ง 1 จะดำเนินการก่อนหรือคำสั่ง 3 จะถูกดำเนินการก่อน หากคำสั่ง 1 ถูกดำเนินการก่อน R2 ไม่ควรเห็นค่าที่เขียนในคำสั่ง 4. หากคำสั่ง 3 ถูกดำเนินการก่อน R1 ไม่ควรเห็นค่าที่เขียนโดยคำสั่ง 2 อย่างไรก็ตามผลการทำงานอาจมี R2 == 2 และ R1 == 1 ซึ่งเป็นผลมาจาก "การสั่งซื้อใหม่" รูปด้านบน (2) เป็นผลการรวบรวมทางกฎหมายที่เป็นไปได้ หลังจากการรวบรวมลำดับของคำสั่ง 1 และคำสั่ง 2 อาจมีการแลกเปลี่ยน ดังนั้นผลลัพธ์ของ R2 == 2 และ R1 == 1 จะปรากฏขึ้น นอกจากนี้ยังสามารถใช้ใน Java เพื่อให้แน่ใจว่ามีการซิงโครไนซ์หรือผันผวน
หกสรุป
บทความนี้อธิบายพื้นฐานทางทฤษฎีของการเขียนโปรแกรมพร้อมกันของ Java และบางสิ่งจะถูกกล่าวถึงในรายละเอียดเพิ่มเติมในการวิเคราะห์ที่ตามมาเช่นการมองเห็นคำสั่ง ฯลฯ บทความที่ตามมาจะถูกกล่าวถึงตามเนื้อหาของบทนี้ หากคุณสามารถเข้าใจเนื้อหาข้างต้นได้ดีฉันเชื่อว่ามันจะช่วยคุณได้อย่างดีไม่ว่าจะเป็นการเข้าใจบทความการเขียนโปรแกรมอื่น ๆ พร้อมกันหรือในงานเขียนโปรแกรมพร้อมกันประจำวันของคุณ