AQS淺析

2019獨角獸企業重金招聘Python工程師標準>>> hot3.png

AQS的原理淺析

本文是《Java特種兵》的樣章,本書即將由工業出版社出版

AQS的全稱為(AbstractQueuedSynchronizer),這個類也是在java.util.concurrent.locks下面。這個類似乎很不容易看懂,因為它僅僅是提供了一系列公共的方法,讓子類來調用。那么要理解意思,就得從子類下手,反過來看才容易看懂。如下圖所示:
QQ圖片20140110194431

圖 5-15 AQS的子類實現

?

這么多類,我們看那一個?剛剛提到過鎖(Lock),我們就從鎖開始吧。這里就先以ReentrantLock排它鎖為例開始展開講解如何利用AQS的,然后再簡單介紹讀寫鎖的要點(讀寫鎖本身的實現十分復雜,要完全說清楚需要大量的篇幅來說明)。
首先來看看ReentrantLock的構造方法,它的構造方法有兩個,如下圖所示:
QQ圖片20140110194534
圖 5-16 排它鎖的構造方法
很顯然,對象中有一個屬性叫sync,有兩種不同的實現類,默認是“NonfairSync”來實現,而另一個“FairSync”它們都是排它鎖的內部類,不論用那一個都能實現排它鎖,只是內部可能有點原理上的區別。先以“NonfairSync”類為例,它的lock()方法是如何實現的呢?
QQ圖片20140110194615
圖 5-17 排它鎖的lock方法
lock()方法先通過CAS嘗試將狀態從0修改為1。若直接修改成功,前提條件自然是鎖的狀態為0,則直接將線程的OWNER修改為當前線程,這是一種理想情況,如果并發粒度設置適當也是一種樂觀情況。
若上一個動作未成功,則會間接調用了acquire(1)來繼續操作,這個acquire(int)方法就是在AbstractQueuedSynchronizer當中了。這個方法表面上看起來簡單,但真實情況比較難以看懂,因為第一次看這段代碼可能不知道它要做什么!不急,一步一步來分解。
首先看tryAcquire(arg)這里的調用(當然傳入的參數是1),在默認的“NonfairSync”實現類中,會這樣來實現:
QQ圖片20140110194650

媽呀,這代碼好費勁,胖哥第一回看也是覺得這樣,細心看看也不是想象當中那么難:

○ 首先獲取這個鎖的狀態,如果狀態為0,則嘗試設置狀態為傳入的參數(這里就是1),若設置成功就代表自己獲取到了鎖,返回true了。狀態為0設置1的動作在外部就有做過一次,內部再一次做只是提升概率,而且這樣的操作相對鎖來講不占開銷。
○ 如果狀態不是0,則判定當前線程是否為排它鎖的Owner,如果是Owner則嘗試將狀態增加acquires(也就是增加1),如果這個狀態值越界,則會拋出異常提示,若沒有越界,將狀態設置進去后返回true(實現了類似于偏向的功能,可重入,但是無需進一步征用)。
○ 如果狀態不是0,且自身不是owner,則返回false。

回到圖 5-17中對tryAcquire()的調用判定中是通過if(!tryAcquire())作為第1個條件的,如果返回true,則判定就不會成立了,自然后面的acquireQueued動作就不會再執行了,如果發生這樣的情況是最理想的。
無論多么樂觀,征用是必然存在的,如果征用存在則owner自然不會是自己,tryAcquire()方法會返回false,接著就會再調用方法:acquireQueued(addWaiter(Node.EXCLUSIVE), arg)做相關的操作。
這個方法的調用的代碼更不好懂,需要從里往外看,這里的Node.EXCLUSIVE是節點的類型,看名稱應該清楚是排它類型的意思。接著調用addWaiter()來增加一個排它鎖類型的節點,這個addWaiter()的代碼是這樣寫的:
QQ圖片20140110194812
圖 5-19 addWaiter的代碼
這里創建了一個Node的對象,將當前線程和傳入的Node.EXCLUSIVE傳入,也就是說Node節點理論上包含了這兩項信息。代碼中的tail是AQS的一個屬性,剛開始的時候肯定是為null,也就是不會進入第一層if判定的區域,而直接會進入enq(node)的代碼,那么直接來看看enq(node)的代碼。

