Java基礎(chǔ)之代碼死循環(huán)詳解
一、前言
代碼死循環(huán)
這個話題,個人覺得還是挺有趣的。因為只要是開發(fā)人員,必定會踩過這個坑。如果真的沒踩過,只能說明你代碼寫少了,或者是真正的大神。
盡管很多時候,我們在極力避免這類問題的發(fā)生,但很多時候,死循環(huán)卻悄咪咪的來了,坑你于無形之中。我敢保證,如果你讀完這篇文章,一定會對代碼死循環(huán)有一些新的認識,學(xué)到一些非常實用的經(jīng)驗,少走一些彎路。
二、死循環(huán)的危害
我們先來一起了解一下,代碼死循環(huán)到底有哪些危害?
- 程序進入假死狀態(tài), 當(dāng)某個請求導(dǎo)致的死循環(huán),該請求將會在很大的一段時間內(nèi),都無法獲取接口的返回,程序好像進入假死狀態(tài)一樣。
- cpu使用率飆升,代碼出現(xiàn)死循環(huán)后,由于沒有休眠,一直不斷搶占cpu資源,導(dǎo)致cpu長時間處于繁忙狀態(tài),必定會使cpu使用率飆升。
- 內(nèi)存使用率飆升,如果代碼出現(xiàn)死循環(huán)時,循環(huán)體內(nèi)有大量創(chuàng)建對象的邏輯,垃圾回收器無法及時回收,會導(dǎo)致內(nèi)存使用率飆升。同時,如果垃圾回收器頻繁回收對象,也會造成cpu使用率飆升。
- StackOverflowError,在一些遞歸調(diào)用的場景,如果出現(xiàn)死循環(huán),多次循環(huán)后,最終會報StackOverflowError棧溢出,程序直接掛掉。
三、哪些場景會產(chǎn)生死循環(huán)?
3.1 一般循環(huán)遍歷
這里說的一般循環(huán)遍歷主要是指:
- for語句
- foreach語句
- while語句
這三種循環(huán)語句可能是我們平常使用最多的循環(huán)語句了,但是如果沒有用好,也是最容易出現(xiàn)死循環(huán)的問題的地方。讓我們一起看看,哪些情況會出現(xiàn)死循環(huán)。
3.1.1 條件恒等
很多時候我們使用for語句
循環(huán)遍歷,不滿足指定條件,程序會自動退出循環(huán),比如:
for(int i=0; i<10; i++) { System.out.println(i); }
但是,如果不小心把條件寫錯了,變成這樣的:
for(int i=0; i>=0; i++) { System.out.println(i); }
結(jié)果就悲劇了,必定會出現(xiàn)死循環(huán),因為循環(huán)中的條件變成恒等
的了。
很多朋友看到這里,心想這種錯誤我肯定不會犯的。不過我需要特別說明的是,這里舉的例子相對來說比較簡單,如果i>=0
這里是個非常復(fù)雜的計算,還真說不準一定不會出現(xiàn)死循環(huán)。
3.1.2 不正確的continue
for語句
在循環(huán)遍歷數(shù)組
和list
時更方便,而while語句
的使用場景卻更多。
有時候,在使用while語句
遍歷數(shù)據(jù)時,如果遇到特別的條件,可以使用continue
關(guān)鍵字跳過本次循環(huán),直接執(zhí)行下次循環(huán)。
例如:
int count = 0; while(count < 10) { count++; if(count == 4) { continue; } System.out.println(count); }
當(dāng)count等于4時,不打印count。
但如果continue
沒有被正確使用,可能會出現(xiàn)莫名奇怪的問題:
int count = 0; while(count < 10) { if(count == 4) { continue; } System.out.println(count); count++; }
當(dāng)count等于4時直接推出本次循環(huán),count沒有加1,而直接進入下次循環(huán),下次循環(huán)時count依然等4,最后無限循環(huán)了。
這種是我們要千萬小心的場景,說不定,已經(jīng)進入了死循環(huán)你還不知道呢。
3.1.3 flag線程間不可見
有時候我們的代碼需要一直做某件事情,直到某個條件達到,有個狀態(tài)告訴它,要終止任務(wù)了,它就會自動退出。
這時候,很多人都會想到用while(flag)
實現(xiàn)這個功能:
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); } }
這段代碼在子線程中執(zhí)行無限循環(huán),當(dāng)主線程休眠200毫秒后,將flag變成false,這時子線程就會自動退出了。想法是好的,但是實際上這段代碼進入了死循環(huán),不會因為flag變成false而自動退出。
為什么會這樣?
線程間flag
是不可見的,這時如果flag
加上了volatile
關(guān)鍵字,變成:
private volatile boolean flag = true;
會強制把共享內(nèi)存中的值刷新到主內(nèi)存中,讓多個線程間可見,程序可以正常退出。
3.2 Iterator遍歷
除了前面介紹過的一般循環(huán)遍歷
之外,遍歷集合的元素,還可以使用Iterator遍歷
。當(dāng)然并非所有集合都能使用Iterator遍歷
,只有實現(xiàn)了Iterator
接口的集合,或者該集合的內(nèi)部類實現(xiàn)了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()); } } }
就會出現(xiàn)死循環(huán)。
這是什么呢?
如果看過ArrayList
源碼的朋友,會發(fā)現(xiàn)它的底層iterator方法是這樣的實現(xiàn)的:
public Iterator<E> iterator() { return new Itr(); }
每次都new
了一個新的Itr
對象。而hasNext
方法的底層是通過判斷游標(biāo)和元素個數(shù)是否相等實現(xiàn)的:
public boolean hasNext() { return cursor != size; }
每次new
了一個新的Itr
對象的時候cursor
值是默認值0,肯定和元素個數(shù)不相等。所以導(dǎo)致while語句中的條件一直都成立,所以才會出現(xiàn)死循環(huán)。
我們都需要注意:在while循環(huán)中使用list.iterator().hasNext(),是個非常大的坑,千萬小心。
3.3 類中使用自己的對象
在某個類中把自己的對象定義成成員變量,不知道你有沒有這樣做過。
有些可能會很詫異,為什么要這么做。
假如,你需要在一個方法中調(diào)用另一個打了@Transactional
注解的方法,這時如果直接方法調(diào)用,另外一個方法由于無法走代理事務(wù)會失效。比如:
@Service public class ServiceA { public void save(User user) { System.out.println("業(yè)務(wù)處理"); doSave(user); } @Transactional(rollbackFor=Exception.class) public void doSave(User user) { System.out.println("保存數(shù)據(jù)"); } }
這種場景事務(wù)會失效。
這時可以通過把該類自己定義成一個成員變量,通過該變量調(diào)用doSave方法就能有效的避免該問題。
@Service public class ServiceA { @Autowired private ServiceA serviceA; public void save(User user) { System.out.println("業(yè)務(wù)處理"); serviceA.doSave(user); } @Transactional(rollbackFor=Exception.class) public void doSave(User user) { System.out.println("保存數(shù)據(jù)"); } }
當(dāng)然還有其他辦法解決這個問題,不過這種方法是最簡單的。
問題來了,如果成員變量不是通過@Autowired
注入,而是直接new
出來的,可以嗎?
成員變量改成這樣之后:
private ServiceA serviceA = new ServiceA();
項目在啟動的時候,程序進入無限循環(huán),不斷創(chuàng)建ServiceA對象,但一直都無法成功。最后會報java.lang.StackOverflowError
棧溢出,當(dāng)棧深度超過虛擬機分配給線程的棧大小時就會出現(xiàn)此錯誤。
為什么會出現(xiàn)這個問題?
因為程序在實例化ServiceA對象時,要先實例化它的成員變量serviceA,但是它的成員變量serviceA,又需要實例化它自己的成員變量serviceA,如此一層層實例化下去,最終也沒能實例化。
而@Autowired
注入為什么沒有問題?
因為@Autowired
是在ServiceA對象實例化成功之外,在依賴注入階段,把實例注入到成員變量serviceA的。在spring中使用了三級緩存,通過提前暴露ObjectFactory
對象來解決這個自己依賴自己的循環(huán)依賴問題。
對spring循環(huán)依賴問題有興趣的朋友,可以看看我之前寫的一篇文章《》。
3.4 無限遞歸
在日常工作中,我們需要經(jīng)常使用樹形結(jié)構(gòu)展示數(shù)據(jù),比如:分類、地區(qū)、組織、菜單等功能。
很多時候需要從根節(jié)點遍歷找到所有葉子節(jié)點,也需要從葉子節(jié)點,往上一直追溯到根節(jié)點。
我們以通過根節(jié)點遍歷找到所有葉子節(jié)點為例。由于每次需要一層層遍歷查找,而且調(diào)用的方法基本相同。為了簡化代碼,我們一般都會選擇使用遞歸
來實現(xiàn)這個功能。
這里我們以根據(jù)葉子節(jié)點找到根節(jié)點為例,大致代碼如下:
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); }
根據(jù)categoryId往上遞歸查找,如果發(fā)現(xiàn)parentId為null或者0的時候,就是根節(jié)點了,這時直接返回。
這可能是最普通不過的遞歸調(diào)用了,但是如果有人使壞,或者由于數(shù)據(jù)庫誤操作,把根節(jié)點的parentId改成了二級分類的categoryId一樣,比如都改成:1222。這樣遞歸調(diào)用會進入無限循環(huán),最終會報java.lang.StackOverflowError
異常。
為了避免這種慘案的發(fā)生,其實是有辦法的。
可以定義一個運行遞歸的最大層級MAX_LEVEL,達到了最大層級則直接退出。以上代碼可以做如下調(diào)整:
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的值,然后第一次調(diào)用遞歸方法的時候level字段的值傳1,每遞歸一次level的值加1,當(dāng)發(fā)現(xiàn)level的值大于等于MAX_LEVEL時,說明出現(xiàn)了異常情況,則直接返回null。
我們在寫遞歸方法的時候,要養(yǎng)成好習(xí)慣,最好定義一個最大遞歸層級MAX_LEVEL,防止由于代碼bug,或者數(shù)據(jù)異常,導(dǎo)致出現(xiàn)無限遞歸的情況。
3.5 hashmap
我們在寫代碼時,為了提高效率,使用集合的概率非常大。通常情況下,我們喜歡先把數(shù)據(jù)收集到集合當(dāng)中,然后對數(shù)據(jù)進行批處理,比如批量insert或update,提升數(shù)據(jù)庫操作的性能。
我們使用比較多的集合有:ArrayList、HashSet、HashMap等。我個人非常喜歡使用HashMap,特別是在java8中需要嵌套循環(huán)的地方,將其中一層循環(huán)的數(shù)據(jù)(list或者set)轉(zhuǎn)換成HashMap,可以減少一層遍歷,提升代碼的執(zhí)行效率。
但是如果HashMap使用不當(dāng),可能會出現(xiàn)死循環(huán),怎么回事呢?
3.5.1 jdk1.7的HashMap
jdk1.7的HashMap中采用 數(shù)組
+ 鏈表
的結(jié)構(gòu)存儲數(shù)據(jù)。在多線程環(huán)境下,同時往HaspMap中put數(shù)據(jù)時,會觸發(fā)resize
方法中的transfer
方法,進行數(shù)據(jù)重新分配的過程,需要重新組織鏈表的數(shù)據(jù)。
由于采用了頭插法
,最終會形成key3的next等于key7,而key7的next又等于key3的情況,從而構(gòu)成了死循環(huán)。
3.5.2 jdk1.8的HashMap
有了解決jdk1.7擴容時出現(xiàn)死循環(huán)的問題,在jdk1.8中對HashMap進行了優(yōu)化,將jdk1.7中的頭插法
改成了尾插法
,另外采用 數(shù)組
+ 鏈表
+ 紅黑樹
的結(jié)構(gòu)存儲數(shù)據(jù)。如果鏈表中元素超過8個時,就將鏈表轉(zhuǎn)化為紅黑樹,以減少查詢的復(fù)雜度,將時間復(fù)雜度降低為O(logN)。
在多線程環(huán)境下,同時往HaspMap中put數(shù)據(jù)時,會觸發(fā)root
方法重新組織樹形結(jié)構(gòu)的數(shù)據(jù)。
在for循環(huán)中會出現(xiàn)兩個TreeNode節(jié)點的Parent引用都是對方,從而構(gòu)成死循環(huán)的情況。
3.5.3 ConcurrentHashMap
由于在多線程環(huán)境下,使用無論是jdk1.7,還是jdk1.8的HashMap會有死循環(huán)的問題。所以很多人建議,不用在多線程環(huán)境下,使用HashMap
,而應(yīng)該改用ConcurrentHashMap
。
ConcurrentHashMap
是線程安全的,同樣采用了 數(shù)組
+ 鏈表
+ 紅黑樹
的結(jié)構(gòu)存儲數(shù)據(jù),此外還是使用了 cas
+ 分段鎖
,默認是16段鎖,保證并發(fā)寫入時,數(shù)據(jù)不會產(chǎn)生錯誤。
在多線程環(huán)境下,同時往ConcurrentHashMap
中computeIfAbsent
數(shù)據(jù)時,如果里面還有一個computeIfAbsent,它們的key對應(yīng)的hashCode是一樣的,這時就會產(chǎn)生死循環(huán)。
意不意外,驚不驚喜?
幸好這個bug在jdk1.9中已經(jīng)被Doug Lea修復(fù)了。
3.6 動態(tài)代理
我們在實際工作中,即使沒有自己動手寫過動態(tài)代理程序,但也聽過或者接觸過,因為很多優(yōu)秀的開發(fā)框架,它們的底層必定都會使用動態(tài)代理,實現(xiàn)一些附加的功能。通常情況下,我們使用最多的動態(tài)代理是:JDK動態(tài)代理
和 Cglib
,spring的AOP就是通過這兩種動態(tài)代理技術(shù)實現(xiàn)的。
我們在這里以JDK動態(tài)代理為例:
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(); } }
實現(xiàn)起來主要有三步:
1.實現(xiàn)某個具體業(yè)務(wù)接口
2.InvocationHandler接口,創(chuàng)建調(diào)用關(guān)系
3.使用Proxy
創(chuàng)建代理類,指定被代理類的相關(guān)信息
這樣在調(diào)用proxy的add方式時,會自動調(diào)用before和after方法,實現(xiàn)了動態(tài)代理的效果,是不是很酷?
通常情況下,這種寫法是沒有問題的,但是如果在invoke
方法中調(diào)用了proxy
對象的toString
方法,加了這段代碼:
proxy.toString();
程序再次運行,循環(huán)很多次之后,就會報java.lang.StackOverflowError
異常。
很多人看到這里可能一臉懵逼,到底發(fā)生了什么?
代理對象本身并沒有自己的方法,它的所有方法都是基于被代理對象的。通常情況下,如果訪問代理對象的方法,會經(jīng)過攔截器的invoke
方法。但是如果在invoke
方法調(diào)了代理對象
的方法,比如:toString
方法,會經(jīng)過另外一個攔截器的invoke
方法,如此一直反復(fù)調(diào)用,最終形成死循環(huán)。
切記不要在invoke方法中調(diào)用代理對象的方法,不然會產(chǎn)生死循環(huán),坑你于無形之中。
3.7 我們自己寫的死循環(huán)
很多朋友看到這個標(biāo)題,可能會質(zhì)疑,我們自己會寫死循環(huán)?
沒錯,有些場景我們還真的會寫。
3.7.1 定時任務(wù)
不知道你有沒有手寫過定時任務(wù),反正我寫過,是非常簡單的那種(當(dāng)然復(fù)雜的也寫過,在這里就不討論了)。如果有個需求要求每隔5分鐘,從遠程下載某個文件最新的版本,覆蓋當(dāng)前文件。
這時候,如果你不想用其他的定時任務(wù)框架,可以實現(xiàn)一個簡單的定時任務(wù),具體代碼如下:
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
中的定時任務(wù),比如:Timer
類的底層,也是用了while(true)的無限循環(huán)(也就是死循環(huán))來實現(xiàn)的。
3.7.2 生產(chǎn)者消費者
不知道你有沒有手寫過生產(chǎn)者和消費者。假設(shè)有個需求需要把用戶操作日志寫入表中,但此時消費中還沒有引入消息中間件,比如:kafka
等。
最常規(guī)的做法是在接口中同步把日志寫入表中,保存邏輯跟業(yè)務(wù)邏輯可能在同一個事務(wù)中,但為了性能考慮,避免大事務(wù)的產(chǎn)生,一般建議不放在同一個事務(wù)。
原本挺好的,但是如果接口并發(fā)量
上來了,為了優(yōu)化接口性能,可能會把同步寫日志到表中的邏輯,拆分出來,做成異步處理的。
這時候,就可以手動擼一個生產(chǎn)者消費者解決這個問題了。
@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
阻塞隊列內(nèi)部通過notEmpty
和 notFull
這兩個Condition
實現(xiàn)了阻塞和喚醒機制,所以我們無需再做額外控制,用它實現(xiàn)生產(chǎn)者消費者相對來說要容易多了。
四、自己寫的死循環(huán)要注意什么?
不知道聰明的小伙伴們有沒有發(fā)現(xiàn),我們自定義的定時任務(wù)
和生產(chǎn)者消費者
例子中,也寫了死循環(huán),但跟上面其他的例子都不一樣,我們寫的死循環(huán)沒有出現(xiàn)問題,這是為什么?
定時任務(wù)
中我們用了sleep
方法做休眠:Thread.sleep(300000);
。
生產(chǎn)者消費者
用了Condition
類的await
和signal
方法實現(xiàn)了阻塞和喚醒機制。
這兩種機制說白了,都會主動讓出cpu一段時間,讓其他的線程有機會使用cpu資源。這樣cpu有上下文切換的過程,有一段時間是處于空閑狀態(tài)的,不會像其他的列子中一直處于繁忙狀態(tài)。
一直處于繁忙狀態(tài)才是cpu使用率飆高的真正原因,我們要避免這種情況的產(chǎn)生。
就像我們平時騎共享單車(cpu資源)一樣,我們一般騎1-2小時就會歸還了,這樣其他人就有機會使用這輛共享單車。但如果有個人,騎了一個天還沒歸還,那么這一天當(dāng)中自行車一直處于繁忙之中,其他人就沒有機會騎這輛自行車了。
到此這篇關(guān)于Java基礎(chǔ)之代碼死循環(huán)詳解的文章就介紹到這了,更多相關(guān)Java代碼死循環(huán)內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
mybatis-plus?Wrapper條件構(gòu)造器updateForSet更新方式
這篇文章主要介紹了mybatis-plus?Wrapper條件構(gòu)造器updateForSet更新方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-03-03SpringBoot Session共享實現(xiàn)圖解
這篇文章主要介紹了SpringBoot Session共享實現(xiàn)圖解,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2020-01-01Java針對ArrayList自定義排序的2種實現(xiàn)方法
這篇文章主要介紹了Java針對ArrayList自定義排序的2種實現(xiàn)方法,結(jié)合實例形式總結(jié)分析了Java操作ArrayList自定義排序的原理與相關(guān)實現(xiàn)技巧,需要的朋友可以參考下2018-01-01