1. 程式人生 > 其它 >如果你這樣回答“什麼是執行緒安全”,面試官都會對你刮目相看

如果你這樣回答“什麼是執行緒安全”,面試官都會對你刮目相看

  不是執行緒的安全

  面試官問:“什麼是執行緒安全”,如果你不能很好的回答,那就請往下看吧。

  論語中有句話叫“學而優則仕”,相信很多人都覺得是“學習好了可以做官”。然而,這樣理解卻是錯的。切記望文生義。

  同理,“執行緒安全”也不是指執行緒的安全,而是指記憶體的安全。為什麼如此說呢?這和作業系統有關。

  目前主流作業系統都是多工的,即多個程序同時執行。為了保證安全,每個程序只能訪問分配給自己的記憶體空間,而不能訪問別的程序的,這是由作業系統保障的。

  在每個程序的記憶體空間中都會有一塊特殊的公共區域,通常稱為堆(記憶體)。程序內的所有執行緒都可以訪問到該區域,這就是造成問題的潛在原因。

  假設某個執行緒把資料處理到一半,覺得很累,就去休息了一會,回來準備接著處理,卻發現數據已經被修改了,不是自己離開時的樣子了。可能被其它執行緒修改了。

  比如把你住的小區看作一個程序,小區裡的道路/綠化等就屬於公共區域。你拿1萬塊錢往地上一扔,就回家睡覺去了。睡醒後你打算去把它撿回來,發現錢已經不見了。可能被別人拿走了。

  因為公共區域人來人往,你放的東西在沒有看管措施時,一定是不安全的。記憶體中的情況亦然如此。

  所以執行緒安全指的是,在堆記憶體中的資料由於可以被任何執行緒訪問到,在沒有限制的情況下存在被意外修改的風險。

  即堆記憶體空間在沒有保護機制的情況下,對多執行緒來說是不安全的地方,因為你放進去的資料,可能被別的執行緒“破壞”。

  那我們該怎麼辦呢?解決問題的過程其實就是一個取捨的過程,不同的解決方案有不同的側重點。

  私有的東西就不該讓別人知道

  現實中很多人都會把1萬塊錢藏著掖著,不讓無關的人知道,所以根本不可能扔到大馬路上。因為這錢是你的私有物品。

  在程式中也是這樣的,所以作業系統會為每個執行緒分配屬於它自己的記憶體空間,通常稱為棧記憶體,其它執行緒無權訪問。這也是由作業系統保障的。

  如果一些資料只有某個執行緒會使用,其它執行緒不能操作也不需要操作,這些資料就可以放入執行緒的棧記憶體中。較為常見的就是區域性變數。

  double avgScore(double[] scores) {

  double sum=0;

  for (double score : scores) {

  sum +=score;

  }

  int count=scores.length;

  double avg=sum / count;

  return avg;

  }

  這裡的變數sum,count,avg都是區域性變數,它們都會被分配線上程棧記憶體中。

  假如現在A執行緒來執行這個方法,這些變數會在A的棧記憶體分配。與此同時,B執行緒也來執行這個方法,這些變數也會在B的棧記憶體中分配。

  也就是說這些區域性變數會在每個執行緒的棧記憶體中都分配一份。由於執行緒的棧記憶體只能自己訪問,所以棧記憶體中的變數只屬於自己,其它執行緒根本就不知道。

  就像每個人的家只屬於自己,其他人不能進來。所以你把1萬塊錢放到家裡,其他人是不會知道的。且一般還會放到某個房間裡,而不是仍在客廳的桌子上。

  所以把自己的東西放到自己的私人地盤,是安全的,因為其他人無法知道。而且越隱私的地方越好。

  大家不要搶,人人有份

  相信聰明的你已經發現,上面的解決方案是基於“位置”的。因為你放東西的“位置”只有你自己知道(或能到達),所以東西是安全的,因此這份安全是由“位置”來保障的。

  在程式裡就對應於方法的區域性變數。區域性變數之所以是安全的,就是因為定義它的“位置”是在方法裡。這樣一來安全是達到了,但是它的使用範圍也就被限制在這個方法裡了,其它方法想用也不用了啦。

  現實中往往會有一個變數需要多個方法都能夠使用的情況,此時定義這個變數的“位置”就不能在方法裡面了,而應該在方法外面。即從(方法的)區域性變數變為(類的)成員變數,其實就是“位置”發生了變化。

  那麼按照主流程式語言的規定,類的成員變數不能再分配線上程的棧記憶體中,而應該分配在公共的堆記憶體中。其實也就是變數在記憶體中的“位置”發生了變化,由一個私有區域來到了公共區域。因此潛在的安全風險也隨之而來。

  那怎麼保證在公共區域的東西安全呢?答案就是,大家不要搶,人人有份。設想你在街頭免費發放礦泉水,來了1萬人,你卻只有1千瓶水,結果可想而知,一擁而上,場面失守。但如果你有10萬瓶水,大家一看,水多著呢,不用著急,一個個排著隊來,因為肯定會領到。

  東西多了,自然就不值錢了,從另一個角度來說,也就安全了。大街上的共享單車,現在都很安全,因為太多了,到處都是,都長得一樣,所以連搞破壞的人都放棄了。因此要讓一個東西安全,就瘋狂的copy它吧。

  回到程式裡,要讓公共區域堆記憶體中的資料對於每個執行緒都是安全的,那就每個執行緒都拷貝它一份,每個執行緒只處理自己的這一份拷貝而不去影響別的執行緒的,這不就安全了嘛。相信你已經猜到了,我要表達的就是ThreadLocal類了。

  class StudentAssistant {

  ThreadLocal realName=new ThreadLocal<>();

  ThreadLocal totalScore=new ThreadLocal<>();

  String determineDegree() {

  double score=totalScore.get();

  if (score >=90) {

  return "A";

  }

  if (score >=80) {

  return "B";

  }

  if (score >=70) {

  return "C";

  }

  if (score >=60) {

  return "D";

  }

  return "E";

  }

  double determineOptionalcourseScore() {

  double score=totalScore.get();

  if (score >=90) {

  return 10;

  }

  if (score >=80) {

  return 20;

  }

  if (score >=70) {

  return 30;

  }

  if (score >=60) {

  return 40;

  }

  return 60;

  }

  }

  這個學生助手類有兩個成員變數,realName和totalScore,都是ThreadLocal型別的。每個執行緒在執行時都會拷貝一份儲存到自己的本地。

  A執行緒執行的是“張三”和“90”,那麼這兩個資料“張三”和“90”是儲存到A執行緒物件(Thread類的例項物件)的成員變數裡去了。假設此時B執行緒也在執行,是“李四”和“85”,那麼“李四”和“85”這兩個資料是儲存到了B執行緒物件(Thread類的例項物件)的成員變數裡去了。

  執行緒類(Thread)有一個成員變數,類似於Map型別的,專門用於儲存ThreadLocal型別的資料。從邏輯從屬關係來講,這些ThreadLocal資料是屬於Thread類的成員變數級別的。從所在“位置”的角度來講,這些ThreadLocal資料是分配在公共區域的堆記憶體中的。

  說的直白一些,就是把堆記憶體中的一個數據複製N份,每個執行緒認領1份,同時規定好,每個執行緒只能玩自己的那份,不準影響別人的。

  需要說明的是這N份資料都還是儲存在公共區域堆記憶體裡的,經常聽到的“執行緒本地”,是從邏輯從屬關係上來講的,這些資料和執行緒一一對應,彷彿成了執行緒自己“領地”的東西了。其實從資料所在“位置”的角度來講,它們都位於公共的堆記憶體中,只不過被執行緒認領了而已。這一點我要特地強調一下。

  其實就像大街上的共享單車。原來只有1輛,大家搶著騎,老出問題。現在從這1輛複製出N輛,每人1輛,各騎各的,問題得解。共享單車就是資料,你就是執行緒。騎行期間,這輛單車從邏輯上來講是屬於你的,從所在位置上來講還是在大街上這個公共區域的,因為你發現每個小區大門口都貼著“共享單車,禁止入門”。哈哈哈哈。

  共享單車是不是和ThreadLocal很像呀。再重申一遍,ThreadLocal就是,把一個數據複製N份,每個執行緒認領一份,各玩各的,互不影響。

  只能看,不能摸

  放在公共區域的東西,只是存在潛在的安全風險,並不是說一定就不安全。有些東西雖然也在公共區域放著,但也是十分安全的。比如你在大街上放一個上百噸的石頭雕像,就非常安全,因為大家都弄不動它。

  再比如你去旅遊時,經常發現一些珍貴的東西,會被用鐵柵欄圍起來,上面掛一個牌子,寫著“只能看,不能摸”。當然可以國際化一點,“only look,don't touch”。這也是很安全的,因為光看幾眼是不可能看壞的。

  回到程式裡,這種情況就屬於,只能讀取,不能修改。其實就是常量或只讀變數,它們對於多執行緒是安全的,想改也改不了。

  class StudentAssistant {

  final double passScore=60;

  }

  比如把及格分數設定為60分,在前面加上一個final,這樣所有執行緒都動不了它了。這就很安全了。

  小節一下:以上三種解決方案,其實都是在“耍花招”。

  第一種,找個只有自己知道的地方藏起來,當然安全了。

  第二種,每人複製1份,各玩各的,互不影響,當然也安全了。

  第三種,更狠了,直接規定,只能讀取,禁止修改,當然也安全了。

  是不是都在“避重就輕”呀。如果這三種方法都解決不了,該怎麼辦呢?Don't worry,just continue reading。

  沒有規則,那就先入為主

  前面給出的三種方案,有點“理想化”了。現實中的情況其實是非常混亂嘈雜的,沒有規則的。

  比如在中午高峰期你去飯店吃飯,進門後發現只剩一個空桌子了,你心想先去點餐吧,回來就坐這裡吧。當你點完餐回來後,發現已經被別人捷足先登了。

  因為桌子是屬於公共區域的物品,任何人都可以坐,那就只能誰先搶到誰坐。雖然你在人群中曾多看了它一眼,但它並不會記住你容顏。

  解決方法就不用我說了吧,讓一個人在那兒看著座位,其它人去點餐。這樣當別人再來的時候,你就可以理直氣壯的說,“不好意思,這個座位,我,已經佔了”。

  我再次相信聰明的你已經猜到了我要說的東西了,沒錯,就是(互斥)鎖。

  回到程式裡,如果公共區域(堆記憶體)的資料,要被多個執行緒操作時,為了確保資料的安全(或一致)性,需要在資料旁邊放一把鎖,要想操作資料,先獲取鎖再說吧。

  假設一個執行緒來到資料跟前一看,發現鎖是空閒的,沒有人持有。於是它就拿到了這把鎖,然後開始操作資料,幹了一會活,累了,就去休息了。

  這時,又來了一個執行緒,發現鎖被別人持有著,按照規定,它不能操作資料,因為它無法得到這把鎖。當然,它可以選擇等待,或放棄,轉而去幹別的。

  第一個執行緒之所以敢大膽的去睡覺,就是因為它手裡拿著鎖呢,其它執行緒是不可能操作資料的。當它回來後繼續把資料操作完,就可以把鎖給釋放了。鎖再次回到空閒狀態,其它執行緒就可以來搶這把鎖了。還是誰先搶到鎖誰操作資料。

  class ClassAssistant {

  double totalScore=60;

  final Lock lock=new Lock();

  void addScore(double score) {

  lock.obtain();

  totalScore +=score;

  lock.release();

  }

  void subScore(double score) {

  lock.obtain();

  totalScore -=score;

  lock.release();

  }

  }

  假定一個班級的初始分數是60分,這個班級抽出10名學生來同時參加10個不同的答題節目,每個學生答對一次為班級加上5分,答錯一次減去5分。因為10個學生一起進行,所以這一定是一個併發情形。

  因此加分和減分這兩個方法被併發的呼叫,它們共同操作總分數。為了保證資料的一致性,需要在每次操作前先獲取鎖,操作完成後再釋放鎖。

  相信世界充滿愛,即使被傷害

  再回到一開始的例子,假如你往地上仍1萬塊錢,是不是一定會丟呢?這要看情況了,如果是在人來人往的都市,可以說肯定會丟的。如果你跑到無人區扔地上,可以說肯定不會丟。

  可以看到,都是把東西無保護的放到公共區域裡,結果卻相差很大。這說明安全問題還和公共區域的環境狀況有關係。

  比如我把資料放到公共區域的堆記憶體中,但是始終都只會有1個執行緒,也就是單執行緒模型,那這資料肯定是安全的。

  再者說,2個執行緒操作同一個資料和200個執行緒操作同一個資料,這個資料的安全概率是完全不一樣的。肯定執行緒越多資料不安全的概率越大,執行緒越少資料不安全的概率越小。取個極限情況,那就是隻有1個執行緒,那不安全概率就是0,也就是安全的。

  可能你又猜到了我想表達的內容了,沒錯,就是CAS。可能大家覺得既然鎖可以解決問題,那就用鎖得了,為啥又冒出了個CAS呢?

  那是因為鎖的獲取和釋放是要花費一定代價的,如果線上程數目特別少的時候,可能根本就不會有別的執行緒來操作資料,此時你還要獲取鎖和釋放鎖,可以說是一種浪費。

  針對這種“地廣人稀”的情況,專門提出了一種方法,叫CAS(Compare And Swap)。就是在併發很小的情況下,資料被意外修改的概率很低,但是又存在這種可能性,此時就用CAS。

  假如一個執行緒操作資料,幹了一半活,累了,想要去休息。(貌似今天的執行緒體質都不太好)。於是它記錄下當前資料的狀態(就是資料的值),回家睡覺了。

  醒來後打算繼續接著幹活,但是又擔心資料可能被修改了,於是就把睡覺前儲存的資料狀態拿出來和現在的資料狀態比較一下,如果一樣,說明自己在睡覺期間,資料沒有被人動過(當然也有可能是先被改成了其它,然後又改回來了,這就是ABA問題了),那就接著繼續幹。如果不一樣,說明資料已經被修改了,那之前做的那些操作其實都白瞎了,就乾脆放棄,從頭再重新開始處理一遍。

  所以CAS這種方式適用於併發量不高的情況,也就是資料被意外修改的可能性較小的情況。如果併發量很高的話,你的資料一定會被修改,每次都要放棄,然後從頭再來,這樣反而花費的代價更大了,還不如直接加鎖呢。

  這裡再解釋下ABA問題,假如你睡覺前資料是5,醒來後資料還是5,並不能肯定資料沒有被修改過。可能資料先被修改成8然後又改回到5,只是你不知道罷了。對於這個問題,其實也很好解決,再加一個版本號欄位就行了,並規定只要修改資料,必須使版本號加1。

  這樣你睡覺前資料是5版本號是0,醒來後資料是5版本號是0,表明資料沒有被修改。如果資料是5版本號是2,表明資料被改動了2次,先改為其它,然後又改回到5。

  我再次相信聰明的你已經發現了,這裡的CAS其實就是樂觀鎖,上一種方案裡的獲取鎖和釋放鎖其實就是悲觀鎖。樂觀鎖持樂觀態度,就是假設我的資料不會被意外修改,如果修改了,就放棄,從頭再來。悲觀鎖持悲觀態度,就是假設我的資料一定會被意外修改,那乾脆直接加鎖得了。

  作者觀點:

  前兩種屬於隔離法,一個是位置隔離,一個是資料隔離。

  然後兩種是標記法,一個是隻讀標記,一個是加鎖標記。

  最後一種是大膽法,先來懟一把試試,若不行從頭再來。

  對於大膽法,還是有必要嘗試的。有人曾說過,“夢想還是要有的,萬一實現了呢”。