Java 動態代理 — 你每天用的 Spring AOP 和 MyBatis Mapper,底層都是同一招
你寫了一個介面,叫 UserMapper,裡面宣告一個方法 findById。沒有寫任何一行實作。然後 @Autowired 把它注進來,呼叫 userMapper.findById(1),資料庫真的回你一筆資料。
停一下。這個方法的實作在哪裡?
介面不能被 new,這是 Java 的鐵則。UserMapper 沒有任何一個 class implements 它。那你手上這個能跑的 userMapper 物件,到底是誰生出來的?
答案是:它是在程式跑起來那一刻,才被「現場捏出來」的一個類別。捏它的那一招,叫動態代理。而且不只 MyBatis,你每天在用的 @Transactional、@Async、Spring AOP、各種 RPC 框架,底層插進來的都是同一招。搞懂它,這些框架的魔法會一次全部除魅。
先別急著問它能幹嘛。先看它到底在幹嘛。
代理是什麼:先想像一個經紀人
假設你想找某個很紅的明星談合作。你不會直接打給本人——你打給他的經紀人。經紀人接了電話,先登記你是誰、要幹嘛,過濾掉一些莫名其妙的邀約,該收訂金的先收,然後才把訊息轉給明星本人。事後還幫忙記帳。
明星還是那個明星,做的事沒變。但你跟他之間多了一層。這層就是代理。
程式裡的代理一模一樣:你想呼叫某個物件(明星),但呼叫先經過一個代理物件(經紀人),代理決定要在前後做什麼——記 log、檢查權限、開事務、計時——做完再把球轉給真正的物件。
這個模式老到不行,叫代理模式。真正有意思的是「代理物件怎麼生出來」。
靜態代理:能動,但會膨脹
最土法煉鋼的做法,是自己手寫一個代理類。
1 | public class UserServiceProxy implements UserService { |
這能跑。代理跟真身實作同一個介面,方法裡先做事、再把球轉給 target。
問題是它會膨脹到讓你想哭。你有 100 個 Service 介面,就得手寫 100 個 proxy class。而且每個介面新增一個方法,對應的 proxy 也要跟著補一個方法——不然編譯就紅給你看。你只是想在每個方法前後印一行 log,結果搞出一坨跟業務邏輯數量一樣多的樣板程式碼。
這就是耦合在膨脹的味道:你想加的東西(記 log)明明是同一件事,卻被迫散在幾百個地方各寫一遍。
動態代理:不事先寫,跑起來才捏
動態代理解掉的就是這個。它不要你事先寫任何 proxy class。它在程式跑起來的當下,直接在記憶體裡生一個。
JDK 內建的做法長這樣:
1 | UserService proxy = (UserService) Proxy.newProxyInstance( |
Proxy.newProxyInstance 做的事,是在記憶體現場生成一個類別,讓它 implements UserService,而且這個類別的每一個方法,實作都一樣:把呼叫轉交給那個 lambda——也就是 InvocationHandler。
換句話說,你不用替每個明星寫經紀人了。你只寫一條規則:「接到任何電話,都先登記、再轉接、最後記帳」。然後有一台工廠(Proxy),照這條規則,現場幫任何一個明星捏出一個聽話的分身。一條規則,管所有方法。
invoke 收到的三個參數是關鍵:method 是被呼叫的那個方法本身,args 是傳進來的參數。method.invoke(target, args) 就是「按下轉接鍵」,把球丟回真身。你想在轉接前後插什麼,就寫在這一行的上下。
MyBatis 的騙術:連真身都沒有
回到開頭那個問題——UserMapper 只有介面,沒有實作,為什麼能跑?
現在你有工具拆它了。MyBatis 一樣用動態代理幫你的 Mapper 介面生了一個代理物件。但它玩得更絕:它的 invoke 裡面,根本不轉呼叫任何真身,因為壓根沒有真身。
它做的事是:讀你方法上的 @Select 註解(或對應的 XML),把裡面的 SQL 拿出來,帶上 args 當參數,丟給資料庫執行,再把回來的結果集轉成 User 物件回傳給你。
所以「實作」其實一直都在——只是它不在某個 UserMapperImpl 裡,而是「解析註解、拼 SQL、跑查詢、轉物件」這段對所有 Mapper 都通用的邏輯。你不需要為每個 Mapper 寫實作,因為實作根本長得一模一樣,寫死在框架裡就好。你只要用介面把「我要查什麼」聲明清楚。
介面沒實作卻能查資料庫,這件事第一次搞懂的時候是真的有點爽。
沒有介面怎麼辦:CGLIB 換一招
JDK 動態代理有個硬限制:它生的類是靠 implements 介面來冒充你的物件的。所以你的目標必須有介面。要是某個類天生就沒實作任何介面呢?
這時候換 CGLIB 上場。它不靠介面,改靠繼承——runtime 幫你生一個「你這個類的子類」,把每個非 final 的方法都 override 一遍,在覆寫的版本裡插入攔截邏輯,再呼叫 super 回到你原本的方法。
一個是「假扮成同事」(實作同介面),一個是「當你兒子」(繼承你)。目的一樣:在中間插一層。
也因為 CGLIB 靠繼承,它代理不了 final 的類和 final 的方法——子類沒辦法覆寫 final,這是 Java 語言擋死的。踩過「為什麼我這個方法的 AOP 沒生效」的人,八成就是把方法或類設成 final 了。
Spring 的選擇邏輯就是這兩招的組合:目標有介面,預設用 JDK 動態代理;沒介面,就用 CGLIB。(Spring Boot 後來乾脆預設全走 CGLIB,省得糾結。)
@Transactional 除魅,還有那個經典的坑
現在來拆最常被當成黑魔法的 @Transactional。
你只是在方法上標了個註解,事務就自己開了、出錯自己 rollback、正常自己 commit。它怎麼知道要這樣做?
答案已經呼之欲出:Spring 在啟動時,發現你的 bean 上有 @Transactional,就幫它生了一個代理。你呼叫方法時,其實是先進到代理——代理先跟資料庫要一個連線、開啟事務,然後 method.invoke 你的真身,你的業務邏輯跑完沒丟例外就 commit,丟了例外就 rollback。
事務不是你的方法自己開的。是外面那層經紀人幫你開的。
這一句話,直接解釋了一個坑到今天還在坑人的經典問題:同一個類裡,A 方法呼叫 B 方法,B 上的 @Transactional 不會生效。
1 | public void a() { |
為什麼?因為 this 指的是你的真身,不是那層代理。你從 a 用 this.b() 呼叫,等於繞過經紀人直接找本人——本人才不會幫你開事務,開事務是經紀人的活。同樣的道理,private 方法標 @Transactional 也沒用,因為代理覆寫不到 private。
知道機制之後,這種 bug 你三秒就能看出來。不知道的話,可以 debug 到懷疑人生。
拆完了,回頭看它到底撐起了多少東西
把機制攤開之後你會發現,一堆看起來八竿子打不著的功能,骨子裡是同一招:
Spring AOP 切面、@Transactional 事務、@Async 非同步、@Cacheable 快取、RPC 框架那個「呼叫遠端就像呼叫本地方法」的 stub、Mockito 幫你生的 mock 物件、MyBatis 的 Mapper——全部都是在「你」跟「真正要跑的邏輯」之間,塞一個 runtime 生成的中間人,然後在轉呼叫的前後動手腳。
差別只在中間人動的手腳是什麼:有的開事務,有的查快取,有的解析註解拼 SQL,有的根本把呼叫打包成網路封包送到另一台機器。機制是同一個,變體無窮。
一個可以帶走的判斷習慣
以後你再碰到任何框架,跟你說「你只要標個註解,就會自動幫你 XXX」「你只要寫個介面,不用實作就能用」——先別驚嘆它多神奇,先問自己一句:
它是不是在 runtime 幫我生了一個代理?
九成的答案是「對」。所謂框架的魔法,拆到底往往不是什麼高深的東西,而是有人在你看不見的地方,幫你悄悄插了一層。看懂這一層在哪、它前後做了什麼,你對整個框架的掌控感會完全不一樣——出事的時候,你知道要去哪裡站著看。
當然,runtime 生成代理是要付成本的:它靠反射,有效能開銷,也是 GraalVM native image 這種「編譯期就要把一切定死」的環境最頭痛的地方之一。所以還有另一條路線——編譯期就把攔截邏輯織進去(AspectJ、註解處理器那一派),用啟動慢一點換執行時零反射。那是另一個要拆的黑盒,改天再說。
先記住這一個就夠了:介面能查庫、註解能開事務,背後站著的,都是同一個在你看不見的地方被現場捏出來的經紀人。