看到了tail就應該猜到了AQS是鏈表吧,沒錯,而且它還應該有一個head引用來指向鏈表的頭節點,AQS在初始化的時候head、tail都是null,在運行時來回移動。此時,我們最少至少知道AQS是一個基于狀態(state)的鏈表管理方式。

QQ圖片20140110194853

圖 5-20 enq(Node)的源碼
這段代碼就是鏈表的操作,某些同學可能很牛,一下就看懂了,某些同學一掃而過覺得知道大概就可以了,某些同學可能會莫不著頭腦。胖哥為了給第三類同學來“開開葷”,簡單講解下這個代碼。
首先這個是一個死循環,而且本身沒有鎖,因此可以有多個線程進來,假如某個線程進入方法,此時head、tail都是null,自然會進入if(t == null)所在的代碼區域,這部分代碼會創建一個Node出來名字叫h,這個Node沒有像開始那樣給予類型和線程,很明顯是一個空的Node對象,而傳入的Node對象首先被它的next引用所指向,此時傳入的node和某一個線程創建的h對象如下圖所示。
QQ圖片20140110194922
圖 5-21 臨時的h對象創建后的與傳入的Node指向關系
剛才我們很理想的認為只有一個線程會出現這種情況,如果有多個線程并發進入這個if判定區域,可能就會同時存在多個這樣的數據結構,在各自形成數據結構后,多個線程都會去做compareAndSetHead(h)的動作,也就是嘗試將這個臨時h節點設置為head,顯然并發時只有一個線程會成功,因此成功的那個線程會執行tail = node的操作,整個AQS的鏈表就成為:

QQ圖片20140110194957

圖 5-22 AQS被第一個請求成功的線程初始化后
有一個線程會成功修改head和tail的值,其它的線程會繼續循環,再次循環就不會進入if (t == null)的邏輯了,而會進入else語句的邏輯中。
在else語句所在的邏輯中,第一步是node.prev = t,這個t就是tail的臨時值,也就是首先讓嘗試寫入的node節點的prev指針指向原來的結束節點,然后嘗試通過CAS替換掉AQS中的tail的內容為當前線程的Node,無論有多少個線程并發到這里,依然只會有一個能成功,成功者執行t.next = node,也就是讓原先的tail節點的next引用指向現在的node,現在的node已經成為了最新的結束節點,不成功者則會繼續循環。
簡單使用圖解的方式來說明,3個步驟如下所示,如下圖所示:

QQ圖片20140110194957

圖 5-23 插入一個節點步驟前后動作
插入多個節點的時候,就以此類推了哦,總之節點都是在鏈表尾部寫入的,而且是線程安全的。
知道了AQS大致的寫入是一種雙向鏈表的插入操作,但插入鏈表節點對鎖有何用途呢,我們還得退回到前面圖 5-19的代碼中addWaiter方法最終返回了要寫入的node節點, 再回退到圖5-17中所在的代碼中需要將這個返回的node節點作為acquireQueued方法入口參數,并傳入另一個參數(依然是1),看看它里面到底做了些什么?請看下圖:
QQ圖片20140110195059

圖 5-24 acquireQueued的方法內容
這里也是一個死循環,除非進入if(p == head && tryAcquire(arg))這個判定條件,而p為node.predcessor()得到,這個方法返回node節點的前一個節點,也就是說只有當前一個節點是head的時候,進一步嘗試通過tryAcquire(arg)來征用才有機會成功。tryAcquire(arg)這個方法我們前面介紹過,成立的條件為:鎖的狀態為0,且通過CAS嘗試設置狀態成功或線程的持有者本身是當前線程才會返回true,我們現在來詳細拆分這部分代碼。
○ 如果這個條件成功后,發生的幾個動作包含:
(1) 首先調用setHead(Node)的操作,這個操作內部會將傳入的node節點作為AQS的head所指向的節點。線程屬性設置為空(因為現在已經獲取到鎖,不再需要記錄下這個節點所對應的線程了),再將這個節點的perv引用賦值為null。
(2) 進一步將的前一個節點的next引用賦值為null。
在進行了這樣的修改后,隊列的結構就變成了以下這種情況了,通過這樣的方式,就可以讓執行完的節點釋放掉內存區域,而不是無限制增長隊列,也就真正形成FIFO了:

