Java併發程式設計—共享模型
共享模型之管程
共享模型 是傳統的多執行緒模式下,執行緒之間的資源是共享的
共享模型的問題
在多個執行緒對共享資源進行操作的時候可能會出現一些問題,例如對讓兩個執行緒對初始值為 0 的靜態變數分別做5000次自增和自減操作,結果是0嗎?測試程式碼如下:
/** * @description: 測試併發安全性問題 * @author: lwh * @create: 2021/8/3 14:09 * @version: v1.0 **/ @Slf4j public class TestConcurrency { static int i = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int j = 0; j < 5000; j++) { i++; } }, "t1"); Thread t2 = new Thread(() -> { for (int j = 0; j < 5000; j++) { i--; } }, "t2"); t1.start(); t2.start(); t1.join(); t2.join(); log.debug("i:{}", i); } }
上面的程式碼執行所得結果可能是 0
,也可能是 正數
、 負數
,為什麼呢?
問題分析
因為我們線上程中對變數 i
的 自增
和 自減
操作並不是 原子操作
所謂原子操作就是一系列不能分割的操作,這些操作要麼全部完成,要麼都不執行
那麼就會出現這種情況:執行緒 t1 正在讓 i 從 0 自增到 1,恰好執行到往記憶體中回寫自增結果 1 的操作的時候 CPU 時間片用完了,發生了上下文切換,此時執行緒 t2 讀取到了 i 的值為 0 並進行了自減操作,i 變成了 -1 並且被成功回寫到記憶體當中,而當 t1 再次獲得 CPU 時間片執行回寫操作時,又將結果 1 回寫到了記憶體中,經過一次自增和自減操作,共享變數 i 從 0 變為了 1,這顯然是不正確的
臨界區 Critical Section
- 一個程式執行多個執行緒本身是沒有問題的
- 問題出在多個執行緒訪問共享資源
- 多個執行緒讀共享資源也沒有問題
- 在多個執行緒中對共享資源的讀寫操作發生交錯執行的情況時,就會出現問題
- 一段程式碼塊內如果存在對共享資源的多執行緒讀寫操作,稱這段程式碼塊為臨界區
競態條件 Race Condition
多個執行緒在臨界區內執行,由於程式碼的執行順序不同而導致執行結果無法預測,稱之為發生了競態條件
synchronized 解決方案
為了避免臨界區的競態條件的發生,有多種手段可以達到目的
- 阻塞式的解決方案:
synchronized
,Lock
- 非阻塞式的解決方案:原子變數
本節使用阻塞式的解決方案: synchronized
,即俗稱的【物件鎖】,它採用互斥的方式讓同一時刻至多隻有一個執行緒能持有【物件鎖】,其它執行緒再想獲取這個【物件鎖】時就會阻塞住,這樣就能保證擁有鎖的執行緒可以安全的執行臨界區內的程式碼,不用擔心上下文切換導致共享資源被其它執行緒修改
雖然 Java 中互斥和同步都可以採用
synchronized
關鍵字來完成,但它們還是有區別的:
- 互斥是避免臨界區競態條件的發生,同一時刻只能有一個執行緒執行臨界區程式碼
- 同步是由於執行緒執行的先後順序不同,需要一個執行緒等待其它執行緒執行到某個點
下面是採用 synchronized
關鍵字時的解決方案程式碼:
/**
* @description: 測試併發安全性問題
* @author: lwh
* @create: 2021/8/3 14:09
* @version: v1.0
**/
@Slf4j
public class TestConcurrency {
static int i = 0;
// 模擬共享資源的物件,因為i為基本資料型別,並不是物件,所以不能作為synchronized的引數
static final Object resource = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
// 獲取鎖之後才能執行臨界區程式碼
synchronized (resource) {
i++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
// 獲取鎖之後才能執行臨界區程式碼
synchronized (resource) {
i--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("i:{}", i);
}
}
理解
下面是一個順序圖,在順序圖中解釋了 synchronized
關鍵字的作用
上面順序圖中的時間片結束時間點只是模擬了會產生競態條件的一種情況,真正執行過程中是不確定的,並且執行緒數量也不止兩個,但是可以肯定的是,不管時間片在當前執行緒執行到臨界區內哪行程式碼時結束,
synchronized
關鍵字都可以保證競態條件不會發生,因為當前執行緒持有物件鎖時,其它所有執行緒即使都能獲取時間片開始執行,但在沒有獲得共享資源物件鎖的條件下,臨界區程式碼是不能執行的,只能阻塞等待,待持有鎖的執行緒執行完臨界區程式碼釋放鎖之後,下一個獲得鎖的執行緒才能繼續執行臨界區程式碼,這樣就保證了臨界區程式碼的執行是原子性的
總結
上節中說過, synchronized
實際是使用物件鎖保證了臨界區內程式碼執行的原子性,臨界區內的程式碼對外是不可分割的,不會被執行緒切換所打斷
為了加深理解,請思考以下問題:
- 如果把
synchronized(obj)
關鍵字放在 for 迴圈外面,執行情況是怎樣的? - 如果
t1 synchronized(obj1)
而t2 synchronized(obj2)
,有沒有效果? - 如果
t1 synchronized(obj)
而 t2 沒有加會怎麼樣?
改進
在實際程式設計過程中,為了遵循 Java 面向物件的程式設計思想,上述例子中的共享資源 i 應該被封裝到一個物件中,只向外提供必要的讀取和修改 i 的方法,外部應該通過呼叫共享資源提供的一些列操作完成對共享資源的讀取和修改,所以我們對上面的例子做出以下改進:
/**
* @description: 測試併發安全性問題
* @author: lwh
* @create: 2021/8/3 14:09
* @version: v1.0
**/
@Slf4j
public class TestConcurrency {
static final Room room = new Room();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("i:{}", room.getCount());
}
}
class Room {
private int count = 0;
public void increment() {
synchronized (this) {
this.count++;
}
}
public void decrement() {
synchronized (this) {
this.count--;
}
}
public int getCount() {
synchronized (this) {
return this.count;
}
}
}
方法前的 synchronized
上小節中改進的程式碼中的 Room
物件的各個涉及臨界區操作的方法都可以簡化為下面這樣:
class Room {
private int count = 0;
public synchronized void increment() {
this.count++;
}
public synchronized void decrement() {
this.count--;
}
public synchronized int getCount() {
return this.count;
}
}
這樣的寫法與上一小節中的寫法是等價的,也就是方法前的 synchronized
關鍵字的語義並不是給它所修飾的方法加鎖,而是給 this
即當前物件加鎖
另外,靜態方法 前的 synchronized
關鍵字會給類物件加鎖
特別的,處於同一個類中的被
synchronized
關鍵字修飾的靜態方法和非靜態方法之間是 不互斥 的,因為它們加鎖的物件不是同一個物件,靜態方法是給類物件
加鎖,而非靜態方法是給this
物件加鎖
class Test{
public synchronized static void test() {
}
// 等價於
public static void test() {
synchronized (Test.class) {
}
}
}
變數的執行緒安全分析
成員變數和靜態變數
- 如果它們沒有共享,則執行緒安全
- 如果它們被共享了,根據它們的狀態是否能夠改變,分為以下兩種情況:
- 如果所有執行緒對該變數只有讀操作,則執行緒安全
- 如果既有讀又有寫,則需要考慮執行緒安全問題
區域性變數
- 基本資料型別是執行緒安全的
- 引用資料型別根據其引用的作用範圍分為以下兩種情況:
- 如果該物件的引用未逃離臨界區,則執行緒安全
- 如果該物件的引用逃離了臨界區,則需要考慮執行緒安全問題
特別的,針對引用資料型別作用範圍逃離臨界區的這種情況,請看下面的例子:
/**
* @description: 看起來執行緒安全的操作
* @author: lwh
* @create: 2021/8/4 14:06
* @version: v1.0
**/
public class ThreadSeemSafe {
public static void main(String[] args) {
// ThreadSeemSafe t = new ThreadSeemSafe();
ThreadSeemSafeSubClass t = new ThreadSeemSafeSubClass();
Thread t1 = new Thread(t::run, "t1");
Thread t2 = new Thread(t::run, "t2");
t1.start();
t2.start();
}
public void run() {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < 2000; i++) {
add(list);
remove(list);
}
}
public void add(List<String> list) {
list.add("1");
}
public void remove(List<String> list) {
list.remove(0);
}
}
class ThreadSeemSafeSubClass extends ThreadSeemSafe {
@Override
public void remove(List<String> list) {
new Thread(() -> list.remove(0)).start();
}
}
上面程式碼中 t 的兩種建立方式大家都分別多執行幾遍,可以發現第一種方式不會出錯,但是第二種方式偶爾會發生越界錯誤,問題在哪呢?
run
方法中的 list
變數在子類 ThreadSeemSafeSubClass
中發生了暴露,即該物件的引用逃離了臨界區
ThreadSeemSafe
的子類 ThreadSeemSafeSubClass
重寫了父類的 remove
方法,在自己的 remove
方法中建立了新的執行緒執行 list
的 remove
操作,就是這個時候,區域性變數 list
被暴露給了一個新的執行緒,該執行緒操作的 list
物件和建立該執行緒的執行緒(t1 或 t2)中的 list
物件是同一個,這時就會產生競態條件,例如還沒有完成 add
操作時 remove執行緒 進行了 remove
操作,這時 list
是空的,自然會報錯
如何能夠避免需要作為共享資源的區域性變數暴露?
/**
* @description: 執行緒安全的操作
* @author: lwh
* @create: 2021/8/4 14:48
* @version: v1.0
**/
public class ThreadSeemSafe {
public static void main(String[] args) {
ThreadSeemSafeSubClass t = new ThreadSeemSafeSubClass();
Thread t1 = new Thread(t::run, "t1");
Thread t2 = new Thread(t::run, "t2");
t1.start();
t2.start();
}
public final void run() {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < 2000; i++) {
add(list);
remove(list);
}
}
// public final void add(List<String> list) {
private void add(List<String> list) {
list.add("1");
}
// public final void remove(List<String> list) {
private void remove(List<String> list) {
list.remove(0);
}
}
class ThreadSeemSafeSubClass extends ThreadSeemSafe {
// @Override
public void remove(List<String> list) {
new Thread(() -> list.remove(0)).start();
}
}
通過限制訪問許可權為 private
或者增加 final
避免由於繼承和重寫而產生不可預知的自定義行為導致破壞程式的執行緒安全性
run
方法只建議新增 final
關鍵字而不建議將其訪問許可權修改為 private
的原因是它作為處理邏輯的入口方法,需要能夠被外界呼叫,否則怎麼讓多個執行緒執行任務?但是,它不應該被重寫,所以應該使用 final
而非 private
從這個例子中可以看出 private
和 final
提供 【安全】的意義所在,請體會 開閉原則
中的【閉】
常見執行緒安全類
- String、Integer、StringBuilder、Random、Vector、Hashtable、java.util.concurrent包下的類
這裡說他們是執行緒安全的是指多個執行緒呼叫它們同一個例項的某個方法時,是執行緒安全的,也可以理解為:
- 它們的每個方法都是原子的
- 但注意它們多個方法的組合不是原子的,見後面分析
String
和Integer
類是不同於其他類的執行緒安全類,其他類使用了synchronized
關鍵字保證了執行緒安全,但String
和Integer
由於是 不可變類 保證了執行緒安全,之後會進行講解
不安全的方法組合
分析下面的程式碼是否執行緒安全?
Hashtable table = new Hashtable();
// 有多個執行緒都會執行下面的程式碼
Object obj = table.get("key");
if(Objects.isNull(obj)){
table.put("key", value);
}
雖然 get
和 put
方法都是原子性的,是執行緒安全的,但是假如某個執行緒執行到了 get
方法之後, put
方法之前的 Objects.isNull(obj)
語句時被打斷了,由於已經執行完了 get
方法, table
物件的鎖已經被釋放,所以接下來獲得時間片執行的執行緒可以對 table
加鎖並對 table
進行讀寫,造成了執行緒安全問題
不可變類
Sting、Integer 等都是不可變類,因為其內部的狀態(屬性)不可改變,因此它們的方法都是執行緒安全的,下面我們來看看 String 類中對字串的 substring 修改操作是如何完成的:
// 截自String類的原始碼
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
// 注意對於String的修改操作全是基於原字串新建立了一個字串實現修改效果的,原字串物件並沒有被修改,也就是內部狀態不會被改變
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
所有的修改操作都是返回了一個新的結果物件而不對原本的物件做出修改的類,即內部狀態不可改變的類,叫做不可變類,這種類是執行緒安全的
除此之外,無狀態的類(沒有成員變數的類)一般也都是執行緒安全的
例項分析
在 Web 專案中經常遇到的 Servlet
以及 Spring 框架中的 Component
元件等在程式執行期間往往都是單例模式存在的,而我們都知道在處理請求時,對於每個請求都會新建一個執行緒去處理,所以在多執行緒下對於這種單例存在的類需要考慮執行緒安全相關問題
例1:
public class MyServlet extends HttpServlet {
// 是否執行緒安全?否
Map<String, Object> map = new HashMap<>();
// 是否執行緒安全?是
String s1 = "";
// 是否執行緒安全?是
final String s2 = "";
// 是否執行緒安全?否
Date d1 = new Date();
// 是否執行緒安全?否
final Date d2 = new Date();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
// 使用上述變數
}
}
例2:
public class MyServlet extends HttpServlet {
// 是否執行緒安全?否
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update();
}
}
public class UserServiceImpl implements UserService {
// 是否安全?否
// 記錄呼叫次數
private int count = 0;
public void update() {
count++;
}
}
例3:
@Aspect
@Component
public class MyAspect {
// 是否執行緒安全?否
private long start = 0L;
@Before("exection(* *(...))")
public void before() {
start = System.nanoTime();
}
@After("exection(* *(...))")
public void after() {
long end = System.nanoTime();
System.out.println("cost time: " + (end - start));
}
}
// 可以使用 @Around 環繞通知結合區域性變數實現耗時記錄
例4:
public class MyServlet extends HttpServlet {
// 是否執行緒安全?是
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update();
}
}
public class UserServiceImpl implements UserService {
// 是否安全?是
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
public void update() {
String sql = "update table set what=0 where id = 0";
// 是否安全?是
// con物件是區域性變數,是執行緒私有的
try(Connection con = DriverManager.getConnection("","","")) {
// ...
} catch(...) {
.// ...
} finally {
con.close();
}
}
}
例5:
public class MyServlet extends HttpServlet {
// 是否執行緒安全?否
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update();
}
}
public class UserServiceImpl implements UserService {
// 是否安全?否
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
// 是否安全?否
private Connection con = null;
public void update() {
String sql = "update table set what=0 where id = 0";
// 是否安全?否
// con物件是各個執行緒共享的資源,共享資源沒有加鎖
con = DriverManager.getConnection("","","");
// ...
con.close();
}
}
例6:
public class MyServlet extends HttpServlet {
// 是否執行緒安全?是
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update();
}
}
public class UserServiceImpl implements UserService {
public void update() {
// 是否安全?是
// userDao 是區域性變數,每個執行緒之間是獨立的,所以 userDao 中的 con 物件也是獨立的
UserDao userDao = new UserDaoImpl();
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
// 是否安全?否
private Connection con = null;
public void update() {
String sql = "update table set what=0 where id = 0";
con = DriverManager.getConnection("","","");
// ...
con.close();
}
}
例7:
public abstract class Test {
public void bar() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
foo(sdf);
}
public abstract void foo(SimpleDateFormat sdf);
public static void main(String[] args) {
new Test().bar();
}
}
上面例7程式碼中的 foo 的行為是不確定的,可能導致執行緒安全問題,被稱之為 外星方法 ,屬於區域性引用變數洩露產生的執行緒安全問題
// 有一個子類覆蓋了 foo 方法
public void foo(SimpleDateFormat sdf) {
String date = "1999-04-18 18:18:18";
for (int i = 0; i < 100; i++) {
// sdf 物件發生了洩露
new Thread(() -> {
try {
sdf.parse(date);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
綜合訓練-賣票
測試下面的程式碼是否存線上程安全問題,並進行修改
/**
* @description: 賣票問題
* @author: lwh
* @create: 2021/8/5 9:56
* @version: v1.0
**/
public class SellTicketTest {
static Random random = new Random();
public static void main(String[] args) {
// 售票視窗
Window window = new Window(1000);
// 售出的票數,執行緒共享資源,使用執行緒安全的 Vector
List<Integer> sellCount = new Vector<>();
// 所有的執行緒集合
List<Thread> threadList = new ArrayList<>();
// 先建立好所有執行緒
for (int i = 0; i < 10000; i++) {
Thread thread = new Thread(() -> {
// 非原子操作
int amount = window.sell(randomAmount());
// 原子操作
sellCount.add(amount);
});
threadList.add(thread);
}
// 讓執行緒儘可能同時啟動
for (Thread thread : threadList) {
thread.start();
}
// 等待執行結果
for (Thread thread : threadList) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("售出的票數:" + sellCount.stream().mapToInt(i -> i).sum());
System.out.println("剩餘票數:" + window.getCount());
}
public static int randomAmount() {
return random.nextInt(5) + 1;
}
}
class Window {
private int count;
public Window(int count) {
this.count = count;
}
public int getCount() {
return this.count;
}
public int sell(int amount) {
try {
// 為了使 sell 操作在一個時間片內不能完成,減緩操作速度,因為如果處理速度足夠塊那麼就不會發生執行緒安全問題
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (count >= amount) {
count -= amount;
return amount;
}
return 0;
}
}
解決
class Window {
// ...
public synchronized int sell(int amount) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (count >= amount) {
count -= amount;
return amount;
}
return 0;
}
}
Monitor 概念
顧名思義,Monitor 是 監視器 的意思,既然是監視器,那麼就要有被監視的物件,這個物件就是共享資源物件。Monitor 是 synchronized
底層實現的一個工具物件,每個共享資源物件都各自擁有一個 Monitor 物件,Monitor 物件持有與之對應的共享資源物件的鎖並且擁有這個鎖的控制權,在搞清楚 Monitor 物件的工作原理之前我們先要了解一下Java 物件頭的相關知識
Java 物件頭
物件頭由一個標記字和一個類指標構成,其頂層結構根據物件型別分為兩種情況
普通物件:
graph TD A[物件頭 Object Header] --> B[標記字 Mark Word] A --> C[類指標 Klass Word]陣列物件:
graph TD A[物件頭 Object Header] --> B[標記字 Mark Word] A --> C[類指標 Klass Word] A --> D[陣列長度 Array Length]在32位系統中,普通物件頭的大小是
64 bits
,其中標記字佔用32 bits
,類指標佔用32 bits
;而陣列物件頭的大小是96 bits
,相比普通物件多了一個佔用32 bits
的陣列長度欄位
在64位系統中標記字、類指標、陣列長度都佔用
64 bits
標記字的資料結構如下:
32位虛擬機器
64位虛擬機器
關於標記字的資料結構我們可以在OpenJdk中的jvm原始碼檔案中找到一些解釋說明如下:
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
//
// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
也可以通過訪問下面的網址檢視具體的原始碼:
- https://wiki.openjdk.java.net/display/HotSpot/CompressedOops
- https://wiki.openjdk.java.net/display/HotSpot/Synchronization