最近,一直有小夥伴讓我整理下關於JVM的知識,經過十幾天的收集與整理,初版算是整理出來了。希望對大家有所幫助。
JDK 是用於支援 Java 程式開發的最小環境。
JRE 是支援 Java 程式執行的標準環境。
程式計數器(Program Counter Register)是一塊較小的記憶體空間,可以看作是當前執行緒所執行位元組碼的行號指示器。分支、迴圈、跳轉、例外處理、執行緒恢復等基礎功能都需要依賴這個計數器完成。
由於 Java 虛擬機器器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式實現的。為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要一個獨立的程式計數器,各執行緒之間的計數器互不影響,獨立儲存。
程式計數器是唯一一個沒有規定任何 OutOfMemoryError 的區域。
Java 虛擬機器器棧(Java Virtual Machine Stacks)是執行緒私有的,生命週期與執行緒相同。
虛擬機器器棧描述的是 Java 方法執行的記憶體模型:每個方法被執行的時候都會建立一個棧幀(Stack Frame),儲存
每一個方法被呼叫到執行完成的過程,就對應著一個棧幀在虛擬機器器棧中從入棧到出棧的過程。
這個區域有兩種異常情況:
虛擬機器器棧為虛擬機器器執行 Java 方法(位元組碼)服務。
本地方法棧(Native Method Stacks)為虛擬機器器使用到的 Native 方法服務。
Java 堆(Java Heap)是 Java 虛擬機器器中記憶體最大的一塊。Java 堆在虛擬機器器啟動時建立,被所有執行緒共用。
作用:存放物件範例。垃圾收集器主要管理的就是 Java 堆。Java 堆在物理上可以不連續,只要邏輯上連續即可。
方法區(Method Area)被所有執行緒共用,用於儲存已被虛擬機器器載入的類資訊、常數、靜態變數、即時編譯器編譯後的程式碼等資料。
和 Java 堆一樣,不需要連續的記憶體,可以選擇固定的大小,更可以選擇不實現垃圾收集。
執行時常數池(Runtime Constant Pool)是方法區的一部分。儲存 Class 檔案中的符號參照、翻譯出來的直接參照。執行時常數池可以在執行期間將新的常數放入池中。
Object obj = new Object();
對於上述最簡單的存取,也會涉及到 Java 棧、Java 堆、方法區這三個最重要記憶體區域。
Object obj
如果出現在方法體中,則上述程式碼會反映到 Java 棧的本地變數表中,作為 reference 型別資料出現。
new Object()
反映到 Java 堆中,形成一塊儲存了 Object 型別所有物件範例資料值的記憶體。Java堆中還包含物件型別資料的地址資訊,這些型別資料儲存在方法區中。
給物件新增一個參照計數器,每當有一個地方參照它,計數器就+1,;當參照失效時,計數器就-1;任何時刻計數器都為0的物件就是不能再被使用的。
很難解決物件之間的迴圈參照問題。
通過一系列的名為「GC Roots」的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為參照鏈(Reference Chain),當一個物件到 GC Roots 沒有任何參照鏈相連(用圖論的話來說就是從 GC Roots 到這個物件不可達)時,則證明此物件是不可用的。
在 JDK 1.2 之後,Java 對參照的概念進行了擴充,將參照分為
Object obj = new Object();
程式碼中普遍存在的,像上述的參照。只要強參照還在,垃圾收集器永遠不會回收掉被參照的物件。
用來描述一些還有用,但並非必須的物件。軟參照所關聯的物件,有在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍,並進行第二次回收。如果這次回收還是沒有足夠的記憶體,才會丟擲記憶體異常。提供了 SoftReference 類實現軟參照。
描述非必須的物件,強度比軟參照更弱一些,被弱參照關聯的物件,只能生存到下一次垃圾收集發生前。當垃圾收集器工作時,無論當前記憶體是否足夠,都會回收掉只被弱參照關聯的物件。提供了 WeakReference 類來實現弱參照。
一個物件是否有虛參照,完全不會對其生存時間夠成影響,也無法通過虛參照來取得一個物件範例。為一個物件關聯虛參照的唯一目的,就是希望在這個物件被收集器回收時,收到一個系統通知。提供了 PhantomReference 類來實現虛參照。
分為標記和清除兩個階段。首先標記出所有需要回收的物件,在標記完成後統一回收被標記的物件。
效率問題:標記和清除過程的效率都不高。
空間問題:標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能導致,程式分配較大物件時無法找到足夠的連續記憶體,不得不提前出發另一次垃圾收集動作。
將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中一塊。當這一塊的記憶體用完了,就將存活著的物件複製到另一塊上面,然後再把已經使用過的記憶體空間一次清理掉。
複製演演算法使得每次都是針對其中的一塊進行記憶體回收,記憶體分配時也不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。
將記憶體縮小為原來的一半。在物件存活率較高時,需要執行較多的複製操作,效率會變低。
商業的虛擬機器器都採用複製演演算法來回收新生代。因為新生代中的物件容易死亡,所以並不需要按照1:1的比例劃分記憶體空間,而是將記憶體分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間。每次使用 Eden 和其中的一塊 Survivor。
當回收時,將 Eden 和 Survivor 中還存活的物件一次性拷貝到另外一塊 Survivor 空間上,最後清理掉 Eden 和剛才用過的 Survivor 空間。Hotspot 虛擬機器器預設 Eden 和 Survivor 的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80% + 10%),只有10%的記憶體是會被「浪費」的。
標記過程仍然與「標記-清除」演演算法一樣,但不是直接對可回收物件進行清理,而是讓所有存活的物件向一端移動,然後直接清理掉邊界以外的記憶體。
根據物件的存活週期,將記憶體劃分為幾塊。一般是把 Java 堆分為新生代和老年代,這樣就可以根據各個年代的特點,採用最適當的收集演演算法。
Minor GC:新生代 GC,指發生在新生代的垃圾收集動作,因為 Java 物件大多死亡頻繁,所以 Minor GC 非常頻繁,一般回收速度較快。
Full GC:老年代 GC,也叫 Major GC,速度一般比 Minor GC 慢 10 倍以上。
對於一個大型的系統,當建立的物件及方法變數比較多時,即堆記憶體中的物件比較多,如果逐一分析物件是否該回收,效率很低。分割區是為了進行模組化管理,管理不同的物件及變數,以提高 JVM 的執行效率。
主要用來儲存新建立的物件,記憶體較小,垃圾回收頻繁。這個區又分為三個區域:一個 Eden Space 和兩個 Survivor Space。
Tenure Generation Space(採用標記-整理演演算法)
主要用來儲存長時間被參照的物件。它裡面存放的是經過幾次在 Young Generation Space 進行掃描判斷過仍存活的物件,記憶體較大,垃圾回收頻率較小。
儲存不變的類定義、位元組碼和常數等。
Class檔案是一組以8位元位元組為基礎單位的二進位制流,各個資料專案間沒有任何分隔符。當遇到8位元位元組以上空間的資料項時,則會按照高位在前的方式分隔成若干個8位元位元組進行儲存。
每個Class檔案的頭4個位元組稱為魔數(Magic Number),它的唯一作用是用於確定這個檔案是否為一個能被虛擬機器器接受的Class檔案。OxCAFEBABE。
接下來是Class檔案的版本號:第5,6位元組是次版本號(Minor Version),第7,8位元組是主版本號(Major Version)。
使用JDK 1.7編譯輸出Class檔案,格式程式碼為:
前四個位元組為魔數,次版本號是0x0000,主版本號是0x0033,說明本檔案是可以被1.7及以上版本的虛擬機器器執行的檔案。
類載入器實現類的載入動作,同時用於確定一個類。對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在Java虛擬機器器中的唯一性。即使兩個類來源於同一個Class檔案,只要載入它們的類載入器不同,這兩個類就不相等。
雙親委派模型(Parents Delegation Model)要求除了頂層的啟動類載入器外,其餘載入器都應當有自己的父類別載入器。類載入器之間的父子關係,通過組合關係複用。
工作過程:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類別載入器完成。每個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有到父載入器反饋自己無法完成這個載入請求(它的搜尋範圍沒有找到所需的類)時,子載入器才會嘗試自己去載入。
Java類隨著它的類載入器一起具備了一種帶優先順序的層次關係。比如java.lang.Object,它存放在rt.jar中,無論哪個類載入器要載入這個類,最終都是委派給啟動類載入器進行載入,因此Object類在程式的各個類載入器環境中,都是同一個類。
如果沒有使用雙親委派模型,讓各個類載入器自己去載入,那麼Java型別體系中最基礎的行為也得不到保障,應用程式會變得一片混亂。
Class檔案描述的各種資訊,都需要載入到虛擬機器器後才能執行。虛擬機器器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器器直接使用的Java型別,這就是虛擬機器器的類載入機制。
這兩種機器都有程式碼執行的能力,但是:
棧幀是用於支援虛擬機器器進行方法呼叫和方法執行的資料結構, 儲存了方法的
每一個方法從呼叫開始到執行完成的過程,就對應著一個棧幀在虛擬機器器棧裡面從入棧到出棧的過程。
方法呼叫唯一的任務是確定被呼叫方法的版本(呼叫哪個方法),暫時還不涉及方法內部的具體執行過程。
Class檔案的編譯過程不包含傳統編譯的連線步驟,一切方法呼叫在Class檔案裡面儲存的都只是符號參照,而不是方法在實際執行時記憶體佈局中的入口地址。這使得Java有強大的動態擴充套件能力,但使Java方法的呼叫過程變得相對複雜,需要在類載入期間甚至到執行時才能確定目標方法的直接參照。
解釋執行(通過直譯器執行)
編譯執行(通過即時編譯器產生原生程式碼)
當主流的虛擬機器器中都包含了即時編譯器後,Class檔案中的程式碼到底會被解釋執行還是編譯執行,只有虛擬機器器自己才能準確判斷。
Javac編譯器完成了程式程式碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的位元組碼指令流的過程。因為這一動作是在Java虛擬機器器之外進行的,而直譯器在虛擬機器器的內部,所以Java程式的編譯是半獨立的實現。
Java編譯器輸出的指令流,裡面的指令大部分都是零地址指令,它們依賴運算元棧進行工作。
計算「1+1=2」,基於棧的指令集是這樣的:
iconst_1
iconst_1
iadd
istore_0
兩條iconst_1指令連續地把兩個常數1壓入棧中,iadd指令把棧頂的兩個值出棧相加,把結果放回棧頂,最後istore_0把棧頂的值放到區域性變數表的第0個Slot中。
最典型的是x86的地址指令集,依賴暫存器工作。
計算「1+1=2」,基於暫存器的指令集是這樣的:
mov eax, 1
add eax, 1
mov指令把EAX暫存器的值設為1,然後add指令再把這個值加1,結果就儲存在EAX暫存器裡。
優點:
缺點:
頻繁的存取棧,意味著頻繁的存取記憶體,相對於處理器,記憶體才是執行速度的瓶頸。
Java程式最初是通過直譯器進行解釋執行的,當虛擬機器器發現某個方法或程式碼塊的執行特別頻繁,就會把這些程式碼認定為「熱點程式碼」(Hot Spot Code)。
為了提高熱點程式碼的執行效率,在執行時,虛擬機器器將會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個任務的編譯器成為即時編譯器(Just In Time Compiler,JIT編譯器)。
許多主流的商用虛擬機器器,都同時包含直譯器和編譯器。
如果記憶體資源限制較大(部分嵌入式系統),可以使用解釋執行節約記憶體,反之可以使用編譯執行來提升效率。同時編譯器的程式碼還能退回成直譯器的程式碼。
因為即時編譯器編譯原生程式碼需要佔用程式執行時間,要編譯出優化程度更高的程式碼,所花費的時間越長。
分層編譯根據編譯器編譯、優化的規模和耗時,劃分不同的編譯層次,包括:
用Client Compiler和Server Compiler將會同時工作。用Client Compiler獲取更高的編譯速度,用Server Compiler獲取更好的編譯質量。
要知道一段程式碼是不是熱點程式碼,是不是需要觸發即時編譯,這個行為稱為熱點探測。主要有兩種方法:
統計的是一個相對的執行頻率,即一段時間內方法被呼叫的次數。當超過一定的時間限度,如果方法的呼叫次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的呼叫計數器就會被減少一半,這個過程稱為方法呼叫計數器的熱度衰減,這個時間就被稱為半衰週期。
普遍應用於各種編譯器的經典優化技術,它的含義是:
如果一個表示式E已經被計算過了,並且從先前的計算到現在E中所有變數的值都沒有發生變化,那麼E的這次出現就成了公共子表示式。沒有必要重新計算,直接用結果代替E就可以了。
因為Java會自動檢查陣列越界,每次陣列元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量陣列存取的程式程式碼,這無疑是一種效能負擔。
如果陣列存取發生在迴圈之中,並且使用迴圈變數來進行陣列存取,如果編譯器只要通過資料流分析就可以判定迴圈變數的取值範圍永遠在陣列區間內,那麼整個迴圈中就可以把陣列的上下界檢查消除掉,可以節省很多次的條件判斷操作。
內聯消除了方法呼叫的成本,還為其他優化手段建立良好的基礎。
編譯器在進行內聯時,如果是非虛方法,那麼直接內聯。如果遇到虛方法,則會查詢當前程式下是否有多個目標版本可供選擇,如果查詢結果只有一個版本,那麼也可以內聯,不過這種內聯屬於激進優化,需要預留一個逃生門(Guard條件不成立時的Slow Path),稱為守護內聯。
如果程式的後續執行過程中,虛擬機器器一直沒有載入到會令這個方法的接受者的繼承關係發現變化的類,那麼內聯優化的程式碼可以一直使用。否則需要拋棄掉已經編譯的程式碼,退回到解釋狀態執行,或者重新進行編譯。
逃逸分析的基本行為就是分析物件動態作用域:當一個物件在方法裡面被定義後,它可能被外部方法所參照,這種行為被稱為方法逃逸。被外部執行緒存取到,被稱為執行緒逃逸。
運算任務,除了需要處理器計算之外,還需要與記憶體互動,如讀取運算資料、儲存運算結果等(不能僅靠暫存器來解決)。
計算機的儲存裝置和處理器的運算速度差了幾個數量級,所以不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache),作為記憶體與處理器之間的緩衝:將運算需要的資料複製到快取中,讓運算快速執行。當運算結束後再從快取同步回記憶體,這樣處理器就無需等待緩慢的記憶體讀寫了。
基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是引入了一個新的問題:快取一致性。在多處理器系統中,每個處理器都有自己的快取記憶體,它們又共用同一主記憶體。當多個處理器的運算任務都涉及同一塊主記憶體時,可能導致各自的快取資料不一致。
為了解決一致性的問題,需要各個處理器存取快取時遵循快取一致性協定。同時為了使得處理器充分被利用,處理器可能會對輸出程式碼進行亂序執行優化。Java虛擬機器器的即時編譯器也有類似的指令重排序優化。
Java虛擬機器器的規範,用來遮蔽掉各種硬體和作業系統的記憶體存取差異,以實現讓Java程式在各個平臺下都能達到一致的並行效果。
定義程式中各個變數的存取規則,即在虛擬機器器中將變數儲存到記憶體和從記憶體中取出這樣的底層細節。此處的變數包括範例欄位、靜態欄位和構成陣列物件的元素,但是不包括區域性變數和方法引數,因為這些是執行緒私有的,不會被共用,所以不存在競爭問題。
所以的變數都儲存在主記憶體,每條執行緒還有自己的工作記憶體,儲存了被該執行緒使用到的變數的主記憶體副本拷貝。執行緒對變數的所有操作(讀取、賦值)都必須在工作記憶體中進行,不能直接讀寫主記憶體的變數。不同的執行緒之間也無法直接存取對方工作記憶體的變數,執行緒間變數值的傳遞需要通過主記憶體。
一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體,Java記憶體模型定義了8種操作:
關鍵字volatile是Java虛擬機器器提供的最輕量級的同步機制。當一個變數被定義成volatile之後,具備兩種特性:
volatile變數在各個執行緒的工作記憶體,不存在一致性問題(各個執行緒的工作記憶體中volatile變數,每次使用前都要重新整理到主記憶體)。但是Java裡面的運算並非原子操作,導致volatile變數的運算在並行下一樣是不安全的。
在某些情況下,volatile同步機制的效能要優於鎖(synchronized關鍵字),但是由於虛擬機器器對鎖實行的許多消除和優化,所以並不是很快。
volatile變數讀操作的效能消耗與普通變數幾乎沒有差別,但是寫操作則可能慢一些,因為它需要在原生程式碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。
並行不一定要依賴多執行緒,PHP中有多程序並行。但是Java裡面的並行是多執行緒的。
執行緒是比程序更輕量級的排程執行單位。執行緒可以把一個程序的資源分配和執行排程分開,各個執行緒既可以共用程序資源(記憶體地址、檔案I/O),又可以獨立排程(執行緒是CPU排程的最基本單位)。
作業系統支援怎樣的執行緒模型,在很大程度上就決定了Java虛擬機器器的執行緒是怎樣對映的。
執行緒排程是系統為執行緒分配處理器使用權的過程。
雖然Java執行緒排程是系統自動完成的,但是我們可以建議系統給某些執行緒多分配點時間——設定執行緒優先順序。Java語言有10個級別的執行緒優先順序,優先順序越高的執行緒,越容易被系統選擇執行。
但是並不能完全依靠執行緒優先順序。因為Java的執行緒是被對映到系統的原生執行緒上,所以執行緒排程最終還是由作業系統說了算。如Windows中只有7種優先順序,所以Java不得不出現幾個優先順序相同的情況。同時優先順序可能會被系統自行改變。Windows系統中存在一個「優先順序推進器」,當系統發現一個執行緒執行特別勤奮,可能會越過執行緒優先順序為它分配執行時間。
當多個執行緒存取一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方法進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件就是執行緒安全的。
在Java語言裡,不可變的物件一定是執行緒安全的,只要一個不可變的物件被正確構建出來,那其外部的可見狀態永遠也不會改變,永遠也不會在多個執行緒中處於不一致的狀態。
虛擬機器器提供了同步和鎖機制。
互斥是實現同步的一種手段,臨界區、互斥量和號誌都是主要的互斥實現方式。Java中最基本的同步手段就是synchronized關鍵字,其編譯後會在同步塊的前後分別形成monitorenter和monitorexit兩個位元組碼指令。這兩個位元組碼都需要一個Reference型別的引數指明要鎖定和解鎖的物件。如果Java程式中的synchronized明確指定了物件引數,那麼這個物件就是Reference;如果沒有明確指定,那就根據synchronized修飾的是實體方法還是類方法,去獲取對應的物件範例或Class物件作為鎖物件。
在執行monitorenter指令時,首先要嘗試獲取物件的鎖。
除了synchronized之外,還可以使用java.util.concurrent包中的重入鎖(ReentrantLock)來實現同步。ReentrantLock比synchronized增加了高階功能:等待可中斷、可實現公平鎖、鎖可以繫結多個條件。
等待可中斷:當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,對處理執行時間非常長的同步塊很有用。
公平鎖:多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖。synchronized中的鎖是非公平的。
互斥同步最大的問題,就是進行執行緒阻塞和喚醒所帶來的效能問題,是一種悲觀的並行策略。總是認為只要不去做正確的同步措施(加鎖),那就肯定會出問題,無論共用資料是否真的會出現競爭,它都要進行加鎖、使用者態核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要被喚醒等操作。
隨著硬體指令集的發展,我們可以使用基於衝突檢測的樂觀並行策略。先進行操作,如果沒有其他執行緒徵用資料,那操作就成功了;如果共用資料有徵用,產生了衝突,那就再進行其他的補償措施。這種樂觀的並行策略的許多實現不需要執行緒掛起,所以被稱為非阻塞同步。
JDK1.6的一個重要主題,就是高效並行。HotSpot虛擬機器器開發團隊在這個版本上,實現了各種鎖優化:
互斥同步對效能最大的影響是阻塞的實現,掛起執行緒和恢復執行緒的操作都需要轉入核心態中完成,這些操作給系統的並行性帶來很大壓力。同時很多應用共用資料的鎖定狀態,只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。先不掛起執行緒,等一會兒。
如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,讓後面請求鎖的執行緒稍等一會,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放。為了讓執行緒等待,我們只需讓執行緒執行一個忙迴圈(自旋)。
自旋等待本身雖然避免了執行緒切換的開銷,但它要佔用處理器時間。所以如果鎖被佔用的時間很短,自旋等待的效果就非常好;如果時間很長,那麼自旋的執行緒只會白白消耗處理器的資源。所以自旋等待的時間要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,那就應該使用傳統的方式掛起執行緒了。
自旋的時間不固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。
有了自適應自旋,隨著程式執行和效能監控資訊的不斷完善,虛擬機器器對程式鎖的狀況預測就會越來越準確,虛擬機器器也會越來越聰明。
鎖消除是指虛擬機器器即時編譯器在執行時,對一些程式碼上要求同步,但被檢測到不可能存在共用資料競爭的鎖進行消除。主要根據逃逸分析。
程式設計師怎麼會在明知道不存在資料競爭的情況下使用同步呢?很多不是程式設計師自己加入的。
原則上,同步塊的作用範圍要儘量小。但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作在迴圈體內,頻繁地進行互斥同步操作也會導致不必要的效能損耗。
鎖粗化就是增大鎖的作用域。
在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。
消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能。即在無競爭的情況下,把整個同步都消除掉。這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要同步。
參考:《深入理解Java虛擬機器器:JVM高階特性與最佳實踐(第2版)》
如果覺得文章對你有點幫助,請微信搜尋並關注「 冰河技術 」微信公眾號,跟冰河學習高並行程式設計技術。
最後,附上並行程式設計需要掌握的核心技能知識圖,祝大家在學習並行程式設計時,少走彎路。
記住:你比別人強的地方,不是你做過多少年的CRUD工作,而是你比別人掌握了更多深入的技能。不要總停留在CRUD的表面工作,理解並掌握底層原理並熟悉原始碼實現,並形成自己的抽象思維能力,做到靈活運用,才是你突破瓶頸,脫穎而出的重要方向!
你在刷抖音,玩遊戲的時候,別人都在這裡學習,成長,提升,人與人最大的差距其實就是思維。你可能不信,優秀的人,總是在一起。