QQ圖片20140110195124
圖 5-25 CAS成功獲取鎖后,隊列的變化
○ 如果這個判定條件失敗
會首先判定:“shouldParkAfterFailedAcquire(p , node)”,這個方法內部會判定前一個節點的狀態是否為:“Node.SIGNAL”,若是則返回true,若不是都會返回false,不過會再做一些操作:判定節點的狀態是否大于0,若大于0則認為被“CANCELLED”掉了(我們沒有說明幾個狀態的值,不過大于0的只可能被CANCELLED的狀態),因此會從前一個節點開始逐步循環找到一個沒有被“CANCELLED”節點,然后與這個節點的next、prev的引用相互指向;如果前一個節點的狀態不是大于0的,則通過CAS嘗試將狀態修改為“Node.SIGNAL”,自然的如果下一輪循環的時候會返回值應該會返回true。
如果這個方法返回了true,則會執行:“parkAndCheckInterrupt()”方法,它是通過LockSupport.park(this)將當前線程掛起到WATING狀態,它需要等待一個中斷、unpark方法來喚醒它,通過這樣一種FIFO的機制的等待,來實現了Lock的操作。
相應的,可以自己看看FairSync實現類的lock方法,其實區別不大,有些細節上的區別可能會決定某些特定場景的需求,你也可以自己按照這樣的思路去實現一個自定義的鎖。
接下來簡單看看unlock()解除鎖的方式,如果獲取到了鎖不釋放,那自然就成了死鎖,所以必須要釋放,來看看它內部是如何釋放的。同樣從排它鎖(ReentrantLock)中的unlock()方法開始,請先看下面的代碼截圖:

QQ圖片20140110195158

圖 5-26 unlock方法間接調用AQS的release(1)來完成
通過tryRelease(int)方法進行了某種判定,若它成立則會將head傳入到unparkSuccessor(Node)方法中并返回true,否則返回false。首先來看看tryRelease(int)方法,如下圖所示:
QQ圖片20140110195239

圖 5-27 tryRelease(1)方法
這個動作可以認為就是一個設置鎖狀態的操作,而且是將狀態減掉傳入的參數值(參數是1),如果結果狀態為0,就將排它鎖的Owner設置為null,以使得其它的線程有機會進行執行。
在排它鎖中,加鎖的時候狀態會增加1(當然可以自己修改這個值),在解鎖的時候減掉1,同一個鎖,在可以重入后,可能會被疊加為2、3、4這些值,只有unlock()的次數與lock()的次數對應才會將Owner線程設置為空,而且也只有這種情況下才會返回true。
這一點大家寫代碼要注意了哦,如果是在循環體中lock()或故意使用兩次以上的lock(),而最終只有一次unlock(),最終可能無法釋放鎖。在本書的src/chapter05/locks/目錄下有相應的代碼,大家可以自行測試的哦。
在方法unparkSuccessor(Node)中,就意味著真正要釋放鎖了,它傳入的是head節點(head節點是已經執行完的節點,在后面闡述這個方法的body的時候都叫head節點),內部首先會發生的動作是獲取head節點的next節點,如果獲取到的節點不為空,則直接通過:“LockSupport.unpark()”方法來釋放對應的被掛起的線程,這樣一來將會有一個節點喚醒后繼續進入圖 5-24中的循環進一步嘗試tryAcquire()方法來獲取鎖,但是也未必能完全獲取到哦,因為此時也可能有一些外部的請求正好與之征用,而且還奇跡般的成功了,那這個線程的運氣就有點悲劇了,不過通常樂觀認為不會每一次都那么悲劇。
再看看共享鎖,從前面的排它鎖可以看得出來是用一個狀態來標志鎖的,而共享鎖也不例外,但是Java不希望去定義兩個狀態,所以它與排它鎖的第一個區別就是在鎖的狀態上,它用int來標志鎖的狀態,int有4個字節,它用高16位標志讀鎖(共享鎖),低16位標志寫鎖(排它鎖),高16位每次增加1相當于增加65536(通過1 << 16得到),自然的在這種讀寫鎖中,讀鎖和寫鎖的個數都不能超過65535個(條件是每次增加1的,如果遞增是跳躍的將會更少)。在計算讀鎖數量的時候將狀態左移16位,而計算排它鎖會與65535“按位求與”操作,如下圖所示。

