前言
代碼死回圈這個話題,個人覺得還是挺有趣的,因為只要是開發人員,必定會踩過這個坑,如果真的沒踩過,只能說明你代碼寫少了,或者是真正的大神,
盡管很多時候,我們在極力避免這類問題的發生,但很多時候,死回圈卻悄咪咪的來了,坑你于無形之中,我敢保證,如果你讀完這篇文章,一定會對代碼死回圈有一些新的認識,學到一些非常實用的經驗,少走一些彎路,
死回圈的危害
我們先來一起了解一下,代碼死回圈到底有哪些危害?

- 程式進入假死狀態, 當某個請求導致的死回圈,該請求將會在很大的一段時間內,都無法獲取介面的回傳,程式好像進入假死狀態一樣,
- 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>=0; i++) {
System.out.println(i);
}
結果就悲劇了,必定會出現死回圈,因為回圈中的條件變成恒等的了,
很多朋友看到這里,心想這種錯誤我肯定不會犯的,不過我需要特別說明的是,這里舉的例子相對來說比較簡單,如果i>=0這里是個非常復雜的計算,還真說不準一定不會出現死回圈,
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回圈依賴問題有興趣的朋友,可以看看我之前寫的一篇文章《》,
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段鎖,保證并發寫入時,資料不會產生錯誤,
在多執行緒環境下,同時往ConcurrentHashMap中computeIfAbsent資料時,如果里面還有一個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();
}
}
實作起來主要有三步:
- 實作某個具體業務
介面 - 實作
InvocationHandler介面,創建呼叫關系 - 使用
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類的await和signal方法實作了阻塞和喚醒機制,
這兩種機制說白了,都會主動讓出cpu一段時間,讓其他的執行緒有機會使用cpu資源,這樣cpu有背景關系切換的程序,有一段時間是處于空閑狀態的,不會像其他的列子中一直處于繁忙狀態,
一直處于繁忙狀態才是cpu使用率飆高的真正原因,我們要避免這種情況的產生,
就像我們平時騎共享單車(cpu資源)一樣,我們一般騎1-2小時就會歸還了,這樣其他人就有機會使用這輛共享單車,但如果有個人,騎了一個天還沒歸還,那么這一天當中自行車一直處于繁忙之中,其他人就沒有機會騎這輛自行車了,
最后說一句(求關注,別白嫖我)
傳統美德不能丟,如果讀完這篇文章有些識訓的話,記得給我點個贊 😙,你的支持是我繼續堅持寫作唯一的動力,
求一鍵三連:點贊、轉發、在看,
關注公眾號:【蘇三說技術】,在公眾號中回復:面試、代碼神器、開發手冊、時間管理有超贊的粉絲福利,另外回復:加群,可以跟很多BAT大廠的前輩交流和學習,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/280684.html
標籤:java
