1. 程式人生 > 其它 >Java學習筆記:2022年1月6日

Java學習筆記:2022年1月6日

Java學習筆記:2022年1月6日

摘要:不可變字串為什麼不可變?StringBuffer類與StringBuilder類,字串操作拾遺,記事本原理,進位制轉化問題。

@

目錄

1.深入探討不可變字串

​ 在1月4日的筆記中,我們詳細介紹了Java語言中的字串型別以及它的特性:不可變,那麼字串為什麼是不可變的呢?在Java中所有的字串都是不可變的嗎?接下來我們將對於這個問題展開深入的探討。

​ 字串為什麼是不可變的?這個問題其實和字串的實現方式有著密不可分的聯絡,字串的基本字元儲存機制是使用的字元陣列,也就是字串的實現使用了陣列,我們都知道,無論是在C語言中還是在Java中,陣列都是長度固定的一種東西,它的長度一旦宣告,就無法變化,在Java中,字元陣列可以通過下標進行原地址上的修改,而在Java中,連原地址上的修改都無法實現,這是為什麼呢?這一切還是要從陣列這個東西說起。

1.陣列的特質

​ 如果你簡要了解過一些計算機底層的知識,就會知道陣列儘管是一種簡單的資料結構,但是它是一種極為重要的資料結構,在作業系統中,存在著很多各種各樣的程式執行棧,管控佇列,進行表之類的東西,在Java執行時中,棧區的執行緒棧,就是使用陣列實現的,為什麼我們不用連結串列實現而是用陣列實現他們呢?原因只有一個:,是的,陣列很快,非常快,對於經常查詢而很少做修改的資料,或者說對於棧和佇列這種沒用從中間插入資料,只在表頭進行資料修改的結構來說,陣列又快又節省空間,是實現這些結構的不二之選。

​ 陣列為什麼快呢?這是因為在陣列中,裡邊所有的元素都是定長的,這意味著如果你能獲得陣列的首地址,使用首地址+下標X元素長度

這個公式就能很快的得到你想要查詢的元素,此所謂隨機存取,這比連結串列的順序搜尋快的多,同時陣列中的元素與元素之間緊緊相鄰,除了這些型別的元素再無其他型別的了,不像連結串列一樣還要儲存其他資訊,因此數字具備這種檢索快,佔用少的特性。而知道陣列的優點之後,我們便可以將陣列這一元素放到C語言中和Java語言中做類比,進而審視陣列在兩種語言中的表現。

2.C語言中的陣列和Java中的陣列

​ 在C語言中,陣列是一種自由的型別,我們可以肆意使用它,以至於讓陣列越界,在C語言中,陣列不是一種安全的型別,它存在越界現象。這個越界對陣列本身其實是沒什麼影響的,頂多就是產生歧義,讓我們誤以為超出陣列範圍之後這個陣列還能用,但是對於其他程式而言,這可能會導致嚴重的後果,因為在計算機中,記憶體上的程式們有可能是相鄰儲存的,也就是說在記憶體上,某個陣列的後邊可能存放的是一個重要的且正在執行的程式,因此輕易的進行陣列越界,首先就會覆蓋掉這個程式的資料,當這個程式需要這些資料時,就會發生缺頁現象,如果這個程式的健壯性不夠強,可能連缺頁都檢測不出來,直接崩潰,如果這個程式是一個系統程式,則會造成更嚴重的影響,如系統崩潰,因此,陣列越界是一個嚴重的不合規範的危險行為。然而,在C語言中,沒有提供任何防止陣列越界的機制,我們只能人為的設定標誌位,人為的去管控這個行為,這就導致了C語言中的陣列並不安全。

