Spring架構已是JAVA項目的标配,其中Spring事務管理也是最常用的一個功能,但如果不了解其實作原理,使用姿勢不對,一不小心就可能掉坑裡。
為了更透徹的說明這些坑,本文分四部分展開闡述:第一部分簡單介紹下Spring事務內建的幾種方式;第二部分結合Spring源代碼說明Spring事務的實作原理;第三部分通過實際測試代碼介紹關于Spring事務的坑;第四部分是對本文的總結。
一、Spring事務管理的幾種方式:
Spring事務在具體使用方式上可分為兩大類:
1. 聲明式
- 基于 TransactionProxyFactoryBean的聲明式事務管理
- 基于
和<tx>
命名空間的事務管理<aop>
-
的聲明式事務管理@Transactional
2. 程式設計式
- 基于事務管理器API 的程式設計式事務管理
- 基于TransactionTemplate 的程式設計式事務管理
目前大部分項目使用的是聲明式的後兩種:
-
<tx>
命名空間的聲明式事務管理可以充分利用切點表達式的強大支援,使得管理事務更加靈活。<aop>
-
的方式需要實施事務管理的方法或者類上使用@Transactional
指定事務規則即可實作事務管理,在Spring Boot中通常也建議使用這種注解方式來标記事務。@Transactional
二、Spring事務實作機制
接下來我們詳細看下Spring事務的源代碼,進而了解其工作原理。我們從
<tx>
标簽的解析類開始:
@Override
public void init() {
registerBeanDefinitionParser("advice", new TxAdviceBeanDefinitionParser());
registerBeanDefinitionParser("annotation-driven", new AnnotationDrivenBeanDefinitionParser());
registerBeanDefinitionParser("jta-transaction-manager", new JtaTransactionManagerBeanDefinitionParser());
}
}
class TxAdviceBeanDefinitionParser extends AbstractSingleBeanDefinitionParser {
@Override
protected Class<?> getBeanClass(Element element) {
return TransactionInterceptor.class;
}
}
由此可看到Spring事務的核心實作類TransactionInterceptor及其父類TransactionAspectSupport,其實作了事務的開啟、資料庫操作、事務送出、復原等。我們平時在開發時如果想确定是否在事務中,也可以在該方法進行斷點調試。
TransactionInterceptor:
public Object invoke(final MethodInvocation invocation) throws Throwable {
Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null);
// Adapt to TransactionAspectSupport's invokeWithinTransaction...
return invokeWithinTransaction(invocation.getMethod(), targetClass, new InvocationCallback() {
@Override
public Object proceedWithInvocation() throws Throwable {
return invocation.proceed();
}
});
}
TransactionAspectSupport
protected Object invokeWithinTransaction(Method method, Class<?> targetClass, final InvocationCallback invocation)
throws Throwable {
// If the transaction attribute is null, the method is non-transactional.
final TransactionAttribute txAttr = getTransactionAttributeSource().getTransactionAttribute(method, targetClass);
final PlatformTransactionManager tm = determineTransactionManager(txAttr);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
Object retVal = null;
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
commitTransactionAfterReturning(txInfo);
return retVal;
}
}
至此我們了解事務的整個調用流程,但還有一個重要的機制沒分析到,那就是Spring 事務針對不同的傳播級别控制目前擷取的資料庫連接配接。接下來我們看下Spring擷取連接配接的工具類DataSourceUtils,JdbcTemplate、Mybatis-Spring也都是通過該類擷取Connection。
public abstract class DataSourceUtils {
…
public static Connection getConnection(DataSource dataSource) throws CannotGetJdbcConnectionException {
try {
return doGetConnection(dataSource);
}
catch (SQLException ex) {
throw new CannotGetJdbcConnectionException("Could not get JDBC Connection", ex);
}
}
public static Connection doGetConnection(DataSource dataSource) throws SQLException {
Assert.notNull(dataSource, "No DataSource specified");
ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);
if (conHolder != null && (conHolder.hasConnection() || conHolder.isSynchronizedWithTransaction())) {
conHolder.requested();
if (!conHolder.hasConnection()) {
logger.debug("Fetching resumed JDBC Connection from DataSource");
conHolder.setConnection(dataSource.getConnection());
}
return conHolder.getConnection();
}
…
}
TransactionSynchronizationManager也是一個事務同步管理的核心類,它實作了事務同步管理的職能,包括記錄目前連接配接持有connection holder。
TransactionSynchronizationManager
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<Map<Object, Object>>("Transactional resources");
…
public static Object getResource(Object key) {
Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
Object value = doGetResource(actualKey);
if (value != null && logger.isTraceEnabled()) {
logger.trace("Retrieved value [" + value + "] for key [" + actualKey + "] bound to thread [" +
Thread.currentThread().getName() + "]");
}
return value;
}
/**
* Actually check the value of the resource that is bound for the given key.
*/
private static Object doGetResource(Object actualKey) {
Map<Object, Object> map = resources.get();
if (map == null) {
return null;
}
Object value = map.get(actualKey);
// Transparently remove ResourceHolder that was marked as void...
if (value instanceof ResourceHolder && ((ResourceHolder) value).isVoid()) {
map.remove(actualKey);
// Remove entire ThreadLocal if empty...
if (map.isEmpty()) {
resources.remove();
}
value = null;
}
return value;
}
在事務管理器類AbstractPlatformTransactionManager中,getTransaction擷取事務時,會處理不同的事務傳播行為,例如目前存在事務,但調用方法事務傳播級别為REQUIRES_NEW、PROPAGATION_NOT_SUPPORTED時,對目前事務進行挂起、恢複等操作,以此保證了目前資料庫操作擷取正确的Connection。
具體是在子事務送出的最後會将挂起的事務恢複,恢複時重新調用TransactionSynchronizationManager. bindResource設定之前的connection holder,這樣再擷取的連接配接就是被恢複的資料庫連接配接, TransactionSynchronizationManager目前激活的連接配接隻能是一個。
AbstractPlatformTransactionManager
private TransactionStatus handleExistingTransaction(
TransactionDefinition definition, Object transaction, boolean debugEnabled)
throws TransactionException {
…
if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW) {
if (debugEnabled) {
logger.debug("Suspending current transaction, creating new transaction with name [" +
definition.getName() + "]");
}
SuspendedResourcesHolder suspendedResources = suspend(transaction);
try {
boolean newSynchronization = (getTransactionSynchronization() != SYNCHRONIZATION_NEVER);
DefaultTransactionStatus status = newTransactionStatus(
definition, transaction, true, newSynchronization, debugEnabled, suspendedResources);
doBegin(transaction, definition);
prepareSynchronization(status, definition);
return status;
}
catch (RuntimeException beginEx) {
resumeAfterBeginException(transaction, suspendedResources, beginEx);
throw beginEx;
}
catch (Error beginErr) {
resumeAfterBeginException(transaction, suspendedResources, beginErr);
throw beginErr;
}
}
/**
* Clean up after completion, clearing synchronization if necessary,
* and invoking doCleanupAfterCompletion.
* @param status object representing the transaction
* @see #doCleanupAfterCompletion
*/
private void cleanupAfterCompletion(DefaultTransactionStatus status) {
status.setCompleted();
if (status.isNewSynchronization()) {
TransactionSynchronizationManager.clear();
}
if (status.isNewTransaction()) {
doCleanupAfterCompletion(status.getTransaction());
}
if (status.getSuspendedResources() != null) {
if (status.isDebug()) {
logger.debug("Resuming suspended transaction after completion of inner transaction");
}
resume(status.getTransaction(), (SuspendedResourcesHolder) status.getSuspendedResources());
}
}
Spring的事務是通過AOP代理類中的一個Advice(TransactionInterceptor)進行生效的,而傳播級别定義了事務與子事務擷取連接配接、事務送出、復原的具體方式。
AOP(Aspect Oriented Programming),即面向切面程式設計。Spring AOP技術實作上其實就是代理類,具體可分為靜态代理和動态代理兩大類,其中靜态代理是指使用 AOP 架構提供的指令進行編譯,進而在編譯階段就可生成 AOP 代理類,是以也稱為編譯時增強;(AspectJ);而動态代理則在運作時借助于 默寫類庫在記憶體中“臨時”生成 AOP 動态代理類,是以也被稱為運作時增強。其中java是使用的動态代理模式 (JDK+CGLIB)。
JDK動态代理 JDK動态代理主要涉及到java.lang.reflect包中的兩個類:Proxy和InvocationHandler。InvocationHandler是一個接口,通過實作該接口定義橫切邏輯,并通過反射機制調用目标類的代碼,動态将橫切邏輯和業務邏輯編制在一起。Proxy利用InvocationHandler動态建立一個符合某一接口的執行個體,生成目标類的代理對象。
CGLIB動态代理 CGLIB全稱為Code Generation Library,是一個強大的高性能,高品質的代碼生成類庫,可以在運作期擴充Java類與實作Java接口,CGLIB封裝了asm,可以再運作期動态生成新的class。和JDK動态代理相比較:JDK建立代理有一個限制,就是隻能為接口建立代理執行個體,而對于沒有通過接口定義業務方法的類,則可以通過CGLIB建立動态代理。
CGLIB 建立代理的速度比較慢,但建立代理後運作的速度卻非常快,而 JDK 動态代理正好相反。如果在運作的時候不斷地用 CGLIB 去建立代理,系統的性能會大打折扣。是以如果有接口,Spring預設使用JDK 動态代理,源代碼如下:
public class DefaultAopProxyFactory implements AopProxyFactory, Serializable {
@Override
public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) {
Class<?> targetClass = config.getTargetClass();
if (targetClass == null) {
throw new AopConfigException("TargetSource cannot determine target class: " +
"Either an interface or a target is required for proxy creation.");
}
if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) {
return new JdkDynamicAopProxy(config);
}
return new ObjenesisCGLIBAopProxy(config);
}
else {
return new JdkDynamicAopProxy(config);
}
}
}
在了解Spring代理的兩種特點後,我們也就知道在做事務切面配置時的一些注意事項,例如JDK代理時方法必須是public,CGLIB代理時必須是public、protected,且類不能是final的;在依賴注入時,如果屬性類型定義為實作類,JDK代理時會報如下注入異常:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'com.wwb.test.TxTestAop': Unsatisfied dependency expressed through field 'service'; nested exception is org.springframework.beans.factory.BeanNotOfRequiredTypeException: Bean named 'stockService' is expected to be of type 'com.wwb.service.StockProcessServiceImpl' but was actually of type 'com.sun.proxy.$Proxy14'
但如果修改為CGLIB代理時則會成功注入,是以如果有接口,建議注入時該類屬性都定義為接口。另外事務切點都配置在實作類和接口都可以生效,但建議加在實作類上。
官網關于Spring AOP的詳細介紹
docs.spring.io/spring/docs…
三、Spring事務的那些坑
通過之前章節,相信您已經掌握了spring事務的使用方式與原理,不過還是要注意,因為一不小心就可能就掉坑。首先看第一個坑:
3.1 事務不生效
測試代碼,事務AOP配置:
<tx:advice id="txAdvice" transaction-manager="myTxManager">
<tx:attributes>
<!-- 指定在連接配接點方法上應用的事務屬性 -->
<tx:method name="openAccount" isolation="DEFAULT" propagation="REQUIRED"/>
<tx:method name="openStock" isolation="DEFAULT" propagation="REQUIRED"/>
<tx:method name="openStockInAnotherDb" isolation="DEFAULT" propagation="REQUIRES_NEW"/>
<tx:method name="openTx" isolation="DEFAULT" propagation="REQUIRED"/>
<tx:method name="openWithoutTx" isolation="DEFAULT" propagation="NEVER"/>
<tx:method name="openWithMultiTx" isolation="DEFAULT" propagation="REQUIRED"/>
</tx:advice>
public class StockProcessServiceImpl implements IStockProcessService{
@Autowired
private IAccountDao accountDao;
@Autowired
private IStockDao stockDao;
@Override
public void openAccount(String aname, double money) {
accountDao.insertAccount(aname, money);
}
@Override
public void openStock(String sname, int amount) {
stockDao.insertStock(sname, amount);
}
@Override
public void openStockInAnotherDb(String sname, int amount) {
stockDao.insertStock(sname, amount);
}
}
public void insertAccount(String aname, double money) {
String sql = "insert into account(aname, balance) values(?,?)";
this.getJdbcTemplate().update(sql, aname, money);
DbUtils.printDBConnectionInfo("insertAccount",getDataSource());
}
public void insertStock(String sname, int amount) {
String sql = "insert into stock(sname, count) values (?,?)";
this.getJdbcTemplate().update(sql , sname, amount);
DbUtils.printDBConnectionInfo("insertStock",getDataSource());
}
public static void printDBConnectionInfo(String methodName,DataSource ds) {
Connection connection = DataSourceUtils.getConnection(ds);
System.out.println(methodName+" connection hashcode="+connection.hashCode());
}
//調用同類方法,外圍配置事務
public void openTx(String aname, double money) {
openAccount(aname,money);
openStock(aname,11);
}
1.運作輸出:
insertAccount connection hashcode=319558327
insertStock connection hashcode=319558327
//調用同類方法,外圍未配置事務
public void openWithoutTx(String aname, double money) {
openAccount(aname,money);
openStock(aname,11);
}
2.運作輸出:
insertAccount connection hashcode=1333810223
insertStock connection hashcode=1623009085
//通過AopContext.currentProxy()方法擷取代理
@Override
public void openWithMultiTx(String aname, double money) {
openAccount(aname,money);
openStockInAnotherDb(aname, 11);//傳播級别為REQUIRES_NEW
}
3.運作輸出:
insertAccount connection hashcode=303240439
insertStock connection hashcode=303240439
可以看到2、3測試方法跟我們事務預期并一樣,結論:調用方法未配置事務、本類方法直接調用,事務都不生效!
究其原因,還是因為Spring的事務本質上是個代理類,而本類方法直接調用時其對象本身并不是織入事務的代理,是以事務切面并未生效。具體可以參見#Spring事務實作機制#章節。
Spring也提供了判斷是否為代理的方法:
public static void printProxyInfo(Object bean) {
System.out.println("isAopProxy"+AopUtils.isAopProxy(bean));
System.out.println("isCGLIBProxy="+AopUtils.isCGLIBProxy(bean));
System.out.println("isJdkProxy="+AopUtils.isJdkDynamicProxy(bean));
}
那如何修改為代理類調用呢?最直接的想法是注入自身,代碼如下:
@Autowired
private IStockProcessService stockProcessService;
//注入自身類,循環依賴,親測可以
public void openTx(String aname, double money) {
stockProcessService.openAccount(aname,money);
stockProcessService.openStockInAnotherDb (aname,11);
}
當然Spring提供了擷取目前代理的方法:代碼如下:
//通過AopContext.currentProxy()方法擷取代理
@Override
public void openWithMultiTx(String aname, double money) {
((IStockProcessService)AopContext.currentProxy()).openAccount(aname,money);
((IStockProcessService)AopContext.currentProxy()).openStockInAnotherDb(aname, 11);
}
另外Spring是通過TransactionSynchronizationManager類中線程變量來擷取事務中資料庫連接配接,是以如果是多線程調用或者繞過Spring擷取資料庫連接配接,都會導緻Spring事務配置失效。
最後Spring事務配置失效的場景:
- 事務切面未配置正确
- 本類方法調用
- 多線程調用
- 繞開Spring擷取資料庫連接配接
接下來我們看下Spring的事務的另外一個坑:
3.2 事務不復原
測試代碼:
<tx:advice id="txAdvice" transaction-manager="myTxManager">
<tx:attributes>
<!-- 指定在連接配接點方法上應用的事務屬性 -->
<tx:method name="buyStock" isolation="DEFAULT" propagation="REQUIRED"/>
</tx:attributes>
</tx:advice>
public void buyStock(String aname, double money, String sname, int amount) throws StockException {
boolean isBuy = true;
accountDao.updateAccount(aname, money, isBuy);
// 故意抛出異常
if (true) {
throw new StockException("購買股票異常");
}
stockDao.updateStock(sname, amount, isBuy);
}
@Test
public void testBuyStock() {
try {
service.openAccount("dcbs", 10000);
service.buyStock("dcbs", 2000, "dap", 5);
} catch (StockException e) {
e.printStackTrace();
}
double accountBalance = service.queryAccountBalance("dcbs");
System.out.println("account balance is " + accountBalance);
}
輸出結果:
insertAccount connection hashcode=656479172
updateAccount connection hashcode=517355658
account balance is 8000.0
應用抛出異常,但accountDao.updateAccount卻進行了送出。究其原因,直接看Spring源代碼:
protected void completeTransactionAfterThrowing(TransactionInfo txInfo, Throwable ex) {
if (txInfo != null && txInfo.hasTransaction()) {
if (logger.isTraceEnabled()) {
logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() +
"] after exception: " + ex);
}
if (txInfo.transactionAttribute.rollbackOn(ex)) {
try {
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
}
catch (TransactionSystemException ex2) {
logger.error("Application exception overridden by rollback exception", ex);
ex2.initApplicationException(ex);
throw ex2;
}
…
}
public class DefaultTransactionAttribute extends DefaultTransactionDefinition implements TransactionAttribute {
@Override
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
…
}
由代碼可見,Spring事務預設隻對RuntimeException和Error進行復原,如果應用需要對指定的異常類進行復原,可配置rollback-for=屬性,例如:
<!-- 注冊事務通知 -->
<tx:advice id="txAdvice" transaction-manager="myTxManager">
<tx:attributes>
<!-- 指定在連接配接點方法上應用的事務屬性 -->
<tx:method name="buyStock" isolation="DEFAULT" propagation="REQUIRED" rollback-for="StockException"/>
</tx:attributes>
</tx:advice>
事務不復原的原因:
- 事務配置切面未生效
- 應用方法中将異常捕獲
- 抛出的異常不屬于運作時異常(例如IOException),
- rollback-for屬性配置不正确
接下來我們看下Spring事務的第三個坑:
3.3 事務逾時不生效
<!-- 注冊事務通知 -->
<tx:advice id="txAdvice" transaction-manager="myTxManager">
<tx:attributes>
<tx:method name="openAccountForLongTime" isolation="DEFAULT" propagation="REQUIRED" timeout="3"/>
</tx:attributes>
</tx:advice>
@Override
public void openAccountForLongTime(String aname, double money) {
accountDao.insertAccount(aname, money);
try {
Thread.sleep(5000L);//在資料庫操作之後逾時
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Test
public void testTimeout() {
service.openAccountForLongTime("dcbs", 10000);
}
正常運作,事務逾時未生效
public void openAccountForLongTime(String aname, double money) {
try {
Thread.sleep(5000L); //在資料庫操作之前逾時
} catch (InterruptedException e) {
e.printStackTrace();
}
accountDao.insertAccount(aname, money);
}
抛出事務逾時異常,逾時生效
org.springframework.transaction.TransactionTimedOutException: Transaction timed out: deadline was Fri Nov 23 17:03:02 CST 2018
at org.springframework.transaction.support.ResourceHolderSupport.checkTransactionTimeout(ResourceHolderSupport.java:141)
…
通過源碼看看Spring事務逾時的判斷機制:
ResourceHolderSupport
/**
* Return the time to live for this object in milliseconds.
* @return number of millseconds until expiration
* @throws TransactionTimedOutException if the deadline has already been reached
*/
public long getTimeToLiveInMillis() throws TransactionTimedOutException{
if (this.deadline == null) {
throw new IllegalStateException("No timeout specified for this resource holder");
}
long timeToLive = this.deadline.getTime() - System.currentTimeMillis();
checkTransactionTimeout(timeToLive <= 0);
return timeToLive;
}
/**
* Set the transaction rollback-only if the deadline has been reached,
* and throw a TransactionTimedOutException.
*/
private void checkTransactionTimeout(boolean deadlineReached) throws TransactionTimedOutException {
if (deadlineReached) {
setRollbackOnly();
throw new TransactionTimedOutException("Transaction timed out: deadline was " + this.deadline);
}
}
通過檢視getTimeToLiveInMillis方法的Call Hierarchy,可以看到被DataSourceUtils的applyTimeout所調用, 繼續看applyTimeout的Call Hierarchy,可以看到有兩處調用,一個是JdbcTemplate,一個是TransactionAwareInvocationHandler類,後者是隻有TransactionAwareDataSourceProxy類調用,該類為DataSource的事務代理類,我們一般并不會用到。難道逾時隻能在這調用JdbcTemplate中生效?寫代碼親測:
<!-- 注冊事務通知 -->
<tx:advice id="txAdvice" transaction-manager="myTxManager">
<tx:attributes>
<tx:method name="openAccountForLongTimeWithoutJdbcTemplate" isolation="DEFAULT" propagation="REQUIRED" timeout="3"/>
</tx:attributes>
</tx:advice>
public void openAccountForLongTimeWithoutJdbcTemplate(String aname, double money) {
try {
Thread.sleep(5000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
accountDao.queryAccountBalanceWithoutJdbcTemplate(aname);
}
public double queryAccountBalanceWithoutJdbcTemplate(String aname) {
String sql = "select balance from account where aname = ?";
PreparedStatement prepareStatement;
try {
prepareStatement = this.getConnection().prepareStatement(sql);
prepareStatement.setString(1, aname);
ResultSet executeQuery = prepareStatement.executeQuery();
while(executeQuery.next()) {
return executeQuery.getDouble(1);
}
} catch (CannotGetJdbcConnectionException | SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return 0;
}
運作正常,事務逾時失效
由上可見:Spring事務逾時判斷在通過JdbcTemplate的資料庫操作時,是以如果逾時後未有JdbcTemplate方法調用,則無法準确判斷逾時。另外也可以得知,如果通過Mybatis等操作資料庫,Spring的事務逾時是無效的。鑒于此,Spring的事務逾時謹慎使用。
四、 總結
JDBC規範中Connection 的setAutoCommit是原生控制手動事務的方法,但傳播行為、異常復原、連接配接管理等很多技術問題都需要開發者自己處理,而Spring事務通過AOP方式非常優雅的屏蔽了這些技術複雜度,使得事務管理變的異常簡單。
但凡事有利弊,如果對實作機制了解不透徹,很容易掉坑裡。最後總結下Spring事務的可能踩的坑:
1. Spring事務未生效
- 調用方法本身未正确配置事務
- 本類方法直接調用
- 資料庫操作未通過Spring的DataSourceUtils擷取Connection
2. Spring事務復原失效
- 未準确配置rollback-for屬性
- 異常類不屬于RuntimeException與Error
- 應用捕獲了異常未抛出
3. Spring事務逾時不準确或失效
- 逾時發生在最後一次JdbcTemplate操作之後
- 通過非JdbcTemplate操作資料庫,例如Mybatis
Java 的知識面非常廣,面試問的涉及也非常廣泛,重點包括:Java 基礎、Java 并發,JVM、MySQL、資料結構、算法、Spring、微服務、MQ 等等,涉及的知識點何其龐大,是以我們在複習的時候也往往無從下手,今天小編給大家帶來一套 Java 面試題,題庫非常全面,包括 Java 基礎、Java 集合、JVM、Java 并發、Spring全家桶、Redis、MySQL、Dubbo、Netty、MQ 等等,包含 Java 後端知識點 2000 +
資料擷取方式:關注工種号:“程式員白楠楠”擷取上述資料