QQ圖片20140110195308

圖 5-28 讀寫鎖中的數量計算及限制
寫鎖的功能與“ReentrantLock”基本一致,區域在于它會在tryAcquire操作的時候,判定狀態的時候會更加復雜一些(因此有些時候它的性能未必好)。
讀鎖也會寫入隊列,Node的類型被改為:“Node.SHARED”這種類型,lock()時候調用的是AQS的acquireShared(int)方法,進一步調用tryAcquireShared()操作里面只需要檢測是否有排它鎖,如果沒有則可以嘗試通過CAS修改鎖的狀態,如果沒有修改成功,則會自旋這個動作(可能會有很多線程在這自旋開銷CPU)。如果這個自旋的過程中檢測到排它鎖競爭成功,那么tryAcquireShared()會返回-1,從而會走如排它鎖的Node類似的流程,可能也會被park住,等待排它鎖相應的線程最終調用unpark()動作來喚醒。
這就是Java提供的這種讀寫鎖,不過這并不是共享鎖的詮釋,在共享鎖里面也有多種機制 ,或許這種讀寫鎖只是其中一種而已。在這種鎖下面,讀和寫的操作本身是互斥的,但是讀可以多個一起發生。這樣的鎖理論上是非常適合應用在“讀多寫少”的環境下(當然我們所講的讀多寫少是讀的比例遠遠大于寫,而不是多一點點),理論上講這樣鎖征用的粒度會大大降低,同時系統的瓶頸會減少,效率得到總體提升。
在本節中我們除了學習到AQS的內在,還應看到Java通過一個AQS隊列解決了許多問題,這個是Java層面的隊列模型,其實我們也可以利用許多隊列模型來解決自己的問題,甚至于可以改寫模型模型來滿足自己的需求,在本章的5.6.1節中將會詳細介紹。
關于Lock及AQS的一些補充:
1、 Lock的操作不僅僅局限于lock()/unlock(),因為這樣線程可能進入WAITING狀態,這個時候如果沒有unpark()就沒法喚醒它,可能會一直“睡”下去,可以嘗試用tryLock()、tryLock(long , TimeUnit)來做一些嘗試加鎖或超時來滿足某些特定場景的需要。例如有些時候發現嘗試加鎖無法加上,先釋放已經成功對其它對象添加的鎖,過一小會再來嘗試,這樣在某些場合下可以避免“死鎖”哦。
2、 lockInterruptibly() 它允許拋出InterruptException異常,也就是當外部發起了中斷操作,程序內部有可能會拋出這種異常,但是并不是絕對會拋出異常的,大家仔細看看代碼便清楚了。
3、 newCondition()操作,是返回一個Condition的對象,Condition只是一個接口,它要求實現await()、awaitUninterruptibly()、awaitNanos(long)、await(long , TimeUnit)、awaitUntil(Date)、signal()、signalAll()方法,AbstractQueuedSynchronizer中有一個內部類叫做ConditionObject實現了這個接口,它也是一個類似于隊列的實現,具體可以參考源碼。大多數情況下可以直接使用,當然覺得自己比較牛逼的話也可以參考源碼自己來實現。
4、 在AQS的Node中有每個Node自己的狀態(waitStatus),我們這里歸納一下,分別包含:
SIGNAL 從前面的代碼狀態轉換可以看得出是前面有線程在運行,需要前面線程結束后,調用unpark()方法才能激活自己,值為:-1
CANCELLED 當AQS發起取消或fullyRelease()時,會是這個狀態。值為1,也是幾個狀態中唯一一個大于0的狀態,所以前面判定狀態大于0就基本等價于是CANCELLED的意思。
CONDITION 線程基于Condition對象發生了等待,進入了相應的隊列,自然也需要Condition對象來激活,值為-2。
PROPAGATE 讀寫鎖中,當讀鎖最開始沒有獲取到操作權限,得到后會發起一個doReleaseShared()動作,內部也是一個循環,當判定后續的節點狀態為0時,嘗試通過CAS自旋方式將狀態修改為這個狀態,表示節點可以運行。
狀態0 初始化狀態,也代表正在嘗試去獲取臨界資源的線程所對應的Node的狀態。