​ 陣列固然不太安全,可是它又卻異常好用,特別是在進行查詢遍歷以及儲存的時候,它都比連結串列要好得多,因此在Java語言中,設計者將陣列設計成了一個絕對安全的型別,Java中加入了防越界機制,首先是最基礎的越界報錯,對於基本型別的陣列,我們在宣告好陣列長度之後,就無法更改其長度,沒有辦法在陣列被填滿之後繼續插入資料,這是如果繼續在表尾插入資料,就會報錯。然而字元陣列有著更為嚴格的保護機制,因為字元的空間佔位不是定長的,在眾多Unicode編碼,乃至其他一些編碼中,字元的編碼不是定長的,陣列為它們分配空間時必須要小心謹慎,比如UTF-16的編碼長度有可能是16位有可能是32位,而陣列又要求絕對緻密,這就導致了16位的字元和32位的字元完全是拼在一起的,當然這裡我們不用擔心計算機不能識別,在編碼的解碼程式中提供給了計算機識別不同編碼的能力,因此單純的對於編碼來說,計算機可以識別16位的和32位的混合長度編碼,然而這對於記憶體來說並不是好事,因為計算機對人展示的,是一個一個的字元,簡而言之,就是無論是32位字元還16位字元,對我們來說都是一個字元。因此我們在進行字元操作時,如果想單獨修改字串中的一個字元時,如果我們打算將其從16位字元修改為一個32位字元,那麼問題就大了,它多出去的編碼長度會直接覆蓋下一個位置的字元,導致整個字串出現錯誤,因此單個的字元修改,首先就很危險,與此同時,一些在C語言中很常見的字元擴充套件,也會直接導致字元陣列長度增加,導致記憶體覆蓋問題,在C語言中,這些問題都無法通過自身提供的機制解決,任何草率的操作都會導致整個程式全軍覆沒,但是在Java中,設計者為了完全的杜絕這些情況,設計了不可變字串的機制,至於如何不可變,我想前邊講解的已經夠清楚了,而至於Java中的字串為何不可變,原因就在於此。

3.關於記憶體和硬碟中的儲存機制以及Java陣列在記憶體中的真實狀態

​ 在1月4日的學習中,我在最後探討了關於計算機中硬碟和記憶體的儲存機制,在這裡,我將結合Java陣列再探討一次,進而闡述Java陣列在記憶體中的真實狀態。首先我們定一個基調:在Java中不允許任何陣列在當前地址下進行長度擴充套件,想要長度更長的陣列都必須要重新申請空間。

​ 當前計算機的記憶體和硬碟之中使用的都是分頁機制,分頁機制在作業系統中是重要的一環,也就是說CPU在從硬碟上取資訊時,是一頁一頁的從硬碟拿到記憶體,然後再拿到快取記憶體的,在CPU中都存在快取記憶體,如圖:

​ 仔細觀察的話,我們可以發現兩個快取都是4KB的倍數,儘管這會引起米4達的反感,但這其中也是有原因的:“當前的作業系統中的單頁大小,就是4KB。”這是認為規定的大小,具體數值並沒有什麼道理,只能是說比較符合當下記憶體空間以及硬碟空間中的需求,在前面我們也已經說過,計算機中的儲存單元如果劃分的太小的話會導致CPU效能過剩,等待次數過多,頻繁進行從低速儲存區索取資訊,頻煩缺頁以及定址資訊過多的現象,人們為了合理的解決這個問題,便制定了硬體和軟體的統一規範,在進行實驗之後確定了4KB為當前來說比較合適的頁大小,這使得在硬碟上,即使是一個最小的檔案,它也會佔用4KB的儲存空間,在記憶體上,一個變數,一個型別,也會佔用一個4KB的頁。

​ 因此,陣列自然而然也是最少佔用一個4KB的頁,當然對於記憶體來說,陣列是個大好人,因為它的長度通常來說會比基本變數大很多,因此對於單個頁的使用率也非常高,需注意的是儘管在記憶體中每個基本變數型別都要獨自佔用一頁,陣列中的基本型別因為具備陣列的特性加持,他們可以致密的排布在一個頁中,這也是為什麼陣列儲存效率高而作業系統中都喜歡使用陣列的原因之一。當然,這個陣列特別長的時候,系統會為他分配符合其長度的,最少的頁來儲存他,比如有一個數組連續佔據了7KB的空間,那麼它就是橫亙在這7KB上的一串連續空間,系統這時會為它分配兩個頁,即8KB來儲存他,需要注意的是,系統分配記憶體空間時,是按照變數分配的,而不是按照記憶體單元分配,或者說按照陣列中的基本變數分配。系統在為一個數組分配空間時,是將這個陣列看成一個7KB大的單獨個體,因此這7KB的資訊是緻密的連續的分佈在這8KB上的,也就是這8KB的空間上的前7KB緻密的排布了這些資訊,這8KB確實得到了高效的利用,而不是像儲存單個變數一樣,每個基本變數都佔4KB,在陣列上的單個基本元素,是真的只佔用了它自身真實長度的空間,基本上沒有空間浪費,因此注意這個知識點,系統分配變數是按照變數分配的。這個知識點在之後也會有提及。因此,在Java中,陣列的儲存方式就是在記憶體中以連續的緻密的資訊串的形式存在,他們通常佔用符合他們長度的最少的頁。

