深入理解與應用多線程技術
為什么要使用多線程
- 提高響應速度:對于耗時操作,使用線程可以避免阻塞主線程,提高應用程序的響應速度。
- 實現(xiàn)并行操作:在多CPU系統(tǒng)中,使用線程可以并行處理任務,提高CPU利用率。
- 改善程序結構:將一個既長又復雜的進程分為多個線程,可以使其成為幾個獨立或半獨立的運行部分,這樣有利于程序的修改和理解。
- 方便的通信機制:線程間可以通過共享內(nèi)存等方式進行通信,比進程間通信更方便、高效。
創(chuàng)建線程有幾種方式?
創(chuàng)建線程有四種方式:
- 通過繼承Thread類來創(chuàng)建線程。
- 通過實現(xiàn)Runnable接口來創(chuàng)建線程。
- 通過實現(xiàn)Callable接口來創(chuàng)建線程。
- 使用Executor框架來創(chuàng)建線程池。
簡單實現(xiàn)
public class ThreadTest {
public static void main(String[] args) {
Thread thread = new MyThread();
thread.start();
}
}
class MyThread extends Thread {
@Override
public void run() {
System.out.println("關注公眾號:一安未來");
}
}
public class ThreadTest {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
}
}
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("關注公眾號:一安未來");
}
}
public class ThreadTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyThreadCallable mc = new MyThreadCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}
}
class MyThreadCallable implements Callable {
@Override
public String call()throws Exception {
return "關注公眾號:一安未來";
}
}
public class ThreadTest {
public static void main(String[] args) throws Exception {
ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20), new CustomizableThreadFactory("Yian-Thread-pool"));
executorOne.execute(() -> {
System.out.println("關注公眾號:一安未來");
});
//關閉線程池
executorOne.shutdown();
}
}
線程和進程的區(qū)別
線程和進程是操作系統(tǒng)中重要的概念,都是操作系統(tǒng)資源分配的基本單位,但它們有一些關鍵的區(qū)別。
- 地址空間和資源擁有:進程是執(zhí)行中的一個程序,具有自己的地址空間和文件描述符等資源。線程是在進程中執(zhí)行的一個單獨的執(zhí)行路徑,共享進程的地址空間和資源。
- 開銷:創(chuàng)建和銷毀一個進程需要保存寄存器、棧信息以及進行資源分配和回收等操作,開銷較大。而線程的創(chuàng)建和銷毀只需保存寄存器和棧信息,開銷較小。
- 通信切換:進程之間必須通過IPC(進程間通信)進行通信,切換開銷相對較大。線程之間可以直接共享進程的地址空間和資源,切換開銷相對較小。
- 并發(fā)性:進程是獨立的執(zhí)行單元,具有自己的調(diào)度算法,在并發(fā)條件下更加穩(wěn)定可靠。而線程共享進程的資源,線程之間的調(diào)度和同步比較復雜,對并發(fā)條件的處理需要更多的注意。
- 一對多的關系:一個線程只能屬于一個進程,而一個進程可以擁有多個線程。
Runnable和 Callable有什么區(qū)別
- Runnable接口只有一個需要實現(xiàn)的方法,即run()。當你啟動一個線程時,這個run()方法就會被執(zhí)行。Runnable的主要問題是它不支持返回結果
- Callable可以返回結果,也可以拋出異常。它有一個call()方法,當調(diào)用這個方法時,這個方法就會被執(zhí)行。
volatile作用,原理
主要用于聲明變量,以指示該變量可能會被多個線程同時訪問,從而防止編譯器進行一些優(yōu)化,確保線程之間能夠正確地讀寫共享變量。volatile 提供了一種輕量級的同步機制,但它并不能替代 synchronized,因為它無法解決復合操作的原子性問題。
作用:
- 可見性: 當一個線程修改了一個被 volatile 修飾的變量的值,其他線程能夠立即看到這個修改,即保證了變量的可見性。
- 禁止指令重排序: volatile 修飾的變量的讀寫操作會禁止指令重排序,確保變量的寫操作不會被重排序到其它操作之前。
原理:
volatile 的實現(xiàn)原理涉及到 CPU 的緩存一致性和內(nèi)存屏障(Memory Barrier)的概念。
- 內(nèi)存可見性: 當一個線程寫入一個 volatile 變量時,會強制將該線程對應的本地內(nèi)存中的值刷新到主內(nèi)存中,從而保證了其他線程能夠看到最新的值。同樣,當一個線程讀取一個 volatile 變量時,會強制從主內(nèi)存中讀取最新的值到本地內(nèi)存中。
- 禁止指令重排序: volatile 修飾的變量的讀寫操作會在其前后插入內(nèi)存屏障,防止在其前后的指令被重排序。
synchronized 的實現(xiàn)原理以及鎖優(yōu)化
如果synchronized作用于代碼塊,反編譯可以看到兩個指令:monitorenter、monitorexit,JVM使用monitorenter和monitorexit兩個指令實現(xiàn)同步;如果作用synchronized作用于方法,反編譯可以看到ACCSYNCHRONIZED標記,JVM通過在方法訪問標識符(flags)中加入ACCSYNCHRONIZED來實現(xiàn)同步功能。
- 同步代碼塊,當線程執(zhí)行到monitorenter的時候要先獲得monitor鎖,才能執(zhí)行后面的方法。當線程執(zhí)行到monitorexit的時候則要釋放鎖。
- 同步方法,當線程執(zhí)行有ACCSYNCHRONI標志的方法,需要獲得monitor鎖。每個對象都與一個monitor相關聯(lián),線程可以占有或者釋放monitor。
monitor監(jiān)視器
操作系統(tǒng)的管程(monitors)是概念原理,ObjectMonitor是它的原理實現(xiàn)。
圖片
在Java虛擬機(HotSpot)中,Monitor(管程)是由ObjectMonitor實現(xiàn)的,其主要數(shù)據(jù)結構如下:
ObjectMonitor() {
_header = NULL;
_count = 0; // 記錄個數(shù)
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 處于wait狀態(tài)的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 處于等待鎖block狀態(tài)的線程,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
Java Monitor 的工作機理
圖片
- 要獲取monitor的線程,首先會進入EntryList隊列。
- 當某個線程獲取到對象的monitor后,進入Owner區(qū)域,設置為當前線程,同時計數(shù)器count加1。
- 如果線程調(diào)用了wait()方法,則會進入WaitSet隊列阻塞等待。它會釋放monitor鎖,即將owner賦值為null,count自減1。
- 如果其他線程調(diào)用 notify()/notifyAll() ,會喚醒WaitSet中的某個或全部線程,該線程再次嘗試獲取monitor鎖,成功即進入Owner區(qū)域。
- 同步方法執(zhí)行完畢了,線程退出臨界區(qū),會將monitor的owner設為null,并釋放監(jiān)視鎖
對象與monitor關聯(lián)
圖片
- 在HotSpot虛擬機中,對象在內(nèi)存中存儲的布局可以分為3塊區(qū)域:對象頭(Header),實例數(shù)據(jù)(Instance Data)和對象填充(Padding)。
- 對象頭主要包括兩部分數(shù)據(jù):Mark Word(標記字段)、Class Pointer(類型指針)。
Mark Word 是用于存儲對象自身的運行時數(shù)據(jù),如哈希碼(HashCode)、GC分代年齡、鎖狀態(tài)標志、線程持有的鎖、偏向線程 ID、偏向時間戳等。
圖片
重量級鎖,指向互斥量的指針。其實synchronized是重量級鎖,也就是說Synchronized的對象鎖,Mark Word鎖標識位為10,其中指針指向的是Monitor對象的起始地址。
在JDK1.6之前,synchronized的實現(xiàn)直接調(diào)用ObjectMonitor的enter和exit,這種鎖被稱之為重量級鎖。從JDK6開始,HotSpot虛擬機開發(fā)團隊對Java中的鎖進行優(yōu)化,如增加了適應性自旋、鎖消除、鎖粗化、輕量級鎖和偏向鎖等優(yōu)化策略,提升了synchronized的性能。
- 偏向鎖:在無競爭的情況下,只是在Mark Word里存儲當前線程指針,CAS操作都不做。
- 輕量級鎖:在沒有多線程競爭時,相對重量級鎖,減少操作系統(tǒng)互斥量帶來的性能消耗。但是,如果存在鎖競爭,除了互斥量本身開銷,還額外有CAS操作的開銷。
- 自旋鎖:減少不必要的CPU上下文切換。在輕量級鎖升級為重量級鎖時,就使用了自旋加鎖的方式
- 鎖粗化:將多個連續(xù)的加鎖、解鎖操作連接在一起,擴展成一個范圍更大的鎖。
- 鎖消除:虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數(shù)據(jù)競爭的鎖進行消除。
線程有哪些狀態(tài)
圖片
- New:線程對象創(chuàng)建之后、但還沒有調(diào)用start()方法,就是這個狀態(tài)。
- Runnable:它包括就緒(ready)和運行中(running)兩種狀態(tài)。如果調(diào)用start方法,線程就會進入Runnable狀態(tài)。它表示我這個線程可以被執(zhí)行啦(此時相當于ready狀態(tài)),如果這個線程被調(diào)度器分配了CPU時間,那么就可以被執(zhí)行(此時處于running狀態(tài))。
- Blocked:阻塞的(被同步鎖或者IO鎖阻塞)。表示線程阻塞于鎖,線程阻塞在進入synchronized關鍵字修飾的方法或代碼塊(等待獲取鎖)時的狀態(tài)。比如前面有一個臨界區(qū)的代碼需要執(zhí)行,那么線程就需要等待,它就會進入這個狀態(tài)。它一般是從RUNNABLE狀態(tài)轉(zhuǎn)化過來的。如果線程獲取到鎖,它將變成RUNNABLE狀態(tài)。
- WAITING : 永久等待狀態(tài),進入該狀態(tài)的線程需要等待其他線程做出一些特定動作(比如通知)。處于該狀態(tài)的線程不會被分配CPU執(zhí)行時間,它們要等待被顯式地喚醒,否則會處于無限期等待的狀態(tài)。一般Object.wait。
- TIMED_WATING: 等待指定的時間重新被喚醒的狀態(tài)。有一個計時器在里面計算的,最常見就是使用Thread.sleep方法觸發(fā),觸發(fā)后,線程就進入了Timed_waiting狀態(tài),隨后會由計時器觸發(fā),再進入Runnable狀態(tài)。
- 終止(TERMINATED):表示該線程已經(jīng)執(zhí)行完成。
CountDownLatch與CyclicBarrier 區(qū)別
CountDownLatch和CyclicBarrier都用于讓線程等待,達到一定條件時再運行。主要區(qū)別是:
- CountDownLatch:一個或者多個線程,等待其他多個線程完成某件事情之后才能執(zhí)行;
- CyclicBarrier:多個線程互相等待,直到到達同一個同步點,再繼續(xù)一起執(zhí)行。
圖片
多線程環(huán)境下的偽共享
CPU的緩存是以緩存行(cache line)為單位進行緩存的,當多個線程修改相互獨立的變量,而這些變量又處于同一個緩存行時就會影響彼此的性能。這就是偽共享
現(xiàn)代計算機計算模型:
圖片
- CPU執(zhí)行速度比內(nèi)存速度快好幾個數(shù)量級,為了提高執(zhí)行效率,現(xiàn)代計算機模型演變出CPU、緩存(L1,L2,L3),內(nèi)存的模型。
- CPU執(zhí)行運算時,如先從L1緩存查詢數(shù)據(jù),找不到再去L2緩存找,依次類推,直到在內(nèi)存獲取到數(shù)據(jù)。
- 為了避免頻繁從內(nèi)存獲取數(shù)據(jù),聰明的科學家設計出緩存行,緩存行大小為64字節(jié)。
也正是因為緩存行的存在,就導致了偽共享問題,如圖所示:
圖片
假設數(shù)據(jù)a、b被加載到同一個緩存行。
- 當線程1修改了a的值,這時候CPU1就會通知其他CPU核,當前緩存行(Cache line)已經(jīng)失效。
- 這時候,如果線程2發(fā)起修改b,因為緩存行已經(jīng)失效了,所以「core2 這時會重新從主內(nèi)存中讀取該 Cache line 數(shù)據(jù)」。讀完后,因為它要修改b的值,那么CPU2就通知其他CPU核,當前緩存行(Cache line)又已經(jīng)失效。
- 所以,如果同一個Cache line的內(nèi)容被多個線程讀寫,就很容易產(chǎn)生相互競爭,頻繁回寫主內(nèi)存,會大大降低性能。
解決偽共享問題的一種方法是通過填充(Padding)來確保共享的變量獨立存儲于不同的緩存行中。填充的思想是在變量之間插入一些無關的數(shù)據(jù),使它們分布到不同的緩存行,從而避免多個變量共享同一個緩存行。
在Java中,可以使用@Contended注解來避免偽共享。這個注解可以在字段上使用,它會在字段的前后插入填充,使得字段單獨占據(jù)一個緩存行。
Fork/Join框架
Fork/Join框架是Java7提供的一個用于并行執(zhí)行任務的框架,是一個把大任務分割成若干個小任務,最終匯總每個小任務結果后得到大任務結果的框架。
Fork/Join框架需要理解兩個點,「分而治之」和「工作竊取」。
分而治之
圖片
工作竊取
圖片
一般就是指做得快的線程(盜竊線程)搶慢的線程的任務來做,同時為了減少鎖競爭,通常使用雙端隊列,即快線程和慢線程各在一端。
ThreadLocal原理
ThreadLocal的內(nèi)存結構圖:
圖片
- Thread線程類有一個類型為ThreadLocal.ThreadLocalMap的實例變量threadLocals,即每個線程都有一個屬于自己的ThreadLocalMap。
- ThreadLocalMap內(nèi)部維護著Entry數(shù)組,每個Entry代表一個完整的對象,key是ThreadLocal本身,value是ThreadLocal的泛型值。
- 并發(fā)多線程場景下,每個線程Thread,在往ThreadLocal里設置值的時候,都是往自己的ThreadLocalMap里存,讀也是以某個ThreadLocal作為引用,在自己的map里找對應的key,從而可以實現(xiàn)了線程隔離。
內(nèi)存泄露問題:指程序中動態(tài)分配的堆內(nèi)存由于某種原因沒有被釋放或者無法釋放,造成系統(tǒng)內(nèi)存的浪費,導致程序運行速度減慢或者系統(tǒng)奔潰等嚴重后果。內(nèi)存泄露堆積將會導致內(nèi)存溢出。
ThreadLocal的內(nèi)存泄露問題一般考慮和Entry對象有關,ThreadLocal::Entry被弱引用所修飾。JVM會將弱引用修飾的對象在下次垃圾回收中清除掉。這樣就可以實現(xiàn)ThreadLocal的生命周期和線程的生命周期解綁。但實際上并不是使用了弱引用就會發(fā)生內(nèi)存泄露問題,考慮下面幾個過程:
圖片
當ThreadLocal Ref被回收了,由于在Entry使用的是強引用,在Current Thread還存在的情況下就存在著到達Entry的引用鏈,無法清除掉ThreadLocal的內(nèi)容,同時Entry的value也同樣會被保留;也就是說就算使用了強引用仍然會出現(xiàn)內(nèi)存泄露問題。
圖片
當ThreadLocal Ref被回收了,由于在Entry使用的是弱引用,因此在下次垃圾回收的時候就會將ThreadLocal對象清除,這個時候Entry中的KEY=null。但是由于ThreadLocalMap中任然存在Current Thread Ref這個強引用,因此Entry中value的值任然無法清除。還是存在內(nèi)存泄露的問題。
AQS實現(xiàn)原理
AbstractQueuedSynchronizer(AQS)是Java中用于構建同步器的基礎框架。它提供了一個靈活的、可重用的同步器實現(xiàn),可以用來構建各種同步工具,如ReentrantLock、Semaphore、CountDownLatch等。AQS的核心思想是基于FIFO等待隊列,通過狀態(tài)(state)來管理線程的同步。
核心原理:
- State(狀態(tài)): AQS 的同步狀態(tài)是一個整數(shù),表示被同步的資源的狀態(tài)。不同的同步器會使用不同的方式來表示狀態(tài)的含義,例如,ReentrantLock 使用 state 表示持有鎖的線程的數(shù)量,Semaphore 使用 state 表示可用的許可數(shù)量等。
- FIFO 等待隊列: AQS 使用一個FIFO的等待隊列來管理獲取同步資源失敗的線程。每個節(jié)點(Node)表示一個等待線程,節(jié)點中保存了等待狀態(tài)、前驅(qū)節(jié)點、后繼節(jié)點等信息。當一個線程嘗試獲取鎖但失敗時,它會被包裝成一個節(jié)點并加入到等待隊列中。
- 獨占模式和共享模式: AQS 支持獨占模式和共享模式。獨占模式表示只有一個線程能夠獲取同步資源,如ReentrantLock 就是獨占模式的同步器。共享模式表示多個線程可以同時獲取同步資源,如Semaphore 就是共享模式的同步器。AQS 使用 acquire 和 release 方法來分別表示獲取和釋放同步資源。
- acquire 方法: 當線程嘗試獲取同步資源時,它會調(diào)用 AQS 的 acquire 方法。acquire 方法會根據(jù)同步狀態(tài)的不同情況進行處理,如果同步狀態(tài)允許當前線程獲取資源,則直接返回;否則,當前線程會被包裝成節(jié)點并加入到等待隊列中,然后進入自旋等待狀態(tài),直到獲取到資源。
- release 方法: 當線程釋放同步資源時,它會調(diào)用 AQS 的 release 方法。release 方法會根據(jù)同步狀態(tài)的不同情況進行處理,然后喚醒等待隊列中的下一個線程,使其有機會獲取資源。
- 獨占鎖和共享鎖的實現(xiàn): AQS 提供了獨占鎖的實現(xiàn)方法 tryAcquire 和 tryRelease,以及共享鎖的實現(xiàn)方法 tryAcquireShared 和 tryReleaseShared。
ReentrantLock 解析:
圖片
圖片
上下文切換
CPU上下文:CPU 寄存器,是CPU內(nèi)置的容量小、但速度極快的內(nèi)存。而程序計數(shù)器,則是用來存儲 CPU 正在執(zhí)行的指令位置、或者即將執(zhí)行的下一條指令位置。它們都是 CPU 在運行任何任務前,必須的依賴環(huán)境,因此叫做
CPU上下文切換:把前一個任務的CPU上下文(也就是CPU寄存器和程序計數(shù)器)保存起來,然后加載新任務的上下文到這些寄存器和程序計數(shù)器,最后再跳轉(zhuǎn)到程序計數(shù)器所指的新位置,運行新任務。
圖片
- 分時調(diào)度:讓所有的線程輪流獲得CPU的使用權,并且平均分配每個線程占用的 CPU 的時間片。
- 搶占式調(diào)度:優(yōu)先讓可運行池中優(yōu)先級高的線程占用CPU,如果可運行池中的線程優(yōu)先級相同,那么就隨機選擇一個線程,使其占用CPU。處于運行狀態(tài)的線程會一直運行,直至它不得不放棄 CPU。