那些坑你沒商量的程式碼死迴圈

蘇三說技術發表於2021-04-25

前言

程式碼死迴圈這個話題,個人覺得還是挺有趣的。因為只要是開發人員,必定會踩過這個坑。如果真的沒踩過,只能說明你程式碼寫少了,或者是真正的大神。

儘管很多時候,我們在極力避免這類問題的發生,但有時候,死迴圈卻悄咪咪的就來了,坑你於無形之中。如果你讀完這篇文章,也許會對程式碼死迴圈問題有一些新的認識,學到一些非常實用的經驗,少走一些彎路。

死迴圈的危害

讓我們一起先來了解一下,程式碼死迴圈到底有哪些危害?圖片

 

 

  • 程式進入假死狀態: 當某個請求導致的死迴圈,該請求將會在很大的一段時間內,都無法獲取介面的返回,程式好像進入假死狀態一樣。
  • cpu使用率飆升:程式碼出現死迴圈後,由於沒有休眠,一直不斷搶佔cpu資源,導致cpu長時間處於繁忙狀態,必定會使cpu使用率飆升。
  • 記憶體使用率飆升:如果程式碼出現死迴圈時,迴圈體內有大量建立物件的邏輯,垃圾回收器無法及時回收,會導致記憶體使用率飆升。同時,如果垃圾回收器頻繁回收物件,也會造成cpu使用率飆升問題。
  • StackOverflowError:在一些遞迴呼叫的場景,如果出現無限遞迴,最終會報StackOverflowError棧溢位,導致程式直接掛掉。

哪些場景會產生死迴圈?

1.一般迴圈遍歷

這裡說的一般迴圈遍歷主要是指:

  • for語句
  • foreach語句
  • while語句

這三種迴圈語句可能是我們平常使用最多的迴圈語句了,但是如果沒有用好,也是最容易出現死迴圈的問題的地方。讓我們一起看看,哪些情況會出現死迴圈。

1.1 條件恆等

很多時候我們使用for語句迴圈遍歷,不滿足指定條件,程式會自動退出迴圈,比如:

for(int i=0; i<10; i++) {
   System.out.println(i);
}

但是,如果不小心把條件寫錯了,變成這樣的:

for(int i=0; i>10; i++) {
   System.out.println(i);
}

結果就悲劇了,必定會出現死迴圈,因為迴圈中的條件變成恆等的了。

很多朋友看到這裡,心想這種錯誤我肯定不會犯的。不過我需要特別說明的是,這裡舉的例子相對來說比較簡單,如果i>10這裡是個非常複雜的計算,還真說不準一定不會出現死迴圈。

1.2 不正確的continue

for語句在迴圈遍歷陣列list時更方便,而while語句的使用場景卻更多。

有時候,在使用while語句遍歷資料時,如果遇到特別的條件,需要用continue關鍵字跳過本次迴圈,直接執行下次迴圈。

例如:

int count = 0;
while(count < 10) {
   count++;
   if(count == 4) {
      continue;
   }
   System.out.println(count);
}

當count等於4時,不列印count。

但如果continue沒有被正確使用,可能會出現莫名奇怪的問題:

int count = 0;
while(count < 10) {
   if(count == 4) {
      continue;
   }
   System.out.println(count);
   count++;
}

當count等於4時直接推出本次迴圈,count沒有加1,而直接進入下次迴圈,下次迴圈時count依然等4,最後無限迴圈了。

這種是我們要千萬小心的場景,說不定,已經進入了死迴圈你還不知道呢。

1.3 flag執行緒間不可見

有時候我們的程式碼需要一直做某件事情,直到某個條件達到時,有個狀態告訴它,要終止任務了,它就會自動退出。

這時候,很多人都會想到用while(flag)實現這個功能:

public class FlagTest {
    private boolean flag = true;

    public void setFlag(boolean flag) {
        this.flag = flag;
    }

    public void fun() {
        while (flag) {
        }
        System.out.println("done");
    }

    public static void main(String[] args) throws InterruptedException {
        final FlagTest flagTest = new FlagTest();
        new Thread(() -> flagTest.fun()).start();
        Thread.sleep(200);
        flagTest.setFlag(false);
    }
}

