詳解Tomcat系列(一)-從原始碼分析Tomcat的啟動

炭燒生蠔發表於2019-05-27

在整個Tomcat系列文章講解之前, 我想說的是雖然整個Tomcat體系比較複雜, 但是Tomcat中的程式碼並不難讀, 只要認真花點功夫, 一定能啃下來.
由於篇幅的原因, 很難把Tomcat所有的知識點都放到同一篇文章中, 我將把Tomcat系列文章分為Tomcat的啟動, Tomcat中各模組的介紹和Tomcat中的設計模式三部分, 歡迎閱讀與關注.

一:通過idea搭建Tomcat原始碼閱讀環境

  • 首先我們到Tomcat的官網(http://tomcat.apache.org/)上下載Tomcat的原始碼, 本文分析的Tomcat版本是Tomcat7.0.94.
  • 進入官網後在左邊的目錄中選擇Tomcat7, 然後到頁面末尾的原始碼區進行下載.

詳解Tomcat系列(一)-從原始碼分析Tomcat的啟動

  • 下載完成後開啟idea, 選擇File->Open->選擇tomcat的原始碼目錄
  • 然後到專案配置中設定JDK和原始碼目錄. File->Project Structure->project SDK

詳解Tomcat系列(一)-從原始碼分析Tomcat的啟動

詳解Tomcat系列(一)-從原始碼分析Tomcat的啟動

  • 設定完畢後我們便可以開始愉快的原始碼閱讀之旅了. (eclipse或其他ide搭建環境的思路也是差不多的, 可以摸索一下).

二:startup原始碼分析

  • 當我們初學tomcat的時候, 肯定先要學習怎樣啟動tomcat. 在tomcat的bin目錄下有兩個啟動tomcat的檔案, 一個是startup.bat, 它用於windows環境下啟動tomcat; 另一個是startup.sh, 它用於linux環境下tomcat的啟動. 兩個檔案中的邏輯是一樣的, 我們只分析其中的startup.bat.
  • 下面給出startup.bat的原始碼
@echo off
rem Licensed to the Apache Software Foundation (ASF) under one or more
rem contributor license agreements.  See the NOTICE file distributed with
rem this work for additional information regarding copyright ownership.
rem The ASF licenses this file to You under the Apache License, Version 2.0
rem (the "License"); you may not use this file except in compliance with
rem the License.  You may obtain a copy of the License at
rem
rem     http://www.apache.org/licenses/LICENSE-2.0
rem
rem Unless required by applicable law or agreed to in writing, software
rem distributed under the License is distributed on an "AS IS" BASIS,
rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
rem See the License for the specific language governing permissions and
rem limitations under the License.

rem ---------------------------------------------------------------------------
rem Start script for the CATALINA Server
rem ---------------------------------------------------------------------------

setlocal

rem Guess CATALINA_HOME if not defined
set "CURRENT_DIR=%cd%"
if not "%CATALINA_HOME%" == "" goto gotHome
set "CATALINA_HOME=%CURRENT_DIR%"
if exist "%CATALINA_HOME%\bin\catalina.bat" goto okHome
cd ..
set "CATALINA_HOME=%cd%"
cd "%CURRENT_DIR%"
:gotHome
if exist "%CATALINA_HOME%\bin\catalina.bat" goto okHome
echo The CATALINA_HOME environment variable is not defined correctly
echo This environment variable is needed to run this program
goto end
:okHome

set "EXECUTABLE=%CATALINA_HOME%\bin\catalina.bat"

rem Check that target executable exists
if exist "%EXECUTABLE%" goto okExec
echo Cannot find "%EXECUTABLE%"
echo This file is needed to run this program
goto end
:okExec

rem Get remaining unshifted command line arguments and save them in the
set CMD_LINE_ARGS=
:setArgs
if ""%1""=="""" goto doneSetArgs
set CMD_LINE_ARGS=%CMD_LINE_ARGS% %1
shift
goto setArgs
:doneSetArgs

call "%EXECUTABLE%" start %CMD_LINE_ARGS%

:end
  • .bat檔案中@echo是列印指令, 用於控制檯輸出資訊, rem是註釋符.
  • 跳過開頭的註釋, 我們來到配置CATALINA_HOME的程式碼段, 執行startup.bat檔案首先會設定CATALINA_HOME.
set "CURRENT_DIR=%cd%"
if not "%CATALINA_HOME%" == "" goto gotHome
set "CATALINA_HOME=%CURRENT_DIR%"
if exist "%CATALINA_HOME%\bin\catalina.bat" goto okHome
cd ..
set "CATALINA_HOME=%cd%"
cd "%CURRENT_DIR%"
:gotHome
if exist "%CATALINA_HOME%\bin\catalina.bat" goto okHome
echo The CATALINA_HOME environment variable is not defined correctly
echo This environment variable is needed to run this program
goto end
:okHome
  • 先通過set指令把當前目錄設定到一個名為CURRENT_DIR的變數中,
  • 如果系統中配置過CATALINA_HOME則跳到gotHome程式碼段. 正常情況下我們的電腦都沒有配置CATALINA_HOME, 所以往下執行, 把當前目錄設定為CATALINA_HOME.
  • 然後判斷CATALINA_HOME目錄下是否存在catalina.bat檔案, 如果存在就跳到okHome程式碼塊.
  • 在okHome中, 會把catalina.bat檔案的的路徑賦給一個叫EXECUTABLE的變數, 然後會進一步判斷這個路徑是否存在, 存在則跳轉到okExec程式碼塊, 不存在的話會在控制檯輸出一些錯誤資訊.
  • 在okExec中, 會把setArgs程式碼塊的返回結果賦值給CMD_LINE_ARGS變數, 這個變數用於儲存啟動引數.
  • setArgs中首先會判斷是否有引數, (if ""%1""==""""判斷第一個引數是否為空), 如果沒有引數則相當於引數項為空. 如果有引數則迴圈遍歷所有的引數(每次拼接第一個引數).
  • 最後執行call "%EXECUTABLE%" start %CMD_LINE_ARGS%, 也就是說執行catalina.bat檔案, 如果有引數則帶上引數.
  • 總結: startup.bat檔案實際上就做了一件事情: 啟動catalina.bat.
  • ps: 這樣看來, 在windows下啟動tomcat未必一定要通過startup.bat, 用catalina.bat start也是可以的.

catalina.bat原始碼分析

  • 為了不讓文章看起來太臃腫, 這裡就不先把整個檔案貼出來了, 我們逐塊程式碼進行分析.
  • 跳過開頭的註釋, 我們來到下面的程式碼段:
setlocal

rem Suppress Terminate batch job on CTRL+C
if not ""%1"" == ""run"" goto mainEntry
if "%TEMP%" == "" goto mainEntry
if exist "%TEMP%\%~nx0.run" goto mainEntry
echo Y>"%TEMP%\%~nx0.run"
if not exist "%TEMP%\%~nx0.run" goto mainEntry
echo Y>"%TEMP%\%~nx0.Y"
call "%~f0" %* <"%TEMP%\%~nx0.Y"
rem Use provided errorlevel
set RETVAL=%ERRORLEVEL%
del /Q "%TEMP%\%~nx0.Y" >NUL 2>&1
exit /B %RETVAL%
:mainEntry
del /Q "%TEMP%\%~nx0.run" >NUL 2>&1
  • 大多情況下我們啟動tomcat都沒有設定引數, 所以直接跳到mainEntry程式碼段, 刪除了一個臨時檔案後, 繼續往下執行.
rem Guess CATALINA_HOME if not defined
set "CURRENT_DIR=%cd%"
if not "%CATALINA_HOME%" == "" goto gotHome
set "CATALINA_HOME=%CURRENT_DIR%"
if exist "%CATALINA_HOME%\bin\catalina.bat" goto okHome
cd ..
set "CATALINA_HOME=%cd%"
cd "%CURRENT_DIR%"
:gotHome

if exist "%CATALINA_HOME%\bin\catalina.bat" goto okHome
echo The CATALINA_HOME environment variable is not defined correctly
echo This environment variable is needed to run this program
goto end
:okHome

rem Copy CATALINA_BASE from CATALINA_HOME if not defined
if not "%CATALINA_BASE%" == "" goto gotBase
set "CATALINA_BASE=%CATALINA_HOME%"
  • 可以看到這段程式碼與startup.bat中開頭的程式碼相似, 在確定CATALINA_HOME下有catalina.bat後把CATALINA_HOME賦給變數CATALINA_BASE.
rem Get standard environment variables
if not exist "%CATALINA_BASE%\bin\setenv.bat" goto checkSetenvHome
call "%CATALINA_BASE%\bin\setenv.bat"
goto setenvDone
:checkSetenvHome
if exist "%CATALINA_HOME%\bin\setenv.bat" call "%CATALINA_HOME%\bin\setenv.bat"
:setenvDone

rem Get standard Java environment variables
if exist "%CATALINA_HOME%\bin\setclasspath.bat" goto okSetclasspath
echo Cannot find "%CATALINA_HOME%\bin\setclasspath.bat"
echo This file is needed to run this program
goto end
:okSetclasspath
call "%CATALINA_HOME%\bin\setclasspath.bat" %1
if errorlevel 1 goto end

rem Add on extra jar file to CLASSPATH
rem Note that there are no quotes as we do not want to introduce random
rem quotes into the CLASSPATH
if "%CLASSPATH%" == "" goto emptyClasspath
set "CLASSPATH=%CLASSPATH%;"
:emptyClasspath
set "CLASSPATH=%CLASSPATH%%CATALINA_HOME%\bin\bootstrap.jar"
  • 上面這段程式碼依次執行了setenv.bat和setclasspath.bat檔案, 目的是獲得CLASSPATH, 相信會Java的同學應該都會在配置環境變數時都配置過classpath, 系統拿到classpath路徑後把它和CATALINA_HOME拼接在一起, 最終定位到一個叫bootstrap.jar的檔案. 雖然後面還有很多程式碼, 但是這裡必須暫停提示一下: bootstrap.jar將是我們啟動tomcat的環境.
  • 接下來從gotTmpdir程式碼塊到noEndorsedVar程式碼塊進行了一些配置, 由於不是主要內容暫且跳過.
echo Using CATALINA_BASE:   "%CATALINA_BASE%"
echo Using CATALINA_HOME:   "%CATALINA_HOME%"
echo Using CATALINA_TMPDIR: "%CATALINA_TMPDIR%"
if ""%1"" == ""debug"" goto use_jdk
echo Using JRE_HOME:        "%JRE_HOME%"
goto java_dir_displayed
:use_jdk
echo Using JAVA_HOME:       "%JAVA_HOME%"
:java_dir_displayed
echo Using CLASSPATH:       "%CLASSPATH%"

set _EXECJAVA=%_RUNJAVA%
set MAINCLASS=org.apache.catalina.startup.Bootstrap
set ACTION=start
set SECURITY_POLICY_FILE=
set DEBUG_OPTS=
set JPDA=

if not ""%1"" == ""jpda"" goto noJpda
set JPDA=jpda
if not "%JPDA_TRANSPORT%" == "" goto gotJpdaTransport
set JPDA_TRANSPORT=dt_socket
:gotJpdaTransport
if not "%JPDA_ADDRESS%" == "" goto gotJpdaAddress
set JPDA_ADDRESS=8000
:gotJpdaAddress
if not "%JPDA_SUSPEND%" == "" goto gotJpdaSuspend
set JPDA_SUSPEND=n
:gotJpdaSuspend
if not "%JPDA_OPTS%" == "" goto gotJpdaOpts
set JPDA_OPTS=-agentlib:jdwp=transport=%JPDA_TRANSPORT%,address=%JPDA_ADDRESS%,server=y,suspend=%JPDA_SUSPEND%
:gotJpdaOpts
shift
:noJpda

if ""%1"" == ""debug"" goto doDebug
if ""%1"" == ""run"" goto doRun
if ""%1"" == ""start"" goto doStart
if ""%1"" == ""stop"" goto doStop
if ""%1"" == ""configtest"" goto doConfigTest
if ""%1"" == ""version"" goto doVersion
  • 接下來, 我們能看到一些重要的資訊, 其中的重點是:
  • set _EXECJAVA=%_RUNJAVA%, 設定了jdk中bin目錄下的java.exe檔案路徑.
  • set MAINCLASS=org.apache.catalina.startup.Bootstrap, 設定了tomcat的啟動類為Bootstrap這個類. (後面會分析這個類)
  • set ACTION=start設定tomcat啟動
  • 大家可以留意這些引數, 最後執行tomcat的啟動時會用到.
if not ""%1"" == ""jpda"" goto noJpda
set JPDA=jpda
if not "%JPDA_TRANSPORT%" == "" goto gotJpdaTransport
set JPDA_TRANSPORT=dt_socket
:gotJpdaTransport
if not "%JPDA_ADDRESS%" == "" goto gotJpdaAddress
set JPDA_ADDRESS=8000
:gotJpdaAddress
if not "%JPDA_SUSPEND%" == "" goto gotJpdaSuspend
set JPDA_SUSPEND=n
:gotJpdaSuspend
if not "%JPDA_OPTS%" == "" goto gotJpdaOpts
set JPDA_OPTS=-agentlib:jdwp=transport=%JPDA_TRANSPORT%,address=%JPDA_ADDRESS%,server=y,suspend=%JPDA_SUSPEND%
:gotJpdaOpts
shift
:noJpda

if ""%1"" == ""debug"" goto doDebug
if ""%1"" == ""run"" goto doRun
if ""%1"" == ""start"" goto doStart
if ""%1"" == ""stop"" goto doStop
if ""%1"" == ""configtest"" goto doConfigTest
if ""%1"" == ""version"" goto doVersion
  • 接著判斷第一個引數是否是jpda, 是則進行一些設定. 而正常情況下第一個引數是start, 所以跳過這段程式碼. 接著會判斷第一個引數的內容, 根據判斷, 我們會跳到doStart程式碼段. (有餘力的同學不妨看下debug, run等啟動方式)
:doStart
shift
if "%TITLE%" == "" set TITLE=Tomcat
set _EXECJAVA=start "%TITLE%" %_RUNJAVA%
if not ""%1"" == ""-security"" goto execCmd
shift
echo Using Security Manager
set "SECURITY_POLICY_FILE=%CATALINA_BASE%\conf\catalina.policy"
goto execCmd
  • 可以看到doStart中無非也是設定一些引數, 最終會跳轉到execCmd程式碼段
:execCmd
rem Get remaining unshifted command line arguments and save them in the
set CMD_LINE_ARGS=
:setArgs
if ""%1""=="""" goto doneSetArgs
set CMD_LINE_ARGS=%CMD_LINE_ARGS% %1
shift
goto setArgs
:doneSetArgs
  • 可以看到這段程式碼也是在拼接引數, 把引數拼接到一個叫CMD_LINE_ARGS的變數中, 接下來就是catalina最後的一段程式碼了.
rem Execute Java with the applicable properties
if not "%JPDA%" == "" goto doJpda
if not "%SECURITY_POLICY_FILE%" == "" goto doSecurity
%_EXECJAVA% %LOGGING_CONFIG% %LOGGING_MANAGER% %JAVA_OPTS% %CATALINA_OPTS% %DEBUG_OPTS% -D%ENDORSED_PROP%="%JAVA_ENDORSED_DIRS%" -classpath "%CLASSPATH%" -Dcatalina.base="%CATALINA_BASE%" -Dcatalina.home="%CATALINA_HOME%" -Djava.io.tmpdir="%CATALINA_TMPDIR%" %MAINCLASS% %CMD_LINE_ARGS% %ACTION%
goto end
:doSecurity
%_EXECJAVA% %LOGGING_CONFIG% %LOGGING_MANAGER% %JAVA_OPTS% %CATALINA_OPTS% %DEBUG_OPTS% -D%ENDORSED_PROP%="%JAVA_ENDORSED_DIRS%" -classpath "%CLASSPATH%" -Djava.security.manager -Djava.security.policy=="%SECURITY_POLICY_FILE%" -Dcatalina.base="%CATALINA_BASE%" -Dcatalina.home="%CATALINA_HOME%" -Djava.io.tmpdir="%CATALINA_TMPDIR%" %MAINCLASS% %CMD_LINE_ARGS% %ACTION%
goto end
:doJpda
if not "%SECURITY_POLICY_FILE%" == "" goto doSecurityJpda
%_EXECJAVA% %LOGGING_CONFIG% %LOGGING_MANAGER% %JAVA_OPTS% %JPDA_OPTS% %CATALINA_OPTS% %DEBUG_OPTS% -D%ENDORSED_PROP%="%JAVA_ENDORSED_DIRS%" -classpath "%CLASSPATH%" -Dcatalina.base="%CATALINA_BASE%" -Dcatalina.home="%CATALINA_HOME%" -Djava.io.tmpdir="%CATALINA_TMPDIR%" %MAINCLASS% %CMD_LINE_ARGS% %ACTION%
goto end
:doSecurityJpda
%_EXECJAVA% %LOGGING_CONFIG% %LOGGING_MANAGER% %JAVA_OPTS% %JPDA_OPTS% %CATALINA_OPTS% %DEBUG_OPTS% -D%ENDORSED_PROP%="%JAVA_ENDORSED_DIRS%" -classpath "%CLASSPATH%" -Djava.security.manager -Djava.security.policy=="%SECURITY_POLICY_FILE%" -Dcatalina.base="%CATALINA_BASE%" -Dcatalina.home="%CATALINA_HOME%" -Djava.io.tmpdir="%CATALINA_TMPDIR%" %MAINCLASS% %CMD_LINE_ARGS% %ACTION%
goto end

:end
  • 跳過前面兩行判斷後, 來到了關鍵語句:
  • %_EXECJAVA% %LOGGING_CONFIG% %LOGGING_MANAGER% %JAVA_OPTS% %CATALINA_OPTS% %DEBUG_OPTS% -D%ENDORSED_PROP%="%JAVA_ENDORSED_DIRS%" -classpath "%CLASSPATH%" -Dcatalina.base="%CATALINA_BASE%" -Dcatalina.home="%CATALINA_HOME%" -Djava.io.tmpdir="%CATALINA_TMPDIR%" %MAINCLASS% %CMD_LINE_ARGS% %ACTION%
  • _EXECJAVA也就是_RUNJAVA, 也就是平時說的java指令, 但在之前的doStart程式碼塊中把_EXECJAVA改為了start "%TITLE%" %_RUNJAVA%, 所以系統會另啟一個命令列視窗, 名字叫Tomcat.
  • 在拼接一系列引數後, 我們會看見%MAINCLASS%, 也就是org.apache.catalina.startup.Bootstrap啟動類, 拼接完啟動引數後, 最後拼接的是%ACTION%, 也就是start.
  • 總結: catalina.bat最終執行了Bootstrap類中的main方法.
  • ps: 分析完整個catalina.bat我們發現我們可以通過設定不同的引數讓tomcat以不同的方式執行. 在ide中我們是可以選擇debug等模式啟動tomcat的, 也可以為其配置引數, 在catalina.bat中我們看到了啟動tomcat背後的運作流程.

Tomcat啟動流程分析

  • 上面我們從執行startup.bat一路分析來到Bootstrap的啟動, 下面我們先對Tomcat中的元件進行一次總覽. 由於篇幅的原因, tomcat中各個模組的關係將留到下一篇文章敘述, 這裡先給出一張tomcat中各模組的關係圖.

詳解Tomcat系列(一)-從原始碼分析Tomcat的啟動

  • 從圖中我們可以看出tomcat中模組還是挺多的, 那麼tomcat是如何啟動這些模組的呢? 請看下面這張示意圖.

詳解Tomcat系列(一)-從原始碼分析Tomcat的啟動

  • 由圖中我們可以看到從Bootstrap類的main方法開始, tomcat會以鏈的方式逐級呼叫各個模組的init()方法進行初始化, 待各個模組都初始化後, 又會逐級呼叫各個模組的start()方法啟動各個模組. 下面我們通過部分原始碼看一下這個過程.
  • 首先我們來到Bootstrap類的main方法
 public static void main(String args[]) {

        if (daemon == null) {
            // Don't set daemon until init() has completed
            Bootstrap bootstrap = new Bootstrap();
            try {
                bootstrap.init();
            } catch (Throwable t) {
                handleThrowable(t);
                t.printStackTrace();
                return;
            }
            daemon = bootstrap;
        } else {
            // When running as a service the call to stop will be on a new
            // thread so make sure the correct class loader is used to prevent
            // a range of class not found exceptions.
            Thread.currentThread().setContextClassLoader(daemon.catalinaLoader);
        }

        try {
            String command = "start";
            if (args.length > 0) {
                command = args[args.length - 1];
            }

            if (command.equals("startd")) {
                args[args.length - 1] = "start";
                daemon.load(args);
                daemon.start();
            } else if (command.equals("stopd")) {
                args[args.length - 1] = "stop";
                daemon.stop();
            } else if (command.equals("start")) {
                daemon.setAwait(true);
                daemon.load(args);
                daemon.start();
                if (null == daemon.getServer()) {
                    System.exit(1);
                }
            } else if (command.equals("stop")) {
                daemon.stopServer(args);
            } else if (command.equals("configtest")) {
                daemon.load(args);
                if (null == daemon.getServer()) {
                    System.exit(1);
                }
                System.exit(0);
            } else {
                log.warn("Bootstrap: command \"" + command + "\" does not exist.");
            }
        } catch (Throwable t) {
            // Unwrap the Exception for clearer error reporting
            // 省略
            System.exit(1);
        }
    }
  • 我們可以看到, Bootstrap類首先會建立一個本類物件, 然後呼叫init()方法進行初始化. 執行完init()方法後會判斷啟動引數的值, 由於我們採取預設的啟動方式, 所以main方法的引數是start, 會進入下面的判斷程式碼塊
 else if (command.equals("start")) {
                daemon.setAwait(true);
                daemon.load(args);
                daemon.start();
                if (null == daemon.getServer()) {
                    System.exit(1);
                }
  • 可以看到在設定等待後, 呼叫了本類物件的load()方法. 我們跟進檢視load()方法的原始碼.
    private void load(String[] arguments)
        throws Exception {

        // Call the load() method
        String methodName = "load";
        Object param[];
        Class<?> paramTypes[];
        if (arguments==null || arguments.length==0) {
            paramTypes = null;
            param = null;
        } else {
            paramTypes = new Class[1];
            paramTypes[0] = arguments.getClass();
            param = new Object[1];
            param[0] = arguments;
        }
        Method method =
            catalinaDaemon.getClass().getMethod(methodName, paramTypes);
        if (log.isDebugEnabled())
            log.debug("Calling startup class " + method);
        method.invoke(catalinaDaemon, param);
    }
  • 可以看到方法的最後通過反射的方式呼叫了成員變數catalinaDaemon的load()方法. 通過跟蹤原始碼我們可以看到catalinaDaemon是Bootstrap類的一個私有成員變數
/**
 * Daemon reference.
 */
private Object catalinaDaemon = null;
  • 它會在Bootstrap的init()方法中通過反射的方式完成初始化. 下面我們回過頭來看init()方法的原始碼.
public void init()
    throws Exception
{

    // Set Catalina path
    setCatalinaHome();
    setCatalinaBase();

    initClassLoaders();

    Thread.currentThread().setContextClassLoader(catalinaLoader);

    SecurityClassLoad.securityClassLoad(catalinaLoader);

    // Load our startup class and call its process() method
    if (log.isDebugEnabled())
        log.debug("Loading startup class");
    Class<?> startupClass =
        catalinaLoader.loadClass
        ("org.apache.catalina.startup.Catalina");
    Object startupInstance = startupClass.newInstance();

    // Set the shared extensions class loader
    if (log.isDebugEnabled())
        log.debug("Setting startup class properties");
    String methodName = "setParentClassLoader";
    Class<?> paramTypes[] = new Class[1];
    paramTypes[0] = Class.forName("java.lang.ClassLoader");
    Object paramValues[] = new Object[1];
    paramValues[0] = sharedLoader;
    Method method =
        startupInstance.getClass().getMethod(methodName, paramTypes);
    method.invoke(startupInstance, paramValues);

    catalinaDaemon = startupInstance;
}
  • 可以看到init()方法建立了一個Catalina物件, 並把該物件賦給了catalinaDaemon.
  • 再回過頭來看之前分享給大家的啟動流程圖, 對應的是這一塊

詳解Tomcat系列(一)-從原始碼分析Tomcat的啟動

  • 後面的過程我就不逐一分析了, 你可以按照這張圖去跟蹤程式碼進行驗證. 最終所有模組都能被初始化並啟動.

    本篇結束

  • 看到這裡相信你應該對tomcat的啟動過程已經有一個清晰的認識了, 但是這僅僅是理解tomcat的開始, 下一篇文章開始我將會詳細介紹tomcat中的元件, 以及元件間的關係. 瞭解了各個元件後, tomcat的結構於你而言將不再神祕.


 


公眾號二維碼

  • 歡迎關注我的公眾號, 於19年5.25開始更新, 更新原創Java後端面試題分析, 從Java基礎到主流框架, 力求深入每個知識點背後的原理. 還會分享和程式設計有關的趣味漫畫, 歡迎關注~

相關文章