轉載于:https://my.oschina.net/u/1185936/blog/857268

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/259419.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/259419.shtml
英文地址,請注明出處:http://en.pswp.cn/news/259419.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

str045漏洞提權linux,Linux運維知識之CVE-2016-5195 Dirtycow: Linux內核提權漏洞

本文主要向大家介Linux運維知識之CVE-2016-5195 Dirtycow&#xff1a; Linux內核提權漏洞紹了&#xff0c;通過具體的內容向大家展現&#xff0c;希望對大家學習Linux運維知識有所幫助。CVE-2016-5195 Dirtycow&#xff1a; Linux內核提權漏洞以下都是github上找的源碼&#xf…

編程如寫作

昨晚似乎是個適合寫作的夜&#xff0c;不論是自己還是朋友&#xff0c;都比平常更容易被觸動。看著微博上朋友們的心路&#xff0c;想寫點什么卻似乎找不出非常值得大書特書的主題&#xff0c;只是歪坐在電腦旁&#xff0c;喝著咖啡&#xff0c;單曲循環著倉木麻衣的《time aft…

C++中cin、cin.get()、cin.getline()、getline()等函數的用法

轉載&#xff1a;http://www.cnblogs.com/flatfoosie/archive/2010/12/22/1914055.html c輸入流函數主要以下幾個&#xff1a; 1、cin 2、cin.get() 3、cin.getline() 4、getline() 附:cin.ignore();cin.get()//跳過一個字符,例如不想要的回車,空格等字符 1、cin>>…

工作環境總結(1)開發環境搭建

1、安裝git 安裝文件&#xff1a;Git-2.12.0-64-bit.exe 下載地址&#xff1a;https://github.com/git-for-windows/git/releases/download/v2.12.0.windows.1/Git-2.12.0-64-bit.exe 在git bash中配置&#xff0c;git bash命令行中執行&#xff08;只有使用到egit時使用&…

c語言煙花百度云,C語言實現放煙花的程序

這是一個利用C語言編寫放煙花的程序(同時也可以播放音樂)&#xff0c;供大家參考&#xff0c;具體內容如下代碼如下#pragma once#include#include //圖形界面庫頭文件#include //計算圓形的軌跡坐標#include#include#include#include#pragma comment(lib,"winmm.lib"…

決定人生的七條公式

1 .積跬步以致千里&#xff0c;積怠惰以致深淵 1.01^365 37.80.99^365 0.032.拖延癥 U EV/ID U完成任務的程度 E對成功的信心 V 對任務的愉悅度 I 你的分心程度 D你多久會獲得回報3.三天打魚兩天曬網&#xff0c;終將一無所獲 1.01^3 x 0.99^2 < 1.01 4.愛因斯坦的成…

strncpy與strcpy的區別與注意事項

strncpy 是 C語言的庫函數之一&#xff0c;來自 C語言標準庫&#xff0c;定義于 string.h&#xff0c;char *strncpy(char *dest, char *src, int n)&#xff0c;把src所指字符串的前n個字節復制到dest所指的數組中&#xff0c;并返回指向dest的指針。 strcpy只是復制字符串&am…

使用ssh公鑰實現免密碼登錄

ssh 無密碼登錄要使用公鑰與私鑰。linux下可以用用ssh-keygen生成公鑰/私鑰對&#xff0c;下面我以CentOS為例。 有機器A(192.168.1.155)&#xff0c;B(192.168.1.181)。現想A通過ssh免密碼登錄到B。 首先以root賬戶登陸為例。 1.在A機下生成公鑰/私鑰對。 [rootA ~]# ssh-keyg…

15款的視頻處理軟件免費下載

因為需要購買昂貴的視頻處理軟件和高性能圖形計算機&#xff0c;所以視頻處理是一項比較耗費金錢的技術活。正是由于這樣&#xff0c;一部分人選擇使用性能較好的免費在線編輯軟件&#xff0c;無需太多視頻處理知識便可在瀏覽器中剪切和編輯視頻。然而&#xff0c;當我們無法連…

液位系統c語言程序,超聲波自動測量物體液位系統的設計

