首頁 > Java > java教程 > 主體

詳解Java中synchronized關鍵字的死鎖和記憶體佔用問題

高洛峰
發布: 2017-01-05 16:10:34
原創
1961 人瀏覽過

先看一段synchronized 的詳解:
synchronized 是 java語言的關鍵字,當它用來修飾一個方法或一個程式碼區塊的時候,能夠保證在同一時刻最多只有一個執行緒執行該段程式碼。

一、當兩個並發線程訪問同一個物件object中的這個synchronized(this)同步程式碼區塊時,一個時間內只能有一個執行緒來執行。另一個執行緒必須等待目前執行緒執行完這個程式碼區塊以後才能執行該程式碼區塊。

二、然而,當一個執行緒存取object的一個synchronized(this)同步程式碼區塊時,另一個執行緒仍然可以存取該object中的非synchronized(this)同步程式碼區塊。

三、尤其關鍵的是,當一個執行緒存取object的一個synchronized(this)同步程式碼區塊時,其他執行緒對object中所有其它synchronized(this)同步程式碼區塊的存取將被阻塞。

四、第三個例子同樣適用其它同步程式碼區塊。也就是說,當一個執行緒存取object的一個synchronized(this)同步程式碼區塊時,它就獲得了這個object的物件鎖定。結果,其它執行緒對該object物件所有同步程式碼部分的存取都被暫時阻塞。

五、以上規則對其它對象鎖同樣適用.
簡單來說, synchronized就是為當前的線程聲明一個鎖, 擁有這個鎖的線程可以執行區塊裡面的指令, 其他的線程只能等待獲取鎖,然後才能相同的操作.
這個很好用, 但是筆者遇到另一種比較奇葩的情況.
1. 在同一類中, 有兩個方法是用了synchronized關鍵字聲明
2. 在執行完其中一個方法的時候, 需要等待另一個方法(非同步執行緒回呼)也執行完, 所以用了一個countDownLatch來做等待
3. 程式碼解構如下:

synchronized void a(){
 countDownLatch = new CountDownLatch(1);
 // do someing
 countDownLatch.await();
}
 
synchronized void b(){
   countDownLatch.countDown();
}
登入後複製

其中
a方法由主執行緒執行, b方法由非同步執行緒執行後回呼
執行結果是:
主執行緒執行a方法後開始卡住, 不再往下做, 任你等多久都沒用.
這是一個很經典的死鎖問題
a等待b執行,其實不要看b是回調的, b也在等待a執行. 為什麼呢? synchronized 起了作用.
一般來說, 我們要synchronized一段程式碼區塊的時候, 我們需要使用一個共享變數來鎖住, 例如:

byte[] mutex = new byte[0];
 
void a1(){
   synchronized(mutex){
     //dosomething
   }
}
 
void b1(){
 
   synchronized(mutex){
     // dosomething
   }
 
}
登入後複製

如果把a方法和b方法的內容分別遷移到a1和b1 方法的synchronized塊裡面, 就很好理解了.
a1執行完後會間接等待(countDownLatch)b1方法執行.
然而由於a1 中的然而由於a1 中的mutex並沒有釋放, 就開始等待b1了, 這時候, 即使是異步的回調b1方法, 由於需要等待mutex釋放鎖, 所以b方法並不會執行.
於是就引起了死鎖!
而這裡的synchronized關鍵字放在方法前面, 起的作用就是一樣的. 只是java語言幫你隱去了mutex的聲明和使用而已. 同一個對像中的synchronized 方法用到的mutex是相同的, 所以即使是異步回調, 也會引起死鎖, 所以要注意這個問題. 這種級別的錯誤是屬於synchronized關鍵字使用不當. 不要亂用, 而且要用對.
那麼這樣的隱形的mutex 對象究竟是什麼呢?
很容易想到的就是實例本身. 因為這樣就不用去定義新的物件了做鎖了. 為了證明這個設想, 可以寫一段程式來證明.
思路很簡單, 定義一個類別, 有兩個方法,一個方法聲明為synchronized, 一個在方法體裡面使用synchronized(this), 然後啟動兩個線程, 來分別調用這兩個方法, 如果兩個方法之間發生鎖競爭(等待)的話, 就可以說明方法聲明的synchronized 中的隱形的mutex其實就是實例本身了.

