一、簡介
在之前的線程系列文章中,我們介紹了線程創建的幾種方式以及常用的方法介紹。
今天我們接着聊聊多線程線程安全的問題,以及解決辦法。
實際上,在多線程環境中,難免會出現多個線程對一個對象的實例變量進行同時訪問和操作,如果編程處理不當,會產生髒讀現象。
二、線程安全問題介紹
我們先來看一個簡單的線程安全問題的例子!
public class DataEntity {
private int count = 0;
public void addCount(){
count++;
}
public int getCount(){
return count;
}
}
public class MyThread extends Thread {
private DataEntity entity;
public MyThread(DataEntity entity) {
this.entity = entity;
}
@Override
public void run() {
for (int j = 0; j < 1000000; j++) {
entity.addCount();
}
}
}
public class MyThreadTest {
public static void main(String[] args) {
// 初始化數據實體
DataEntity entity = new DataEntity();
//使用多線程編程對數據進行計算
for (int i = 0; i < 10; i++) {
MyThread thread = new MyThread(entity);
thread.start();
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + entity.getCount());
}
}
多次運行結果如下:
第一次運行:result: 9788554
第二次運行:result: 9861461
第三次運行:result: 6412249
...
上面的代碼中,總共開啓了 10 個線程,每個線程都累加了 1000000 次,如果結果正確的話,自然而然總數就應該是 10 * 1000000 = 10000000。
但是多次運行結果都不是這個數,而且每次運行結果都不一樣,爲什麼會出現這個結果呢?
簡單的說,這是主內存和線程的工作內存數據不一致,以及多線程執行時無序,共同造成的結果!
我們先簡單的瞭解一下 Java 的內存模型,後期我們在介紹裏面的原理!
如上圖所示,線程 A 和線程 B 之間,如果要完成數據通信的話,需要經歷以下幾個步驟:
- 1.線程 A 從主內存中將共享變量讀入線程 A 的工作內存後並進行操作,之後將數據重新寫回到主內存中;
- 2.線程 B 從主存中讀取最新的共享變量,然後存入自己的工作內存中,再進行操作,數據操作完之後再重新寫入到主內存中;
如果線程 A 更新後數據並沒有及時寫回到主存,而此時線程 B 從主內存中讀到的數據,可能就是過期的數據,於是就會出現“髒讀”現象。
因此在多線程環境下,如果不進行一定干預處理,可能就會出現像上文介紹的那樣,採用多線程編程時,程序的實際運行結果與預期會不一致,就會產生非常嚴重的問題。
針對多線程編程中,程序運行不安全的問題,Java 提供了synchronized
關鍵字來解決這個問題,當多個線程同時訪問共享資源時,會保證線程依次排隊操作共享變量,從而保證程序的實際運行結果與預期一致。
我們對上面示例中的DataEntity.addCount()
方法進行改造,再看看效果如下。
public class DataEntity {
private int count = 0;
/**
* 在方法上加上 synchronized 關鍵字
*/
public synchronized void addCount(){
count++;
}
public int getCount(){
return count;
}
}
多次運行結果如下:
第一次運行:result: 10000000
第二次運行:result: 10000000
第三次運行:result: 10000000
...
運行結果與預期一致!
三、synchronized 使用詳解
synchronized
作爲 Java 中的關鍵字,在多線程編程中,有着非常重要的地位,也是新手瞭解併發編程的基礎,從功能角度看,它有以下幾個比較重要的特性:
- 原子性:即一個或多個操作要麼全部執行成功,要麼全部執行失敗。
synchronized
關鍵字可以保證只有一個線程拿到鎖,訪問共享資源 - 可見性:即一個線程對共享變量進行修改後,其他線程可以立刻看到。執行
synchronized
時,線程獲取鎖之後,一定從主內存中讀取數據,釋放鎖之前,一定會將數據寫回主內存,從而保證內存數據可見性 - 有序性:即保證程序的執行順序會按照代碼的先後順序執行。
synchronized
關鍵字,可以保證每個線程依次排隊操作共享變量
synchronized
也被稱爲同步鎖,它可以把任意一個非 NULL 的對象當成鎖,只有拿到鎖的線程能進入方法體,並且只有一個線程能進入,其他的線程必須等待鎖釋放了才能進入,它屬於獨佔式的悲觀鎖,同時也屬於可重入鎖。
關於鎖的知識,我們後面在介紹,大家先了解一下就行。
從實際的使用角度來看,synchronized
修飾的對象有以下幾種:
- 修飾一個方法:被修飾的方法稱爲同步方法,其作用的範圍是整個方法,作用的對象是調用這個方法的對象
- 修飾一個靜態的方法:其作用的範圍是整個靜態方法,作用的對象是這個類的所有對象
- 修飾一個代碼塊:被修飾的代碼塊稱爲同步語句塊,其作用的範圍是大括號
{}
括起來的代碼,作用的對象是調用這個代碼塊的對象,使用上比較靈活
下面我們一起來看看它們的具體用法。
3.1、修飾一個方法
當synchronized
修飾一個方法時,多個線程訪問同一個對象,哪個線程持有該方法所屬對象的鎖,就擁有執行權限,否則就只能等待。
如果多線程訪問的不是同一個對象,不會起到保證線程同步的作用。
示例如下:
public class DataEntity {
private int count;
/**
* 在方法上加上 synchronized 關鍵字
*/
public synchronized void addCount(){
for (int i = 0; i < 3; i++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public int getCount() {
return count;
}
}
public class MyThreadA extends Thread {
private DataEntity entity;
public MyThreadA(DataEntity entity) {
this.entity = entity;
}
@Override
public void run() {
entity.addCount();
}
}
public class MyThreadB extends Thread {
private DataEntity entity;
public MyThreadB(DataEntity entity) {
this.entity = entity;
}
@Override
public void run() {
entity.addCount();
}
}
public class MyThreadTest {
public static void main(String[] args) {
// 初始化數據實體
DataEntity entity = new DataEntity();
MyThreadA threadA = new MyThreadA(entity);
threadA.start();
MyThreadB threadB = new MyThreadB(entity);
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + entity.getCount());
}
}
運行結果如下:
Thread-0:0
Thread-0:1
Thread-0:2
Thread-1:3
Thread-1:4
Thread-1:5
result: 6
當兩個線程共同操作一個對象時,此時每個線程都會依次排隊執行。
假如兩個線程操作的不是一個對象,此時沒有任何效果,示例如下:
public class MyThreadTest {
public static void main(String[] args) {
DataEntity entity1 = new DataEntity();
MyThreadA threadA = new MyThreadA(entity1);
threadA.start();
DataEntity entity2 = new DataEntity();
MyThreadA threadB = new MyThreadA(entity2);
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + entity1.getCount());
System.out.println("result: " + entity2.getCount());
}
}
運行結果如下:
Thread-0:0
Thread-1:0
Thread-0:1
Thread-1:1
Thread-0:2
Thread-1:2
result: 3
result: 3
從結果上可以看出,當synchronized
修飾一個方法,當多個線程訪問同一個對象的方法,每個線程會依次排隊;如果訪問的不是一個對象,線程不會進行排隊,像正常執行一樣。
3.2、修飾一個靜態的方法
synchronized
修改一個靜態的方法時,代表的是對當前.java
文件對應的 Class 類加鎖,不區分對象實例。
示例如下:
public class DataEntity {
private static int count;
/**
* 在靜態方法上加上 synchronized 關鍵字
*/
public synchronized static void addCount(){
for (int i = 0; i < 3; i++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static int getCount() {
return count;
}
}
public class MyThreadA extends Thread {
@Override
public void run() {
DataEntity.addCount();
}
}
public class MyThreadB extends Thread {
@Override
public void run() {
DataEntity.addCount();
}
}
public class MyThreadTest {
public static void main(String[] args) {
MyThreadA threadA = new MyThreadA();
threadA.start();
MyThreadB threadB = new MyThreadB();
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + DataEntity.getCount());
}
}
運行結果如下:
Thread-0:0
Thread-0:1
Thread-0:2
Thread-1:3
Thread-1:4
Thread-1:5
result: 6
靜態同步方法和非靜態同步方法持有的是不同的鎖,前者是類鎖,後者是對象鎖,類鎖可以理解爲這個類的所有對象。
3.3、修飾一個代碼塊
synchronized
用於修飾一個代碼塊時,只會控制代碼塊內的執行順序,其他試圖訪問該對象的線程將被阻塞,編程比較靈活,在實際開發中用的應用比較廣泛。
示例如下
public class DataEntity {
private int count;
/**
* 在方法上加上 synchronized 關鍵字
*/
public void addCount(){
synchronized (this){
for (int i = 0; i < 3; i++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public int getCount() {
return count;
}
}
public class MyThreadTest {
public static void main(String[] args) {
// 初始化數據實體
DataEntity entity = new DataEntity();
MyThreadA threadA = new MyThreadA(entity);
threadA.start();
MyThreadB threadB = new MyThreadB(entity);
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + entity.getCount());
}
}
運行結果如下:
Thread-0:0
Thread-0:1
Thread-0:2
Thread-1:3
Thread-1:4
Thread-1:5
result: 6
其中synchronized (this)
中的this
,表示的是當前類實例的對象,效果等同於public synchronized void addCount()
。
除此之外,synchronized()
還可以修飾任意實例對象,作用的範圍就是具體的實例對象。
比如,修飾個自定義的類實例對象,作用的範圍是擁有lock
對象,其實也等價於synchronized (this)
。
public class DataEntity {
private Object lock = new Object();
/**
* synchronized 可以修飾任意實例對象
*/
public void addCount(){
synchronized (lock){
// todo...
}
}
}
當然也可以用於修飾類,表示類鎖,效果等同於public synchronized static void addCount()
。
public class DataEntity {
/**
* synchronized 可以修飾類,表示類鎖
*/
public void addCount(){
synchronized (DataEntity.class){
// todo...
}
}
}
synchronized
修飾代碼塊,比較經典的應用案例,就是單例設計模式中的雙重校驗鎖實現。
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
採用代碼塊的實現方式,編程會更加靈活,可以顯著的提升併發查詢的效率。
四、synchronized 鎖重入介紹
synchronized
關鍵字擁有鎖重入的功能,所謂鎖重入的意思就是:當一個線程得到一個對象鎖後,再次請求此對象鎖時可以再次得到該對象的鎖,而無需等待。
我們看個例子就能明白。
public class DataEntity {
private int count = 0;
public synchronized void addCount1(){
System.out.println(Thread.currentThread().getName() + ":" + (count++));
addCount2();
}
public synchronized void addCount2(){
System.out.println(Thread.currentThread().getName() + ":" + (count++));
addCount3();
}
public synchronized void addCount3(){
System.out.println(Thread.currentThread().getName() + ":" + (count++));
}
public int getCount() {
return count;
}
}
public class MyThreadA extends Thread {
private DataEntity entity;
public MyThreadA(DataEntity entity) {
this.entity = entity;
}
@Override
public void run() {
entity.addCount1();
}
}
public class MyThreadB extends Thread {
private DataEntity entity;
public MyThreadB(DataEntity entity) {
this.entity = entity;
}
@Override
public void run() {
entity.addCount1();
}
}
public class MyThreadTest {
public static void main(String[] args) {
// 初始化數據實體
DataEntity entity = new DataEntity();
MyThreadA threadA = new MyThreadA(entity);
threadA.start();
MyThreadB threadB = new MyThreadB(entity);
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + entity.getCount());
}
}
運行結果如下:
Thread-0:0
Thread-0:1
Thread-0:2
Thread-1:3
Thread-1:4
Thread-1:5
result: 6
從結果上看線程沒有交替執行,線程Thread-0
獲取到鎖之後,再次調用其它帶有synchronized
關鍵字的方法時,可以快速進入,而Thread-1
線程需等待對象鎖完全釋放之後再獲取,這就是鎖重入。
五、小結
從上文中我們可以得知,在多線程環境下,恰當的使用synchronized
關鍵字可以保證線程同步,使程序的運行結果與預期一致。
- 1.當
synchronized
修飾一個方法時,作用的範圍是整個方法,作用的對象是調用這個方法的對象; - 2..當
synchronized
修飾一個靜態方法時,作用的範圍是整個靜態方法,作用的對象是這個類的所有對象; - 3.當
synchronized
修飾一個代碼塊時,作用的範圍是代碼塊,作用的對象是修飾的內容,如果是類,則這個類的所有對象都會受到控制;如果是任意對象實例子,則控制的是具體的對象實例,誰擁有這個對象鎖,就能進入方法體
synchronized
是一種同步鎖,屬於獨佔式,使用它進行線程同步,JVM 性能開銷很大,大量的使用未必會帶來好處。
關於更深入的原理知識,我們會在 JVM 系列中進行詳解。文章內容難免有所遺漏,歡迎網友留言指出。