一、多線程基礎
一個採用了多線程技術的應用程序可以更好地利用系統資源。其主要優勢在於充 分利用了CPU的空閒時間片,可以用盡可能少的時間來對用户的要求做出響應,使 得進程的整體運行效率得到較大提高,同時增強了應用程序的靈活性。
更為重要的是,由於同一進程的所有線程是共享同一內存,所以不需要特殊的數 據傳送機制,不需要建立共享存儲區或共享文件,從而使得不同任務之間的協調操 作與運行、數據的交互、資源的分配等問題更加易於解決。
1. 線程和進程
進程:是指一個內存中運行的應用程序,每個進程都有一個獨立的內存空間,一個應用程序可以同時運行多個進程;進程也是程序的一次執行過程,是系統運行程序的基本單位;系統運行一個程序即是一個進程從創建、運行到消亡的過程。
線程:進程內部的一個獨立執行單元;一個進程可以同時併發的運行多個線程,可以理解為一個進程便相當於一個單 CPU 操作系統,而線程便是這個系統中運行的多個任務。
進程與線程的區別:
進程:有獨立的內存空間,進程中的數據存放空間(堆空間和棧空間)是獨立的,至少有一個線程。
線程:堆空間是共享的,棧空間是獨立的,線程消耗的資源比進程小的多。
注意:
1)因為一個進程中的多個線程是併發運行的,那麼從微觀角度看也是有先後順序的,哪個線程執行完全取決於 CPU 的調度,程序員是不能完全控制的(可以設置線程優先級)。而這也就造成的多線程的隨機性。
2)Java 程序的進程裏面至少包含兩個線程,主線程也就是 main()方法線程,另外一個是垃圾回收機制線程。每 當使用 java 命令執行一個類時,實際上都會啓動一個 JVM,每一個 JVM 實際上就是在操作系統中啓動了一個 線程,java 本身具備了垃圾的收集機制,所以在 Java 運行時至少會啓動兩個線程。
3)由於創建一個線程的開銷比創建一個進程的開銷小的多,那麼我們在開發多任務運行的時候,通常考慮創建 多線程,而不是創建多進程。
2. 多線程的創建
創建Maven工程,編寫測試類
2.1 繼承Thread類
第一種繼承Thread類 重寫run方法
public class Demo1CreateThread extends Thread {
public static void main(String[] args) throws InterruptedException {
System.out.println("-----多線程創建開始-----");
// 1.創建一個線程
CreateThread createThread1 = new CreateThread();
CreateThread createThread2 = new CreateThread();
// 2.開始執行線程 注意 開啓線程不是調用run方法,而是start方法
System.out.println("-----多線程創建啓動-----");
createThread1.start();
createThread2.start();
System.out.println("-----多線程創建結束-----");
}
static class CreateThread extends Thread {
public void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 5; i++) {
System.out.println(name + "打印內容是:" + i);
}
}
}
}
2.2 實現Runnable接口
實現Runnable接口,重寫run方法
實際上所有的多線程代碼都是通過運行Thread的start()方法來運行的。因此,不管是繼承Thread類還是實現Runnable接口來實現多線程,最終還是通過Thread的對象的API來控制線程的。
public class Demo2CreateRunnable {
public static void main(String[] args) {
System.out.println("-----多線程創建開始-----");
// 1.創建線程
CreateRunnable createRunnable = new CreateRunnable();
Thread thread1 = new Thread(createRunnable);
Thread thread2 = new Thread(createRunnable);
// 2.開始執行線程 注意 開啓線程不是調用run方法,而是start方法
System.out.println("-----多線程創建啓動-----");
thread1.start();
thread2.start();
System.out.println("-----多線程創建結束-----");
}
static class CreateRunnable implements Runnable {
public void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 5; i++) {
System.out.println(name + "的內容:" + i);
}
}
}
}
實現Runnable接口比繼承Thread類所具有的優勢:
1)適合多個相同的程序代碼的線程去共享同一個資源。
2)可以避免java中的單繼承的侷限性。
3)增加程序的健壯性,實現解耦操作,代碼可以被多個線程共享,代碼和數據獨立。
4)線程池只能放入實現Runable或callable類線程,不能直接放入繼承Thread的類
2.3 匿名內部類方式
使用線程的內匿名內部類方式,可以方便的實現每個線程執行不同的線程任務操作
public class Demo3Runnable {
public static boolean exit = true;
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
public void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 5; i++) {
System.out.println(name + "執行內容:" + i);
}
}
}).start();
new Thread(new Runnable() {
public void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 5; i++) {
System.out.println(name + "執行內容:" + i);
}
}
}).start();
Thread.sleep(1000l);
}
}
2.4 守護線程
Java中有兩種線程,一種是用户線程,另一種是守護線程。
用户線程是指用户自定義創建的線程,主線程停止,用户線程不會停止。
守護線程當進程不存在或主線程停止,守護線程也會被停止。
public class Demo4Daemon {
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(10);
} catch (Exception e) {
}
System.out.println("子線程..." + i);
}
}
});
// 設置線程為守護線程
//thread.setDaemon(true);
thread.start();
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(10);
System.out.println("主線程" + i);
} catch (Exception e) {
}
}
System.out.println("主線程執行完畢!");
}
}
3. 線程安全
3.1 賣票案例
如果有多個線程在同時運行,而這些線程可能會同時運行這段代碼。程序每次運行結果和單線程運行的結果是一樣的,而且其他的變量的值也和預期的是一樣的,就是線程安全的,反之則是線程不安全的。
public class Demo5Ticket {
public static void main(String[] args) {
//創建線程任務對象
Ticket ticket = new Ticket();
//創建三個窗口對象
Thread t1 = new Thread(ticket, "窗口1");
Thread t2 = new Thread(ticket, "窗口2");
Thread t3 = new Thread(ticket, "窗口3");
//賣票
t1.start();
t2.start();
t3.start();
}
static class Ticket implements Runnable {
//Object lock = new Object();
ReentrantLock lock = new ReentrantLock();
private int ticket = 10;
public void run() {
String name = Thread.currentThread().getName();
while (true) {
sell(name);
if (ticket <= 0) {
break;
}
}
}
private void sell(String name) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (ticket > 0) {
System.out.println(name + "賣票:" + ticket);
ticket--;
}
}
}
}
線程安全問題都是由全局變量及靜態變量引起的。若每個線程中對全局變量、靜態變量只有讀操作,而無寫 操作,一般來説,這個全局變量是線程安全的;若有多個線程同時執行寫操作,一般都需要考慮線程同步, 否則的話就可能影響線程安全。
3.2 線程同步
當我們使用多個線程訪問同一資源的時候,且多個線程中對資源有寫的操作,就容易出現線程安全問題。 要解決上述多線程併發訪問一個資源的安全問題,Java中提供了同步機制(synchronized)來解決。
同步代碼塊
Object lock = new Object(); //創建鎖
synchronized(lock){
//可能會產生線程安全問題的代碼
}
同步方法
//同步方法
public synchronized void method(){
//可能會產生線程安全問題的代碼
}
同步方法使用的是this鎖
證明方式: 一個線程使用同步代碼塊(this明鎖),另一個線程使用同步函數。如果兩個線程搶票不能實現同步,那麼會出現數據錯誤。
//使用this鎖的同步代碼塊
synchronized(this){
//需要同步操作的代碼
}
Lock鎖
Lock lock = new ReentrantLock();
lock.lock();
//需要同步操作的代碼
lock.unlock();
3.2 死鎖
多線程死鎖:同步中嵌套同步,導致鎖無法釋放。
死鎖解決辦法:不要在同步中嵌套同步
public class Demo6DeadLock {
public static void main(String[] args) {
//創建線程任務對象
Ticket ticket = new Ticket();
//創建三個窗口對象
Thread t1 = new Thread(ticket, "窗口1");
Thread t2 = new Thread(ticket, "窗口2");
Thread t3 = new Thread(ticket, "窗口3");
//賣票
t1.start();
t2.start();
t3.start();
}
static class Ticket implements Runnable {
Object lock = new Object();
private int ticket = 100;
public void run() {
String name = Thread.currentThread().getName();
while (true) {
if ("窗口1".equals(name)) {
synchronized (lock) {
sell(name);
}
} else {
sell(name);
}
if (ticket <= 0) {
break;
}
}
}
private synchronized void sell(String name) {
synchronized (lock) {
if (ticket > 0) {
System.out.println(name + "賣票:" + ticket);
ticket--;
}
}
}
}
4. 線程狀態
4.1 線程狀態介紹
查看Thread源碼,能夠看到java的線程有六種狀態:
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
NEW(新建) 線程剛被創建,但是並未啓動。
RUNNABLE(可運行) 線程可以在java虛擬機中運行的狀態,可能正在運行自己代碼,也可能沒有,這取決於操作系統處理器。
BLOCKED(鎖阻塞) 當一個線程試圖獲取一個對象鎖,而該對象鎖被其他的線程持有,則該線程進入Blocked狀態;當該線程持有鎖時,該線程將變成Runnable狀態。
WAITING(無限等待) 一個線程在等待另一個線程執行一個(喚醒)動作時,該線程進入Waiting狀態。進入這個狀態後是不能自動喚醒的,必須等待另一個線程調用notify或者notifyAll方法才能夠喚醒。
TIMED_WAITING(計時等待) 同waiting狀態,有幾個方法有超時參數,調用他們將進入Timed Waiting狀態。這一狀態將一直保持到超時期滿或者接收到喚醒通知。帶有超時參數的常用方法有Thread.sleep 、Object.wait。
TERMINATED(被終止) 因為run方法正常退出而死亡,或者因為沒有捕獲的異常終止了run方法而死亡。
4.2 線程狀態圖
4.3 wait()、notify()
wait()、notify()、notifyAll()是三個定義在Object類裏的方法,可以用來控制線程的狀態。
wait 方法會使持有該對象的線程把該對象的控制權交出去,然後處於等待狀態。 notify 方法會通知某個正在等待這個對象的控制權的線程繼續運行。 notifyAll 方法會通知所有正在等待這個對象的控制權的線程繼續運行。
注意:一定要在線程同步中使用,並且是同一個鎖的資源
wait和notify方法例子,一個人進站出站:
public class Demo7WaitAndNotify {
public static void main(String[] args) {
State state = new State();
InThread inThread = new InThread(state);
OutThread outThread = new OutThread(state);
Thread in = new Thread(inThread);
Thread out = new Thread(outThread);
in.start();
out.start();
}
// 控制狀態
static class State {
//狀態標識
public String flag = "車站外";
}
static class InThread implements Runnable {
private State state;
public InThread(State state) {
this.state = state;
}
public void run() {
while (true) {
synchronized (state) {
if ("車站內".equals(state.flag)) {
try {
// 如果在車站內,就不用進站,等待,釋放鎖
state.wait();
} catch (Exception e) {
}
}
System.out.println("進站");
state.flag = "車站內";
// 喚醒state等待的線程
state.notify();
}
}
}
}
static class OutThread implements Runnable {
private State state;
public OutThread(State state) {
this.state = state;
}
public void run() {
while (true) {
synchronized (state) {
if ("車站外".equals(state.flag)) {
try {
// 如果在車站外,就不用出站了,等待,釋放鎖
state.wait();
} catch (Exception e) {
}
}
System.out.println("出站");
state.flag = "車站外";
// 喚醒state等待的線程
state.notify();
}
}
}
}
}
4.4 wait與sleep區別
- 對於sleep()方法,首先要知道該方法是屬於Thread類中的。而wait()方法,則是屬於Object類中的。
- sleep()方法導致了程序暫停執行指定的時間,讓出cpu該其他線程,但是他的監控狀態依然保持者,當指定的時間到了又會自動恢復運行狀態。
wait()是把控制權交出去,然後進入等待此對象的等待鎖定池處於等待狀態,只有針對此對象調用notify()方法後本線程才進入對象鎖定池準備獲取對象鎖進入運行狀態。 - 在調用sleep()方法的過程中,線程不會釋放對象鎖。而當調用wait()方法的時候,線程會放棄對象鎖。
5. 線程停止
結束線程有以下三種方法: (1)設置退出標誌,使線程正常退出。 (2)使用interrupt()方法中斷線程。 (3)使用stop方法強行終止線程(不推薦使用Thread.stop, 這種終止線程運行的方法已經被廢棄,使用它們是極端不安全的!)
5.1 使用退出標誌
一般run()方法執行完,線程就會正常結束,然而,常常有些線程是伺服線程。它們需要長時間的運行,只有在外部某些條件滿足的情況下,才能關閉這些線程。使用一個變量來控制循環,例如:最直接的方法就是設一個boolean類型的標誌,並通過設置這個標誌為true或false來控制while循環是否退出,代碼示例:
public class Demo8Exit {
public static boolean exit = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new Runnable() {
public void run() {
while (exit) {
try {
System.out.println("線程執行!");
Thread.sleep(100l);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
t.start();
Thread.sleep(1000l);
exit = false;
System.out.println("退出標識位設置成功");
}
}
5.2 使用interrupt()方法
使用interrupt()方法來中斷線程有兩種情況:
1) 線程處於阻塞狀態
如使用了sleep,同步鎖的wait,socket中的receiver,accept等方法時,會使線程處於阻塞狀態。當調用線程的interrupt()方法時,會拋出InterruptException異常。阻塞中的那個方法拋出這個異常,通過代碼捕獲該異常,然後break跳出循環狀態,從而讓我們有機會結束這個線程的執行。
2) 線程未處於阻塞狀態
使用isInterrupted()判斷線程的中斷標誌來退出循環。當使用interrupt()方法時,中斷標誌就會置true,和使用自定義的標誌來控制循環是一樣的道理。
public class Demo9Interrupt {
public static boolean exit = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new Runnable() {
public void run() {
while (exit) {
try {
System.out.println("線程執行!");
//判斷線程的中斷標誌來退出循環
if (Thread.currentThread().isInterrupted()) {
break;
}
Thread.sleep(100l);
} catch (InterruptedException e) {
e.printStackTrace();
//線程處於阻塞狀態,當調用線程的interrupt()方法時,
//會拋出InterruptException異常,跳出循環
break;
}
}
}
});
t.start();
Thread.sleep(1000l);
//中斷線程
t.interrupt();
System.out.println("線程中斷了");
}
}
6. 線程優先級
6.1 優先級priority
現今操作系統基本採用分時的形式調度運行的線程,線程分配得到時間片的多少決定了線程使用處理器資源的多少,也對應了線程優先級這個概念。
在JAVA線程中,通過一個int priority來控制優先級,範圍為1-10,其中10最高,默認值為5。
public class Demo10Priorityt {
public static void main(String[] args) {
PrioritytThread prioritytThread = new PrioritytThread();
// 如果8核CPU處理3線程,無論優先級高低,每個線程都是單獨一個CPU執行,就無法體現優先級
// 開啓10個線程,讓8個CPU處理,這裏線程就需要競爭CPU資源,優先級高的能分配更多的CPU資源
for (int i = 0; i < 10; i++) {
Thread t = new Thread(prioritytThread, "線程" + i);
if (i == 1) {
t.setPriority(10);
}
if (i == 2) {
t.setPriority(1);
}
t.setDaemon(true);
t.start();
}
try {
Thread.sleep(1000l);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("線程1總計:" + PrioritytThread.count1);
System.out.println("線程2總計:" + PrioritytThread.count2);
}
static class PrioritytThread implements Runnable {
public static Integer count1 = 0;
public static Integer count2 = 0;
public void run() {
while (true) {
if ("線程1".equals(Thread.currentThread().getName())) {
count1++;
}
if ("線程2".equals(Thread.currentThread().getName())) {
count2++;
}
if (Thread.currentThread().isInterrupted()) {
break;
}
}
}
}
}
6.2 join()方法
join作用是讓其他線程變為等待。thread.Join把指定的線程加入到當前線程,可以將兩個交替執行的線程合併為順序執行的線程。比如在線程B中調用了線程A的Join()方法,直到線程A執行完畢後,才會繼續執行線程B。
public class Demo11Join {
public static void main(String[] args) {
JoinThread joinThread = new JoinThread();
Thread thread1 = new Thread(joinThread, "線程1");
Thread thread2 = new Thread(joinThread, "線程2");
Thread thread3 = new Thread(joinThread, "線程3");
thread1.start();
thread2.start();
thread3.start();
try {
thread1.join();
} catch (Exception e) {
}
for (int i = 0; i < 5; i++) {
System.out.println("main ---i:" + i);
}
}
static class JoinThread implements Runnable {
private Random random = new Random();
public void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(random.nextInt(10));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(name + "內容是:" + i);
}
}
}
}
6.3 yield方法
Thread.yield()方法的作用:暫停當前正在執行的線程,並執行其他線程。(可能沒有效果) yield()讓當前正在運行的線程回到可運行狀態,以允許具有相同優先級的其他線程獲得運行的機會。因此,使用yield()的目的是讓具有相同優先級的線程之間能夠適當的輪換執行。但是,實際中無法保證yield()達到讓步的目的,因為,讓步的線程可能被線程調度程序再次選中。
查看源碼介紹:
結論:大多數情況下,yield()將導致線程從運行狀態轉到可運行狀態,但有可能沒有效果。
二、多線程併發的3個特性
多線程併發開發中,要知道什麼是多線程的原子性,可見性和有序性,以避免相關的問題產生。
1. 原子性
原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行
一個很經典的例子就是銀行賬户轉賬問題:比如從賬户A向賬户B轉1000元,那麼必然包括2個操作:從賬户A減去1000元,往賬户B加上1000元。
試想一下,如果這2個操作不具備原子性,會造成什麼樣的後果。假如從賬户A減去1000元之後,操作突然中止。這樣就會導致賬户A雖然減去了1000元,但是賬户B沒有收到這個轉過來的1000元。
所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。
2. 可見性
可見性:當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值
舉個簡單的例子,看下面這段代碼:
//線程1執行的代碼
int i = 0;
i = 10;
//線程2執行的代碼
j = i;
當線程1執行int i = 0這句時,i的初始值0加載到內存中,然後再執行i = 10,那麼在內存中i的值變為10了。
如果當線程1執行到int i = 0這句時,此時線程2執行 j = i,它讀取i的值並加載到內存中,注意此時內存當中i的值是0,那麼就會使得j的值也為0,而不是10。
這就是可見性問題,線程1對變量i修改了之後,線程2沒有立即看到線程1修改的值。
3. 有序性
有序性:程序執行的順序按照代碼的先後順序執行
int count = 0;
boolean flag = false;
count = 1; //語句1
flag = true; //語句2
以上代碼定義了一個int型變量,定義了一個boolean類型變量,然後分別對兩個變量進行賦值操作。從代碼順序上看,語句1是在語句2前面的,那麼JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,為什麼呢?這裏可能會發生指令重排序(Instruction Reorder)。
什麼是重排序?一般來説,處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先後順序同代碼中的順序一致。
as-if-serial:無論如何重排序,程序最終執行結果和代碼順序執行的結果是一致的。Java編譯器、運行時和處理器都會保證Java在單線程下遵循as-if-serial語意)
上面的代碼中,語句1和語句2誰先執行對最終的程序結果並沒有影響,那麼就有可能在執行過程中,語句2先執行而語句1後執行。但是要注意,雖然處理器會對指令進行重排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那麼它靠什麼保證的呢?
再看下面一個例子:
int a = 10; //語句1
int b = 2; //語句2
a = a + 3; //語句3
b = a*a; //語句4
這段代碼有4個語句,那麼可能的一個執行順序是: 語句2 語句1 語句3 語句4
不可能是這個執行順序: 語句2 語句1 語句4 語句3
因為處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令Instruction 2必須用到Instruction 1的結果,那麼處理器會保證Instruction 1會在Instruction 2之前執行。雖然重排序不會影響單個線程內程序執行的結果,但是多線程會有影響
下面看一個例子:
//線程1:
init = false
context = loadContext(); //語句1
init = true; //語句2
//線程2:
while(!init){//如果初始化未完成,等待
sleep();
}
execute(context);//初始化完成,執行邏輯
上面代碼中,由於語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以為初始化工作已經完成,那麼就會跳出while循環,去執行execute(context)方法,而此時context並沒有被初始化,就會導致程序出錯。
從上面可以看出,重排序不會影響單個線程的執行,但是會影響到線程併發執行的正確性。
要想併發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。
三、Java內存可見性
1. 瞭解Java內存模型
JVM內存結構、Java對象模型和Java內存模型,這就是三個截然不同的概念,而這三個概念很容易混淆。這裏詳細區別一下
1.1 JVM內存結構
我們都知道,Java代碼是要運行在虛擬機上的,而虛擬機在執行Java程序的過程中會把所管理的內存劃分為若干個不同的數據區域,這些區域都有各自的用途。其中有些區域隨着虛擬機進程的啓動而存在,而有些區域則依賴用户線程的啓動和結束而建立和銷燬。
在《Java虛擬機規範(Java SE 8)》中描述了JVM運行時內存區域結構如下:
JVM內存結構,由Java虛擬機規範定義。描述的是Java程序執行過程中,由JVM管理的不同數據區域。各個區域有其特定的功能。
1.2 Java對象模型
Java是一種面向對象的語言,而Java對象在JVM中的存儲也是有一定的結構的。而這個關於Java對象自身的存儲模型稱之為Java對象模型。
HotSpot虛擬機中(Sun JDK和OpenJDK中所帶的虛擬機,也是目前使用範圍最廣的Java虛擬機),設計了一個OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通對象指針,而Klass用來描述對象實例的具體類型。
每一個Java類,在被JVM加載的時候,JVM會給這個類創建一個instanceKlass對象,保存在方法區,用來在JVM層表示該Java類。當我們在Java代碼中,使用new創建一個對象的時候,JVM會創建一個instanceOopDesc對象,這個對象中包含了對象頭以及實例數據。
這就是一個簡單的Java對象的OOP-Klass模型,即Java對象模型。
1.3 內存模型
Java內存模型就是一種符合內存模型規範的,屏蔽了各種硬件和操作系統的訪問差異的,保證了Java程序在各種平台下對內存的訪問都能保證效果一致的機制及規範。
Java內存模型是根據英文Java Memory Model(JMM)翻譯過來的。其實JMM並不像JVM內存結構一樣是真實存在的。他只是一個抽象的概念。JSR-133: Java Memory Model and Thread Specification中描述了,JMM是和多線程相關的,他描述了一組規則或規範,這個規範定義了一個線程對共享變量的寫入時對另一個線程是可見的。
簡單總結下,Java的多線程之間是通過共享內存進行通信的,而由於採用共享內存進行通信,在通信過程中會存在一系列如可見性、原子性、順序性等問題,而JMM就是圍繞着多線程通信以及與其相關的一系列特性而建立的模型。JMM定義了一些語法集,這些語法集映射到Java語言中就是volatile、synchronized等關鍵字。
JMM線程操作內存的基本的規則:
第一條關於線程與主內存:線程對共享變量的所有操作都必須在自己的工作內存(本地內存)中進行,不能直接從主內存中讀寫
第二條關於線程間本地內存:不同線程之間無法直接訪問其他線程本地內存中的變量,線程間變量值的傳遞需要經過主內存來完成。
- 主內存
主要存儲的是Java實例對象,所有線程創建的實例對象都存放在主內存中,不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量),當然也包括了共享的類信息、常量、靜態變量。由於是共享數據區域,多條線程對同一個變量進行訪問可能會發現線程安全問題。 - 本地內存
主要存儲當前方法的所有本地變量信息(本地內存中存儲着主內存中的變量副本拷貝),每個線程只能訪問自己的本地內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在自己的工作內存中創建屬於當前線程的本地變量,當然也包括了字節碼行號指示器、相關Native方法的信息。注意由於工作內存是每個線程的私有數據,線程間無法相互訪問工作內存,因此存儲在工作內存的數據不存在線程安全問題。
1.4 小結
JVM內存結構,和Java虛擬機的運行時區域有關。 Java對象模型,和Java對象在虛擬機中的表現形式有關。 Java內存模型,和Java的併發編程有關。
2. 內存可見性
2.1 內存可見性介紹
可見性:一個線程對共享變量值的修改,能夠及時的被其他線程看到
共享變量:如果一個變量在多個線程的工作內存中都存在副本,那麼這個變量就是這幾個線程的共享變量
線程 A 與線程 B 之間如要通信的話,必須要經歷下面 2 個步驟:
1)首先,線程 A 把本地內存 A 中更新過的共享變量刷新到主內存中去。
2)然後,線程 B 到主內存中去讀取線程 A 之前已更新過的共享變量。
如上圖所示,本地內存 A 和 B 有主內存中共享變量 x 的副本。假設初始時,這三個內存中的 x 值都為 0。線程 A 在執行時,把更新後的 x 值(假設值為 1)臨時存放在自己的本地內存 A 中。當線程 A 和線程 B 需要通信時,線程 A 首先會把自己本地內存中修改後的 x 值刷新到主內存中,此時主內存中的 x 值變為了 1。隨後,線程 B 到主內存中去讀取線程 A 更新後的 x 值,此時線程 B 的本地內存的 x 值也變為了 1。
從整體來看,這兩個步驟實質上是線程 A 在向線程 B 發送消息,而且這個通信過程必須要經過主內存。JMM 通過控制主內存與每個線程的本地內存之間的交互,來為 java 程序員提供內存可見性保證。
2.2 可見性問題
前面講過多線程的內存可見性,現在我們寫一個內存不可見的問題。
案例如下:
public class Demo1Jmm {
public static void main(String[] args) throws InterruptedException {
JmmDemo demo = new JmmDemo();
Thread t = new Thread(demo);
t.start();
Thread.sleep(100);
demo.flag = false;
System.out.println("已經修改為false");
System.out.println(demo.flag);
}
static class JmmDemo implements Runnable {
public boolean flag = true;
public void run() {
System.out.println("子線程執行。。。");
while (flag) {
}
System.out.println("子線程結束。。。");
}
}
}
執行結果
按照main方法的邏輯,我們已經把flag設置為false,那麼從邏輯上講,子線程就應該跳出while死循環,因為這個時候條件不成立,但是我們可以看到,程序仍舊執行中,並沒有停止。
原因:線程之間的變量是不可見的,因為讀取的是副本,沒有及時讀取到主內存結果。 解決辦法:強制線程每次讀取該值的時候都去“主內存”中取值
四、synchronized
synchronized可以保證方法或者代碼塊在運行時,同一時刻只有一個線程執行synchronized聲明的代碼塊。還可以保證共享變量的內存可見性。同一時刻只有一個線程執行,這部分代碼塊的重排序也不會影響其執行結果。也就是説使用了synchronized可以保證併發的原子性,可見性,有序性。
1. 解決可見性問題
JMM關於synchronized的兩條規定:
線程解鎖前(退出同步代碼塊時):必須把自己工作內存中共享變量的最新值刷新到主內存中
線程加鎖時(進入同步代碼塊時):將清空本地內存中共享變量的值,從而使用共享變量時需要從主內存中重新讀取最新的值(加鎖與解鎖是同一把鎖)
做如下修改,在死循環中添加同步代碼塊
while (flag) {
synchronized (this) {
}
}
synchronized實現可見性的過程
1)獲得互斥鎖(同步獲取鎖)
2)清空本地內存
3)從主內存拷貝變量的最新副本到本地內存
4)執行代碼
5)將更改後的共享變量的值刷新到主內存
6)釋放互斥鎖
2. 同步原理
synchronized的同步可以解決原子性、可見性和有序性的問題,那是如何實現同步的呢?
Java中每一個對象都可以作為鎖,這是synchronized實現同步的基礎:
1)普通同步方法,鎖是當前實例對象this
2)靜態同步方法,鎖是當前類的class對象
3)同步方法塊,鎖是括號裏面的對象
當一個線程訪問同步代碼塊時,它首先是需要得到鎖才能執行同步代碼,當退出或者拋出異常時必須要釋放鎖。
synchronized的同步操作主要是monitorenter和monitorexit這兩個jvm指令實現的,先寫一段簡單的代碼:
public class Demo2Synchronized {
public void test2() {
synchronized (this) {
}
}
}
在cmd命令行執行javac編譯和javap -c Java 字節碼的指令
javac Demo2Synchronized.java
javap -c Demo2Synchronized.class
從結果可以看出,同步代碼塊是使用monitorenter和monitorexit這兩個jvm指令實現的:
3. 鎖優化
synchronized是重量級鎖,效率不高。但在jdk 1.6中對synchronize的實現進行了各種優化,使得它顯得不是那麼重了。jdk1.6對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。
鎖主要存在四中狀態,依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,他們會隨着競爭的激烈而逐漸升級。
注意鎖可以升級不可降級,這種策略是為了提高獲得鎖和釋放鎖的效率。
3.1 自旋鎖
線程的阻塞和喚醒需要CPU從用户態轉為核心態,頻繁的阻塞和喚醒對CPU來説是一件負擔很重的工作,勢必會給系統的併發性能帶來很大的壓力。同時我們發現在許多應用上面,對象鎖的鎖狀態只會持續很短一段時間,為了這一段很短的時間頻繁地阻塞和喚醒線程是非常不值得的。所以引入自旋鎖。
所謂自旋鎖,就是讓該線程等待一段時間,不會被立即掛起,看持有鎖的線程是否會很快釋放鎖。怎麼等待呢?執行一段無意義的循環即可(自旋)。
自旋等待不能替代阻塞,雖然它可以避免線程切換帶來的開銷,但是它佔用了處理器的時間。如果持有鎖的線程很快就釋放了鎖,那麼自旋的效率就非常好,反之,自旋的線程就會白白消耗掉處理的資源,它不會做任何有意義的工作,典型的佔着茅坑不拉屎,這樣反而會帶來性能上的浪費。所以説,自旋等待的時間(自旋的次數)必須要有一個限度,如果自旋超過了定義的時間仍然沒有獲取到鎖,則應該被掛起。
自旋鎖在JDK 1.4.2中引入,默認關閉,但是可以使用-XX:+UseSpinning開開啓,在JDK1.6中默認開啓。同時自旋的默認次數為10次,可以通過參數-XX:PreBlockSpin來調整;
如果通過參數-XX:preBlockSpin來調整自旋鎖的自旋次數,會帶來諸多不便。假如我將參數調整為10,但是系統很多線程都是等你剛剛退出的時候就釋放了鎖(假如你多自旋一兩次就可以獲取鎖),你是不是很尷尬。於是JDK1.6引入自適應的自旋鎖,讓虛擬機會變得越來越聰明。
3.2 適應自旋鎖
JDK 1.6引入了更加聰明的自旋鎖,即自適應自旋鎖。所謂自適應就意味着自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。它怎麼做呢?線程如果自旋成功了,那麼下次自旋的次數會更加多,因為虛擬機認為既然上次成功了,那麼此次自旋也很有可能會再次成功,那麼它就會允許自旋等待持續的次數更多。反之,如果對於某個鎖,很少有自旋能夠成功的,那麼在以後要或者這個鎖的時候自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源。
有了自適應自旋鎖,隨着程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測會越來越準確,虛擬機會變得越來越聰明。
3.3 鎖消除
為了保證數據的完整性,我們在進行操作時需要對這部分操作進行同步控制,但是在有些情況下,JVM檢測到不可能存在共享數據競爭,這是JVM會對這些同步鎖進行鎖消除。鎖消除的依據是逃逸分析的數據支持。
如果不存在競爭,為什麼還需要加鎖呢?所以鎖消除可以節省毫無意義的請求鎖的時間。變量是否逃逸,對於虛擬機來説需要使用數據流分析來確定,但是對於我們程序員來説這還不清楚麼?我們會在明明知道不存在數據競爭的代碼塊前加上同步嗎?但是有時候程序並不是我們所想的那樣?我們雖然沒有顯示使用鎖,但是我們在使用一些JDK的內置API時,如StringBuffer、Vector、HashTable等,這個時候會存在隱形的加鎖操作。比如StringBuffer的append()方法,Vector的add()方法:
public void test(){
Vector<Integer> vector = new Vector<Integer>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i);
}
System.out.println(vector);
}
在運行這段代碼時,JVM可以明顯檢測到變量vector沒有逃逸出方法vectorTest()之外,所以JVM可以大膽地將vector內部的加鎖操作消除。
3.4 鎖粗化
在使用同步鎖的時候,需要讓同步塊的作用範圍儘可能小,僅在共享數據的實際作用域中才進行同步,這樣做的目的是為了使需要同步的操作量儘可能縮小,如果存在鎖競爭,那麼等待鎖的線程也能儘快拿到鎖。
在大多數的情況下,上述觀點是正確的。但是如果一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗,所以引入鎖粗化的概念。
鎖粗話概念比較好理解,就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖。如上面實例:vector每次add的時候都需要加鎖操作,JVM檢測到對同一個對象(vector)連續加鎖、解鎖操作,會合並一個更大範圍的加鎖、解鎖操作,即加鎖解鎖操作會移到for循環之外。
3.5 偏向鎖
輕量級鎖的加鎖解鎖操作是需要依賴多次CAS原子指令的。而偏向鎖只需要檢查是否為偏向鎖、鎖標識為以及ThreadID即可,可以減少不必要的CAS操作。
3.6 輕量級鎖
引入輕量級鎖的主要目的是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。當關閉偏向鎖功能或者多個線程競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖。輕量級鎖主要使用CAS進行原子操作。
但是對於輕量級鎖,其性能提升的依據是“對於絕大部分的鎖,在整個生命週期內都是不會存在競爭的”,如果打破這個依據則除了互斥的開銷外,還有額外的CAS操作,因此在有多線程競爭的情況下,輕量級鎖比重量級鎖更慢。
3.7 重量鎖
重量級鎖通過對象內部的監視器(monitor)實現,其中monitor的本質是依賴於底層操作系統的Mutex Lock(互斥鎖)實現,操作系統實現線程之間的切換需要從用户態到內核態的切換,切換成本非常高。