public class MultiThreadSync {
 
  public synchronized void m1() throws InterruptedException{
     System. out.println("m1 call" );
     Thread. sleep(2000);
     System. out.println("m1 call done" );
  }
 
  public void m2() throws InterruptedException{
     synchronized (this ) {
       System. out.println("m2 call" );
       Thread. sleep(2000);
       System. out.println("m2 call done" );
     }
  }
 
  public static void main(String[] args) {
     final MultiThreadSync thisObj = new MultiThreadSync();
 
     Thread t1 = new Thread(){
       @Override
       public void run() {
         try {
           thisObj.m1();
         } catch (InterruptedException e) {
           e.printStackTrace();
         }
       }
     };
 
     Thread t2 = new Thread(){
       @Override
       public void run() {
         try {
           thisObj.m2();
         } catch (InterruptedException e) {
           e.printStackTrace();
         }
       }
     };
 
     t1.start();
     t2.start();
 
  }
 
}
登入後複製

結果輸出是:

m1 call
m1 call done
m2 call
m2 call done
登入後複製

說明方法m2的sync塊等待了m1的執行. 這樣就可以證實上面的設想了.
另外需要說明的是, 當sync加在static的方法上的時候, 由於是類別級別的方法, 所以鎖住的物件是當前類別的class實例. 同樣也可以寫程式進行證明.這裡略.
所以方法的synchronized 關鍵字,在閱讀的時候可以自動替換為synchronized(this){}就很好理解了.

                    void method(){
void synchronized method(){         synchronized(this){
   // biz code                // biz code
}               ------>>>   }
                    }
登入後複製

由Synchronized的內存可見性說開去
在Java中,我們都知道關鍵字synchronized可以用於實現線程間的互斥,但我們卻常常忘記了它還有另外一個作用,那就是確保變量在內存的可見性- 即當讀寫兩個線程同時訪問同一個變量時,synchronized用於確保寫線程更新變量後,讀線程再訪問該變數時可以讀取到該變數最新的值。

比如說下面的例子:

public class NoVisibility {
  private static boolean ready = false;
  private static int number = 0;
 
  private static class ReaderThread extends Thread {
    @Override
    public void run() {
      while (!ready) {
        Thread.yield(); //交出CPU让其它线程工作
      }
      System.out.println(number);
    }
  }
 
  public static void main(String[] args) {
    new ReaderThread().start();
    number = 42;
    ready = true;
  }
}
登入後複製

你認為讀取執行緒會輸出什麼? 42? 在正常情況下是會輸出42. 但是由於重排序問題,讀線程還有可能會輸出0 或什麼都不輸出。

我們知道,編譯器在將Java程式碼編譯成字節碼的時候可能會對程式碼進行重排序,而CPU在執行機器指令的時候也可能會對其指令進行重排序,只要重排序不會破壞程式的語意-

在单一线程中,只要重排序不会影响到程序的执行结果,那么就不能保证其中的操作一定按照程序写定的顺序执行,即使重排序可能会对其它线程产生明显的影响。
这也就是说,语句"ready=true"的执行有可能要优先于语句"number=42"的执行,这种情况下,读线程就有可能会输出number的默认值0.

而在Java内存模型下,重排序问题是会导致这样的内存的可见性问题的。在Java内存模型下,每个线程都有它自己的工作内存(主要是CPU的cache或寄存器),它对变量的操作都在自己的工作内存中进行,而线程之间的通信则是通过主存和线程的工作内存之间的同步来实现的。