​ 因此我們可以得出一個結論:烙餅大不過烙它的餅鐺,字串的長度一定也不會長過分配給它的儲存空間。所以我們不禁會想,在儲存單元長度允許的範圍內,小小的在原地址上修改一下它的長度大小,這多是一件美事啊,然而答案是:這多是一件美逝啊。這種行為是絕對危險的行為,誠然儲存陣列使用的記憶體空間通常會比他大,但是我們可以肯定的是最大也大不過4KB的大小,因此我們如果進行大規模的資料新增,當這個新增行為加入的資料大於4KB時,也是非常危險的,更別說當這個陣列長7.99999KB的時候,我們只要在裡邊進行稍微的新增,就會導致陣列越界。Java希望整個系統絕對安全,因此是絕對不允許這種可能導致陣列越界的行為發生的。因此,在Java中設定了不可變字串的重要機制。

4.String型別的缺點

​ String型別字串的不可變機制確實不錯,非常安全,但這不代表它沒有缺陷,我們在字串的操作中也見識到了,對於字串的修改以及拼接非常麻煩,實際上,它不僅麻煩,還佔用時間。試想一下,我們如果想進行一個字串拼接,系統首先會根據兩個字串拼接的長度重新申請一塊空間,然而再將兩個字串複製在上面,最後刪除原字串,這是一個繁瑣複雜的行為,特別是我們的操作只有拼接並且是頻繁進行拼接時,整個系統的效率會非常慢。因此係統中便引出了其他型別的字串。

2.StringBuffer類和StringBuilder類的出現

​ 為了很好的彌補String在字串拼接上的不足,人們設計了StringBuilder和StringBuffer兩個類,這兩個類專門用於進行字串拼接。接下來我們可以做一個實驗:

public class Tester {//使用字串進行拼接十萬次,檢視所用時間

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		long start = System.currentTimeMillis();
		String a = "";
		for(int i = 0;i<100000;i++) {
			a += i;
		} 
		long end = System.currentTimeMillis();
		System.out.println("總計花費時間: "+(end - start));
	}

}

​ 我計算機上執行得到答案是:

總計花費時間: 6542

​ 接下來我們使用StringBuilder來進行測試:

public class Tester {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		long start = System.currentTimeMillis();
		//String a = "";
		StringBuilder a2 = new StringBuilder();
		for(int i = 0;i<100000;i++) {
			//a += i;
			a2.append(i);
		} 
		long end = System.currentTimeMillis();
		System.out.println("總計花費時間: "+(end - start));
	}

}

​ 我計算機上執行得到的答案是:

總計花費時間: 8

​ 可能你在你的計算機上兩個答案和我的略有差別,但是可以肯定的是,使用String型別進行拼接花費時間要多得多,相差是百倍乃至於千倍。這是為什麼呢,這就是因為二者底層的實現機制不太一樣。同時,需要注意在Java中還存在StringBuffer型別,它是StringBuilder在多執行緒下的安全版本,內部實現原理同StringBuilder一樣,只不過是為了多執行緒下的安全加了鎖,因此相關操作比StringBuilder慢一些。

3.StringBuilder與StringBuffer實現的基本原理

​ 我最初以為二位的實現方法是連結串列,但實際上不是的,他們並沒有使用其他的資料結構,而是使用了新的程式設計理念以及一些比較複雜的操作。兩個類的基本實現仍然是字元陣列,只不過他們中增加了一個數組擴張機制以及初始化機制。

