จุดเน้นของบทความนี้อยู่ที่ปัญหาประสิทธิภาพของแอปพลิเคชันแบบมัลติเธรด ก่อนอื่นเราจะกำหนดประสิทธิภาพและความสามารถในการปรับขนาดแล้วศึกษากฎของ Amdahl อย่างระมัดระวัง ในเนื้อหาต่อไปนี้เราจะตรวจสอบวิธีการใช้วิธีการทางเทคนิคที่แตกต่างกันเพื่อลดการแข่งขันล็อคและวิธีการใช้งานด้วยรหัส
1. ประสิทธิภาพ
เราทุกคนรู้ว่าสามารถใช้มัลติเธรดเพื่อปรับปรุงประสิทธิภาพของโปรแกรมและเหตุผลที่อยู่เบื้องหลังนี้คือเรามีซีพียูหลายคอร์หรือซีพียูหลายตัว CPU Core แต่ละตัวสามารถทำงานให้เสร็จสมบูรณ์ด้วยตัวเองดังนั้นการแบ่งงานขนาดใหญ่เป็นชุดของงานเล็ก ๆ ที่สามารถทำงานได้อย่างอิสระซึ่งกันและกันสามารถปรับปรุงประสิทธิภาพโดยรวมของโปรแกรม คุณสามารถยกตัวอย่าง ตัวอย่างเช่นมีโปรแกรมที่เปลี่ยนขนาดของรูปภาพทั้งหมดในโฟลเดอร์บนฮาร์ดดิสก์และการประยุกต์ใช้เทคโนโลยีมัลติเธรดสามารถปรับปรุงประสิทธิภาพได้ การใช้วิธีเกลียวเดียวสามารถสำรวจไฟล์รูปภาพทั้งหมดตามลำดับและทำการแก้ไข หากซีพียูของเรามีหลายคอร์ไม่ต้องสงสัยเลยว่ามันสามารถใช้หนึ่งในนั้นได้ การใช้มัลติเธรดเราสามารถมีเธรดผู้ผลิตสแกนระบบไฟล์เพื่อเพิ่มแต่ละภาพลงในคิวจากนั้นใช้เธรดพนักงานหลายคนเพื่อทำงานเหล่านี้ หากจำนวนเธรดของคนงานเหมือนกับจำนวนคอร์ CPU ทั้งหมดเราสามารถมั่นใจได้ว่าแต่ละแกน CPU มีงานต้องทำจนกว่างานทั้งหมดจะถูกดำเนินการ
สำหรับโปรแกรมอื่นที่ต้องใช้ IO มากขึ้นประสิทธิภาพโดยรวมยังสามารถปรับปรุงได้โดยใช้เทคโนโลยีมัลติเธรด สมมติว่าเราต้องการเขียนโปรแกรมดังกล่าวที่เราจำเป็นต้องรวบรวมข้อมูลไฟล์ HTML ทั้งหมดของเว็บไซต์บางแห่งและจัดเก็บไว้ในดิสก์ท้องถิ่น โปรแกรมสามารถเริ่มต้นจากหน้าเว็บที่แน่นอนจากนั้นแยกวิเคราะห์ลิงก์ทั้งหมดไปยังเว็บไซต์นี้ในหน้าเว็บนี้จากนั้นรวบรวมข้อมูลลิงค์เหล่านี้ในทางกลับกันเพื่อให้มันซ้ำตัวเอง เนื่องจากต้องใช้เวลาสักครู่เพื่อรอจากเวลาที่เราเริ่มคำขอไปยังเว็บไซต์ระยะไกลถึงเวลาที่เราได้รับข้อมูลหน้าเว็บทั้งหมดเราจึงสามารถมอบงานนี้ให้กับหลายเธรดเพื่อดำเนินการ ให้เธรดหนึ่งหรืออีกเล็กน้อยแยกวิเคราะห์หน้า HTML ที่ได้รับและใส่ลิงก์ที่พบลงในคิวทิ้งเธรดอื่น ๆ ทั้งหมดที่รับผิดชอบในการขอหน้า ซึ่งแตกต่างจากตัวอย่างก่อนหน้าในตัวอย่างนี้คุณยังสามารถรับการปรับปรุงประสิทธิภาพแม้ว่าคุณจะใช้เธรดมากกว่าจำนวนคอร์ CPU
ตัวอย่างสองตัวอย่างข้างต้นบอกเราว่าประสิทธิภาพสูงคือการทำสิ่งต่าง ๆ ให้ได้มากที่สุดในช่วงเวลาสั้น ๆ นี่คือคำอธิบายที่คลาสสิกที่สุดของคำว่าประสิทธิภาพ แต่ในเวลาเดียวกันการใช้เธรดสามารถปรับปรุงความเร็วในการตอบสนองของโปรแกรมของเราได้ดี ลองนึกภาพเรามีแอปพลิเคชันอินเตอร์เฟสกราฟิกที่มีกล่องอินพุตด้านบนและปุ่มชื่อ "กระบวนการ" ด้านล่างกล่องอินพุต เมื่อผู้ใช้กดปุ่มนี้แอปพลิเคชันจะต้องแสดงสถานะของปุ่มใหม่อีกครั้ง (ปุ่มปรากฏขึ้นและจะถูกกดและจะกลับสู่สถานะเดิมเมื่อปุ่มซ้ายของเมาส์ถูกปล่อยออกมา) และเริ่มประมวลผลอินพุตของผู้ใช้ หากงานนี้ใช้เวลานานในการประมวลผลอินพุตของผู้ใช้โปรแกรมเธรดเดียวจะไม่สามารถตอบสนองต่อการดำเนินการป้อนข้อมูลผู้ใช้รายอื่นต่อไปเช่นผู้ใช้คลิกเหตุการณ์เมาส์หรือตัวชี้เมาส์ที่ย้ายเหตุการณ์ที่ส่งจากระบบปฏิบัติการ ฯลฯ การตอบสนองต่อเหตุการณ์เหล่านี้จำเป็นต้องเป็นเธรดอิสระเพื่อตอบสนอง
ความสามารถในการปรับขนาดได้หมายความว่าโปรแกรมมีความสามารถในการรับประสิทธิภาพที่สูงขึ้นโดยการเพิ่มทรัพยากรการคำนวณ ลองนึกภาพว่าเราจำเป็นต้องปรับขนาดของภาพจำนวนมากเนื่องจากจำนวนคอร์ CPU ของเครื่องของเรามี จำกัด การเพิ่มจำนวนเธรดไม่ได้ปรับปรุงประสิทธิภาพตามลำดับ ในทางตรงกันข้ามเนื่องจากตัวกำหนดตารางเวลาจะต้องรับผิดชอบในการสร้างและปิดเธรดเพิ่มเติมจึงจะครอบครองทรัพยากร CPU ซึ่งอาจลดประสิทธิภาพ
1.1 กฎของ Amdahl
ย่อหน้าก่อนหน้ากล่าวว่าในบางกรณีการเพิ่มทรัพยากรการคำนวณเพิ่มเติมสามารถปรับปรุงประสิทธิภาพโดยรวมของโปรแกรม ในการคำนวณจำนวนการปรับปรุงประสิทธิภาพที่เราจะได้รับเมื่อเราเพิ่มทรัพยากรเพิ่มเติมจำเป็นต้องตรวจสอบว่าส่วนใดของโปรแกรมทำงานแบบอนุกรม (หรือซิงโครนัส) และชิ้นส่วนใดที่ทำงานแบบขนาน หากเราหาปริมาณสัดส่วนของรหัสที่จำเป็นต้องดำเนินการแบบซิงโครนัสกับ B (ตัวอย่างเช่นจำนวนบรรทัดของรหัสที่ต้องดำเนินการแบบซิงโครนัส) และบันทึกจำนวนคอร์ทั้งหมดของ CPU เป็น N จากนั้นตามกฎหมายของ AMDAHL ขีด จำกัด สูงสุดของการปรับปรุงประสิทธิภาพที่เราได้รับคือ:
ถ้า n มีแนวโน้มที่จะไม่มีที่สิ้นสุด (1-b)/n มาบรรจบกันเป็น 0 ดังนั้นเราสามารถเพิกเฉยต่อค่าของนิพจน์นี้ดังนั้นการนับบิตการปรับปรุงประสิทธิภาพจะรวมกันเป็น 1/b ซึ่ง B แสดงถึงสัดส่วนของรหัสที่ต้องทำงานแบบซิงโครนัส หาก B เท่ากับ 0.5 หมายความว่าครึ่งหนึ่งของรหัสของโปรแกรมไม่สามารถทำงานได้ในแบบคู่ขนานและค่าตอบแทนที่ 0.5 คือ 2 ดังนั้นแม้ว่าเราจะเพิ่มคอร์ CPU นับไม่ถ้วนเราจะได้รับการปรับปรุงประสิทธิภาพสูงสุด 2 เท่า สมมติว่าเราได้ปรับเปลี่ยนโปรแกรมแล้วและหลังจากการแก้ไขต้องใช้รหัสเพียง 0.25 รหัสเท่านั้น ตอนนี้ 1/0.25 = 4 หมายความว่าหากโปรแกรมของเราทำงานบนฮาร์ดแวร์ด้วยซีพียูจำนวนมากมันจะเร็วขึ้นประมาณ 4 เท่าในฮาร์ดแวร์แบบคอร์เดียว
ในทางกลับกันผ่านกฎหมายของ Amdahl เรายังสามารถคำนวณสัดส่วนของรหัสการซิงโครไนซ์ที่โปรแกรมควรขึ้นอยู่กับเป้าหมายการเร่งความเร็วที่เราต้องการ หากเราต้องการเพิ่มความเร็ว 100 เท่าและ 1/100 = 0.01 หมายความว่าจำนวนสูงสุดของรหัสที่โปรแกรมของเราดำเนินการแบบซิงโครนัสไม่เกิน 1%
ในการสรุปกฎหมายของ Amdahl เราจะเห็นว่าการปรับปรุงประสิทธิภาพสูงสุดที่เราได้รับโดยการเพิ่ม CPU พิเศษขึ้นอยู่กับว่าสัดส่วนของโปรแกรมดำเนินการส่วนหนึ่งของรหัสที่ซิงโครนัสน้อยเพียงใด แม้ว่าในความเป็นจริงมันไม่ง่ายเลยที่จะคำนวณอัตราส่วนนี้ แต่เพียงอย่างเดียวก็ต้องเผชิญกับแอพพลิเคชั่นระบบการค้าขนาดใหญ่บางอย่างกฎหมาย Amdahl ทำให้เราได้รับแรงบันดาลใจที่สำคัญนั่นคือเราต้องพิจารณารหัสที่ต้องดำเนินการแบบซิงโครนัสและพยายามลดส่วนนี้ของรหัสนี้
1.2 ผลกระทบต่อประสิทธิภาพ
ในขณะที่บทความเขียนที่นี่เราได้ทำจุดที่การเพิ่มเธรดเพิ่มเติมสามารถปรับปรุงประสิทธิภาพของโปรแกรมและการตอบสนอง แต่ในทางกลับกันมันไม่ใช่เรื่องง่ายที่จะบรรลุผลประโยชน์เหล่านี้และมันก็ต้องใช้ราคาบางอย่าง การใช้เธรดจะส่งผลต่อการปรับปรุงประสิทธิภาพ
ครั้งแรกผลกระทบแรกมาจากเวลาของการสร้างเธรด ในระหว่างการสร้างเธรด JVM จำเป็นต้องใช้สำหรับทรัพยากรที่สอดคล้องกันจากระบบปฏิบัติการพื้นฐานและเริ่มต้นโครงสร้างข้อมูลในตัวกำหนดตารางเวลาเพื่อกำหนดลำดับของเธรดการดำเนินการ
หากจำนวนเธรดของคุณเหมือนกับจำนวนคอร์ CPU แต่ละเธรดจะทำงานบนแกนกลางเพื่อที่พวกเขาจะไม่ถูกขัดจังหวะบ่อยครั้ง แต่ในความเป็นจริงเมื่อโปรแกรมของคุณกำลังทำงานระบบปฏิบัติการจะมีการดำเนินการของตัวเองบางอย่างที่ต้องดำเนินการโดย CPU ดังนั้นแม้ในกรณีนี้เธรดของคุณจะถูกขัดจังหวะและรอให้ระบบปฏิบัติการกลับมาทำงานต่อ เมื่อการนับเธรดของคุณเกินจำนวนคอร์ CPU สถานการณ์อาจแย่ลง ในกรณีนี้ตัวกำหนดตารางเวลากระบวนการของ JVM จะขัดจังหวะเธรดบางอย่างเพื่อให้เธรดอื่นดำเนินการ เมื่อมีการสลับเธรดสถานะปัจจุบันของเธรดที่รันจะต้องถูกบันทึกไว้เพื่อให้สถานะข้อมูลสามารถกู้คืนได้ในครั้งต่อไปที่จะเรียกใช้ ไม่เพียงแค่นั้นตัวกำหนดตารางเวลาจะอัปเดตโครงสร้างข้อมูลภายในของตัวเองซึ่งต้องใช้วงจร CPU ด้วย ทั้งหมดนี้หมายความว่าการสลับบริบทระหว่างเธรดนั้นใช้ทรัพยากรการประมวลผล CPU ซึ่งจะนำค่าใช้จ่ายด้านประสิทธิภาพเมื่อเทียบกับในเคสเธรดเดียว
ค่าใช้จ่ายอีกอย่างหนึ่งที่นำโดยโปรแกรมแบบมัลติเธรดมาจากการป้องกันการเข้าถึงแบบซิงโครนัสของข้อมูลที่ใช้ร่วมกัน เราสามารถใช้คำหลักที่ซิงโครไนซ์สำหรับการป้องกันการซิงโครไนซ์หรือเราสามารถใช้คำหลักที่ผันผวนเพื่อแบ่งปันข้อมูลระหว่างหลายเธรด หากมีมากกว่าหนึ่งเธรดที่ต้องการเข้าถึงโครงสร้างข้อมูลที่ใช้ร่วมกันการโต้แย้งจะเกิดขึ้น ในเวลานี้ JVM จำเป็นต้องตัดสินใจว่ากระบวนการใดเป็นขั้นตอนแรกและกระบวนการใดที่อยู่เบื้องหลัง หากเธรดที่จะดำเนินการไม่ใช่เธรดที่กำลังรันอยู่การสลับเธรดจะเกิดขึ้น เธรดปัจจุบันต้องรอจนกว่าจะได้รับวัตถุล็อคสำเร็จ JVM สามารถตัดสินใจว่าจะทำ "รอ" นี้ได้อย่างไร หาก JVM คาดว่าจะสั้นลงที่จะได้รับวัตถุที่ถูกล็อคได้สำเร็จ JVM สามารถใช้วิธีการรอคอยที่ก้าวร้าวเช่นพยายามที่จะได้รับวัตถุที่ถูกล็อคอยู่ตลอดเวลาจนกว่าจะประสบความสำเร็จ ในกรณีนี้วิธีนี้อาจมีประสิทธิภาพมากขึ้นเนื่องจากยังคงเร็วกว่าที่จะเปรียบเทียบการสลับบริบทของกระบวนการ การย้ายเธรดรอกลับไปยังคิวการดำเนินการจะนำค่าใช้จ่ายเพิ่มเติม
ดังนั้นเราต้องพยายามอย่างเต็มที่เพื่อหลีกเลี่ยงการสลับบริบทที่เกิดจากการแข่งขันล็อค ส่วนต่อไปนี้จะอธิบายสองวิธีในการลดการเกิดขึ้นของการแข่งขันดังกล่าว
1.3 การแข่งขันล็อค
ดังที่ได้กล่าวไว้ในส่วนก่อนหน้าการเข้าถึงการล็อคโดยสองเธรดขึ้นไปจะนำค่าใช้จ่ายในการคำนวณเพิ่มเติมเนื่องจากการแข่งขันเกิดขึ้นเพื่อบังคับให้ผู้จัดตารางเวลาเข้าสู่สถานะรอคอยที่ก้าวร้าวหรือปล่อยให้มันดำเนินการสถานะรอ มีบางกรณีที่ผลของการแข่งขันล็อคสามารถบรรเทาได้โดย:
1. ลดขอบเขตของล็อค;
2. ลดความถี่ของล็อคที่จำเป็นต้องได้รับ;
3. พยายามใช้การดำเนินการล็อคในแง่ดีที่รองรับโดยฮาร์ดแวร์แทนที่จะซิงโครไนซ์
4. พยายามใช้การซิงโครไนซ์ให้น้อยที่สุด
5. ลดการใช้แคชวัตถุ
1.3.1 การลดโดเมนการซิงโครไนซ์
หากรหัสถือล็อคได้มากกว่าที่จำเป็นวิธีการแรกนี้สามารถใช้ได้ โดยปกติเราสามารถย้ายรหัสหนึ่งบรรทัดขึ้นไปจากพื้นที่การซิงโครไนซ์เพื่อลดเวลาที่เธรดปัจจุบันถือล็อค รหัสที่น้อยลงทำงานในพื้นที่การซิงโครไนซ์เธรดก่อนหน้านี้จะปล่อยล็อคไว้เพื่อให้เธรดอื่น ๆ ได้รับล็อคก่อนหน้านี้ สิ่งนี้สอดคล้องกับกฎหมายของ Amdahl เพราะการทำเช่นนั้นจะช่วยลดจำนวนรหัสที่ต้องดำเนินการแบบซิงโครนัส
เพื่อความเข้าใจที่ดีขึ้นให้ดูที่ซอร์สโค้ดต่อไปนี้:
คลาสสาธารณะ REDUCELOCKDURATION ใช้งาน RUNNABLE {ส่วนตัวคงที่ int สุดท้าย number_of_threads = 5; แผนที่สุดท้ายคงที่ส่วนตัว <สตริง, จำนวนเต็ม> map = new hashmap <string, integer> (); โมฆะสาธารณะเรียกใช้ () {สำหรับ (int i = 0; i <10,000; i ++) {ซิงโครไนซ์ (แผนที่) {uuid randomuuid = uuid.randomuuid (); ค่าจำนวนเต็ม = จำนวนเต็ม ValueOf (42); คีย์สตริง = randomuuid.toString (); map.put (คีย์, ค่า); } thread.yield (); }} โมฆะคงที่สาธารณะหลัก (สตริง [] args) พ่น InterruptedException {เธรด [] เธรด = เธรดใหม่ [number_of_threads]; สำหรับ (int i = 0; i <number_of_threads; i ++) {เธรด [i] = เธรดใหม่ (ใหม่ reducelockduration ()); } Long StartMillis = System.currentTimeMillis (); สำหรับ (int i = 0; i <number_of_threads; i ++) {เธรด [i] .start (); } สำหรับ (int i = 0; i <number_of_threads; i ++) {เธรด [i] .join (); } system.out.println ((System.currentTimeMillis ()-startMillis)+"MS"); -ในตัวอย่างด้านบนเราปล่อยให้ห้าเธรดแข่งขันเพื่อเข้าถึงอินสแตนซ์แผนที่ที่ใช้ร่วมกัน ในการที่จะมีเธรดเดียวเท่านั้นที่สามารถเข้าถึงอินสแตนซ์ MAP ในเวลาเดียวกันเราได้ทำการดำเนินการของการเพิ่มคีย์/ค่าลงในแผนที่ลงในบล็อกรหัสที่ได้รับการป้องกันแบบซิงโครไนซ์ เมื่อเราดูรหัสนี้อย่างรอบคอบเราจะเห็นได้ว่ารหัสสองสามประโยคที่คำนวณคีย์และค่าไม่จำเป็นต้องดำเนินการแบบซิงโครนัส คีย์และค่าเป็นของเธรดที่เรียกใช้งานรหัสนี้ในปัจจุบัน มันมีความหมายเฉพาะกับเธรดปัจจุบันและจะไม่ถูกแก้ไขโดยเธรดอื่น ดังนั้นเราสามารถย้ายประโยคเหล่านี้ออกจากการป้องกันการซิงโครไนซ์ ดังนี้:
โมฆะสาธารณะเรียกใช้ () {สำหรับ (int i = 0; i <10,00000; i ++) {uuid randomuuid = uuid.randomuuid (); ค่าจำนวนเต็ม = จำนวนเต็ม ValueOf (42); คีย์สตริง = randomuuid.toString (); ซิงโครไนซ์ (แผนที่) {map.put (คีย์, ค่า); } thread.yield (); -ผลของการลดรหัสการซิงโครไนซ์นั้นสามารถวัดได้ ในเครื่องของฉันเวลาดำเนินการของโปรแกรมทั้งหมดลดลงจาก 420ms เป็น 370ms ลองดูเพียงแค่ย้ายรหัสสามบรรทัดออกจากบล็อกป้องกันการซิงโครไนซ์สามารถลดเวลาทำงานของโปรแกรมได้ 11% รหัส thread.yield () คือการชักนำให้เกิดการสลับบริบทของเธรดเนื่องจากรหัสนี้จะบอก JVM ว่าเธรดปัจจุบันต้องการส่งมอบทรัพยากรการคำนวณที่ใช้ในปัจจุบันเพื่อให้เธรดอื่นที่รอการทำงานสามารถเรียกใช้ได้ สิ่งนี้จะนำไปสู่การแข่งขันล็อคมากขึ้นเพราะหากไม่เป็นเช่นนั้นเธรดจะครอบครองแกนกลางที่ยาวขึ้นดังนั้นจึงช่วยลดการสลับบริบทของเธรด
1.3.2 ล็อคแยก
อีกวิธีหนึ่งในการลดการแข่งขันล็อคคือการกระจายบล็อกของรหัสที่ป้องกันล็อคลงในบล็อกป้องกันขนาดเล็กจำนวนหนึ่ง วิธีนี้จะทำงานได้หากคุณใช้ล็อคในโปรแกรมของคุณเพื่อปกป้องวัตถุที่แตกต่างกัน สมมติว่าเราต้องการนับข้อมูลบางอย่างผ่านโปรแกรมและใช้คลาสการนับอย่างง่ายเพื่อเก็บตัวบ่งชี้ทางสถิติที่แตกต่างกันหลายตัวและแสดงด้วยตัวแปรการนับพื้นฐาน (ประเภทยาว) เนื่องจากโปรแกรมของเราเป็นแบบมัลติเธรดเราจึงต้องปกป้องการดำเนินการที่เข้าถึงตัวแปรเหล่านี้เนื่องจากการกระทำเหล่านี้มาจากเธรดที่แตกต่างกัน วิธีที่ง่ายที่สุดในการบรรลุเป้าหมายนี้คือการเพิ่มคำหลักที่ซิงโครไนซ์ในแต่ละฟังก์ชั่นที่เข้าถึงตัวแปรเหล่านี้
คลาสสแตติกระดับสาธารณะ Counteronelock ใช้ตัวนับ {Private Long CustomerCount = 0; การจัดส่งยาวส่วนตัว = 0; โมฆะแบบซิงโครไนซ์สาธารณะเพิ่มขึ้น () {CustomerCount ++; } โมฆะที่ซิงโครไนซ์สาธารณะเพิ่มขึ้น () {ShippingCount ++; } สาธารณะที่ซิงโครไนซ์ยาว getCustomerCount () {return customerCount; } สาธารณะที่ซิงโครไนซ์ Long GetShippingCount () {return ShippingCount; -ซึ่งหมายความว่าการปรับเปลี่ยนแต่ละตัวแปรเหล่านี้จะทำให้ล็อคไปยังอินสแตนซ์เคาน์เตอร์อื่น ๆ หากเธรดอื่นต้องการเรียกใช้วิธีการเพิ่มขึ้นในตัวแปรอื่นที่แตกต่างกันพวกเขาสามารถรอให้เธรดก่อนหน้านี้ปล่อยการควบคุมการล็อคก่อนที่พวกเขาจะมีโอกาสที่จะทำให้เสร็จ ในกรณีนี้การใช้การป้องกันแบบซิงโครไนซ์แยกต่างหากสำหรับแต่ละตัวแปรที่แตกต่างกันจะปรับปรุงประสิทธิภาพการดำเนินการ
CounterSparateLock คลาสสแตติกสาธารณะใช้ตัวนับ {วัตถุสุดท้ายของเอกชนสุดท้าย customerLock = New Object (); วัตถุสุดท้ายคงที่ส่วนตัว ShippingLock = New Object (); CustomerCount ยาวส่วนตัว = 0; การจัดส่งยาวส่วนตัว = 0; โมฆะสาธารณะ uprementCustomer () {ซิงโครไนซ์ (customerLock) {customerCount ++; }} โมฆะสาธารณะเพิ่มขึ้น () {ซิงโครไนซ์ (ShippingLock) {ShippingCount ++; }} public long getCustomerCount () {ซิงโครไนซ์ (customerLock) {return customerCount; }} สาธารณะ Long GetShippingCount () {ซิงโครไนซ์ (ShippingLock) {return ShippingCount; -การใช้งานนี้แนะนำวัตถุที่ซิงโครไนซ์แยกต่างหากสำหรับแต่ละตัวชี้วัดการนับ ดังนั้นเมื่อเธรดต้องการเพิ่มจำนวนลูกค้าจะต้องรอเธรดอื่นที่เพิ่มจำนวนลูกค้าให้เสร็จสมบูรณ์แทนที่จะรอเธรดอื่นที่เพิ่มจำนวนการจัดส่งให้เสร็จสมบูรณ์
การใช้คลาสต่อไปนี้เราสามารถคำนวณการปรับปรุงประสิทธิภาพที่เกิดจากล็อคแบบแยกได้อย่างง่ายดาย
คลาสสาธารณะ LocksPlitting ดำเนินการ runnable {ส่วนตัวคงที่ int final number_of_threads = 5; เคาน์เตอร์ส่วนตัว ตัวนับอินเตอร์เฟสสาธารณะ {void uprementCustomer (); โมฆะเพิ่มขึ้น (); GetCustomerCount () ยาว (); Long GetShippingCount (); } คลาสสแตติกระดับสาธารณะ CounterOnelock ใช้ตัวนับ {... } counterSparateLock คลาสสแตติกระดับสาธารณะใช้ตัวนับ {... } ล็อคสาธารณะสาธารณะ (ตัวนับนับ) {this.counter = counter; } โมฆะสาธารณะเรียกใช้ () {สำหรับ (int i = 0; i <100000; i ++) {ถ้า (threadlocalrandom.current (). nextBoolean ()) {counter.incrementCustomer (); } else {counter.incrementshipping (); }}} โมฆะคงที่สาธารณะหลัก (สตริง [] args) พ่น InterruptedException {เธรด [] เธรด = เธรดใหม่ [number_of_threads]; เคาน์เตอร์เคาน์เตอร์ = ใหม่ counteronelock (); สำหรับ (int i = 0; i <number_of_threads; i ++) {เธรด [i] = เธรดใหม่ (locksplitting ใหม่ (ตัวนับ)); } Long StartMillis = System.currentTimeMillis (); สำหรับ (int i = 0; i <number_of_threads; i ++) {เธรด [i] .start (); } สำหรับ (int i = 0; i <number_of_threads; i ++) {เธรด [i] .join (); } system.out.println ((System.currentTimeMillis () - startMillis) + "MS"); -บนเครื่องของฉันวิธีการใช้งานของล็อคเดียวใช้เวลาเฉลี่ย 56ms และการใช้งานของล็อคสองตัวแยกกันคือ 38ms การใช้เวลานานลดลงประมาณ 32%
อีกวิธีหนึ่งในการปรับปรุงคือเราสามารถไปไกลกว่านี้เพื่อปกป้องการอ่านและเขียนด้วยล็อคที่แตกต่างกัน คลาสเคาน์เตอร์ดั้งเดิมให้วิธีการอ่านและเขียนตัวบ่งชี้การนับตามลำดับ อย่างไรก็ตามในความเป็นจริงการดำเนินการอ่านไม่จำเป็นต้องมีการป้องกันการซิงโครไนซ์ เราสามารถมั่นใจได้ว่าหลายเธรดสามารถอ่านค่าของตัวบ่งชี้ปัจจุบันในแบบขนาน ในเวลาเดียวกันการดำเนินการเขียนจะต้องได้รับการปกป้องแบบซิงโครนัส แพ็คเกจ java.util.concurrent ให้การใช้งานอินเทอร์เฟซ ReadWriteLock ซึ่งสามารถบรรลุความแตกต่างนี้ได้อย่างง่ายดาย
การใช้งาน ReentRantReadWriteLock รักษาล็อคสองตัวที่แตกต่างกันหนึ่งตัวป้องกันการดำเนินการอ่านและการป้องกันการเขียนอื่น ๆ ล็อคทั้งสองมีการดำเนินการเพื่อรับและปล่อยล็อค สามารถรับล็อคการเขียนได้สำเร็จเมื่อไม่มีใครได้รับการล็อคอ่าน ในทางกลับกันตราบใดที่การล็อคการเขียนไม่ได้รับการล็อคการอ่านสามารถรับได้หลายเธรดในเวลาเดียวกัน เพื่อแสดงให้เห็นถึงวิธีการนี้คลาสเคาน์เตอร์ต่อไปนี้ใช้ ReadWriteLock ดังนี้:
คลาสสแตติกระดับสาธารณะ CounterReadWriteLock ใช้ตัวนับ {ส่วนตัวสุดท้าย reentRantReadWriteLock customerLock = ใหม่ reentRantReadWriteLock (); Private Lock Final Lock CustomerWriteLock = CustomerLock.WriteLock (); ล็อคขั้นสุดท้ายล็อค customerReadlock = customerlock.readlock (); Private Final ReentRantReadWriteLock ShippingLock = ใหม่ reentRantReadWriteLock (); ล็อคสุดท้ายล็อคการ ShippingWriteLock = ShippingLock.WriteLock (); ล็อคสุดท้ายล็อคการจัดส่ง rehippedLock = ShippingLock.readlock (); CustomerCount ยาวส่วนตัว = 0; การจัดส่งยาวส่วนตัว = 0; โมฆะสาธารณะ uprementCustomer () {customerWriteLock.lock (); CustomerCount ++; CustomerWriteLock.unlock (); } โมฆะสาธารณะเพิ่มขึ้น () {ShippingWriteLock.lock (); ShippingCount ++; ShippingWriteLock.unlock (); } สาธารณะยาว getCustomerCount () {customerReadlock.lock (); นับยาว = customercount; customerreadlock.unlock (); นับคืน; } สาธารณะ Long GetShippingCount () {ShippingReadLock.lock (); นับยาว = ShippingCount; ShippingReadLock.unlock (); นับคืน; -การดำเนินการอ่านทั้งหมดได้รับการคุ้มครองโดยล็อคการอ่านและการดำเนินการเขียนทั้งหมดได้รับการคุ้มครองโดยล็อคการเขียน หากการดำเนินการอ่านที่ดำเนินการในโปรแกรมมีขนาดใหญ่กว่าการดำเนินการเขียนการดำเนินการนี้สามารถนำการปรับปรุงประสิทธิภาพได้มากกว่าส่วนก่อนหน้านี้เนื่องจากการดำเนินการอ่านสามารถดำเนินการได้พร้อมกัน
1.3.3 ล็อคการแยก
ตัวอย่างข้างต้นแสดงวิธีแยกล็อคเดียวออกเป็นล็อคหลายตัวแยกต่างหากเพื่อให้แต่ละเธรดสามารถรับล็อคของวัตถุที่พวกเขากำลังจะแก้ไข แต่ในทางกลับกันวิธีนี้ยังเพิ่มความซับซ้อนของโปรแกรมและอาจทำให้เกิดการหยุดชะงักหากนำไปใช้อย่างไม่เหมาะสม
การล็อคการปลดเป็นวิธีที่คล้ายกันกับการล็อคการปลด แต่การล็อคการปลดคือการเพิ่มล็อคเพื่อป้องกันตัวอย่างรหัสหรือวัตถุที่แตกต่างกันในขณะที่การล็อคการปลดจะใช้การล็อคที่แตกต่างกันเพื่อป้องกันช่วงที่แตกต่างกันของค่า พร้อมกันในแพ็คเกจ java.util.concurrent ของ JDK ใช้แนวคิดนี้เพื่อปรับปรุงประสิทธิภาพของโปรแกรมที่พึ่งพา HashMap เป็นอย่างมาก ในแง่ของการใช้งาน ConcurrentHashMap ใช้ล็อคที่แตกต่างกัน 16 รายการภายในแทนที่จะห่อหุ้ม HashMap ที่ได้รับการป้องกันแบบซิงโครนัส ล็อค 16 ตัวแต่ละตัวมีหน้าที่ในการปกป้องการเข้าถึงแบบซิงโครนัสไปยังหนึ่งในสิบของบิตถัง (ถัง) ด้วยวิธีนี้เมื่อเธรดที่แตกต่างกันต้องการแทรกปุ่มลงในส่วนที่แตกต่างกันการดำเนินการที่สอดคล้องกันจะได้รับการปกป้องโดยล็อคที่แตกต่างกัน แต่มันก็จะนำปัญหาที่ไม่ดีเช่นการดำเนินการบางอย่างเสร็จสิ้นในขณะนี้ต้องใช้ล็อคหลายตัวแทนที่จะเป็นล็อคหนึ่งครั้ง หากคุณต้องการคัดลอกแผนที่ทั้งหมดจำเป็นต้องได้รับการล็อคทั้ง 16 ตัวเพื่อให้เสร็จสมบูรณ์
1.3.4 การทำงานของอะตอม
อีกวิธีหนึ่งในการลดการแข่งขันล็อคคือการใช้การดำเนินการปรมาณูซึ่งจะอธิบายรายละเอียดเกี่ยวกับหลักการในบทความอื่น ๆ แพ็คเกจ java.util.concurrent ให้คลาสที่ห่อหุ้มด้วยอะตอมสำหรับประเภทข้อมูลพื้นฐานที่ใช้กันทั่วไป การดำเนินการของคลาสปฏิบัติการอะตอมนั้นขึ้นอยู่กับฟังก์ชัน "การเปรียบเทียบการเปรียบเทียบ" (CAS) ที่จัดทำโดยโปรเซสเซอร์ การดำเนินการ CAS จะดำเนินการอัปเดตเมื่อค่าของการลงทะเบียนปัจจุบันเหมือนกับค่าเก่าที่จัดทำโดยการดำเนินการ
หลักการนี้สามารถใช้เพื่อเพิ่มมูลค่าของตัวแปรในวิธีที่มองโลกในแง่ดี หากเธรดของเรารู้ค่าปัจจุบันมันจะพยายามใช้การดำเนินการ CAS เพื่อดำเนินการเพิ่มขึ้น หากเธรดอื่นได้แก้ไขค่าของตัวแปรในช่วงเวลานี้ค่าปัจจุบันที่เรียกว่าที่ให้โดยเธรดจะแตกต่างจากค่าจริง ในเวลานี้ JVM พยายามฟื้นมูลค่าปัจจุบันและลองอีกครั้งซ้ำอีกครั้งจนกว่าจะประสบความสำเร็จ แม้ว่าการดำเนินการวนรอบจะทำให้ CPU บางรอบเสียไป แต่ประโยชน์ของการทำเช่นนี้คือเราไม่จำเป็นต้องมีการควบคุมการซิงโครไนซ์ในรูปแบบใด ๆ
การใช้งานคลาสเคาน์เตอร์ด้านล่างใช้การดำเนินการอะตอม อย่างที่คุณเห็นไม่มีการใช้รหัสแบบซิงโครไนซ์
ระดับสแตติกระดับสาธารณะ counteratomic ใช้เคาน์เตอร์ {private atomiclong customerCount = new Atomiclong (); Atomlong Private ShippingCount = New Atomiclong (); โมฆะสาธารณะเพิ่มขึ้น () {customercount.incrementandget (); } โมฆะสาธารณะเพิ่มขึ้น () {ShippingCount.IncrementAndGet (); } สาธารณะยาว getCustomerCount () {return customerCount.get (); } สาธารณะ Long GetShippingCount () {return ShippingCount.get (); -เมื่อเทียบกับคลาส CounterSeperateLock เวลาทำงานเฉลี่ยลดลงจาก 39ms เป็น 16ms ซึ่งอยู่ที่ประมาณ 58%
1.3.5 หลีกเลี่ยงกลุ่มรหัสฮอตสปอต
การใช้งานรายการทั่วไปบันทึกจำนวนองค์ประกอบที่มีอยู่ในรายการเองโดยการรักษาตัวแปรในเนื้อหา ทุกครั้งที่องค์ประกอบถูกลบหรือเพิ่มจากรายการค่าของตัวแปรนี้จะเปลี่ยน หากมีการใช้รายการในแอปพลิเคชันแบบเธรดเดียววิธีนี้จะเข้าใจได้ ทุกครั้งที่คุณโทรขนาด () คุณสามารถส่งคืนค่าหลังจากการคำนวณล่าสุด หากตัวแปรนับนี้ไม่ได้รับการดูแลภายในตามรายการการโทรแต่ละครั้ง () จะทำให้รายการเดินทางอีกครั้งและคำนวณจำนวนองค์ประกอบ
วิธีการเพิ่มประสิทธิภาพนี้ใช้โดยโครงสร้างข้อมูลจำนวนมากจะกลายเป็นปัญหาเมื่ออยู่ในสภาพแวดล้อมแบบมัลติเธรด สมมติว่าเราแบ่งปันรายการระหว่างหลายเธรดและหลายเธรดพร้อมกันเพิ่มหรือลบองค์ประกอบลงในรายการและสอบถามความยาวขนาดใหญ่ ในเวลานี้ตัวแปรการนับภายในรายการกลายเป็นทรัพยากรที่ใช้ร่วมกันดังนั้นการเข้าถึงทั้งหมดจะต้องดำเนินการแบบซิงโครนัส ดังนั้นตัวแปรนับจึงกลายเป็นจุดร้อนในการใช้งานรายการทั้งหมด
ตัวอย่างโค้ดต่อไปนี้แสดงปัญหานี้:
คลาสสแตติกสาธารณะ carrepositorywithcounter ใช้ carrepository {แผนที่ส่วนตัว <String, car> car = new hashmap <string, car> (); แผนที่ส่วนตัว <String, Car> Trucks = ใหม่ HashMap <String, Car> (); carcountsync วัตถุส่วนตัว = วัตถุใหม่ (); carcount int ส่วนตัว = 0; โมฆะสาธารณะ addcar (รถยนต์รถยนต์) {ถ้า (car.getLicenceplate (). startswith ("C")) {ซิงโครไนซ์ (รถยนต์) {Car FoundCar = cars.get (car.getLicenceplate ()); if (foundCar == null) {cars.put (car.getLicencePlate (), Car); ซิงโครไนซ์ (carcountsync) {carcount ++; }}}} else {ซิงโครไนซ์ (รถบรรทุก) {Car FoundCar = trucks.get (car.getLicencePlate ()); if (foundCar == null) {trucks.put (car.getLicencePlate (), Car); ซิงโครไนซ์ (carcountsync) {carcount ++; }}}}}} สาธารณะ int getCarcount () {ซิงโครไนซ์ (carcountsync) {return carcount; -การใช้งานด้านข้างต้นของ carrepository มีตัวแปรรายการสองรายการอยู่ภายในหนึ่งใช้ในการวางองค์ประกอบการล้างรถและอีกตัวใช้เพื่อวางองค์ประกอบรถบรรทุก ในขณะเดียวกันก็มีวิธีการสอบถามขนาดรวมของสองรายการนี้ วิธีการเพิ่มประสิทธิภาพที่ใช้คือทุกครั้งที่มีการเพิ่มองค์ประกอบรถยนต์ค่าของตัวแปรการนับภายในจะเพิ่มขึ้น ในเวลาเดียวกันการดำเนินการที่เพิ่มขึ้นจะได้รับการปกป้องโดยการซิงโครไนซ์และสิ่งเดียวกันนั้นเป็นจริงสำหรับการคืนค่าการนับ
เพื่อหลีกเลี่ยงค่าใช้จ่ายในการซิงโครไนซ์รหัสเพิ่มเติมนี้ดูการใช้งานอื่น ๆ ของ carrepository ด้านล่าง: มันไม่ได้ใช้ตัวแปรการนับภายในอีกต่อไป แต่นับค่านี้ในเวลาจริงในวิธีการส่งคืนจำนวนรถยนต์ทั้งหมด ดังนี้:
คลาสคงที่ระดับสาธารณะ carrepositorywithoutcounter ใช้ carrepository {แผนที่ส่วนตัว <String, car> car = new hashmap <string, car> (); แผนที่ส่วนตัว <String, Car> Trucks = ใหม่ HashMap <String, Car> (); โมฆะสาธารณะ addcar (รถยนต์รถยนต์) {ถ้า (car.getLicenceplate (). startswith ("C")) {ซิงโครไนซ์ (รถยนต์) {Car FoundCar = cars.get (car.getLicenceplate ()); if (foundCar == null) {cars.put (car.getLicencePlate (), Car); }}} else {ซิงโครไนซ์ (รถบรรทุก) {Car FoundCar = trucks.get (car.getLicencePlate ()); if (foundCar == null) {trucks.put (car.getLicencePlate (), Car); }}}}} public int getCarcount () {ซิงโครไนซ์ (รถยนต์) {ซิงโครไนซ์ (รถบรรทุก) {return cars.size () + trucks.size (); -ตอนนี้เพียงแค่ในวิธีการ getCarcount () การเข้าถึงของทั้งสองรายการต้องการการป้องกันการซิงโครไนซ์ เช่นเดียวกับการใช้งานก่อนหน้านี้ค่าใช้จ่ายในการซิงโครไนซ์ทุกครั้งที่ไม่มีการเพิ่มองค์ประกอบใหม่อีกต่อไป
1.3.6 หลีกเลี่ยงการใช้แคชวัตถุซ้ำ
ในเวอร์ชันแรกของ Java VM ค่าใช้จ่ายของการใช้คำหลักใหม่เพื่อสร้างวัตถุใหม่นั้นค่อนข้างสูงนักพัฒนาจำนวนมากจึงคุ้นเคยกับการใช้โหมดการใช้วัตถุซ้ำ เพื่อหลีกเลี่ยงการสร้างวัตถุซ้ำแล้วซ้ำอีกครั้งและอีกครั้งนักพัฒนาซอฟต์แวร์รักษาสระบัฟเฟอร์ หลังจากการสร้างอินสแตนซ์ของวัตถุแต่ละครั้งพวกเขาสามารถบันทึกไว้ในพูลบัฟเฟอร์ ครั้งต่อไปเธรดอื่น ๆ ที่จำเป็นต้องใช้พวกเขาสามารถเรียกคืนได้โดยตรงจากพูลบัฟเฟอร์
เมื่อมองแวบแรกวิธีนี้มีเหตุผลมาก แต่รูปแบบนี้อาจทำให้เกิดปัญหาในแอปพลิเคชันแบบมัลติเธรด เนื่องจากพูลบัฟเฟอร์ของวัตถุถูกแชร์ระหว่างหลายเธรดการดำเนินการของเธรดทั้งหมดเมื่อเข้าถึงวัตถุในนั้นจำเป็นต้องมีการป้องกันแบบซิงโครนัส ค่าใช้จ่ายของการซิงโครไนซ์นี้มากกว่าการสร้างวัตถุเอง แน่นอนว่าการสร้างวัตถุจำนวนมากเกินไปจะเพิ่มภาระของการรวบรวมขยะ แต่แม้จะคำนึงถึงสิ่งนี้มันก็ยังดีกว่าที่จะหลีกเลี่ยงการปรับปรุงประสิทธิภาพที่เกิดจากการซิงโครไนซ์รหัสมากกว่าการใช้พูลแคชวัตถุ
รูปแบบการเพิ่มประสิทธิภาพที่อธิบายไว้ในบทความนี้แสดงอีกครั้งว่าวิธีการเพิ่มประสิทธิภาพแต่ละวิธีที่เป็นไปได้จะต้องได้รับการประเมินอย่างรอบคอบเมื่อมีการใช้จริง โซลูชันการเพิ่มประสิทธิภาพที่ยังไม่บรรลุนิติภาวะดูเหมือนจะสมเหตุสมผลบนพื้นผิว แต่ในความเป็นจริงมันมีแนวโน้มที่จะกลายเป็นคอขวดประสิทธิภาพในทางกลับกัน