好文章來自:https://www.cnblogs.com/lixinjie/p/a-answer-about-thread-safety-in-a-interview.html#4279679javascript
不是線程的安全
面試官問:「什麼是線程安全」,若是你不能很好的回答,那就請往下看吧。
論語中有句話叫「學而優則仕」,相信不少人都以爲是「學習好了能夠作官」。然而,這樣理解倒是錯的。切記望文生義。
同理,「線程安全」也不是指線程的安全,而是指內存的安全。爲何如此說呢?這和操做系統有關。
目前主流操做系統都是多任務的,即多個進程同時運行。爲了保證安全,每一個進程只能訪問分配給本身的內存空間,而不能訪問別的進程的,這是由操做系統保障的。
在每一個進程的內存空間中都會有一塊特殊的公共區域,一般稱爲堆(內存)。進程內的全部線程均可以訪問到該區域,這就是形成問題的潛在緣由。
假設某個線程把數據處理到一半,以爲很累,就去休息了一會,回來準備接着處理,卻發現數據已經被修改了,不是本身離開時的樣子了。可能被其它線程修改了。
好比把你住的小區看做一個進程,小區裏的道路/綠化等就屬於公共區域。你拿1萬塊錢往地上一扔,就回家睡覺去了。睡醒後你打算去把它撿回來,發現錢已經不見了。可能被別人拿走了。
由於公共區域人來人往,你放的東西在沒有看管措施時,必定是不安全的。內存中的狀況亦然如此。
因此線程安全指的是,在堆內存中的數據因爲能夠被任何線程訪問到,在沒有限制的狀況下存在被意外修改的風險。
即堆內存空間在沒有保護機制的狀況下,對多線程來講是不安全的地方,由於你放進去的數據,可能被別的線程「破壞」。
那咱們該怎麼辦呢?解決問題的過程其實就是一個取捨的過程,不一樣的解決方案有不一樣的側重點。
私有的東西就不應讓別人知道
現實中不少人都會把1萬塊錢藏着掖着,不讓無關的人知道,因此根本不可能扔到大馬路上。由於這錢是你的私有物品。
在程序中也是這樣的,因此操做系統會爲每一個線程分配屬於它本身的內存空間,一般稱爲棧內存,其它線程無權訪問。這也是由操做系統保障的。
若是一些數據只有某個線程會使用,其它線程不能操做也不須要操做,這些數據就能夠放入線程的棧內存中。較爲常見的就是局部變量。
html
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類了。
java
class StudentAssistant {
ThreadLocal<String> realName = new ThreadLocal<>(); ThreadLocal<Double> 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」。這也是很安全的,由於光看幾眼是不可能看壞的。
回到程序裏,這種狀況就屬於,只能讀取,不能修改。其實就是常量或只讀變量,它們對於多線程是安全的,想改也改不了。
nginx
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其實就是樂觀鎖,上一種方案裏的獲取鎖和釋放鎖其實就是悲觀鎖。樂觀鎖持樂觀態度,就是假設個人數據不會被意外修改,若是修改了,就放棄,從頭再來。悲觀鎖持悲觀態度,就是假設個人數據必定會被意外修改,那乾脆直接加鎖得了。
做者觀點:sql
前兩種屬於隔離法,一個是位置隔離,一個是數據隔離。
而後兩種是標記法,一個是隻讀標記,一個是加鎖標記。
最後一種是大膽法,先來懟一把試試,若不行從頭再來。編程