這兩個類在最初進行初始化時,便會申請一個非常大的儲存空間,遠遠大於4KB,這就導致陣列越界很難發生,當然初始值很大不保證他們絕對不越界,因此設計者為它們增加了一個比較有趣的自動擴張機制,當進行字串拼接時,只要當前的空間足夠,就允許在當前地址上修改並進行擴張,當檢測到剩餘記憶體不夠了,就會再次申請一個新的記憶體空間,將原字串複製過去之後,放棄原地址,在新地址上繼續進行字串拼接,這個申請新地址的機制為:申請大小是之前二倍的空間作為新地址。這就導致這個字串越來越大時,它申請的新空間呈指數上升,越來越難以出現越界現象。因此在字串進行拼接時,過程中便很難再出現新的地址不夠現象進而申請新空間,同時也不會像String型別那樣只要進行一次拼接,就發生一次地址申請行為,它可能是在數次拼接之後再進行一次地址申請,這就讓浪費時間的行為大幅度減少,進而提升執行效率,因為申請新地址並回收舊地址是一個很浪費時間的過程,只要有效減少這個行為的發生,就能夠大量提高整體執行速度。

​ 當然這兩個類並不完美,首先當我們的字串很少進行拼接且要求佔用空間較少時,比如儲存姓名資訊之類的短字串,並很少進行修改時,他們的功能會顯得非常多餘且浪費空間,因此,在Java中,很少能絕對的說一個型別是好是壞,只能是說每個型別有著自己合適的應用場景,在特定的應用場景下,某個型別可能表現得很好,當場景變更,它可能不會再是最優選擇。

​ 在很多高階語言中都存在類似這兩個類的型別,因此我們有理由認為,這兩個類的理念並不是Java獨有的,它們是超越語言的程式設計理念,好的程式設計理念往往比熟練使用程式語言更為重要。在這個環節的最後我附上我的筆記草稿,從而在以後能夠從當時學習的思路上找到新的想法,這裡邊的筆記不一定對,因此慎重觀看:

	字串是不可變字串,因此在每次拼接時都要開闢一塊新的空間。
	記憶體的預設儲存單元是一個位元組
	作業系統在讀任何資料之前,首先要把地址的指令發過去,指定在哪讀,然後才能獲得那個地址的資料。這個是根據計算機的儲存器機制決定的,在計算機中,CPU向儲存器傳送一個01程式碼,即地址,然後儲存區反饋出該地址的資訊。
	通常CPU讀取一次資訊的時間是15~29納秒,也就是從記憶體中讀取一個單位用的時間。當單個塊增大時,單元減少,讀寫次數減少,獲取同樣大小的資訊的時間自然而然也會減少。CPU單詞讀寫資料的速度很快,讀了多少時間差異不太大。單個的儲存單元越大,效能越快。
	當單個儲存單元定的比較大的時候,裡邊可能儲存多個數據,可能有資訊找不到。單個儲存越大,空間浪費越嚴重。
	儲存單元越大,效能越快,但是空間浪費越嚴重,儲存單元越小,效能越慢,但是空間利用越充分。
	作業系統對記憶體進行了重新劃分,記憶體和硬碟的單頁大小為4kb,也就是一個儲存單元的大小,硬碟的頁大小是可以變的。記憶體的頁大小通常不能自己改,但會隨著時間的發展而變化。硬碟和記憶體有自己的不同的儲存側重點,記憶體注重速度,硬碟注重儲存質量。現在的單頁已經是4kb了。單頁的基本大小是隨著時代的變化而變化的。
	偏移地址是對儲存單元進行劃分,使得可以進行更加精細的定址。
也就是說,一個檔案的真實大小通常比實際空間小得多,任何一個變數至少消耗4kb。任何一個檔案一個程式也要消耗一個4kb。一個變數實際的儲存空間至少是4kb,這是由於作業系統的特性所導致的。偏移地址我記得是組合語言裡邊的東西,這個是定址用的,以便於在某一塊區進行更加精細的定址。
	陣列比較節省空間,偏移地址對頁內定址是有很大幫助的,在使用陣列時,就會使用偏移地址,這是大量的資料可以被高密度的儲存在一個儲存空間中,這時只需要掌握陣列地址,就可以推算出其他的數字地址,進而進行高效率的高密度儲存。在作業系統中我們通常使用陣列來進行大量實現,以及演算法中用陣列也比較多。
	每次消耗儲存單元都是以4kb為單位。因此在字串拼接的時候,每次的消耗量至少為一個4kb,特別是字串長了之後,每次消耗的資源會更多。
	用String申請的字串本身是可以往後直接填資料的,因為它本身申請的大小就是4kb的大小。它本身佔用的大小就是4kb。java絕對安全,採用了木桶原則,就是不允許往後放,因為怕超出4kb。這意味著我們要進行拼接的話,必須再申請一個新的空間。

	StringBuffer是先申請一個大空間,然後只要原來的空間足夠,就不用重新申請新的地址,這樣一來效率非常高。StringBuilder不是每次迴圈都會申請空間,它每次裡邊都有富裕的空間。這應該也是個側重的事情。