這段程式碼在子執行緒中執行無限迴圈,當主執行緒休眠200毫秒後,將flag變成false,這時子執行緒就會自動退出了。想法是好的,但是實際上這段程式碼會進入死迴圈,不會因為flag變成false而自動退出。

為什麼會這樣?

執行緒間flag是不可見的。

這時如果flag加上了volatile關鍵字:

private volatile boolean flag = true;

會強制把共享記憶體中的值重新整理到主記憶體中,讓多個執行緒間可見,程式可以正常退出。

2.Iterator遍歷

除了前面介紹過的一般迴圈遍歷之外,遍歷集合的元素,還可以使用Iterator遍歷。當然並非所有集合都能使用Iterator遍歷,只有實現了Iterator介面的集合,或者該集合的內部類實現了Iterator介面才可以。

例如:

public class IteratorTest {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("123");
        list.add("456");
        list.add("789");

        Iterator<String> iterator = list.iterator();
        while(iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

但如果程式改成這樣:

public class IteratorTest {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("123");
        list.add("456");
        list.add("789");

        while(list.iterator().hasNext()) {
            System.out.println(list.iterator().next());
        }
    }
}

就會出現死迴圈。

這又是為什麼呢?

如果看過ArrayList原始碼的朋友,會發現它的底層iterator方法是這樣的實現的:

public Iterator<E> iterator() {
    return new Itr();
}

每次都new了一個新的Itr物件。而hasNext方法的底層是通過判斷遊標和元素個數是否相等實現的:

 public boolean hasNext() {
    return cursor != size;
}

每次new了一個新的Itr物件的時候cursor值是預設值0,肯定和元素個數不相等。所以導致while語句中的條件一直都成立,所以才會出現死迴圈。

我們都需要注意:在while迴圈中使用list.iterator().hasNext(),是個非常大的坑,千萬小心。

3.類中使用自己的物件

有時候,在某個類中把自己的物件定義成成員變數,不知道你有沒有這樣做過。

有些可能會很詫異,為什麼要這麼做。

假如,你需要在一個方法中呼叫另一個打了@Transactional註解的方法,這時如果直接方法呼叫,另外一個方法由於無法走代理事務會失效。比如:

@Service
public class ServiceA {

   public void save(User user) {
         System.out.println("業務處理");
         doSave(user);
   }

   @Transactional(rollbackFor=Exception.class)
   public void doSave(User user) {
       System.out.println("儲存資料");
    }
 }

這種場景事務會失效。

這時可以通過把該類自己定義成一個成員變數,通過該變數呼叫doSave方法就能有效的避免該問題。

@Service
public class ServiceA {
   @Autowired
   private ServiceA serviceA;
   
   public void save(User user) {
         System.out.println("業務處理");
         serviceA.doSave(user);
   }