超聲波自動測量物體液位系統的設計(任務書,畢業論文15000字)摘要本系統以STC89C52單片機為核心&#xff0c;通過硬件電路連接和軟件程序的編寫實現通用型超聲波自動測量物體液位系統的設計。其主要原理是由單片機控制超聲波發射電路發射超聲波&#xff0c;超聲波接收電路接收遇…

android-sdk-windows版本號下載

Android SDK 4.0.3 開發環境配置及執行 近期又裝了一次最新版本號的ADK環境 眼下最新版是Android SDK 4.0.3 本文的插圖和文本盡管是Android2.2的 步驟都是一樣的&#xff0c;假設安裝的過程中遇到什么問題&#xff0c;能夠留言&#xff0c;我會盡快回復&#xff01; 系統環境的…

string中c_str()、data()、copy(p,n)函數的用法

轉載&#xff1a;http://www.cnblogs.com/qlwy/archive/2012/03/25/2416937.html 標準庫的string類提供了3個成員函數來從一個string得到c類型的字符數組&#xff1a;c_str()、data()、copy(p,n)。 1. c_str()&#xff1a;生成一個const char*指針&#xff0c;指向以空字符終止…

POJ2402 Palindrome Numbers 回文數

題目鏈接: http://poj.org/problem?id2402 題目大意就是讓你找到第n個回文數是什么. 第一個思路當然是一個一個地構造回文數直到找到第n個回文數為止(也許大部分人一開始都是這樣的思路). 很明顯找到第n個之前的所有操作都是浪費, 這也是這個方法的最大弱點. 抱著僥幸心理(誰知…

離散卷積的c語言編程實驗,數字信號處理實驗一離散卷積c語言編程.ppt

數字信號處理實驗一離散卷積c語言編程實驗一 離散卷積的C語言編程實驗 DSP實驗室 2005 實驗性質 綜合設計性實驗 實驗目的 1 了解和認識常用的各種信號&#xff1b; 2 掌握卷積的定義和計算方法&#xff1b; 3 掌握在計算機中生成以及繪制信號序列圖的方法。 實驗原理 離散時間…

async-await原理解析

在用async包裹的方法體中&#xff0c;可以使用await關鍵字以同步的方式編寫異步調用的代碼。那么它的內部實現原理是什么樣的呢&#xff1f;我們是否可以自定義await以實現定制性的需求呢&#xff1f;先來看一個簡單的例子&#xff1a; 1 class Test {2 public sta…

emacs-w3m查看html幫助手冊

<?xml version"1.0" encoding"utf-8"?> emacs-w3m查看html幫助手冊emacs-w3m查看html幫助手冊 Table of Contents 1. 使用效果2. 為什么要用emacs-w3m來查看html的幫助手冊&#xff1f;3. 什么是w3m?4. 配置5. 額外資源1 使用效果 使用快捷鍵C-c …

c語言生命游戲代碼大全,c++生命游戲源碼

該樓層疑似違規已被系統折疊 隱藏此樓查看此樓glViewport( 0, 0, width, height );glMatrixMode( GL_PROJECTION );glLoadIdentity( );}//程序入口int main(int argc, char *argv[]){//隨機生成細胞的狀態MapRand();std::cout<//SDL初始化const SDL_VideoInfo* info NULL;i…

初學React,setState后獲取到的thisstate沒變,還是初始state?

問題&#xff1a;(javascript)初學React&#xff0c;setState后獲取到的thisstate沒變&#xff0c;還是初始state&#xff1f;描述: getInitialState(){return {data:[]};},componentDidMount(){var data [ { author: "Pete Hunt", text: "This is one comment…

sizeof(數組名)和sizeof(指針)

轉載&#xff1a;http://blog.csdn.net/kangroger/article/details/20653255 在做這道題時&#xff1a; 32位環境下&#xff0c;int *pnew int[10];請問sizeof(p)的值為&#xff08;&#xff09; A、4 B、10 C、40 D、8 我以為正確答…

工作中的問題

今天寫一專題頁面&#xff0c;寫出的結果在各個瀏覽器下都不同&#xff0c;心情不好。。。 就是紅線的地方老對不齊。。。 在朋友指導下改了下樣式好了 右邊代碼結構 1 <div class"fr Img"> 2 <h3>相關專題</h3> 3 <a href"#"…