在每次放入的時候都會判斷是否超出了之前申請的空間,空間不夠後才會重新申請,然後申請一個更大的空間,然後複製,但是它申請的頻率會比以前低。
	用字串的話,每次都要申請新的儲存頁,且累計消耗的儲存頁非常多,StringBuffer申請的儲存頁以及消耗的儲存頁非常小,對記憶體的調號非常少,採取這種策略消耗量非常小,因為它一次申請大空間,然後很長一段時間內不會消耗空間,其次這種乘二的空間增幅量非常穩,n+1項大於前n項和,所以越往後加,其容量越趨於巨大,而需要再次申請空間的機會就會變小,這是一種空間申請策略,專用於大規模增幅資料的記憶體申請。
	StringBuffer是基於陣列的,因此其裡邊是個字元陣列,然後它的使用是有偏向性的,僅有在大規模變化字串時才會效率高。所有語言都會支撐StringBuilder和StringBuffer這種好的理念,這種只是一種字串處理策略。
在StringBuffer以及builder中,其內部是基於字元陣列實現的,也就是說,每個新字元不會導致新的4kb申請,他們本身也不會佔據4kb,而是佔據自身的真實大小,如ASCII編碼中他們就真的只佔1b,普通的String型別其實他們本身也是佔據自己真實大小的,然而,只要進行一次宣告,他們就會請求一次4kb的儲存空間,然後進行一次複製,當字元們的大小超越4kb之後,就會每次申請的越來越多,這是一個等差數列和,而StringBuffer是一個等比數列和,最終佔用的數量一樣,但是申請過的空間次數以及大小之和就會小的多,Java的自動垃圾回收機制會回收不用的空間,反覆的申請並放棄空間是一個非常沉重的負擔。這些因素都會導致執行變慢。
	越是高階崗位越不計較語言的型別,學習語言只是學習程式設計理念或者是問題解決理念的手段,語言只是問題解決理念的表示手段。正如每個人都會說話,每個人都會寫字,有人只會罵人,有人可以解決外交問題,有人可以成為作家。

4.字串操作拾遺

1.格式化輸出

​ 如果你學過C語言,那麼一定會對格式化輸出非常熟悉,因為C語言中的printf函式只能支援格式化輸出,它無法進行Java這樣方便的字串拼接,當然格式化輸出不是一無是處,它在批量輸出格式相同的資料時有著重大的意義,因此在Java中也加入了格式化輸出的方法,即format方法。具體使用方法如下:

str=String.format("Hello~~~,%s", "Gentelman!");

​ 即直接呼叫String類中的方法format,其內部格式和C語言中的格式化輸出相當類似,就是使用替換符的方式進行字元替換,一些轉義字元的用法也和C語言中的方法一樣,比如轉義字元:\的各種搭配,這裡不再詳細解釋。關於格式化輸出這裡又一遍整理的比較細緻的博文,大家可以參考java中String的格式化format()方法,以及關於轉義字元比較詳盡的一篇博文java語言中的轉義字元。日後我也會在更加深入的學習中進行整理。

2.獲取當前路徑的方法

​ 使用這個方法可以獲取當前正在執行的程式所在檔案的路徑,在寫專案時經常會用到:

String dir = System.getProperty("user.dir");//返回這句話所在的檔案在計算機中的儲存路徑。

​ 這個dir獲取的就是當前的程式所謂的資料夾的路徑,這個屬於檔案操作,在寫專案中經常會用到,同時也有更多的深入展開,因此在以後我也會有更多的筆記記錄。

5.關於記事本的實現原理

​ 這個問題值得進行更加深入的探討,詳情見連結

6.關於進位制問題

​ 這個問題值得進行更加深入的探討,詳情見連結