   @Transactional(rollbackFor=Exception.class)
   public void doSave(User user) {
       System.out.println("儲存資料");
    }
 }

當然還有其他辦法解決這個問題,不過這種方法是最簡單的。其他的解決方案,可以看看我的另一篇文章《讓人頭痛的大事務問題到底要如何解決?》。

那麼問題來了,如果成員變數不是通過@Autowired注入,而是直接new出來的,可以嗎?

成員變數改成這樣之後:

private ServiceA serviceA = new ServiceA();

專案重新啟動,程式進入無限迴圈。不斷建立ServiceA物件,但一直都無法成功,最後會報java.lang.StackOverflowError棧溢位。當棧深度超過虛擬機器分配給執行緒的棧大小時就會出現此錯誤。

為什麼會出現這個問題?

因為程式在例項化ServiceA物件時,要先例項化它的成員變數serviceA,但是它的成員變數serviceA,又需要例項化它自己的成員變數serviceA,如此一層層例項化下去,最終也沒能例項化。

@Autowired注入為什麼沒有問題?

因為@Autowired是在ServiceA物件例項化成功之後,在依賴注入階段,把例項注入到成員變數serviceA的。在spring中使用了三級快取,通過提前暴露ObjectFactory物件來解決這個自己依賴自己的迴圈依賴問題。

對spring迴圈依賴問題有興趣的朋友,可以看看我之前寫的一篇文章《spring:我是如何解決迴圈依賴的?》。

4.無限遞迴

在日常工作中,我們需要經常使用樹形結構展示資料,比如:分類、地區、組織、選單等功能。

很多時候需要從根節點遍歷找到所有葉子節點,也需要從葉子節點,往上一直追溯到根節點。

我們以通過根節點遍歷找到所有葉子節點為例。由於每次需要一層層遍歷查詢,而且呼叫的方法基本相同。為了簡化程式碼,我們一般都會選擇使用遞迴來實現這個功能。

這裡我們以根據葉子節點找到根節點為例,大致程式碼如下:

public Category findRoot(Long categoryId) {
    Category category = categoryMapper.findCategoryById(categoryId);
    if(null == category) {
       throw new BusinessException("分類不存在");
    }
    Long parentId = category.getParentId();
    if(null == categoryId || 0 == categoryId) {
       return category;
    }
    return findRoot(parentId);
}

根據categoryId往上遞迴查詢,如果發現parentId為null或者0的時候,就是根節點了,這時直接返回。

這可能是最普通不過的遞迴呼叫了,但是如果有人使壞,或者由於資料庫誤操作,把根節點的parentId改成了二級分類的categoryId一樣,比如都改成:1222。這樣遞迴呼叫會進入無限迴圈,最終會報java.lang.StackOverflowError異常。

為了避免這種慘案的發生,推薦使用如下方法。

可以定義一個執行遞迴的最大層級MAX_LEVEL,達到了最大層級則直接退出。以上程式碼可以做如下調整:

private static final int MAX_LEVEL = 6;

public Category findRoot(Long categoryId, int level) {
    if(level >= MAX_LEVEL) {
       return null;
    }
    Category category = categoryMapper.findCategoryById(categoryId);
    if(null == category) {
       throw new BusinessException("分類不存在");
    }
    Long parentId = category.getParentId();
    if(null == categoryId || 0 == categoryId) {
       return category;
    }
    return findRoot(parentId, ++level);
}

先定義MAX_LEVEL的值,然後第一次呼叫遞迴方法的時候level欄位的值傳1,每遞迴一次level的值加1,當發現level的值大於等於MAX_LEVEL時,說明出現了異常情況,則直接返回null。

我們在寫遞迴方法的時候,要養成好習慣,最好定義一個最大遞迴層級MAX_LEVEL,防止由於程式碼bug,或者資料異常,導致出現無限遞迴的情況。

5.hashmap

我們在寫程式碼時,為了提高效率,使用集合的概率非常大。通常情況下,我們喜歡先把資料收集到集合當中,然後對資料進行批處理,比如批量insert或update,提升資料庫操作的效能。

我們使用比較多的集合有:ArrayList、HashSet、HashMap等。我個人非常喜歡使用HashMap,特別是在java8中需要巢狀迴圈的地方,將其中一層迴圈的資料(list或者set)轉換成HashMap,可以減少一層遍歷,提升程式碼的執行效率。

但是如果HashMap使用不當,可能會出現死迴圈,怎麼回事呢?

5.1 jdk1.7的HashMap

jdk1.7的HashMap中採用 陣列 + 連結串列 的結構儲存資料。在多執行緒環境下,同時往HaspMap中put資料時,會觸發resize方法中的transfer方法,進行資料重新分配的過程,需要重新組織連結串列的資料。

 

 

由於採用了頭插法,最終會形成key3的next等於key7,而key7的next又等於key3的情況,從而構成了死迴圈。

5.2 jdk1.8的HashMap

有了解決jdk1.7擴容時出現死迴圈的問題,在jdk1.8中對HashMap進行了優化,將jdk1.7中的頭插法改成了尾插法,另外採用 陣列 + 連結串列 + 紅黑樹 的結構儲存資料。如果連結串列中元素超過8個時,就將連結串列轉化為紅黑樹,以減少查詢的複雜度,將時間複雜度降低為O(logN)。

在多執行緒環境下,同時往HaspMap中put資料時,會觸發root方法重新組織樹形結構的資料。圖片

 

 

在for迴圈中會出現兩個TreeNode節點的Parent引用都是對方,從而構成死迴圈的情況。

5.3 ConcurrentHashMap

由於在多執行緒環境下,使用無論是jdk1.7,還是jdk1.8的HashMap會有死迴圈的問題。所以很多人建議,不用在多執行緒環境下,使用HashMap,而應該改用ConcurrentHashMap

ConcurrentHashMap是執行緒安全的,同樣採用了 陣列 + 連結串列 + 紅黑樹 的結構儲存資料,此外還是使用了 cas + 分段鎖,預設是16段鎖,保證併發寫入時,資料不會產生錯誤。

在多執行緒環境下,同時往ConcurrentHashMapcomputeIfAbsent資料時,如果裡面還有一個computeIfAbsent,它們的key對應的hashCode是一樣的,這時就會產生死迴圈。圖片

 

 

 

意不意外,驚不驚喜?

幸好這個bug在jdk1.9中已經被Doug Lea修復了。

6.動態代理

我們在實際工作中,即使沒有自己動手寫過動態代理程式,但也聽過或者接觸過,因為很多優秀的開發框架,它們的底層必定都會使用動態代理,實現一些附加的功能。通常情況下,我們使用最多的動態代理是:JDK動態代理 和 Cglib,spring的AOP就是通過這兩種動態代理技術實現的。

我們在這裡以JDK動態代理為例:

public interface IUser {
    String add();
}
public class User implements IUser {
    @Override
    public String add() {
        System.out.println("===add===");
        return "success";
    }
}
public class JdkProxy implements InvocationHandler {

