java多執行緒那點事兒

Allen烽發表於2019-02-27

前段時間應隔壁部門大佬的邀約,簡單地幫他們部門的童靴梳理了下多執行緒相關的內容,客串了一把講師【因為部門內有不少是c#轉java的童鞋,所以講的稍微淺顯了些】

ok,按照個人習慣先來大綱

知識點:

1)程式 多執行緒的相關概念 涉及到CPU排程 稍微談下JVM記憶體模型 程式計數器
2)多執行緒的三種實現手段及其程式碼實現 這邊可以談下futurtask的底層原始碼
3)常用鎖概念及實現說明 隱式鎖 顯式鎖 樂觀鎖 悲觀鎖 CAS 可重入鎖 不可重入鎖 讀寫鎖 執行緒安全 引申的談下分散式鎖 及分散式鎖的原理,常用的三種實現手段 volatile關鍵字及其底層原始碼
4)執行緒池的概念,執行緒池的使用 扒一扒執行緒池的原始碼 快取佇列 核心執行緒池 執行緒池建立任務的過程 執行緒池的生命週期等

多執行緒: 多執行緒是什麼? 多執行緒是一個程式(程式)執行時產生了不止一個執行緒。

程式和執行緒區別 一個正在執行的程式,程式是控制程式的執行順序。這個順序又被稱為一個控制單元。

並行和併發的概念: 並行:多個CPU例項或者多臺機器同時執行一段處理邏輯 併發:通過CPU排程演算法,讓使用者看上去是同時執行的,在CPU層面不是同時。

這邊衍生的可以談下JVM中記憶體模型的程式計數器 就是記錄java執行位元組碼的行號指示器。

jvm記憶體模型: 執行緒私有:
程式計數器:記錄程式執行過程中的位元組碼的行號指示器
java虛擬機器棧: 主要是是被呼叫的java方法代表的是一個個棧幀 區域性變數表 運算元棧 動態連結 方法出口等等 java.lang.StackOverflowError
本地方法棧: 主要是是被呼叫的native方法代表的是一個個棧幀

執行緒公有
堆 : 物件例項
方法區:最重要的就是執行時常量池 gc

為什麼要是用多執行緒:
1)充分的利用CPU資源,如果只有一個執行緒的話,第二個任務必須等第一個任務完成之後才能進行。
2)程式之間無法共享資料,但是執行緒可以
3)建立程式需要為這個程式分配系統資源,建立執行緒的代價小

多執行緒的實現手段【3種手段】
1)Thread

package com.Allen.test;

import java.util.concurrent.TimeUnit;

public class testThread extends Thread{
	public static void main(String[] args) {
		testThread t1=new testThread();
		testThread t2=new testThread();
		t1.start();
		t2.start();
	}
	
	
	 public void run(){
		 System.out.println("start");
		 try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		 System.out.println("end");
	}
}
複製程式碼

2)runnable

package com.Allen.test;

import java.util.concurrent.TimeUnit;

public class testRunnable {
	public static void main(String[] args) {
		testAllen th1=new testAllen();
		for(int i=0;i<5;i++){
			Thread t1=new Thread(th1);
			t1.start();
		}
	}
}

class testAllen implements Runnable{

	@Override
	public void run() {
		 System.out.println("start");
		 try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		 System.out.println("end");
	}	
}
複製程式碼

3)future callable

package com.Allen.test;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;

public class testFuture {
	public static void main(String[] args) throws InterruptedException, ExecutionException {
		futureTask task=new futureTask();
//		//執行緒池  單執行緒的執行緒池
//		ExecutorService service=Executors.newCachedThreadPool();
//		Future<Integer> future=service.submit(task);
//		//說到下面這個方法就要說起執行緒池的狀態  四個種狀態
//		service.shutdown();
		
		FutureTask<Integer>future=new FutureTask<>(task);
		Thread t1=new Thread(future);
		t1.start();
		System.out.println("run task ....");
		TimeUnit.SECONDS.sleep(1);
		System.out.println("result : "+future.get());
	}
}

