本博客系列是學習並發編程過程中的記錄總結。由於文章比較多,寫的時間也比較散,所以我整理了個目錄貼(傳送門),方便查閱。
前言
之前的文章中講到,JMM是內存模型規范在Java語言中的體現。JMM保證了在多核CPU多線程編程環境下,對共享變量讀寫的原子性、可見性和有序性。
本文就具體來講講JMM是如何保證共享變量訪問的原子性的。
原子性問題
原子性是指:一個或多個操作,要么全部執行且在執行過程中不被任何因素打斷,要么全部不執行。在Java中當我們討論一個操作具有原子性問題是一般就是指這個操作會被線程的隨機調度打斷。
下面就是一段會出現原子性問題的代碼:
public class AtomicProblem {
private static Logger logger = LoggerFactory.getLogger(AtomicProblem.class);
public static final int THREAD_COUNT = 10;
public static void main(String[] args) throws Exception {
BankAccount sharedAccount = new BankAccount("account-csx",0.00);
ArrayList<Thread> threads = new ArrayList<>();
for (int i = 0; i < THREAD_COUNT; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 1000 ; j++) {
sharedAccount.deposit(10.00);
}
}
});
thread.start();
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
logger.info("the balance is:{}",sharedAccount.getBalance());
}
public static class BankAccount {
private String accountName;
public double getBalance() {
return balance;
}
private double balance;
public BankAccount(String accountName, double balance){
this.accountName = accountName;
this.balance =balance;
}
public double deposit(double amount){
balance = balance + amount;
return balance;
}
public double withdraw(double amount){
balance = balance - amount;
return balance;
}
public String getAccountName() {
return accountName;
}
public void setAccountName(String accountName) {
this.accountName = accountName;
}
}
}
上面的代碼中開啟了10個線程,每個線程會對共享的銀行賬戶進行1000次存款操作,每次存款10塊,所以理論上最后銀行賬戶中的錢應該是10 * 1000 * 10 = 100000塊。我執行了多次上面的代碼,很多次最后的結果的確是100000,但是也有幾次的結果並不是我們預期的。
14:40:25.981 [main] INFO com.csx.demo.spring.boot.concurrent.jmm.AtomicProblem - the balance is:98260.0
出現上面結果的原因就是因為下面的操作並不是原子操作,其中的balance
是一個共享變量。在多線程環境下可能會被打斷。
balance = balance + amount;
上面的賦值操作被分為多步執行完成,下面簡單解析下兩個線程對balance
同時加10的過程(模擬存款過程,假設balance的初始值還是0)
線程1從共享內存中加載balance的初始值0到工作內存
線程1對工作內存中的值加10
//此時線程1的CPU時間耗盡,線程2獲得執行機會
線程2從共享內存中加載balance的初始值到工作內存,此時balance的值還是0
線程2對工作內存中的值加10,此時線程2工作內存中的副本值是10
線程2將balance的副本值刷新回共享內存,此時共享內存中balance的值是10
//線程2CPU時間片耗盡,線程1又獲得執行機會
線程1將工作內存中的副本值刷新回共享內存,但是此時副本的值還是10,所以最后共享內存中的值也是10
上面簡單模擬了一個原子性問題導致程序最終結果出錯的過程。
JMM對原子性問題的保證
自帶原子性保證
在Java中,對基本數據類型的變量的讀取和賦值操作是原子性操作(例外就是long和double的非原子性協定,大家只要知道這件事情就可以了,無須太過在意這些幾乎不會發生的例外情況)。
a = true; //原子性
a = 5; //原子性
a = b; //非原子性,分兩步完成,第一步加載b的值,第二步將b賦值給a
a = b + 2; //非原子性,分三步完成
a ++; //非原子性,分三步完成
synchronized
synchronized可以保證操作結果的原子性。synchronized保證原子性的原理也很簡單,因為synchronized可以防止多個線程並發執行一段代碼。還是用上面存款的場景做列子,我們只需要將存款的方法設置成synchronized的就能保證原子性了。
public synchronized double getBalance() {
return balance;
}
public synchronized double deposit(double amount){
balance = balance + amount; //1
return balance;
}
加了synchronized后,當一個線程沒執行完deposit
這個方法前,其他線程是不能執行這段代碼的。其實我們發現synchronized並不能將上面的代碼1編程原子性操作,上面的代碼1還是有可能被中斷的,但是即使被中斷了其他線程也不能訪問共享變量balance
,當之前被中斷的線程繼續執行時得到的結果還是正確的。
因此synchronized對原子性問題的保證是從最終結果上來保證的,也就是說它只保證最終的結果正確,中間操作的是否被打斷沒法保證。這個和CAS操作需要對比着看。
PS:對於上面的getBalance
方法大家可能會有點疑惑:只讀操作為什么還要加上synchronized關鍵字。其實這邊加上synchronized關鍵字的目的是為了保證balance變量的可見性,進入synchronized代碼塊每次都會去從主內存中讀取最新值。
Lock鎖
public double deposit(double amount) {
readWriteLock.writeLock().lock();
try {
balance = balance + amount;
return balance;
} finally {
readWriteLock.writeLock().unlock();
}
}
Lock鎖保證原子性的原理和synchronized類似,這邊不進行贅述了。
原子操作類型
public static class BankAccount {
//省略其他代碼
private AtomicDouble balance;
public double deposit(double amount) {
return balance.addAndGet(amount);
}
//省略其他代碼
}
JDK提供了很多原子操作類來保證操作的原子性。原子操作類的底層是使用CAS機制的,這個機制對原子性的保證和synchronized有本質的區別。CAS機制保證了整個賦值操作是原子的不能被打斷的,而synchronized值能保證代碼最后執行結果的正確性,也就是說synchronized能消除原子性問題對代碼最后執行結果的影響。
PS:JVM中的CAS操作是利用處理器提供的CMPXCHG指令實現的。
簡單總結
在多線程編程環境下(無論是多核CPU還是單核CPU),對共享變量的訪問存在原子性問題。這個問題可能會導致程序錯誤的執行結果。JMM主要提供了如下的方式來保證操作的原子,保證程序不受原子性問題的影響。
- synchronized機制:保證程序最終正確性,是的程序不受原子性問題的影響;
- Lock接口:和synchronized類似;
- 原子操作類:底層使用CAS機制,能保證操作真正的原子性。