    private Object target;

    public Object getProxy(Object target) {
        this.target = target;
        return Proxy.newProxyInstance(this.getClass().getClassLoader(),target.getClass().getInterfaces(),this);
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        before();
        Object result = method.invoke(target,args);
        after();
        return result;
    }

    private void before() {
        System.out.println("===before===");
    }

    private void after() {
        System.out.println("===after===");
    }
}
public class Test {
    public static void main(String[] args) {
        User user = new User();
        JdkProxy jdkProxy = new JdkProxy();
        IUser proxy = (IUser)jdkProxy.getProxy(user);
        proxy.add();
    }
}

實現起來主要有三步:

  1. 實現某個具體業務介面
  2. 實現InvocationHandler介面,建立呼叫關係
  3. 使用Proxy建立代理類,指定被代理類的相關資訊

這樣在呼叫proxy的add方式時,會自動呼叫before和after方法,實現了動態代理的效果,是不是很酷?

通常情況下,這種寫法是沒有問題的,但是如果在invoke方法中呼叫了proxy物件的toString方法,加了這段程式碼:

proxy.toString();

程式再次執行,迴圈很多次之後,就會報java.lang.StackOverflowError異常。

很多人看到這裡可能一臉懵逼,到底發生了什麼?

代理物件本身並沒有自己的方法,它的所有方法都是基於被代理物件的。通常情況下,如果訪問代理物件的方法,會經過攔截器的invoke方法。但是如果在invoke方法調了代理物件的方法,比如:toString方法,會經過另外一個攔截器的invoke方法,如此一直反覆呼叫,最終形成死迴圈。

切記不要在invoke方法中呼叫代理物件的方法,不然會產生死迴圈,坑你於無形之中。

7.我們自己寫的死迴圈

很多朋友看到這個標題,可能會質疑,我們自己會寫死迴圈?

沒錯,有些場景我們還真的會寫。

7.1 定時任務

不知道你有沒有手寫過定時任務,反正我寫過,是非常簡單的那種(當然複雜的也寫過,在這裡就不討論了)。如果有個需求要求每隔5分鐘,從遠端下載某個檔案最新的版本,覆蓋當前檔案。

這時候,如果你不想用其他的定時任務框架,可以實現一個簡單的定時任務,具體程式碼如下:

public static void downLoad() {
    new Thread(() -> {
        while (true) {
            try {
                System.out.println("download file");
                Thread.sleep(1000 * 60 * 5);
            } catch (Exception e) {
                log.error(e);
            }
        }
    }).start();
}

其實很多JDK中的定時任務,比如:Timer類的底層,也是用了while(true)的無限迴圈(也就是死迴圈)來實現的。

7.2 生產者消費者

不知道你有沒有手寫過生產者和消費者。假設有個需求需要把使用者操作日誌寫入表中,但此時消費中還沒有引入訊息中介軟體,比如:kafka等。

最常規的做法是在介面中同步把日誌寫入表中,儲存邏輯跟業務邏輯可能在同一個事務中,但為了效能考慮,避免大事務的產生,一般建議不放在同一個事務。

原本挺好的,但是如果介面併發量上來了,為了優化介面效能,可能會把同步寫日誌到表中的邏輯,拆分出來,做成非同步處理的。

這時候,就可以手動擼一個生產者消費者解決這個問題了。

@Data
public class Car {
    private Integer id;
    private String name;
}
@Slf4j
public class Producer implements Runnable {