class futureTask implements Callable<Integer>{

	@Override
	public Integer call() throws Exception {
		TimeUnit.SECONDS.sleep(2);
		int result=0;
		//模擬一個龐大的計算
		for(int i=0;i<100;i++){
			for(int j=0;j<i;j++){
				result+=j;
			}
		}
		return result;
	}
	
}
複製程式碼

扒一扒futureTask的原始碼
首先我們看run方法

java多執行緒那點事兒
1)run方法中state不是new或者執行緒再次給他設定啟動出錯直接讓他return掉
2)完後方法後呼叫set方法 把結果賦值給outcome

java多執行緒那點事兒
這邊用了CAS方式來設定stat狀態

java多執行緒那點事兒
拿到棧頂的元素一個個去喚醒

java多執行緒那點事兒
報錯的話返回異常資訊

接下來我們談下多執行緒操作中比較核心的東西
多執行緒操作共享變數的問題
鎖機制
最常用的鎖機制 synchronized及lock
隱式鎖 synchronized 談下底層原理
顯示鎖 lock
重入鎖

我設計了一個例項來直觀的觀察多執行緒操作共享變數的執行緒安全問題

package com.Allen.test;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TestSynchronized {
	public static void main(String[] args) throws InterruptedException {
		for (int s = 0; s < 10; s++) {
			Person person = new Person();
			person.setAge(0);
			ReentrantLock lock = new ReentrantLock();
			for (int i = 0; i < 100; i++) {
				Thread t1 = new Thread(new test111(person,lock));
				t1.start();
			}
			TimeUnit.SECONDS.sleep(3);
			System.out.println(person.getAge());
		}
	}
}

class test111 implements Runnable {
	Person person;
	ReentrantLock lock;

	public test111(Person person,ReentrantLock lock) {
		this.person = person;
		this.lock=lock;
	}

	public void run() {
//		synchronized (person) {
//			person.setAge(person.getAge() + 1);
//		}
		
		lock.lock();
		person.setAge(person.getAge() + 1);
		lock.unlock();
		 //person.setAge(person.getAge()+1);
		 
	}
}
複製程式碼

悲觀鎖 樂觀鎖 CAS
一 悲觀鎖
在關係型資料庫管理系統中,悲觀併發控制(悲觀鎖)是一種併發控制的方法。 簡單而言,就是它“悲觀”地預設每次拿資料都認為別人會修改,所以在每次拿之前去上鎖,這樣別人想拿這個資料就會block直到它拿到鎖。傳統悲觀鎖實現機制大多利用資料庫提供的鎖機制(也只有資料庫層面提供的鎖機制才能真正保證了資料訪問的排他性)。

悲觀鎖流程如下:
1 對任意記錄進行修改之前,嘗試給它加排它鎖。
2 若是加鎖失敗,說明該記錄正在修改,那麼當前需要等待或者丟擲異常。
3 如果成功加鎖,那麼就可以對記錄進行修改,事務完成之後解鎖。
4 期間其他人需要對該記錄進行修改或者加排查鎖操作,就不許等待我們解鎖或者直接丟擲異常。

以mysql為例
使用悲觀鎖,先關閉mysql的自動提交屬性。
set autocommit=0

begin;
select status from t_goods where id=1 for update;
insert into t_orders(id,goods_id)values(null,1);
update t_goods set status=2;
commit;

發起事務 操作 提交事務

select for update 開啟排它鎖方式實現悲觀鎖,mysql InnoDB預設行級鎖【ps:注意一點,行級鎖都是基於索引,如果用不到索引會使用表級鎖吧整張表鎖住】

