Android复习系列②之《Java进阶》
1 java多線程(非常重要)
1.1. 線程
線程和進程的區別?
線程是CPU調度的最小單位,一個進程中可以包含多個線程,在Android中,一個進程通常是一個App,App中會有一個主線程,主線程可以用來操作界面元素,如果有耗時的操作,必須開啟子線程執行,不然會出現ANR,除此以外,進程間的數據是獨立的,線程間的數據可以共享。
java多線程實現方式主要有:
繼承Thread
優點 : 方便傳參,可以在子類添加成員變量,通過方法設置參數或構造函數傳參。
缺點:
1.因為Java不支持多繼承,所以繼承了Thread類以后,就無法繼承其他類。
2.每次都要新建一個類,不支持通過線程池操作,創建和銷毀線程對資源的開銷比較大。
3.從代碼結構上講,為了啟動一個線程任務,都要創建一個類,耦合性太高。
4.無法獲取線程任務的返回結果。
實現Runnable
優點 : 此方式可以繼承其他類。也可以使用線程池管理,節約資源。創建線程代碼的耦合性較低。推薦使用此種方式創建線程。
缺點: 不方便傳參,只能使用主線程中用final修飾的變量。其次是無法獲取線程任務的返回結果。
實現Callable
此種方式創建線程底層源碼也是使用實現Runnable接口的方式實現的,所以不是一種新的創建線程的方式,只是在實現Runnable接口方式創建線程的基礎上,同時實現了Future接口,實現有返回值的創建線程。
Runnable 與 Callable的區別:
1. Runnable是在JDK1.0的時候提出的多線程的實現接口,而Callable是在JDK1.5之后提出的; 2. Runnable 接口之中只提供了一個run()方法,并且沒有返回值; 3. Callable接口提供有call(),可以有返回值;擴展:
Callable接口支持返回執行結果,此時需要調用FutureTask.get()方法實現,此方法會阻塞主線程直到獲取‘將來’結果; 當不調用此方法時,主線程不會阻塞public class CallableImpl implements Callable<String> {public CallableImpl(String acceptStr) {this.acceptStr = acceptStr;}private String acceptStr;@Overridepublic String call() throws Exception {// 任務阻塞 1 秒Thread.sleep(1000);return this.acceptStr + " append some chars and return it!";}public static void main(String[] args) throws ExecutionException, InterruptedException {Callable<String> callable = new CallableImpl("my callable test!");FutureTask<String> task = new FutureTask<>(callable);long beginTime = System.currentTimeMillis();// 創建線程new Thread(task).start();// 調用get()阻塞主線程,反之,線程不會阻塞String result = task.get();long endTime = System.currentTimeMillis();System.out.println("hello : " + result);System.out.println("cast : " + (endTime - beginTime) / 1000 + " second!");} }//執行結果hello : my callable test! append some chars and return it! cast : 1 second!總結:
根據Oracle提供的JAVA官方文檔的說明,Java創建線程的方法只有兩種方式,即繼承Thread類和實現Runnable接口。其他所有創建線程的方式,底層都是使用這兩種方式中的一種實現的,比如通過線程池、通過匿名類、通過lambda表達式、通過Callable接口等等,全是通過這兩種方式中的一種實現的。所以我們在掌握線程創建的時候,必須要掌握的只有這兩種,通過文章中優缺點的分析,這兩種方法中,最為推薦的就是實現Runnable接口的方式去創建線程。
1.2. 線程的狀態有哪些?
Java中定義線程的狀態有6種,可以查看Thread類的State枚舉:
public static enum State{NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED;private State() {}}- 初始(NEW):新創建了一個線程對象,還沒調用start方法;
- 運行(RUNNABLE):java線程中將就緒(ready)和運行中(running)統稱為運行(RUNNABLE)。線程創建后調用了該對象的start方法,此時處于就緒狀態,當獲得CPU時間片后變為運行中狀態;
- 阻塞(BLOCKED):表現線程阻塞于鎖;
- 等待(WAITING):進入該狀態的線程需要等待其他線程做出一些特定動作(通知或中斷);
- 超時等待(TIMED_WAITING):該狀態不同于WAITING,它可以在指定時間后自行返回;
- 終止(TERMINATED):表示該線程已經執行完畢。
狀態詳細說明:
初始狀態(NEW)
實現Runnable接口和繼承Thread可以得到一個線程類,new一個實例出來,線程就進入了初始狀態。
就緒狀態(RUNNABLE之READY)
就緒狀態只是說你資格運行,調度程序沒有挑選到你,你就永遠是就緒狀態。
調用線程的start()方法,此線程進入就緒狀態。
當前線程sleep()方法結束,其他線程join()結束,等待用戶輸入完畢,某個線程拿到對象鎖,這些線程也將進入就緒狀態。
當前線程時間片用完了,調用當前線程的yield()方法,當前線程進入就緒狀態。
鎖池里的線程拿到對象鎖后,進入就緒狀態。
運行中狀態(RUNNABLE之RUNNING)
線程調度程序從可運行池中選擇一個線程作為當前線程時線程所處的狀態。這也是線程進入運行狀態的唯一的一種方式。
阻塞狀態(BLOCKED)
阻塞狀態是線程阻塞在進入synchronized關鍵字修飾的方法或代碼塊(獲取鎖)時的狀態。
等待(WAITING)
處于這種狀態的線程不會被分配CPU執行時間,它們要等待被顯式地喚醒,否則會處于無限期等待的狀態。
超時等待(TIMED_WAITING)
處于這種狀態的線程不會被分配CPU執行時間,不過無須無限期等待被其他線程顯示地喚醒,在達到一定時間后它們會自動喚醒。
終止狀態(TERMINATED)
當線程的run()方法完成時,或者主線程的main()方法完成時,我們就認為它終止了。這個線程對象也許是活的,但是它已經不是一個單獨執行的線程。線程一旦終止了,就不能復生。
在一個終止的線程上調用start()方法,會拋出java.lang.IllegalThreadStateException異常。
1.3. 線程的狀態轉換及控制
主要由這幾個方法來控制:sleep、join、yield、wait、notify以及notifyAll。
wait() / notify() / notifyAll()
wait(),notify(),notifyAll() 是定義在Object類的實例方法,用于控制線程狀態,三個方法都必須在synchronized 同步關鍵字所限定的作用域中調用(只能在同步控制方法或者同步控制塊中使用),否則會報錯 java.lang.IllegalMonitorStateException。
join() / sleep() / yield()
join()
如果線程A調用了線程B的join方法,線程A將被阻塞,等待線程B執行完畢后線程A才會被執行。這里需要注意一點的是,join方法必須在線程B的start方法調用之后調用才有意義。join方法的主要作用就是實現線程間的同步,它可以使線程之間的并行執行變為串行執行。
sleep()
當線程A調用了 sleep方法,則線程A將被阻塞,直到指定睡眠的時間到達后,線程A才會重新被喚起,進入就緒狀態。
public class Test {public static void main(String[] args) {for (int i = 0; i < 10; i++) {System.out.println(Thread.currentThread().getName() + "---" + i);try {Thread.sleep(1000); // 阻塞當前線程1s} catch (Exception e) {e.printStackTrace();}}} }yield() 當線程A調用了yield方法,它可以暫時放棄處理器,但是線程A不會被阻塞,而是進入就緒狀態。執行了yield方法的線程什么時候會繼續運行由線程調度器來決定。
public class YieldThread extends Thread {@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println(Thread.currentThread().getName() + "---" + i);// 主動放棄Thread.yield();}} }sleep方法和wait方法的區別是什么?
wait方法既釋放cpu,又釋放鎖。 sleep方法只釋放cpu,但是不釋放鎖。
sleep 方法是Thread類的一個靜態方法,其作用是使運行中的線程暫時停止指定的毫秒數,從而該線程進入阻塞狀態并讓出處理器,將執行的機會讓給其他線程。但是這個過程中監控狀態始終保持,當sleep的時間到了之后線程會自動恢復。
wait 方法是Object類的方法,它是用來實現線程同步的。當調用某個對象的wait方法后,當前線程會被阻塞并釋放同步鎖,直到其他線程調用了該對象的 notify 方法或者 notifyAll 方法來喚醒該線程。所以 wait 方法和 notify(或notifyAll)應當成對出現以保證線程間的協調運行。
1.4. Java如何正確停止線程
注意:Java中線程的stop()、suspend()、resume()三個方法都已經被棄用,所以不再使用stop()方法停止線程。
我們只能調用線程的interrupt()方法通知系統停止線程,并不能強制停止線程。線程能否停止,何時停止,取決于系統。
1.5 線程池(非常重要)
線程池的地位十分重要,基本上涉及到跨線程的框架都使用到了線程池,比如說OkHttp、RxJava、LiveData以及協程等。
與新建一個線程相比,線程池的特點?
ExecutorService簡介
通常來說我們說到線程池第一時間想到的就是它:ExecutorService,它是一個接口,其實如果要從真正意義上來說,它可以叫做線程池的服務,因為它提供了眾多接口api來控制線程池中的線程,而真正意義上的線程池就是:ThreadPoolExecutor,它實現了ExecutorService接口,并封裝了一系列的api使得它具有線程池的特性,其中包括工作隊列、核心線程數、最大線程數等。
線程池(ThreadPoolExecutor)中的幾個參數是什么意思?
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler) {//...}參數解釋如下(重要):
corePoolSize:核心線程數量,不會釋放。
maximumPoolSize:允許使用的最大線程池數量,非核心線程數量,閑置時會釋放。
keepAliveTime:閑置線程允許的最大閑置時間。它起作用必須在一個前提下,就是當線程池中的線程數量超過了corePoolSize時,它表示多余的空閑線程的存活時間,即:多余的空閑線程在超過keepAliveTime時間內沒有任務的話則被銷毀。而這個主要應用在緩存線程池中
unit:閑置時間的單位。
workQueue:阻塞隊列,用來存儲已經提交但未被執行的任務,不同的阻塞隊列有不同的特性。
threadFactory:線程工廠,用來創建線程池中的線程,通常用默認的即可
handler:通常叫做拒絕策略,1、在線程池已經關閉的情況下 2、任務太多導致最大線程數和任務隊列已經飽和,無法再接收新的任務 。在上面兩種情況下,只要滿足其中一種時,在使用execute()來提交新的任務時將會拒絕,而默認的拒絕策略是拋一個RejectedExecutionException異常
上面的參數理解起來都比較簡單,不過workQueue這個任務隊列卻要再次說明一下,它是一個BlockingQueue<Runnable>對象,而泛型則限定它是用來存放Runnable對象的,剛剛上面講了,不同的線程池它的任務隊列實現肯定是不一樣的,所以,保證不同線程池有著不同的功能的核心就是這個workQueue的實現了,細心的會發現在剛剛的用來創建線程池的工廠方法中,針對不同的線程池傳入的workQueue也不一樣,五種線程池分別用的是什么BlockingQueue:
1、newFixedThreadPool()—>LinkedBlockingQueue 無界的隊列 2、newSingleThreadExecutor()—>LinkedBlockingQueue 無界的隊列 3、newCachedThreadPool()—>SynchronousQueue 直接提交的隊列 4、newScheduledThreadPool()—>DelayedWorkQueue 等待隊列 5、newSingleThreadScheduledExecutor()—>DelayedWorkQueue 等待隊列線程池中用到的三種阻塞隊列:
LinkedBlockingQueue:無界的隊列
它的容量是 Integer.MAX_VALUE,為 231 -1 ,是一個非常大的值,可以認為是無界隊列。FixedThreadPool 和 SingleThreadExecutor 線程池的線程數是固定的,所以沒有辦法增加特別多的線程來處理任務,這時就需要 LinkedBlockingQueue 這樣一個沒有容量限制的阻塞隊列來存放任務。
SynchronousQueue:直接提交的隊列
如果不希望任務在隊列中等待而是希望將任務直接移交給工作線程,可使用SynchronousQueue作為等待隊列。SynchronousQueue不是一個真正的隊列,而是一種線程之間移交的機制。要將一個元素放入SynchronousQueue中,必須有另一個線程正在等待接收這個元素。只有在使用無界線程池或者有飽和策略時才建議使用該隊列。
DelayedWorkQueue:等待隊列
它對應的線程池分別是 ScheduledThreadPool 和 SingleThreadScheduledExecutor,這兩種線程池的最大特點就是可以延遲執行任務,比如說一定時間后執行任務或是每隔一定的時間執行一次任務。
DelayedWorkQueue 的特點是內部元素并不是按照放入的時間排序,而是會按照延遲的時間長短對任務進行排序,內部采用的是“堆”的數據結構(堆的應用之一就是 優先級隊列)。之所以線程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 選擇 DelayedWorkQueue,是因為它們本身正是基于時間執行任務的,而延遲隊列正好可以把任務按時間進行排序,方便任務的執行。
線程池的種類有哪些:五種功能不一樣的線程池:
這樣創建線程池的話,我們需要配置一堆東西,非常麻煩。所以,官方也不推薦使用這種方法來創建線程池,而是推薦使用Executors的工廠方法來創建線程池,Executors類是官方提供的一個工廠類,它里面封裝好了眾多功能不一樣的線程池(但底層實現還是通過ThreadPoolExecutor),從而使得我們創建線程池非常的簡便,主要提供了如下五種功能不一樣的線程池:
newCachedThreadPool() :返回一個可以根據實際情況調整線程池中線程的數量的線程池。即該線程池中的線程數量不確定,是根據實際情況動態調整的。
newFixedThreadPool() :線程池只能存放指定數量的線程池,線程不會釋放,可重復利用。
newSingleThreadExecutor() :單線程的線程池。即每次只能執行一個線程任務,多余的任務會保存到一個任務隊列中,等待這一個線程空閑,當這個線程空閑了再按FIFO方式順序執行任務隊列中的任務。
newScheduledThreadPool() :可定時和重復執行的線程池。
newSingleThreadScheduledExecutor():同上。和上面的區別是該線程池大小為1,而上面的可以指定線程池的大小。
通過Executors的工廠方法來獲取:
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5); ExecutorService singleThreadPool = Executors.newSingleThreadExecutor(); ExecutorService cachedThreadPool = Executors.newCachedThreadPool(); ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(5); ScheduledExecutorService singleThreadScheduledPool = Executors.newSingleThreadScheduledExecutor();通過Executors的工廠方法來創建線程池極其簡便,其實它的內部還是通過new ThreadPoolExecutor(…)的方式創建線程池的,我們看一下這些工廠方法的內部實現:
public static ExecutorService newFixedThreadPool(int nThreads) {return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());}public static ExecutorService newSingleThreadExecutor() {return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));}public static ExecutorService newCachedThreadPool() {return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());}線程池ThreadPoolExecutor的使用
使用線程池,其中涉及到一個極其重要的方法,即:
execute(Runnable command)該方法意為執行給定的任務,該任務處理可能在新的線程、已入池的線程或者正調用的線程,這由ThreadPoolExecutor的實現決定。
五種線程池使用舉例:
newFixedThreadPool 創建一個固定線程數量的線程池,示例為:
創建了一個線程數為3的固定線程數量的線程池,同理該線程池支持的線程最大并發數也是3,而我模擬了10個任務讓它處理,執行的情況則是首先執行前三個任務,后面7個則依次進入任務隊列進行等待,執行完前三個任務后,再通過FIFO的方式從任務隊列中取任務執行,直到最后任務都執行完畢。
newSingleThreadExecutor
創建一個只有一個線程的線程池,每次只能執行一個線程任務,多余的任務會保存到一個任務隊列中,等待線程處理完再依次處理任務隊列中的任務,示例為:
其實我們通過newSingleThreadExecutor()和newFixedThreadPool()的方法發現,創建一個singleThreadExecutorPool實際上就是創建一個核心線程數和最大線程數都為1的fixedThreadPool。
newCachedThreadPool
創建一個可以根據實際情況調整線程池中線程的數量的線程池,為了體現該線程池可以自動根據實現情況進行線程的重用,而不是一味的創建新的線程去處理任務,我設置了每隔1s去提交一個新任務,這個新任務執行的時間也是動態變化的,示例為
newScheduledThreadPool
創建一個可以定時或者周期性執行任務的線程池,示例為:
newSingleThreadScheduledExecutor
創建一個可以定時或者周期性執行任務的線程池,該線程池的線程數為1,示例為
這個和上面的沒什么太大區別,只不過是線程池內線程數量的不同,效果為:每隔2秒就會執行一次該任務
自定義線程池ThreadPoolExecutor(自行了解)
線程池的狀態:
RUNNING:線程池一旦被創建,就處于 RUNNING 狀態,任務數為 0,能夠接收新任務,對已排隊的任務進行處理。
SHUTDOWN:不接收新任務,但能處理已排隊的任務。調用線程池的 shutdown() 方法,線程池由 RUNNING 轉變為 SHUTDOWN 狀態。
STOP:不接收新任務,不處理已排隊的任務,并且會中斷正在處理的任務。調用線程池的 shutdownNow() 方法,線程池由(RUNNING 或 SHUTDOWN ) 轉變為 STOP 狀態。
TIDYING:SHUTDOWN 狀態下,任務數為 0, 其他所有任務已終止,線程池會變為 TIDYING 狀態,會執行 terminated() 方法。線程池中的 terminated() 方法是空實現,可以重寫該方法進行相應的處理。
線程池在 SHUTDOWN 狀態,任務隊列為空且執行中任務為空,線程池就會由 SHUTDOWN 轉變為 TIDYING 狀態。
線程池在 STOP 狀態,線程池中執行中任務為空時,就會由 STOP 轉變為 TIDYING 狀態。
TERMINATED:線程池徹底終止。線程池在 TIDYING 狀態執行完 terminated() 方法就會由 TIDYING 轉變為 TERMINATED 狀態。
線程池的停止
關于線程池的停止,ExecutorService為我們提供了兩個方法:shutdown和shutdownNow,這兩個方法各有不同,可以根據實際需求方便的運用,如下:
線程池的工作流程
簡單說:
- 任務來了,優先考慮核心線程。
- 核心線程滿了,進入阻塞隊列。
- 阻塞隊列滿了,考慮非核心線程。
- 非核心線程滿了,再觸發拒絕任務。
詳細說明:
1 當一個任務通過submit或者execute方法提交到線程池的時候,如果當前池中線程數(包括閑置線程)小于coolPoolSize,則創建一個線程執行該任務。
2 如果當前線程池中線程數已經達到coolPoolSize,則將任務放入等待隊列。
3 如果任務不能入隊,說明等待隊列已滿,若當前池中線程數小于maximumPoolSize,則創建一個臨時線程(非核心線程)執行該任務。
4 如果當前池中線程數已經等于maximumPoolSize,此時無法執行該任務,根據拒絕執行策略處理。
注意:當池中線程數大于coolPoolSize,超過keepAliveTime時間的閑置線程會被回收掉。回收的是非核心線程,核心線程一般是不會回收的。如果設置allowCoreThreadTimeOut(true),則核心線程在閑置keepAliveTime時間后也會被回收。
任務隊列是一個阻塞隊列,線程執行完任務后會去隊列取任務來執行,如果隊列為空,線程就會阻塞,直到取到任務。
其它面試題:
1.當線程池的核心線程數量過大或者過小的影響?
首先,多線程編程中一般線程的個數都大于CPU核心的個數,而一個CPU核心在任意時刻只能被一個線程使用,為了讓這些線程都能得到有效的執行,CPU采取的策略是為了每個線程分配時間片并輪轉的形式。當一個線程的時間片用完的時候就會重新處于就緒狀態讓其他線程使用,這個過程就屬于一次上下文切換。
當線程池中核心線程數量過大時,線程與線程之間會爭取CPU資源,這樣就會導致上下文切換。過多的上下文切換會增加線程的執行時間,影響了整體執行的效率;
當線程池中的核心線程數量過少時,如果同一時間有大量任務需要處理,可能會導致大量任務在任務隊列中排隊,甚至會出現隊列滿了之后任務無法執行的情況,或者大量任務堆積在任務隊列導致內存溢出(OOM)。
2.CPU密集型和IO密集型?
CPU密集型:比如加密、解密、壓縮、計算等一系列需要大量耗費 CPU 資源的任務。CPU 密集型任務應配置盡可能小的線程,如配置CPU核數 + 1個線程的線程池。如果設置過多的線程數,假設設置的線程數是 CPU 核心數的 2 倍以上,因為計算任務非常重,會占用大量的 CPU 資源,所以這時 CPU 的每個核心工作基本都是滿負荷的,而我們又設置了過多的線程,每個線程都想去利用 CPU 資源來執行自己的任務,這就會造成不必要的上下文切換,此時線程數的增多并沒有讓性能提升,反而由于線程數量過多會導致性能下降。
I/O密集型:比如數據庫、文件的讀寫,網絡通信等任務,這種任務的特點是并不會特別消耗 CPU 資源,但是 IO 操作很耗時,總體會占用比較多的時間。由于 IO 密集型任務線程并不是一直在執行任務,則應配置盡可能多的線程,如CPU核數 * 2。因為 IO 讀寫速度相比于 CPU 的速度而言是比較慢的,如果我們設置過少的線程數,就可能導致 CPU 資源的浪費。而如果我們設置更多的線程數,那么當一部分線程正在等待 IO 的時候,它們此時并不需要 CPU 來計算,那么另外的線程便可以利用 CPU 去執行其他的任務,互不影響,這樣的話在工作隊列中等待的任務就會減少,可以更好地利用資源。
混合型任務:既包含CPU密集型又包含I/O密集型。
3.corePoolSize核心線程數,一般設置為多少?
首先要考慮到 CPU 核心數;可以使用下面的方法獲取
Runtime.getRuntime().availableProcessor() 方法來獲取(可能不準確,作為參考)在確認了核心數后,再去判斷是 CPU 密集型任務還是 IO 密集型任務:
- CPU密集型:核心線程數 = CPU核數 + 1
- IO密集型:核心線程數 = CPU核數 * 2
注:IO密集型(某大廠實踐經驗)
核心線程數 = CPU核數 / (1-阻塞系數)
其中計算密集型阻塞系數為 0,IO 密集型阻塞系數接近 1,一般認為在 0.8 ~ 0.9 之間。比如 8 核
CPU,按照公式就是 2 / ( 1 - 0.9 ) = 20 個線程數
另外,可參考AsyncTask源碼中的設置:
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();// We want at least 2 threads and at most 4 threads in the core pool,// preferring to have 1 less than the CPU count to avoid saturating// the CPU with background workprivate static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;源碼有解釋的:之所以 減掉這個1,是因為為了避免后臺任務將 CPU 資源完全耗盡, 減掉的這個1 是留給我們 主線程 使用的。
1.6. Java鎖機制
在java中,解決同步問題,很多時候都會使用到synchronized和Lock,這兩者都是在多線程并發時候常使用的鎖機制。在JDK1.6后,對synchronized進行了很多優化,如偏向鎖、輕量級鎖等,synchronized的性能已經與Reentrantlock大致相同,除非要使用Reentrantlock的一些高級功能(實現公平鎖、中斷鎖等),一般推薦使用synchronized關鍵字來實現加鎖機制。
Synchronized 是Java 并發編程中很重要的關鍵字,另外一個很重要的是 volatile。Syncronized 一次只允許一個線程進入由他修飾的代碼段,從而允許他們進行自我保護。進入由Synchronized 保護的代碼區首先需要獲取 Synchronized 這把鎖,其他線程想要執行必須進行等待。Synchronized 鎖住的代碼區域執行完成后需要把鎖歸還,也就是釋放鎖,這樣才能夠讓其他線程使用。
Lock 是 Java并發編程中很重要的一個接口,它要比 Synchronized 關鍵字更能直譯"鎖"的概念,Lock需要手動加鎖和手動解鎖,一般通過 lock.lock() 方法來進行加鎖, 通過 lock.unlock() 方法進行解鎖。與 Lock 關聯密切的鎖有 ReetrantLock 和 ReadWriteLock。
ReetrantLock 實現了Lock接口,它是一個可重入鎖,內部定義了公平鎖與非公平鎖。
ReadWriteLock 一個用來獲取讀鎖,一個用來獲取寫鎖。也就是說將文件的讀寫操作分開,分成2個鎖來分配給線程,從而使得多個線程可以同時進行讀操作。ReentrantReadWirteLock實現了ReadWirteLock接口,并未實現Lock接口。
Synchronized 的使用
修飾一個方法:即一次只能有一個線程進入該方法,其他線程要想在此時調用該方法,只能排隊等候。
實例方法:鎖住的是該類的實例對象 靜態方法:鎖住的是該類的類對象。public synchronized void goHOme(){ }public static synchronized void goHOme(){ }修飾代碼塊:表示只能有一個線程進入某個代碼段
public void numDecrease(Object num){synchronized (num){number++;} }修飾一個類:作用的對象是這個類的所有對象,只要是這個類型的class不管有幾個對象都會起作用。
class Person {public void method() {//鎖住的是該類的類對象,如果換成this或其他object,則鎖住的是該類的實例對象synchronized(Person.class) {// todo}}}獲取對象鎖
synchronized(this|object) {} 修飾非靜態方法獲取類鎖
synchronized(類.class) {} 修飾靜態方法Lock 的使用
public interface Lock {void lock();void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }
使用示例:
一般來說,使用Lock必須在try{}catch{}塊中進行,并且將釋放鎖的操作放在finally塊中進行,以保證鎖一定被被釋放,防止死鎖的發生。
注意,當一個線程獲取了鎖之后,是不會被interrupt()方法中斷的。單獨調用interrupt()方法不能中斷正在運行過程中的線程,只能中斷阻塞過程中的線程。因此當通過lockInterruptibly()方法獲取某個鎖時,如果不能獲取到,只有進行等待的情況下,是可以響應中斷的。而用synchronized修飾的話,當一個線程處于等待某個鎖的狀態,是無法被中斷的,只有一直等待下去。
synchronized和Lock的區別?
主要區別:
synchronized是Java中的關鍵字,是Java的內置實現;Lock是Java中的接口。 synchronized遇到異常會釋放鎖;Lock需要在發生異常的時候調用成員方法Lock#unlock()方法。 synchronized是不可以中斷的,Lock可中斷。 synchronized不能去嘗試獲得鎖,沒有獲得鎖就會被阻塞; Lock可以去嘗試獲得鎖,如果未獲得可以嘗試處理其他邏輯。 synchronized多線程效率不如Lock,不過Java在1.6以后已經對synchronized進行大量的優化,所以性能上來講,其實差不了多少。死鎖
所謂死鎖是指兩個或兩個以上的線程在執行過程中,因爭奪資源而造成的一種互相等待的現象,若無外力作用,它們都將無法推進下去。
死鎖觸發的四大條件?
互斥鎖 請求與保持 不可剝奪 循環的請求與等待簡單死鎖代碼示例:
public class DeadLock {public static String obj1 = "obj1";public static String obj2 = "obj2";public static void main(String[] args){Thread a = new Thread(new Lock1());Thread b = new Thread(new Lock2());a.start();b.start();} } class Lock1 implements Runnable{@Overridepublic void run(){try{System.out.println("Lock1 running");while(true){synchronized(DeadLock.obj1){System.out.println("Lock1 lock obj1");Thread.sleep(3000);//獲取obj1后先等一會兒,讓Lock2有足夠的時間鎖住obj2synchronized(DeadLock.obj2){System.out.println("Lock1 lock obj2");}}}}catch(Exception e){e.printStackTrace();}} } class Lock2 implements Runnable{@Overridepublic void run(){try{System.out.println("Lock2 running");while(true){synchronized(DeadLock.obj2){System.out.println("Lock2 lock obj2");Thread.sleep(3000);synchronized(DeadLock.obj1){System.out.println("Lock2 lock obj1");}}}}catch(Exception e){e.printStackTrace();}} }
可以看到,Lock1獲取obj1,Lock2獲取obj2,但是它們都沒有辦法再獲取另外一個obj,因為它們都在等待對方先釋放鎖,這時就是死鎖。
1.7. Java中的主流鎖
Java中往往是按照是否含有某一特性來定義鎖,我們通過特性將鎖進行分組歸類:
1.樂觀鎖 VS 悲觀鎖
概念:對于同一個數據的并發操作,樂觀鎖認為自己在使用數據時不會有別的線程修改數據,所以不會添加鎖,只是在更新數據的時候去判斷之前有沒有別的線程更新了這個數據。如果這個數據沒有被更新,當前線程將自己修改的數據成功寫入。如果數據已經被其他線程更新,則根據不同的實現方式執行不同的操作(例如報錯或者自動重試)。
悲觀鎖認為自己在使用數據的時候一定有別的線程來修改數據,因此在獲取數據的時候會先加鎖,確保數據不會被別的線程修改。Java中,synchronized關鍵字和Lock的實現類都是悲觀鎖。
樂觀鎖在Java中是通過使用無鎖編程來實現,最常采用的是CAS算法,Java原子類中的遞增操作就通過CAS自旋實現的。
根據從上面的概念描述我們可以發現:
- 悲觀鎖適合寫操作多的場景,先加鎖可以保證寫操作時數據正確。
- 樂觀鎖適合讀操作多的場景,不加鎖的特點能夠使其讀操作的性能大幅提升。
調用方式示例:
悲觀鎖基本都是在顯式的鎖定之后再操作同步資源,而樂觀鎖則直接去操作同步資源。那么,為何樂觀鎖能夠做到不鎖定同步資源也可以正確的實現線程同步呢?
樂觀鎖的主要實現方式 “CAS” 的技術原理:
CAS全稱 Compare And Swap(比較與交換),是一種無鎖算法。在不使用鎖(沒有線程被阻塞)的情況下實現多線程之間的變量同步。java.util.concurrent包中的原子類就是通過CAS來實現了樂觀鎖。
CAS算法涉及到三個操作數:
- 需要讀寫的內存值 V。
- 進行比較的值 A。
- 要寫入的新值 B。
當且僅當 V 的值等于 A 時,CAS通過原子方式用新值B來更新V的值(“比較+更新”整體是一個原子操作),否則不會執行任何操作。一般情況下,“更新”是一個不斷重試的操作。
之前提到java.util.concurrent包中的原子類,就是通過CAS來實現了樂觀鎖,那么我們進入原子類AtomicInteger的源碼,看一下AtomicInteger的定義:
根據定義我們可以看出各屬性的作用:
- unsafe: 獲取并操作內存的數據。
- valueOffset: 存儲value在AtomicInteger中的偏移量。
- value: 存儲AtomicInteger的int值,該屬性需要借助volatile關鍵字保證其在線程間是可見的。
接下來,我們查看AtomicInteger的自增函數incrementAndGet()的源碼時,發現自增函數底層調用的是unsafe.getAndAddInt()。但是由于JDK本身只有Unsafe.class,只通過class文件中的參數名,并不能很好的了解方法的作用,所以我們通過OpenJDK 8 來查看Unsafe的源碼:
根據OpenJDK 8的源碼我們可以看出,getAndAddInt()循環獲取給定對象o中的偏移量處的值v,然后判斷內存值是否等于v。如果相等則將內存值設置為 v + delta,否則返回false,繼續循環進行重試,直到設置成功才能退出循環,并且將舊值返回。整個“比較+更新”操作封裝在compareAndSwapInt()中,在JNI里是借助于一個CPU指令完成的,屬于原子操作,可以保證多個線程都能夠看到同一個變量的修改值。
后續JDK通過CPU的cmpxchg指令,去比較寄存器中的 A 和 內存中的值 V。如果相等,就把要寫入的新值 B 存入內存中。如果不相等,就將內存值 V 賦值給寄存器中的值 A。然后通過Java代碼中的while循環再次調用cmpxchg指令進行重試,直到設置成功為止。
CAS雖然很高效,但是它也存在三大問題,這里也簡單說一下:
ABA問題。CAS需要在操作值的時候檢查內存值是否發生變化,沒有發生變化才會更新內存值。但是如果內存值原來是A,后來變成了B,然后又變成了A,那么CAS進行檢查時會發現值沒有發生變化,但是實際上是有變化的。ABA問題的解決思路就是在變量前面添加版本號,每次變量更新的時候都把版本號加一,這樣變化過程就從“A-B-A”變成了“1A-2B-3A”。
JDK從1.5開始提供了AtomicStampedReference類來解決ABA問題,具體操作封裝在compareAndSet()中。compareAndSet()首先檢查當前引用和當前標志與預期引用和預期標志是否相等,如果都相等,則以原子方式將引用值和標志的值設置為給定的更新值。
循環時間長開銷大。CAS操作如果長時間不成功,會導致其一直自旋,給CPU帶來非常大的開銷。
只能保證一個共享變量的原子操作。對一個共享變量執行操作時,CAS能夠保證原子操作,但是對多個共享變量操作時,CAS是無法保證操作的原子性的。
Java從1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,可以把多個變量放在一個對象里來進行CAS操作。
2.自旋鎖 VS 適應性自旋鎖
概念解析:
阻塞或喚醒一個Java線程需要操作系統切換CPU狀態來完成,這種狀態轉換需要耗費處理器時間。如果同步代碼塊中的內容過于簡單,狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長。
在許多場景中,同步資源的鎖定時間很短,為了這一小段時間去切換線程,線程掛起和恢復現場的花費可能會讓系統得不償失。如果物理機器有多個處理器,能夠讓兩個或以上的線程同時并行執行,我們就可以讓后面那個請求鎖的線程不放棄CPU的執行時間,看看持有鎖的線程是否很快就會釋放鎖。
而為了讓當前線程“稍等一下”,我們需讓當前線程進行自旋,如果在自旋完成后前面鎖定同步資源的線程已經釋放了鎖,那么當前線程就可以不必阻塞而是直接獲取同步資源,從而避免切換線程的開銷。這就是自旋鎖。
自旋鎖本身是有缺點的,它不能代替阻塞。自旋等待雖然避免了線程切換的開銷,但它要占用處理器時間。如果鎖被占用的時間很短,自旋等待的效果就會非常好。反之,如果鎖被占用的時間很長,那么自旋的線程只會白浪費處理器資源。所以,自旋等待的時間必須要有一定的限度,如果自旋超過了限定次數(默認是10次,可以使用-XX:PreBlockSpin來更改)沒有成功獲得鎖,就應當掛起線程。
自旋鎖的實現原理同樣也是CAS,AtomicInteger中調用unsafe進行自增操作的源碼中的do-while循環就是一個自旋操作,如果修改數值失敗則通過循環來執行自旋,直至修改成功。
自旋鎖在JDK1.4.2中引入,使用-XX:+UseSpinning來開啟。JDK 6中變為默認開啟,并且引入了自適應的自旋鎖(適應性自旋鎖)。
自適應意味著自旋的時間(次數)不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運行中,那么虛擬機就會認為這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對于某個鎖,自旋很少成功獲得過,那在以后嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。
在自旋鎖中另有三種常見的鎖形式:TicketLock、CLHlock和MCSlock,本文中僅做名詞介紹,不做深入講解,感興趣的同學可以自行查閱相關資料。
3.無鎖 VS 偏向鎖 VS 輕量級鎖 VS 重量級鎖
這四種鎖是指鎖的狀態,專門針對synchronized的。在介紹這四種鎖狀態之前還需要介紹一些額外的知識。
首先為什么Synchronized能實現線程同步?
在回答這個問題之前我們需要了解兩個重要的概念:“Java對象頭”、“Monitor”。
Java對象頭
synchronized是悲觀鎖,在操作同步資源之前需要給同步資源先加鎖,這把鎖就是存在Java對象頭里的,而Java對象頭又是什么呢?
我們以Hotspot虛擬機為例,Hotspot的對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。
Mark Word:默認存儲對象的HashCode,分代年齡和鎖標志位信息。這些信息都是與對象自身定義無關的數據,所以Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲盡量多的數據。它會根據對象的狀態復用自己的存儲空間,也就是說在運行期間Mark Word里存儲的數據會隨著鎖標志位的變化而變化。
Klass Point:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
Monitor
Monitor可以理解為一個同步工具或一種同步機制,通常被描述為一個對象。每一個Java對象就有一把看不見的鎖,稱為內部鎖或者Monitor鎖。
Monitor是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor關聯,同時monitor中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程占用。
現在話題回到synchronized,synchronized通過Monitor來實現線程同步,Monitor是依賴于底層的操作系統的Mutex Lock(互斥鎖)來實現的線程同步。
如同我們在自旋鎖中提到的“阻塞或喚醒一個Java線程需要操作系統切換CPU狀態來完成,這種狀態轉換需要耗費處理器時間。如果同步代碼塊中的內容過于簡單,狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長”。這種方式就是synchronized最初實現同步的方式,這就是JDK 6之前synchronized效率低的原因。這種依賴于操作系統Mutex Lock所實現的鎖我們稱之為“重量級鎖”,JDK 6中為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”。
所以目前鎖一共有4種狀態,級別從低到高依次是:無鎖、偏向鎖、輕量級鎖和重量級鎖。鎖狀態只能升級不能降級。
通過上面的介紹,我們對synchronized的加鎖機制以及相關知識有了一個了解,那么下面我們給出四種鎖狀態對應的的Mark Word內容,然后再分別講解四種鎖狀態的思路以及特點:
無鎖
無鎖沒有對資源進行鎖定,所有的線程都能訪問并修改同一個資源,但同時只有一個線程能修改成功。
無鎖的特點就是修改操作在循環內進行,線程會不斷的嘗試修改共享資源。如果沒有沖突就修改成功并退出,否則就會繼續循環嘗試。如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功。上面我們介紹的CAS原理及應用即是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。
偏向鎖
偏向鎖是指一段同步代碼一直被一個線程所訪問,那么該線程會自動獲取鎖,降低獲取鎖的代價。
在大多數情況下,鎖總是由同一線程多次獲得,不存在多線程競爭,所以出現了偏向鎖。其目標就是在只有一個線程執行同步代碼塊時能夠提高性能。
當一個線程訪問同步代碼塊并獲取鎖時,會在Mark Word里存儲鎖偏向的線程ID。在線程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測Mark Word里是否存儲著指向當前線程的偏向鎖。引入偏向鎖是為了在無多線程競爭的情況下盡量減少不必要的輕量級鎖執行路徑,因為輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令即可。
偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖,線程不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處于被鎖定狀態。撤銷偏向鎖后恢復到無鎖(標志位為“01”)或輕量級鎖(標志位為“00”)的狀態。
偏向鎖在JDK 6及以后的JVM里是默認啟用的。可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,關閉之后程序默認會進入輕量級鎖狀態。
輕量級鎖
是指當鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。
在代碼進入同步塊的時候,如果同步對象鎖狀態為無鎖狀態(鎖標志位為“01”狀態,是否為偏向鎖為“0”),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的Mark Word的拷貝,然后拷貝對象頭中的Mark Word復制到鎖記錄中。
拷貝成功后,虛擬機將使用CAS操作嘗試將對象的Mark Word更新為指向Lock Record的指針,并將Lock Record里的owner指針指向對象的Mark Word。
如果這個更新動作成功了,那么這個線程就擁有了該對象的鎖,并且對象Mark Word的鎖標志位設置為“00”,表示此對象處于輕量級鎖定狀態。
如果輕量級鎖的更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明多個線程競爭鎖。
若當前只有一個等待線程,則該線程通過自旋進行等待。但是當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級為重量級鎖。
重量級鎖
升級為重量級鎖時,鎖標志的狀態值變為“10”,此時Mark Word中存儲的是指向重量級鎖的指針,此時等待鎖的線程都會進入阻塞狀態。
整體的鎖狀態升級流程如下:
綜上,偏向鎖通過對比Mark Word解決加鎖問題,避免執行CAS操作。而輕量級鎖是通過用CAS操作和自旋來解決加鎖問題,避免線程阻塞和喚醒而影響性能。重量級鎖是將除了擁有鎖的線程以外的線程都阻塞。
4. 公平鎖 VS 非公平鎖
公平鎖是指多個線程按照申請鎖的順序來獲取鎖,線程直接進入隊列中排隊,隊列中的第一個線程才能獲得鎖。公平鎖的優點是等待鎖的線程不會餓死,缺點是整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU喚醒阻塞線程的開銷比非公平鎖大。
非公平鎖是多個線程加鎖時直接嘗試獲取鎖,獲取不到才會到等待隊列的隊尾等待。但如果此時鎖剛好可用,那么這個線程可以無需阻塞直接獲取到鎖,所以非公平鎖有可能出現后申請鎖的線程先獲取鎖的場景。非公平鎖的優點是可以減少喚起線程的開銷,整體的吞吐效率高,因為線程有幾率不阻塞直接獲得鎖,CPU不必喚醒所有線程。缺點是處于等待隊列中的線程可能會餓死,或者等很久才會獲得鎖。
直接用語言描述可能有點抽象,這里用一個例子來講述一下公平鎖和非公平鎖。
如上圖所示,假設有一口水井,有管理員看守,管理員有一把鎖,只有拿到鎖的人才能夠打水,打完水要把鎖還給管理員。每個過來打水的人都要管理員的允許并拿到鎖之后才能去打水,如果前面有人正在打水,那么這個想要打水的人就必須排隊。管理員會查看下一個要去打水的人是不是隊伍里排最前面的人,如果是的話,才會給你鎖讓你去打水;如果你不是排第一的人,就必須去隊尾排隊,這就是公平鎖。
但是對于非公平鎖,管理員對打水的人沒有要求。即使等待隊伍里有排隊等待的人,但如果在上一個人剛打完水把鎖還給管理員而且管理員還沒有允許等待隊伍里下一個人去打水時,剛好來了一個插隊的人,這個插隊的人是可以直接從管理員那里拿到鎖去打水,不需要排隊,原本排隊等待的人只能繼續等待。如下圖所示
接下來我們通過ReentrantLock的源碼來講解公平鎖和非公平鎖。
根據代碼可知,ReentrantLock里面有一個內部類Sync,Sync繼承AQS(AbstractQueuedSynchronizer),添加鎖和釋放鎖的大部分操作實際上都是在Sync中實現的。它有公平鎖FairSync和非公平鎖NonfairSync兩個子類。ReentrantLock默認使用非公平鎖,也可以通過構造器來顯示的指定使用公平鎖。
下面我們來看一下公平鎖與非公平鎖的加鎖方法的源碼:
通過上圖中的源代碼對比,我們可以明顯的看出公平鎖與非公平鎖的lock()方法唯一的區別就在于公平鎖在獲取同步狀態時多了一個限制條件:hasQueuedPredecessors()。
再進入hasQueuedPredecessors(),可以看到該方法主要做一件事情:主要是判斷當前線程是否位于同步隊列中的第一個。如果是則返回true,否則返回false。
綜上,公平鎖就是通過同步隊列來實現多個線程按照申請鎖的順序來獲取鎖,從而實現公平的特性。非公平鎖加鎖時不考慮排隊等待問題,直接嘗試獲取鎖,所以存在后申請卻先獲得鎖的情況。
5. 可重入鎖 VS 非可重入鎖
可重入鎖又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,再進入該線程的內層方法會自動獲取鎖(前提鎖對象得是同一個對象或者class),不會因為之前已經獲取過還沒釋放而阻塞。Java中ReentrantLock和synchronized都是可重入鎖,可重入鎖的一個優點是可一定程度避免死鎖。下面用示例代碼來進行分析:
在上面的代碼中,類中的兩個方法都是被內置鎖synchronized修飾的,doSomething()方法中調用doOthers()方法。因為內置鎖是可重入的,所以同一個線程在調用doOthers()時可以直接獲得當前對象的鎖,進入doOthers()進行操作。
如果是一個不可重入鎖,那么當前線程在調用doOthers()之前需要將執行doSomething()時獲取當前對象的鎖釋放掉,實際上該對象鎖已被當前線程所持有,且無法釋放。所以此時會出現死鎖。
而為什么可重入鎖就可以在嵌套調用時可以自動獲得鎖呢?我們通過圖示和源碼來分別解析一下。
還是打水的例子,有多個人在排隊打水,此時管理員允許鎖和同一個人的多個水桶綁定。這個人用多個水桶打水時,第一個水桶和鎖綁定并打完水之后,第二個水桶也可以直接和鎖綁定并開始打水,所有的水桶都打完水之后打水人才會將鎖還給管理員。這個人的所有打水流程都能夠成功執行,后續等待的人也能夠打到水。這就是可重入鎖。
但如果是非可重入鎖的話,此時管理員只允許鎖和同一個人的一個水桶綁定。第一個水桶和鎖綁定打完水之后并不會釋放鎖,導致第二個水桶不能和鎖綁定也無法打水。當前線程出現死鎖,整個等待隊列中的所有線程都無法被喚醒。
之前我們說過ReentrantLock和synchronized都是重入鎖,那么我們通過重入鎖ReentrantLock以及非可重入鎖NonReentrantLock的源碼來對比分析一下為什么非可重入鎖在重復調用同步資源時會出現死鎖。
首先ReentrantLock和NonReentrantLock都繼承父類AQS,其父類AQS中維護了一個同步狀態status來計數重入次數,status初始值為0。
當線程嘗試獲取鎖時,可重入鎖先嘗試獲取并更新status值,如果status == 0表示沒有其他線程在執行同步代碼,則把status置為1,當前線程開始執行。如果status != 0,則判斷當前線程是否是獲取到這個鎖的線程,如果是的話執行status+1,且當前線程可以再次獲取鎖。而非可重入鎖是直接去獲取并嘗試更新當前status的值,如果status != 0的話會導致其獲取鎖失敗,當前線程阻塞。
釋放鎖時,可重入鎖同樣先獲取當前status的值,在當前線程是持有鎖的線程的前提下。如果status-1 == 0,則表示當前線程所有重復獲取鎖的操作都已經執行完畢,然后該線程才會真正釋放鎖。而非可重入鎖則是在確定當前線程是持有鎖的線程之后,直接將status置為0,將鎖釋放。
6. 獨享鎖 VS 共享鎖
獨享鎖和共享鎖同樣是一種概念。我們先介紹一下具體的概念,然后通過ReentrantLock和ReentrantReadWriteLock的源碼來介紹獨享鎖和共享鎖。
獨享鎖也叫排他鎖,是指該鎖一次只能被一個線程所持有。如果線程T對數據A加上排它鎖后,則其他線程不能再對A加任何類型的鎖。獲得排它鎖的線程即能讀數據又能修改數據。JDK中的synchronized和JUC中Lock的實現類就是互斥鎖。
共享鎖是指該鎖可被多個線程所持有。如果線程T對數據A加上共享鎖后,則其他線程只能對A再加共享鎖,不能加排它鎖。獲得共享鎖的線程只能讀數據,不能修改數據。
獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。
下圖為ReentrantReadWriteLock的部分源碼:
我們看到ReentrantReadWriteLock有兩把鎖:ReadLock和WriteLock,由詞知意,一個讀鎖一個寫鎖,合稱“讀寫鎖”。再進一步觀察可以發現ReadLock和WriteLock是靠內部類Sync實現的鎖。Sync是AQS的一個子類,這種結構在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在ReentrantReadWriteLock里面,讀鎖和寫鎖的鎖主體都是Sync,但讀鎖和寫鎖的加鎖方式不一樣。讀鎖是共享鎖,寫鎖是獨享鎖。讀鎖的共享鎖可保證并發讀非常高效,而讀寫、寫讀、寫寫的過程互斥,因為讀鎖和寫鎖是分離的。所以ReentrantReadWriteLock的并發性相比一般的互斥鎖有了很大提升。
那讀鎖和寫鎖的具體加鎖方式有什么區別呢?在了解源碼之前我們需要回顧一下其他知識。
在最開始提及AQS的時候我們也提到了state字段(int類型,32位),該字段用來描述有多少線程獲持有鎖。
在獨享鎖中這個值通常是0或者1(如果是重入鎖的話state值就是重入的次數),在共享鎖中state就是持有鎖的數量。但是在ReentrantReadWriteLock中有讀、寫兩把鎖,所以需要在一個整型變量state上分別描述讀鎖和寫鎖的數量(或者也可以叫狀態)。于是將state變量“按位切割”切分成了兩個部分,高16位表示讀鎖狀態(讀鎖個數),低16位表示寫鎖狀態(寫鎖個數)。如下圖所示:
了解了概念之后我們再來看代碼,先看寫鎖的加鎖源碼:
- 這段代碼首先取到當前鎖的個數c,然后再通過c來獲取寫鎖的個數w。因為寫鎖是低16位,所以取低16位的最大值與當前的c做與運算( int w
= exclusiveCount; ),高16位和0與運算后是0,剩下的就是低位運算的值,同時也是持有寫鎖的線程數目。 - 在取到寫鎖線程的數目后,首先判斷是否已經有線程持有了鎖。如果已經有線程持有了鎖(c!=0),則查看當前寫鎖線程的數目,如果寫線程數為0(即此時存在讀鎖)或者持有鎖的線程不是當前線程就返回失敗(涉及到公平鎖和非公平鎖的實現)。
- 如果寫入鎖的數量大于最大數(65535,2的16次方-1)就拋出一個Error。
- 如果當且寫線程數為0(那么讀線程也應該為0,因為上面已經處理c!=0的情況),并且當前線程需要阻塞那么就返回失敗;如果通過CAS增加寫線程數失敗也返回失敗。
- 如果c=0,w=0或者c>0,w>0(重入),則設置當前線程或鎖的擁有者,返回成功!
tryAcquire()除了重入條件(當前線程為獲取了寫鎖的線程)之外,增加了一個讀鎖是否存在的判斷。如果存在讀鎖,則寫鎖不能被獲取,原因在于:必須確保寫鎖的操作對讀鎖可見,如果允許讀鎖在已被獲取的情況下對寫鎖的獲取,那么正在運行的其他讀線程就無法感知到當前寫線程的操作。
因此,只有等待其他讀線程都釋放了讀鎖,寫鎖才能被當前線程獲取,而寫鎖一旦被獲取,則其他讀寫線程的后續訪問均被阻塞。寫鎖的釋放與ReentrantLock的釋放過程基本類似,每次釋放均減少寫狀態,當寫狀態為0時表示寫鎖已被釋放,然后等待的讀寫線程才能夠繼續訪問讀寫鎖,同時前次寫線程的修改對后續的讀寫線程可見。
接著是讀鎖的代碼:
可以看到在tryAcquireShared(int unused)方法中,如果其他線程已經獲取了寫鎖,則當前線程獲取讀鎖失敗,進入等待狀態。如果當前線程獲取了寫鎖或者寫鎖未被獲取,則當前線程(線程安全,依靠CAS保證)增加讀狀態,成功獲取讀鎖。讀鎖的每次釋放(線程安全的,可能有多個讀線程同時釋放讀鎖)均減少讀狀態,減少的值是“1<<16”。所以讀寫鎖才能實現讀讀的過程共享,而讀寫、寫讀、寫寫的過程互斥。
此時,我們再回頭看一下互斥鎖ReentrantLock中公平鎖和非公平鎖的加鎖源碼:
我們發現在ReentrantLock雖然有公平鎖和非公平鎖兩種,但是它們添加的都是獨享鎖。根據源碼所示,當某一個線程調用lock方法獲取鎖時,如果同步資源沒有被其他線程鎖住,那么當前線程在使用CAS更新state成功后就會成功搶占該資源。而如果公共資源被占用且不是被當前線程占用,那么就會加鎖失敗。所以可以確定ReentrantLock無論讀操作還是寫操作,添加的鎖都是都是獨享鎖。
1.8. Java中Volatile關鍵字(重要)
基本概念:Java 內存模型中的可見性、原子性和有序性。
原子性:(原子是世界上的最小單位,具有不可分割性)原子性就是指該操作是不可再分的。不論是多核還是單核,具有原子性的量,同一時刻只能有一個線程來對它進行操作。簡而言之,在整個操作過程中不會被線程調度器中斷的操作,都可認為是原子性。比如 a = 1;
非原子性:
也就是整個過程中會出現線程調度器中斷操作的現象
類似"a ++"這樣的操作不具有原子性,因為它可能要經過以下兩個步驟:
(1)取出 a 的值
(2)計算 a+1
如果有兩個線程t1,t2都在進行這樣的操作。t1在第一步做完之后還沒來得及加1操作就被線程調度器中斷了,于是t2開始執行,t2執行完畢后t1開始執行第二步(此時t1中a的值可能還是舊值,不是一定的,只有線程t2中a的值沒有及時更新到t1中才會出現)。這個時候就出現了錯誤,t2的操作相當于被忽略了
類似于a += 1這樣的操作都不具有原子性。還有一種特殊情況,就是long跟double類型某些情況也不具有原子性
只有簡單的讀取、賦值(而且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操作)才是原子操作。
舉例:請分析以下哪些操作是原子性操作:
x = 10; //語句1 y = x; //語句2 x++; //語句3 x = x + 1; //語句4其實只有語句1是原子性操作,其他三個語句都不是原子性操作。
語句1是直接將數值10賦值給x,也就是說線程執行這個語句的會直接將數值10寫入到工作內存中。
語句2實際上包含2個操作,它先要去讀取x的值,再將x的值寫入工作內存,雖然讀取x的值以及將x的值寫入工作內存 這2個操作都是原子性操作,但是合起來就不是原子性操作了。
同樣的,x++和 x = x+1包括2個操作:讀取x的值,進行加1操作,寫入新的值。
如何保證原子性?
synchronized、Lock、cas原子類工具由于synchronized和Lock能夠保證任一時刻只有一個線程執行該代碼塊,那么自然就不存在原子性問題了,從而保證了原子性。其次cas原子類工具。
共享變量:如果一個變量在多個線程的工作內存中都存在副本,那么這個變量就是這個幾個線程的共享變量
可見性:一個線程對共享變量值的修改,能夠及時的被其它線程看到。也就是一個線程對共享變量修改的結果,另一個線程馬上就能看到修改的值。
如何保證可見性?
volatile、synchronized、Lock要想實現變量的一定可見,可以使用volatile。另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖然后執行同步代碼,并且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。(其實還有final,但是它初始化后,值不可更改,所以一般不用它實現可見性)。
指令重排:CPU在執行代碼時,其實并不一定會嚴格按照我們編寫的順序去執行,而是可能會考慮一些效率方面的原因,對那些先后順序無關緊要的代碼進行重新排序,這個操作就被稱為指令重排。指令重排在單線程情況下沒有什么影響,但是在多線程就不一定了。
有序性:程序執行的順序按照代碼先后的順序執行。
如何保證有序性?
volatile、synchronized、Lockvolatile:
volatile原理:Java語言提供了一種稍弱的同步機制,即volatile變量,用來確保將變量的更新操作通知到其他線程。當把變量聲明為volatile類型后,編譯器與運行時都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內存操作一起重排序。volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取volatile類型的變量時總會返回最新寫入的值。
在訪問volatile變量時不會執行加鎖操作,因此也就不會使執行線程阻塞,因此volatile變量是一種比sychronized關鍵字更輕量級的同步機制。
當對非 volatile 變量進行讀寫的時候,每個線程先從內存拷貝變量到CPU緩存中。如果計算機有多個CPU,每個線程可能在不同的CPU上被處理,這意味著每個線程可以拷貝到不同的CPU cache 中。
而聲明變量是 volatile 的,JVM 保證了每次讀變量都從內存中讀,跳過 CPU cache 這一步。
當一個變量定義為 volatile 之后,將具備兩種特性:
1.保證此變量對所有的線程的可見性。當一個線程修改了這個變量的值,volatile 保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。而普通的共享變量不能保證可見性,因為普通共享變量被修改之后,什么時候被寫入主內存是不確定的,當其他線程去讀取時,此時主內存中可能還是原來的舊值,因此無法保證可見性。
2.禁止指令重排序優化。有volatile修飾的變量,賦值后多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當于一個內存屏障(指令重排序時不能把后面的指令重排序到內存屏障之前的位置),只有一個CPU訪問內存時,并不需要內存屏障;(什么是指令重排序:是指CPU采用了允許將多條指令不按程序規定的順序分開發送給各相應電路單元處理)。
volatile 性能:
volatile 的讀性能消耗與普通變量幾乎相同,但是寫操作稍慢,因為它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。
volatile為什么不能保證原子性?
簡單的說,修改volatile變量分為四步:
這樣就很容易看出來,前三步都是不安全的,取值和寫回之間,不能保證沒有其他線程修改。原子性需要鎖來保證。(或者可以理解為線程安全需要鎖來保證)。這也就是為什么,volatile只用來保證變量可見性和有序性,但不保證原子性。
1.9.synchronized同步原理
數據同步需要依賴鎖,那鎖的同步又依賴誰?synchronized給出的答案是在軟件層面依賴JVM,而j.u.c.Lock給出的答案是在硬件層面依賴特殊的CPU指令。
package com.paddx.test.concurrent; public class SynchronizedDemo {public void method() {synchronized (this) {System.out.println("Method 1 start");}} }反編譯結果:
2 JVM(java虛擬機)
2.1. 運行時數據區域
Jvm內存區域(運行時數據區)劃分:
程序計數器:當前線程的字節碼執行位置的指示器。內存空間小,線程私有。如果線程正在執行一個 Java 方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是 Native 方法,這個計數器的值則為 (Undefined)。此內存區域是唯一 一個在 Java 虛擬機規范中沒有規定任何 OutOfMemoryError 情況的區域。
Java虛擬機棧:線程私有,生命周期和線程一致。描述的Java方法執行的內存模型,每個方法在執行的同時會創建一個棧幀(Stack Frame),存儲著局部變量、操作數棧、動態鏈接和方法出口等。每一個方法從調用直至執行結束,就對應著一個棧幀從虛擬機棧中入棧到出棧的過程。
局部變量表:存放了編譯期可知的各種基本類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference 類型)和 returnAddress 類型(指向了一條字節碼指令的地址)
StackOverflowError:線程請求的棧深度大于虛擬機所允許的深度。
OutOfMemoryError:如果虛擬機棧可以動態擴展,而擴展時無法申請到足夠的內存。
本地方法棧:區別于 Java 虛擬機棧的是,Java 虛擬機棧為虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧為虛擬機使用到的Native方法服務。也會有StackOverflowError和 OutOfMemoryError 異常。
Java堆:所有對象實例分配的區域。對于絕大多數應用來說,這塊區域是 JVM 所管理的內存中最大的一塊。線程共享,主要是存放對象實例和數組。內部會劃分出多個線程私有的分配緩沖區(Thread Local Allocation Buffer, TLAB)。可以位于物理上不連續的空間,但是邏輯上要連續。
OutOfMemoryError:如果堆中沒有內存完成實例分配,并且堆也無法再擴展時,拋出該異常。
方法區:所有已經被虛擬機加載的類的信息、常量、靜態變量和即時編輯器編譯后的代碼數據
詳細說明:
程序計數器
程序計數器(Program Counter Register) 是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。
字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。
由于 Java 虛擬機的多線程是通過線程輪流切換并分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對于多核處理器來說是一個內核)都只會執行一條線程中的指令。
因此,為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存。
如果線程正在執行的是一個 Java 方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址。
如果線程正在執行的是一個 Native 方法,這個計數器值則為空(Undefined)。
此內存區域是唯一一個在Java虛擬機規范中沒有規定任何OutOfMemoryError情況的區域。
Java 虛擬機棧
Java 虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是 Java 方法執行的內存模型,每個方法在執行的同時都會創建一個棧幀(Stack Frame) 用于存儲局部變量表、操作數棧、動態鏈接、方法出口等消息。每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程。
局部變量表存放了編譯器可知的各種基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它不等同于對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)和 returnAddress 類型(指向了一條字節碼指令的地址)。
其中 64 位長度的 long 和 double 類型的數據會占用兩個局部變量空間(Slot),其余的數據類型只占用一個。局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。
在 Java 虛擬機規范中,對這個區域規定了兩種異常狀態:
如果線程請求的棧深度大于虛擬機所允許的的深度,將拋出 StackOverflowError 異常。
如果虛擬機棧可以動態擴展(當前大部分的Java虛擬機都可動態擴展,只不過Java虛擬機規范中也允許固定長度的虛擬機棧),如果擴展時無法申請到足夠的內存,就會拋出 OutOfMemoryError 異常。
本地方法棧
本地方法棧(Native Method Stack) 與虛擬機棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則為虛擬機使用到的Native方法服務。
在虛擬機規范中對本地方法棧中方法使用的語言、使用方式與數據結構并沒有強制規定,因此具體的虛擬機可以自由實現它。甚至有的虛擬機(例如:Sun HotSpot虛擬機)直接就把虛擬機棧和本地方法棧合二為一。與虛擬機棧一樣,本地方法棧區域也會拋出 StackOverflowError 和 OutOfMemoryError 異常。
Java 堆
對于大多數應用來說,Java 堆(Java Heap) 是 Java 虛擬機所管理的的內存中最大的一塊。Java 堆是被所有線程共享的一塊內存區域,在虛擬機啟動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這里分配內存。
Java堆是垃圾收集器管理的主要區域,從內存回收的角度來看,由于現在收集器基本采用分代收集算法,所以Java堆中還可以細分為:新生代和老年代;再細致一點的有 Eden 空間、From Survivor 空間、To Survivor 空間等。
從內存分配的角度來看,線程共享的Java堆中可能劃分出多個線程私有的分配緩沖區(Thread Local Allocation Buffer,TLAB)。不過無論如何劃分,都與存放內容無關,無論哪個區域,存儲的仍然是對象實例,進一步劃分的目的是為了更好地回收內存,或者更快地分配內存。
方法區
方法區(Method Area)與 Java 堆一樣,是各個線程共享的內存區域,它用于存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據。
運行時常量池(Runtime Constant Pool) 是方法區的一部分。Class 文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用于存放編譯器生成的各種字面量和符號引用,這部分內容將在類加載后進入方法區的運行時常量池中存放。
既然運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時就會拋出 OutOfMemoryError 異常。
擴展:String s1 = "abc"和String s2 = new String(“abc”)的區別,生成對象的情況
指向方法區:"abc"是常量,所以它會在方法區中分配內存,如果方法區已經給"abc"分配過內存,則s1會直接指向這塊內存區域。
指向Java堆:new String(“abc”)是重新生成了一個Java實例,它會在Java堆中分配一塊內存。
2.1. GC機制(重要)
GC 是 garbage collection 的縮寫, 垃圾回收的意思. 也可以是 Garbage Collector, 也就是垃圾回收器.
Java的內存分配與回收全部由JVM垃圾回收進程自動完成。
面試題:“你能不能談談,java GC”
1、哪些對象可以被回收。 2、何時回收這些對象。 3、采用什么樣的方式回收。問題1:哪些對象可以被回收?
對象存活判斷(如何判斷對象可回收/垃圾搜集)
判斷一個對象可以回收通常采用的算法是引用計數算法和可達性分析算法。由于互相引用導致的計數不好判斷,Java采用的可達性算法。
引用計數算法
每個對象有一個引用計數屬性,新增一個引用時計數加1,引用釋放時計數減1,計數為0時可以回收。此方法簡單,效率很高,但是主流的JVM并沒有選用這種算法來判定可回收對象,因為它有一個致命的缺陷,那就是它無法解決對象之間相互循環引用的的問題,對于循環引用的對象它無法進行回收。例:
程序啟動后,objectA和objectB兩個對象被創建并在堆中分配內存,這兩個對象都相互持有對方的引用,除此之外,這兩個對象再無任何其他引用,實際上這兩個對象已經不可能再被訪問(引用被置空,無法訪問),但是它們因為相互引用著對方,導致它們的引用計數器都不為0,于是引用計數算法無法通知GC收集器回收它們。
實際上,當第1步執行時,兩個對象的引用計數器值都為1;當第2步執行時,兩個對象的引用計數器都為2;當第3步執行時,二者都清為空值,引用計數器值都變為1。根據引用計數算法的思想,值不為0的對象被認為是存活的,不會被回收;而事實上這兩個對象已經不可能再被訪問了,應該被回收。
可達性分析算法(根搜索算法)
在主流的JVM實現中,都是通過可達性分析算法來判定對象是否存活的。可達性分析算法的基本思想是:通過一系列被稱為"GC Roots"的對象作為起始點,從這些節點開始向下搜索,搜索走過的路徑稱為引用鏈,當一個對象到GC Roots對象沒有任何引用鏈相連,就認為GC Roots到這個對象是不可達的,判定此對象為不可用對象,可以被回收。
在上圖中,objectA、objectB、objectC是可達的,不會被回收;objectD、objectE雖然有關聯,但是它們到GC Roots是不可達的,所以它們將會被判定為是可回收的對象。
在Java中,可作為GC Roots的對象包括下面幾種:
虛擬機棧中引用的對象
虛擬機棧中的引用的對象可以作為GC Root。我們程序在虛擬機的棧中執行,每次函數調用調用都是一次入棧。在棧中包括局部變量表和操作數棧,局部變量表中的變量可能為引用類型(reference),他們引用的對象即可作為GC Root。不過隨著函數調用結束出棧,這些引用便會消失。
方法區中類靜態屬性引用的對象
簡單的說就是我們在類中使用的static聲明的引用類型字段,例如:
方法區中常量引用的對象
簡單的說就是我們在類中使用final聲明的引用類型字段,例如:
本地方法棧中引用的對象
就是程序中native本地方法引用的對象。
問題3:采用什么樣的方式回收
GC常用算法
可達性分析算法只是知道了哪些對象可以回收,不過垃圾收集顯然還需要解決后兩個問題,什么時候回收以及如何回收,在根搜索算法的基礎上,現代虛擬機的實現當中,垃圾搜集的算法主要有三種,分別是標記-清除算法、復制算法、標記-整理算法,這三種算法都擴充了根搜索算法,不過它們理解起來還是非常好理解的。
標記 -清除算法
就是當程序運行期間,若可以使用的內存被耗盡的時候,GC線程就會被觸發并將程序暫停,隨后將依舊存活的對象標記一遍,最終再將堆中所有沒被標記的對象全部清除掉,接下來便讓程序恢復運行。之所以說它是最基礎的收集算法,是因為后續的收集算法都是基于這種思路并對其缺點進行改進而得到的。
它的主要缺點有兩個:一個是效率問題,標記和清除過程的效率都不高(遞歸與全堆對象遍歷);另外一個是空間問題,標記清除之后會產生大量不連續的內存碎片,內存的布局自然會亂七八糟。空間碎片太多可能會導致,當程序在以后的運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。
復制算法
“復制”(Copying)的收集算法,它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。
這樣使得每次都是對其中的一塊進行內存回收,內存分配時也就不用考慮內存碎片等復雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小為原來的一半,持續復制長生存期的對象則導致效率降低。
標記-整理算法
復制收集算法在對象存活率較高時就要執行較多的復制操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的內存中所有對象都100%存活的極端情況,所以在老年代一般不能直接選用這種算法。
根據老年代的特點,有人提出了另外一種“標記-整理”(Mark-Compact)算法,標記過程仍然與“標記-清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內存。標記/整理算法唯一的缺點就是效率也不高,不僅要標記所有存活對象,還要整理所有存活對象的引用地址。從效率上來說,標記/整理算法要低于復制算法。
分代搜集算法(重要)
GC 分代的基本假設:絕大部分對象的生命周期都非常短暫,存活時間短。
“分代搜集”算法,把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最適當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用復制算法,只需要付出少量存活對象的復制成本就可以完成收集。而老年代中因為對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清理”或“標記-整理”算法來進行回收。
新生代GC(minor GC):指發生在新生代的垃圾回收動作,因為Java對象大多都具備朝生夕滅的特點,所以minor GC發生得非常頻繁,一般回收速度也比較塊。老年代GC(Major GC/Full GC):指發生在老年代的GC,它的速度會比minor GC慢很多。問題2:何時回收這些對象
回收的時機
JVM在進行GC時,并非每次都對上面三個內存區域一起回收的,大部分時候回收的都是指新生代。因此GC按照回收的區域又分了兩種類型,一種是普通GC(minor GC),一種是全局GC(major GC or Full GC),它們所針對的區域如下。普通GC(minor GC):只針對新生代區域的GC。全局GC(major GC or Full GC):針對年老代的GC,偶爾伴隨對新生代的GC以及對永久代的GC。由于年老代與永久代相對來說GC效果不好,而且二者的內存使用增長速度也慢,因此一般情況下,需要經過好幾次普通GC,才會觸發一次全局GC。
內存模型與回收策略
Java 堆(Java Heap)是JVM所管理的內存中最大的一塊,堆又是垃圾收集器管理的主要區域,Java 堆主要分為2個區域-新生代與老年代,其中年輕代又分 Eden 區和 Survivor 區,其中 Survivor 區又分 From 和 To 2個區。
Eden 區
大多數情況下,對象會在新生代 Eden 區中進行分配,當 Eden 區沒有足夠空間進行分配時,虛擬機會發起一次 Minor GC,Minor GC 相比 Major GC 更頻繁,回收速度也更快。 通過 Minor GC 之后,Eden 會被清空,Eden 區中絕大部分對象會被回收,而那些無需回收的存活對象,將會進到 Survivor 的 From 區(若 From 區不夠,則直接進入 Old 區)。
Survivor 區
Survivor 區相當于是 Eden 區和 Old 區的一個緩沖,類似于我們交通燈中的黃燈。Survivor 又分為2個區,一個是 From 區,一個是 To 區。每次執行 Minor GC,會將 Eden 區和 From 存活的對象放到 Survivor 的 To 區(如果 To 區不夠,則直接進入 Old 區)。Survivor 的存在意義就是減少被送到老年代的對象,進而減少 Major GC 的發生。Survivor 的預篩選保證,只有經歷16次 Minor GC 還能在新生代中存活的對象,才會被送到老年代。
Old 區
老年代占據著2/3的堆內存空間,只有在 Major GC 的時候才會進行清理,每次 GC 都會觸發“Stop-The-World”。內存越大,STW 的時間也越長,所以內存也不僅僅是越大就越好。由于復制算法在對象存活率較高的老年代會進行很多次的復制操作,效率很低,所以老年代這里采用的是標記——整理算法。
java垃圾收集器:(共7種,著重了解CMS和G1)
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的 Java 應用都集中在互聯網站或B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。
從名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“標記-清除”算法實現的,它的運作過程相對于前面幾種收集器來說要更復雜一些,整個過程分為4個步驟,包括:
初始標記(CMS initial mark)
并發標記(CMS concurrent mark)
重新標記(CMS remark)
并發清除(CMS concurrent sweep)
其中初始標記、重新標記這兩個步驟仍然需要“Stop The World”。初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快,并發標記階段就是進行GC Roots Tracing的過程,而重新標記階段則是為了修正并發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比并發標記的時間短。
由于整個過程中耗時最長的并發標記和并發清除過程中,收集器線程都可以與用戶線程一起工作,所以總體上來說,CMS收集器的內存回收過程是與用戶線程一起并發地執行。老年代收集器(新生代使用ParNew)
G1收集器
與CMS收集器相比G1收集器有以下特點:
1、空間整合,G1收集器采用標記整理算法,不會產生內存空間碎片。分配大對象時不會因為無法找到連續空間而提前觸發下一次GC。
2、可預測停頓,這是G1的另一大優勢,降低停頓時間是G1和CMS的共同關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為N毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時 Java(RTSJ)的垃圾收集器的特征了。
使用G1收集器時,Java堆的內存布局與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔閡了,它們都是一部分(可以不連續)Region 的集合。
G1的新生代收集跟 ParNew 類似,當新生代占用達到一定比例的時候,開始出發收集。和 CMS 類似,G1 收集器收集老年代對象會有短暫停頓。
2.3. 類加載過程
類加載的時機:
- 隱式加載:new創建類的實例
- 顯式加載:loaderClass,forName等
- 操作類的靜態變量(使用或賦值)
- 調用類的靜態方法
- 使用反射方式創建某個類或者接口對象的Class對象。
- 初始化某個類的子類
- 直接使用java.exe命令來運行某個主類
類的加載過程:
其中加載、驗證、準備、初始化和卸載這五個階段的順序是確定的。解析階段可以在初始化之后再開始(運行時綁定或動態綁定或晚期綁定)。
加載:ClassLoader通過一個類的完全限定名查找此類字節碼文件,并利用字節碼文件創建一個class對象。
驗證:對類的驗證。目的在于確保class文件的字節流中包含信息符合當前虛擬機要求,不會危害虛擬機自身的安全,主要包括四種驗證:文件格式的驗證,元數據的驗證,字節碼驗證,符號引用驗證。
準備:為類變量分配內存并設置初始值。
為類變量(static修飾的字段變量)分配內存并且設置該類變量的初始值,(如static int i = 5 這里只是將 i 賦值為0,在初始化的階段再把 i 賦值為5),這里不包含final修飾的static ,因為final在編譯的時候就已經分配了。這里不會為實例變量分配初始化,類變量會分配在方法區中,實例變量會隨著對象分配到Java堆中。
解析:將常量池的符號引用轉化為直接引用。
初始化:這里是類加載的最后階段,前面過程都是以虛擬機主導,而初始化階段開始執行類中定義的Java程序代碼,包括類變量的賦值動作和構造函數的賦值。
如果該類具有父類就進行對父類進行初始化,執行其靜態初始化器(靜態代碼塊)和靜態初始化成員變量。(前面已經對static 初始化了默認值,這里我們對它進行賦值,成員變量也將被初始化)
使用:
卸載
只有加載、驗證、準備、初始化和卸載的這個五個階段的順序是確定的。
2.4. 雙親委派模型
類加載的機制,以及為什么要這樣設計?
類加載的機制是雙親委派模型。大部分Java程序需要使用的類加載器包括:
- 啟動類加載器:由C++語言實現,負責加載Java中的核心類。
- 擴展類加載器:負責加載Java擴展的核心類之外的類。
- 應用程序類加載器:負責加載用戶類路徑上指定的類庫
雙親委派模型如下:
雙親委派模型要求出了頂層的啟動類加載器之外,其他的類加載器都有自己的父加載器,通過組合實現。
雙親委派模型的工作流程/原理:
簡單說:
當一個類加載的任務來臨的時候,先交給父類加載器完成,父類加載器交給父父類加載器完成,知道傳遞給啟動類加載器,如果完成不了的情況下,再依次往下傳遞類加載的任務。
詳細解釋:
如果一個類收到了類加載的請求,它并不會自己先去加載,而是把這個請求委托給父類加載器去執行,如果父類加載器還存在父類加載器,則進一步向上委托,依次遞歸,請求最后到達頂層的啟動類加載器,如果父類能夠完成類的加載任務,就會成功返回,倘若父類加載器無法完成任務,子類加載器才會嘗試自己去加載,這就是雙親委派模式。就是每個兒子都很懶,遇到類加載的活都給它爸爸干,直到爸爸說我也做不來的時候,兒子才會想辦法自己去加載。
雙親委派模型的優勢?這樣設計的原因?:
簡單說:
雙親委派模型能夠保證Java程序的穩定運行,不同層次的類加載器具有不同優先級,所有的對象的父類Object,無論哪一個類加載器加載,最后都會交給啟動類加載器,保證安全。
詳細解釋:
采用雙親委派模式的好處就是Java類隨著它的類加載器一起具備一種帶有優先級的層次關系,通過這種層級關系可以避免類的重復加載,當父親已經加載了該類的時候,就沒有必要子類加載器(ClassLoader)再加載一次。其次是考慮到安全因素,Java核心API中定義類型不會被隨意替換,假設通過網路傳遞一個名為java.lang.Integer的類,通過雙親委派的的模式傳遞到啟動類加載器,而啟動類加載器在核心Java API發現這個名字類,發現該類已經被加載,并不會重新加載網絡傳遞過來的java.lang.Integer.而之際返回已經加載過的Integer.class,這樣便可以防止核心API庫被隨意篡改。可能你會想,如果我們在calsspath路徑下自定義一個名為java.lang.SingInteger該類并不存在java.lang中,經過雙親委托模式,傳遞到啟動類加載器中,由于父類加載器路徑下并沒有該類,所以不會加載,將反向委托給子類加載器,最終會通過系統類加載器加載該類,但是這樣做是不允許的,因為java.lang是核心的API包,需要訪問權限,強制加載將會報出如下異常。
為什么叫雙親委派?
parents delegate
parents在英文中是“父母”、“雙親”的意思,但其實表達的是“父母這一輩”的人的意思。實際上這個模型中,只是表達“父母這一輩”的class loader而已,并不是說真的有一個父親的class loader和一個母親class loader。
簡單來說,就是翻譯的人,不僅英語不好,而且也不理解jvm的類加載機制,才會導致翻譯成這樣
先自我介紹一下,小編13年上師交大畢業,曾經在小公司待過,去過華為OPPO等大廠,18年進入阿里,直到現在。深知大多數初中級java工程師,想要升技能,往往是需要自己摸索成長或是報班學習,但對于培訓機構動則近萬元的學費,著實壓力不小。自己不成體系的自學效率很低又漫長,而且容易碰到天花板技術停止不前。因此我收集了一份《java開發全套學習資料》送給大家,初衷也很簡單,就是希望幫助到想自學又不知道該從何學起的朋友,同時減輕大家的負擔。添加下方名片,即可獲取全套學習資料哦
總結
以上是生活随笔為你收集整理的Android复习系列②之《Java进阶》的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: pixel(css pixel dev
- 下一篇: android sina oauth2.