    private final ArrayBlockingQueue<Car> queue;

    public Producer(ArrayBlockingQueue<Car> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        int i = 1;
        while (true) {
            try {
                Car car = new Car();
                car.setId(i);
                car.setName("汽車" + i);
                queue.put(car);
                System.out.println("Producer:" + car + ", queueSize:" + queue.size());
            } catch (InterruptedException e) {
                log.error(e.getMessage(),e);
            }
            i++;
        }
    }
}
@Slf4j
public class Consumer implements Runnable {

    private final ArrayBlockingQueue<Car> queue;

    public Consumer(ArrayBlockingQueue<Car> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        while (true) {
            try {
                Car car = queue.take();
                System.out.println("Consumer:" + car + ",queueSize:" + queue.size());
            } catch (InterruptedException e) {
                log.error(e.getMessage(), e);
            }
        }
    }
}
public class ClientTest {

    public static void main(String[] args) {
        ArrayBlockingQueue<Car> queue = new ArrayBlockingQueue<Car>(20);
        new Thread(new Producer(queue)).start();
        new Thread(new Producer(queue)).start();
        new Thread(new Consumer(queue)).start();
    }
}

由於ArrayBlockingQueue阻塞佇列內部通過notEmpty 和 notFull 這兩個Condition實現了阻塞和喚醒機制,所以我們無需再做額外控制,用它實現生產者消費者相對來說要容易多了。

1.3 自己寫的死迴圈要注意什麼?

不知道聰明的小夥伴們有沒有發現,我們自定義的定時任務生產者消費者例子中,也寫了死迴圈,但跟上面其他的例子都不一樣,我們寫的死迴圈沒有出現問題,這是為什麼?

定時任務中我們用了sleep方法做休眠:Thread.sleep(300000);

生產者消費者用了Condition類的awaitsignal方法實現了阻塞和喚醒機制。

這兩種機制說白了,都會主動讓出cpu一段時間,讓其他的執行緒有機會使用cpu資源。這樣cpu有上下文切換的過程,有一段時間是處於空閒狀態的,不會像其他的列子中一直處於繁忙狀態。其他的問題,比如記憶體使用率飆升問題,也會得到相應的緩解。

一直處於繁忙狀態才是cpu使用率飆高的真正原因,我們要避免這種情況的產生。

就像我們平時騎共享單車(cpu資源)一樣,我們一般騎1-2小時就會歸還了,這樣其他人就有機會使用這輛共享單車。但如果有個人,騎了一個天還沒歸還,那麼這一天當中自行車一直處於繁忙之中,其他人就沒有機會騎這輛自行車了。

最後說一句(求關注,別白嫖我)

傳統美德不能丟,如果讀完這篇文章有些收穫的話,記得關注一下我的公眾號,給我點個 在看 ?。你的支援是我繼續堅持寫作唯一的動力。

收藏等於白嫖,收藏等於白嫖,收藏等於白嫖。

求一鍵三連:點贊、轉發、在看。

關注公眾號:【蘇三說技術】,在公眾號中回覆:面試、程式碼神器、開發手冊、時間管理有超讚的粉絲福利,另外回覆:加群,可以跟很多BAT大廠的前輩交流和學習。

相關文章