優點與缺點:
悲觀併發控制實現上是“先取鎖再訪問”的保守策略,為資料安全提供保障,但是犧牲了效率,處理加鎖會讓資料庫產生額外的開銷,還增加了死鎖的機會,降低了並行性,如果一個實物鎖定了某行資料,其他事物必須等待改事務處理完才能處理那一行。適用於狀態修改非常高,衝突非常嚴重的系統。

二 樂觀鎖
假設了多使用者併發事務處理下不會彼此影響,各事務在不產生鎖的情況下處理各自的那部分資料,
每次去拿資料都認為別人不會修改,所以不會上鎖,只會在更新的時候判斷一下此期間別人有沒有更新這個資料。如果有其他事務更新的話,正在提交的事務會回滾。
一般來說樂觀鎖不會使用資料庫提供的機制,我們通常採用記錄資料版本來實現樂觀鎖 記錄的方式有版本號或者時間戳。

在資料初始化的時候指定一個版本號,每次對資料更新在對版本號做+1操作,並判斷當前的版本號是不是該資料的最新版本。

優點與缺點;
樂觀併發控制事務之間資料競爭概率較小,可以一直做下去知道提交才會鎖定,有點類似於svn

java多執行緒那點事兒
當倆個事務都讀到某一行修改回傳資料庫就會遇到問題。

三 CAS 無鎖化程式設計
java.util.concurrent包中藉助CAS實現了區別於synchronouse同步鎖的一種樂觀鎖。 無鎖化程式設計

CAS有三個運算元,記憶體值V,舊的預期值A,修改的新值B。當且僅當預期值A與記憶體值V相同時,將記憶體值V修改為B,否則什麼都不做。

優點與缺點: 可以用CAS在無鎖的情況下實現原子操作,但要明確應用場合,非常簡單的操作且又不想引入鎖可以考慮使用CAS操作,當想要非阻塞地完成某一操作也可以考慮CAS。 CAS雖然很高效的解決原子操作,但是CAS仍然存在三大問題。ABA問題,迴圈時間長開銷大和只能保證一個共享變數的原子操作

  1. ABA問題。因為CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。 從Java1.5開始JDK的atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。 關於ABA問題參考文件: blog.hesey.net/2011/09/res…

  2. 迴圈時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出迴圈的時候因記憶體順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。

  3. 只能保證一個共享變數的原子操作。當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作。比如有兩個共享變數i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用物件之間的原子性,你可以把多個變數放在一個物件裡來進行CAS操作。

compareAndSet有點類似於如下

if (this == expect) {
this = update
return true;
} else {
return false;
}

如下附上CAS例項

java多執行緒那點事兒

volatile關鍵字 但是我們要思考下併發程式設計下的倆個關鍵問題?
1 執行緒之間是如何通訊
1.1 共享記憶體
隱式通訊
1.2 訊息傳遞
顯式通訊
2 執行緒之間是如何同步

在共享記憶體的併發模型中,同步是顯示做的,synchronized
在訊息傳遞的併發模型,由於訊息的傳送必須要在訊息的接受之前,所以同步是隱式的。

2 定位記憶體可見性問題:
什麼物件是記憶體共享的,什麼不是。

主記憶體:共享變數

私有本地記憶體:儲存共享變數的副本

java多執行緒那點事兒
可見性原理
1 volatile宣告的變數進行寫操作的時候,jvm會向處理器傳送一條lock字首的指令,會把這個變數所在快取行的資料回到系統記憶體。
2 在多處理器的情況下,保證各個處理器快取一致性的特點,就會實現快取一致性協議。

synchronized :可重入鎖,互斥性,可見性。
volatile:原子性,可見性。不能做到複合操作的原子性。效能開銷更小。

synchronized 執行緒A釋放鎖之後會把本地記憶體的變數同步到主記憶體
執行緒B獲取鎖的時候會把主記憶體的共享變數同步到本地記憶體中。

呼叫monitorenter monitorexit
物件同步方法呼叫的時候必須要獲取到它的監視器,用完之後會釋放。