比如说,对于上面的例子而言,写线程已经成功的将number更新为42,ready更新为true了,但是很有可能写线程只同步了number到主存中(可能是由于CPU的写缓冲导致),导致后续的读线程读取的ready值一直为false,那么上面的代码就不会输出任何数值。

而如果我们使用了synchronized关键字来进行同步,则不会存在这样的问题,

public class NoVisibility {
  private static boolean ready = false;
  private static int number = 0;
  private static Object lock = new Object();
 
  private static class ReaderThread extends Thread {
    @Override
    public void run() {
      synchronized (lock) {
        while (!ready) {
          Thread.yield();
        }
        System.out.println(number);
      }
    }
  }
 
  public static void main(String[] args) {
    synchronized (lock) {
      new ReaderThread().start();
      number = 42;
      ready = true;
    }
  }
}
登入後複製

这个是因为Java内存模型对synchronized语义做了以下的保证,

即当ThreadA释放锁M时,它所写过的变量(比如,x和y,存在它工作内存中的)都会同步到主存中,而当ThreadB在申请同一个锁M时,ThreadB的工作内存会被设置为无效,然后ThreadB会重新从主存中加载它要访问的变量到它的工作内存中(这时x=1,y=1,是ThreadA中修改过的最新的值)。通过这样的方式来实现ThreadA到ThreadB的线程间的通信。

这实际上是JSR133定义的其中一条happen-before规则。JSR133给Java内存模型定义以下一组happen-before规则,

单线程规则:同一个线程中的每个操作都happens-before于出现在其后的任何一个操作。

对一个监视器的解锁操作happens-before于每一个后续对同一个监视器的加锁操作。

对volatile字段的写入操作happens-before于每一个后续的对同一个volatile字段的读操作。

Thread.start()的调用操作会happens-before于启动线程里面的操作。

一个线程中的所有操作都happens-before于其他线程成功返回在该线程上的join()调用后的所有操作。

一个对象构造函数的结束操作happens-before与该对象的finalizer的开始操作。

传递性规则:如果A操作happens-before于B操作,而B操作happens-before与C操作,那么A动作happens-before于C操作。

实际上这组happens-before规则定义了操作之间的内存可见性,如果A操作happens-before B操作,那么A操作的执行结果(比如对变量的写入)必定在执行B操作时可见。

为了更加深入的了解这些happens-before规则,我们来看一个例子:

//线程A,B共同访问的代码
Object lock = new Object();
int a=0;
int b=0;
int c=0;
 
//线程A,调用如下代码
synchronized(lock){
  a=1; //1
  b=2; //2
} //3
c=3; //4
 
 
//线程B,调用如下代码
synchronized(lock){ //5
  System.out.println(a); //6
  System.out.println(b); //7
  System.out.println(c); //8
}
登入後複製

我们假设线程A先运行,分别给a,b,c三个变量进行赋值(注:变量a,b的赋值是在同步语句块中进行的),然后线程B再运行,分别读取出这三个变量的值并打印出来。那么线程B打印出来的变量a,b,c的值分别是多少?

根据单线程规则,在A线程的执行中,我们可以得出1操作happens before于2操作,2操作happens before于3操作,3操作happens before于4操作。同理,在B线程的执行中,5操作happens before于6操作,6操作happens before于7操作,7操作happens before于8操作。而根据监视器的解锁和加锁原则,3操作(解锁操作)是happens before 5操作的(加锁操作),再根据传递性 规则我们可以得出,操作1,2是happens before 操作6,7,8的。

则根据happens-before的内存语义,操作1,2的执行结果对于操作6,7,8是可见的,那么线程B里,打印的a,b肯定是1和2. 而对于变量c的操作4,和操作8. 我们并不能根据现有的happens before规则推出操作4 happens before于操作8. 所以在线程B中,访问的到c变量有可能还是0,而不是3.


更多详解Java中synchronized关键字的死锁和内存占用问题相关文章请关注PHP中文网!


相關標籤:
來源:php.cn
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板