[线程安全问题] 多线程到底可能会带来哪些风险?
文章目錄
- 1. 目標
- 2. 什么是線程安全
- 3. 線程安全問題以及造成線程不安全的原因
- 3.1 線程安全問題一: 操作系統的隨機調度
- 3.2 線程安全問題二: 多個線程修改同一個變量
- 3.3 線程安全問題三: 修改操作不是原子性的
- 3.4 線程安全問題四: 內存可見性
- 3.5 線程安全問題五: 指令發生重排序
- 4. 出現上述線程安全問題的解決方法
- 4.1 針對 問題一
- 4.2 針對 問題二
- 4.3 針對 問題三
- 4.3.1 詳解 synchronized 關鍵字
- 4.3.2 使用 synchronized 關鍵字在解決問題三存在的問題
- 4.4 針對 問題四 + 問題五
- 5. 總結
1. 目標
????????本文最終目標是熟練掌握在多線程的情況下, 會出現的安全性問題, 以及為什么會出現這樣的問題, 最后引出對應的解決辦法.
2. 什么是線程安全
????????線程安全就是在多線程的情況下執行代碼的結果如果和預期(在單線程情況下執行的結果)的一樣, 那么就是線程安全. 否則, 就是線程不安全, 這時候就很可能會出現線程安全問題.
3. 線程安全問題以及造成線程不安全的原因
????????在探索線程安全問題之前, 這里先舉出一個線程不安全的例子以及運行結果來引出下文:
//創建兩個線程,讓這兩個線程并發執行一個變量,分別進行自增5w次, 最終預計一共自增10w次 class Counter{//保存計數的變量public int count;public void increase(){count++;} }public class Main {public static void main(String[] args) {Counter counter=new Counter();Thread thread1=new Thread(() -> {for(int i=0;i<50000;i++){counter.increase();}});Thread thread2=new Thread(() -> {for(int i=0;i<50000;i++){counter.increase();}});thread1.start();thread2.start();try {thread1.join();thread2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("count="+counter.count);} }????????運行結果:
????????這里會發現: 我們在運行這段代碼后, 結果都是小于10萬的, 且每次的運行結果都是不一樣的(但是大概率都是在5萬和10萬之間, 具體原因放在后面解釋), 在與我們所預期的完全不一樣, 我們所希望的是使用多線程提高運行效率然后達到目標效果, 而運行出來的結果和10萬顯然差別很大. 這是為什么呢?
3.1 線程安全問題一: 操作系統的隨機調度
????????操作系統的隨機調度(或者說是搶占式執行)是引起線程不安全最根本的原因. 由于線程 thread1 和線程 thread2 是并發的, 所以在操作系統內部并發執行這兩個線程的時候, 可能會發生兩個線程同時讀取內存中同一個數據, 但是最后在兩個線程執行完之后, 內存中保存的只會是后存儲到內存中的值(這只是其中可能會出現的一種情況, 還可能會在一個線程將計算之后的數據存儲到內存之前, 另外一個線程就已經在讀取內存中的數據…), 總之這種操作系統的隨機調度使得在執行線程的先后順序是隨機的, 永遠都猜不到操作系統中下一個指令會執行啥, 非常復雜… 這就是造成線程不安全的原因之一, 也是最根本的原因.
3.2 線程安全問題二: 多個線程修改同一個變量
????????就類似上面那樣, 兩個線程(CPU)來對同一塊(內存)變量進行修改(例子中是進行加法操作)的時候, 就很可能會出現線程安全問題. 注意: 這里有三個關鍵點 — 1. 多個線程 -> 2. 對同一個變量 -> 3. 而且進行的必須是修改操作(單純的讀操作是不會出現線程安全問題的). 這追根到底也是操作系統隨機調度(不確定性)所引起的, 因為如果只是一個線程來修改變量(這樣就等于是之前一直寫的普通代碼, 不需要考慮線程安全問題), 或者是多個線程讀取同一個變量, 又或者多個線程來修改多個變量(這也就相當于每個線程各司其職, 變相的單線程), 都是不會引起線程安全問題的, 而多個線程修改同一個變量的時候, 操作系統對內存進行讀寫的先后順序我們是不知道的, 這也就造成線程不安全的原因之一.
3.3 線程安全問題三: 修改操作不是原子性的
????????在上面代碼中, Counter 類中的 increase() 方法每執行一次 ++ 操作, 操作系統底層都會進行三步操作(三個指令): 1. 將內存中的數據加載到CPU(LOAD); 2. 在CPU中執行加法操作(ADD); 3. 將計算之后的結果存儲到內存中(SAVE). 我們將這三個操作視為是一次修改操作. 原子性在之前MySQL中的事物(事物中最核心的特性)就介紹過, 簡單說, 原子性就是把一些操作視為是一個密不可分的整體.
????????那么, 為什么說修改操作不是原子性的就可能會導致線程不安全呢?
????????其實這也還是操作系統隨機調度所造成的. 上面的三步操作如果是分離開來的話, 如若只是單純的單線程的話, 是不會有任何影響的, 但是示例代碼中是有兩個線程的, 那么在時間層序上看, 這三步操作(三個指令)就很有可能是交錯進行執行的, 這就會導致修改之后的結果是錯誤的. 這也就造成線程不安全的原因之一.
3.4 線程安全問題四: 內存可見性
????????內存可見性問題其實是操作系統對代碼進行優化的時候, 所引發的線程安全問題. 如果在線程中有一些操作是一直在重復做某個工作, 那么這時候操作系統可能會對其進行一個優化, 將內存設為不可見, 而是直接讀取寄存器上的內容, 這可能就會省略了一些重復的計算機指令, 保留下有效的指令. 最經典的就是在單線程的情況下, 我們在執行 ++ 的時候, 需要經歷 LOAD , ADD , SAVE 三個指令才能完成, 但是如果是在執行循環的 ++ 操作的時候, 操作系統就會默認地把一直循環的這三個指令優化成了 LOAD , ADD , ADD , ADD … , ADD , SAVE .也就是說, 把原本的三指令循環優化成了只進行一次 LOAD 和 SAVE , 而在 ADD 上進行了循環, 這樣就可以大大地降低了反復讀寫內存的時間, 提高了計算的效率. 當然, 在這樣的單線程情況下, 是不會出現安全問題的, 結果也都是正確的, 但是如果在多線程情況下的話, 就很可能會出現問題了, 這里舉一個很經典的例子(如下代碼):
public class Main {public static int flag=0;public static void main(String[] args) {Thread thread1=new Thread(() -> {while(flag==0){try {;} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("循環結束");});Thread thread2=new Thread(() -> {Scanner scanner=new Scanner(System.in);System.out.println("請輸入一個整數");flag=scanner.nextInt();});thread1.start();thread2.start();} }????????由于在線程 thread1 中反復對 flag 的值進行判斷, 且判斷都為 true (進入循環體), 這時候, 操作系統在進行優化的時候, 就可能會省略了這一步, 這就導致了如果在線程 thread2 對 flag 值進行修改也不會使在線程 thread1 跳出循環. 這也就造成線程不安全的原因之一.
3.5 線程安全問題五: 指令發生重排序
????????指令重排序問題其實也是操作系統在優化的過程中出現的線程安全問題. 指令重排序其實是操作系統幫我們找了指令執行的邏輯順序的一個最優解, 從而來提高代碼執行的效率. 就比如, 本來有幾個指令可以完成一個事情, 但是當把這幾個指令的順序調換一下, 可能會得到一個更優的解決方案, 這時候, 操作系統就很可能會直接對這些指令進行優化, 來提高代碼執行效率. 當然, 這如果是在單線程的情況下肯定是沒事的, 因為無論指令順序如何調整, 最后執行的結果還是那樣, 只是進行一個優化而已. 但是在多線程的情況下, 指令順序的調整, 是會引起最后執行結果的不一樣, 這也就造成線程不安全的原因之一.
4. 出現上述線程安全問題的解決方法
4.1 針對 問題一
????????針對問題一操作系統的隨機調度, 我們是沒有辦法來進行解決的, 操作系統在多線程里面的這種隨機調度是非常討厭的, 我們能做的只是在必要的時候進行避免, 不能夠也無法對操作系統隨機調度這一特性進行修改.
4.2 針對 問題二
????????針對問題二多個線程修改同一個變量這個問題, 我們其實可以直接通過對編寫代碼的結構進行調整, 不讓多線程修改同一個變量即可.
????????但這里有人問: 如果我一定就要通過多線程來修改同一個變量的話, 有沒有什么解決方法? 你要的答案在下面, 請繼續往下看.
4.3 針對 問題三
????????針對問題三修改操作不是原子性的, 就拿上面第一段代碼來說, 我們的解決辦法是: 將 LOAD ADD SAVE 這三個指令進行加鎖操作(也就是把這三個指令打包在一起, 這樣不就是一個密不可分的整體了嗎, 也就不會出現線程安全問題了).
????????在Java中的加鎖操作是有很多種的, 這里介紹一種最常見的加鎖方法: 使用 synchronized 關鍵字.
4.3.1 詳解 synchronized 關鍵字
????????這里的 synchronized 關鍵字有"同步"的意思, 當然, 這里的"同步"不是IO場景下或者上下級調用場景下的"同步"和"異步". 這里"同步"的意思是"互斥", 也就是說, 如果給一個方法加上 synchronized 關鍵字, 那么就相當于給這個方法上了鎖, 在一個線程中調用這個方法的時候, 由于加了鎖, 所以這是其他線程如果想要再調用這個方法的時候, 就需要進行阻塞等待, 直到方法調用結束鎖解開的時候, 才可以被其他線程所調用, 這樣就形成了"互斥"的效果. 當然, "同步"還有其他的意思, 這里做一個補充點: 在IO場景下或者上下級調用場景下, "同步"表示的是調用者自己來負責獲取到調用結果的操作; "異步"表示的是調用者自己不負責獲取調用結果, 而是由被調用者把計算好的結果主動推送給調用者的操作.
????????使用 synchronized 關鍵字加鎖的兩種情況:
????????1. 給對象加鎖. 在給對象加鎖的時候, 有兩種寫法:
????????(1) 直接修飾普通方法, 示例代碼如下:
????????(2) 使用指定 this 的修飾代碼塊, 示例代碼如下:
public class SynchronizedDemo {public void method() {synchronized (this) {...}} }????????注意: 上面的這兩種只是寫法不同, 最終的效果是相同的, 所以這兩種寫法是對等的.
????????2. 給類對象加鎖. 在給類對象加鎖的時候, 也有兩種寫法:
????????(1) 直接修飾靜態方法, 示例代碼如下:
????????(2) 使用指定類.class 的修飾代碼塊, 示例如下:
public class SynchronizedDemo { public void method() {synchronized (SynchronizedDemo.class) {...}} }????????注意: (1) 上面的這兩種只是寫法不同, 最終的效果是相同的, 所以這兩種寫法是對等的. (2) 第二種寫法指定的類.class 不一定是要使用本類(該方法對應的類), 也可以是其他類.
????????在使用 synchronized 關鍵字進行加鎖的時候, 我們只需要認準: 兩個線程一定需要是同一把鎖, 才會發生阻塞等待的情況, 否則是不會發生阻塞等待的, 因為它們并沒有指向同一把鎖(也就可以理解為這兩個線程之間不是原子性的). 那么, 我們又該如何判斷兩個線程之間是否指向同一把鎖呢?
????????情況一: 給對象加鎖. 如果兩個線程中所使用的是同一個對象中的加鎖方法, 那么后執行的線程就會發生阻塞等待的情況. 但是如果兩個線程中使用的是不同對象的加鎖方法, 那么這樣就不會發生阻塞等待了. 舉個例子:
運行結果:
????????盡管有 synchronized 修飾, 但是由于兩個線程中所分別使用的 a1 和 a2 是兩個不同的對象, 所以它們是不會發生阻塞等待的.
class A{synchronized public void m1(String a){System.out.println(a+"開始m1");try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(a+"結束m1");}synchronized public void m2(String a){System.out.println(a+"開始m2");try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(a+"結束m2");} }public class Main {public static void main(String[] args) {A a1=new A();A a2=new A();Thread thread1=new Thread(() -> {a1.m1("線程1");});Thread thread2=new Thread(() -> {a1.m2("線程2");});thread1.start();thread2.start();} }運行結果:
????????由于兩個線程中使用的都是同一個對象中加鎖的方法, 所以就會發生阻塞等待了, 類似這樣的操作就是線程安全了.
????????情況二: 給類對象加鎖. 如果兩個線程中調用的方法的鎖是指向同一個類的, 那么盡管它們所使用的不是同一個對象, 也是會出現阻塞等待(也就是線程安全). 所以可以說, 給類對象加鎖重點就在于看看不同線程之間調用方法的鎖是否指向的是同一個類.
class A{synchronized public static void m1(String a){System.out.println(a+"開始m1");try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(a+"結束m1");}synchronized public static void m2(String a){System.out.println(a+"開始m2");try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(a+"結束m2");} }public class TestDemo4 {public static void main(String[] args) {Thread thread1=new Thread(() -> {A.m1("線程1");});Thread thread2=new Thread(() -> {A.m2("線程2");});thread1.start();thread2.start();} }運行結果:
????????當然, 給類對象加鎖也可以是針對不同類, 兩個線程之間也是可以發生阻塞等待的. 再舉個例子:
class B{public void m(String a){synchronized (B.class){System.out.println(a+"開始");try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(a+"結束");}} }class C{public void m(String a){synchronized (B.class){System.out.println(a+"開始");try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(a+"結束");}} }public class TestDemo5 {public static void main(String[] args) {B b=new B();C c=new C();Thread thread1=new Thread(() -> {b.m("線程1");});Thread thread2=new Thread(() -> {c.m("線程2");});thread1.start();thread2.start();} }運行結果:
4.3.2 使用 synchronized 關鍵字在解決問題三存在的問題
????????通過上面對 synchronized 關鍵字的了解后, 解決問題三修改操作不是原子性的就會變得非常簡單了, 直接在執行 ++ 操作的方法加上 synchronized 關鍵字即可. 具體代碼如下:
public class TestDemo {static class Counter{public int count;synchronized public void crease(){count++;}}public static void main(String[] args) {Counter counter=new Counter();Thread thread1=new Thread(() -> {for(int i=0;i<10000;i++){counter.crease();}});Thread thread2=new Thread(() -> {for(int i=0;i<10000;i++){counter.crease();}});thread1.start();thread2.start();try {thread1.join();thread2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("count="+counter.count);} }4.4 針對 問題四 + 問題五
????????針對問題四內存可見性和問題五指令發生重排序, 其實他們都是操作系統對代碼進行優化之后執行多線程所會出現的問題, 就如上面問題四的那段代碼. 那么面對這個問題, 我們又該如何解決呢? 在多線程的時候, 我們可以使用 volatile 關鍵字來阻止(禁止)操作系統對代碼進行優化的操作(也就是讓內存由不可見變為可見的以及不讓指令發生重排序), 這個關鍵字其實是給說加上的變量加上一段特殊的二進制指令 — “內存保障”. 所以修改之后的代碼就可以是:
public class Main {volatile public static int flag=0;public static void main(String[] args) {Thread thread1=new Thread(() -> {while(flag==0){try {;} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("循環結束");});Thread thread2=new Thread(() -> {Scanner scanner=new Scanner(System.in);System.out.println("請輸入一個整數");flag=scanner.nextInt();});thread1.start();thread2.start();} }????????加上 volatile 之后, 就不會進行優化操作, 運行的結果也就正確了. 這是一種方法, 這里還有一種方法是不用 volatile 關鍵字也不會進行優化的操作, 那就是在這段代碼線程 thread1 中的循環體里面加上 sleep 方法來對代碼起到一個阻塞的作用, 這時候會讓代碼循環轉速變慢了一些, 讀寫內存這個操作也就不會變得那么頻繁, 也就不會觸發代碼優化了(但是這里還是建議: 盡量加上 volatile 關鍵字, 以免可能出現一些優化對代碼邏輯造成不必要的影響). 具體代碼如下:
public class Main {public static int flag=0;public static void main(String[] args) {Thread thread1=new Thread(() -> {while(flag==0){try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("循環結束");});Thread thread2=new Thread(() -> {Scanner scanner=new Scanner(System.in);System.out.println("請輸入一個整數");flag=scanner.nextInt();});thread1.start();thread2.start();} }????????volatile關鍵字的作用主要有兩個:
????????(1) 保證內存可見性: 基于屏障指令實現, 即當一個線程修改一個共享變量時, 另外一個線程能讀到這個修改的值.
????????(2) 保證有序性: 禁止指令重排序. 編譯時 JVM 編譯器遵循內存屏障的約束, 運行時靠屏障指令組織指令順序.
????????還有一點要注意: volatile 不能保證原子性的.
????????說到優化問題, 這里簡單談談上面優化的過程, 在Java中也叫作"JMM(Java Memory Model)".
????????優化之后出現問題的原因就是: 線程優化之后, 主要在操作工作內存, 沒有及時讀取主內存, 從而導致出現了誤判的現象. 其中, 這里的工作內存指的是CPU的寄存器(可能包括CPU緩存); 這里的主內存才是計算機中所說的真正的內存. 所以, 我們也可以把上面這段話簡化成: 線程優化之后, 主要在操作CPU, 沒有及時讀取內存, 從而導致出現了誤判的現象.
5. 總結
????????前面MySQL文章中所講到的事物和多線程很相似, 其實從某種現象上說, 事物可以是多線程的一個簡化版本, 它們都是在執行并發過程中會出現的某些問題, 并且在解決這些問題之后, 都會使代碼的準確性(或隔離性)提高, 但是卻會犧牲掉一部分運行效率.
????????但是話又說回來, 總而言之, 多線程的確會帶來一些風險, 對此我們在寫代碼的時候更要膽大心細, 沉著應對, 減少因為執行多線程而出現bug的情況.
總結
以上是生活随笔為你收集整理的[线程安全问题] 多线程到底可能会带来哪些风险?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: android 最新微信红包,GitHu
- 下一篇: 企业微信再进化:打通视频号上线微信客服,