java多執行緒那點事兒
獲取不到監聽器會放在佇列中。

多程式下訪問共享變數
分散式鎖 包括三種常用的使用手段
1 ) 通過資料庫的方式
create table lock(
ID
Method_Name 唯一約束
)

每次去操作檔案的是否,都去插入表,獲取鎖是否才能去操作檔案,
等鎖釋放【刪除這個記錄】才能insert

缺點:
刪除失敗 【等待程式不可用】
重入鎖【可以對程式進行編號,來判斷重入】

2) 使用zookeeper

java多執行緒那點事兒

臨時有序節點
誰先寫到節點上,獲取鎖
有個watch機制,會判斷節點是否失效,失效之後會讀取下一個節點

3) redis
setnx
誰先set這個值,誰就現獲取鎖
後續set失敗的就是沒有獲取鎖
等待鎖釋放之後你才能set這個值。

執行緒池

執行緒池種類及區別:
執行executors類給我們提供靜態方法呼叫不同的執行緒池
newsingleThreadExecutor:
返回一個單執行緒的executor,將多個任務交給這個Executor時,這個執行緒處理完一個任務之後接著處理下一個任務,若該執行緒出現異常,將會有一個新的執行緒替代。
newFixedThreadPool
返回一個包含指定數目執行緒的執行緒池,任務數量多於執行緒數目,則沒有執行的任務必須等待,直到任務完成。
newCahedThreadPool
根據使用者的任務數建立相應的執行緒數來處理,該執行緒池不會對執行緒數加以限制,完全依賴於JVM能建立的執行緒數量,可能引起記憶體不足。
用法如下:

package com.Allen.TestPoolSize;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class Test {
	public static void main(String[] args) {
		//單執行緒化的執行緒池
		ExecutorService service1=Executors.newSingleThreadExecutor();
		service1.execute(new Runnable() {
			@Override
			public void run() {
				System.out.println("aaa");
			}
		});
		//可快取執行緒池
		ExecutorService service2=Executors.newCachedThreadPool();
		service2.execute(new Runnable() {
			public void run() {
				System.out.println("bbb");
			}
		});
		//定長執行緒池
		ExecutorService service3=Executors.newFixedThreadPool(3);
		service3.execute(new Runnable() {
			public void run() {
				System.out.println("ccc");
			}
		});
		//定長執行緒池支援定時和週期任務
		ScheduledExecutorService service4=Executors.newScheduledThreadPool(5);
		service4.schedule(new Runnable() {
			@Override
			public void run() {
				System.out.println("ddd");	
			}
		}, 3, TimeUnit.SECONDS);	
	}
}
複製程式碼

過程 原理
扒一扒原始碼
首先我們寫一個test類

package com.Allen.studyThread;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class test {
	public static void main(String[] args) {
		ExecutorService threadpool=Executors.newFixedThreadPool(3);
		for(int i=0;i<10;i++){
			threadpool.submit(new testrunnable("allen_"+i));
		}
		threadpool.shutdown();
	}
}

class testrunnable implements Runnable{
	
	private String name;
	
	public testrunnable(String name){
		this.name=name;
	}
	
	public void run() {
		System.out.println(name+" start...");
		try {
			TimeUnit.SECONDS.sleep(2);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(name+" end...");
	} 
}
複製程式碼

看執行緒池原始碼,我們入口從newFixedThreadPool入口入,我們看看他初始化做了什麼 進入到Executors類的newFixedThreadPool方法傳入一個引數

java多執行緒那點事兒
這個引數是執行緒池併發執行的執行緒數量
例如50個任務,10個多執行緒,分五次執行,傳入的引數就是10。
由上面的方法可以看到,它是例項化了ThreadPoolExecutor類
繼承關係 Executor---Executorservice--abstractExecutorservice--Threadpoolexecutor 注意這個就是我們需要學習執行緒池的核心類,這裡面傳入了5個引數。
我們進去看下:

java多執行緒那點事兒
這邊涉及到幾個核心的引數:
1)corepoolsize
2)maximumpoolsize
3)keepalivetime
4)workqueue
5)threadfactory
重要引數及其作用
corepoolsize:核心池大小,預設情況下,建立了執行緒池之後,執行緒池中數量為0,當任務進來之後會建立一個執行緒去執行任務,當執行緒池中的執行緒數到達corepoolsize之後會把任務新增到快取佇列中。
maximumpoolsize:執行緒池中最多可以建立多少執行緒。
keepalivetime:執行緒沒有任務執行,最多儲存多久會終止。當執行緒池中執行緒數大於corepoolsize,這個機制才會生效。
workqueue:阻塞佇列,用來存放等待被執行的任務。
threadfactory:執行緒工廠,用來建立執行緒。
執行緒池狀態
1 當執行緒池被建立後,初始狀態為running
2 呼叫shutdown方法之後,處於shutdown狀態,不接受新的任務,等待已有任務完成。
3 呼叫shutdownnow方法之後,進入stop狀態,不接受新任務,並且嘗試終止正在執行的任務。
4 處於shutdown或者stop狀態,並且所有工作執行緒均被銷燬,任務快取佇列被清空,執行緒池就被設定為terminated狀態。
任務提交到執行緒池的具體操作
1 當前執行緒池中的執行緒數小於corepoolsize,則把任務建立為執行緒執行。
2 當前執行緒池中的執行緒數大於等於corepoolsize,則嘗試把任務新增到快取佇列,新增成功之後,則此任務會等待空閒執行緒來執行此任務,如果新增失敗,則嘗試建立執行緒去執行這個任務。
3 當前執行緒池中執行緒數大於等於macimumpoolsize,則採取拒絕策略(4種拒絕策略)
3.1 abortpolicy丟棄任務,丟擲rejectedExecutionExceptipon
3.2 discardpolicy 拒絕執行,不丟擲異常
3.3 discardoldpolicy 丟棄任務快取佇列中最老的任務,並且嘗試重新提交新任務
3.4 callerrunspolicy 有反饋機制,讓任務提交的速度變慢

然後我們看下他的submit方法

java多執行緒那點事兒
它其實呼叫的是execute方法

java多執行緒那點事兒
核心方法如下:

java多執行緒那點事兒
1)判斷執行的執行緒數量小於核心執行緒數,小於的話直接加入worker啟動
2)判斷執行執行緒數量大於核心執行緒數,上面if分支針對大於corepoolsize,並且快取佇列加入任務操作成功的情況,執行中並且把任務加入緩衝佇列成功,正常而言這樣就完成了處理邏輯
為了保險起見,增加了狀態出現異常的判斷,如果異常,就會繼續remove操作,結果為true的話,就按照拒絕策來拒絕
3)執行執行緒數大於corepoolsize並且緩衝佇列也已經滿了
這邊addworker傳遞的是false,意味著它會去判斷maximumpoolsize
使用拒絕策略
我們主要看增加工作執行緒的流程

java多執行緒那點事兒
主要是runworker 中

java多執行緒那點事兒
執行任務,並且當執行完畢之後再去獲取新的task繼續執行,gettask方法是有ThreadPoolExecutor這個方法提供

java多執行緒那點事兒
執行緒呼叫runWoker,會while迴圈呼叫getTask方法從workerQueue裡讀取任務,然後執行任務。只要getTask方法不返回null,此執行緒就不會退出。我們去看下getTask方法
如果執行執行緒數超過客最大的執行緒數,但是緩衝佇列已經空了,此時遞減worker的數量
如果設定允許執行緒超時或者執行緒數量超過了核心執行緒數,並且執行緒在規定的時間內都沒有poll到任務佇列為空,則遞減worker數量

java多執行緒那點事兒

相關文章