字節(jié)碼調(diào)教入口JVM?寄生插件javaagent
Java Instrumentation 包
Java Instrumentation 概述
Java Instrumentation 這個技術看起來非常神秘,很少有書會詳細介紹。但是有很多工具是基于 Instrumentation 來實現(xiàn)的:
- APM 產(chǎn)品: pinpoint、skywalking、newrelic、聽云的 APM 產(chǎn)品等都基于 Instrumentation 實現(xiàn)
- 熱部署工具:Intellij idea 的 HotSwap、Jrebel 等
- Java 診斷工具:Arthas、Btrace 等
由于對字節(jié)碼修改功能的巨大需求,JDK 從 JDK5 版本開始引入了java.lang.instrument 包。它可以通過 addTransformer 方法設置一個 ClassFileTransformer,可以在這個 ClassFileTransformer 實現(xiàn)類的轉換。
JDK 1.5 支持靜態(tài) Instrumentation,基本的思路是在 JVM 啟動的時候添加一個代理(javaagent),每個代理是一個 jar 包,其 MANIFEST.MF 文件里指定了代理類,這個代理類包含一個 premain 方法。JVM 在類加載時候會先執(zhí)行代理類的 premain 方法,再執(zhí)行 Java 程序本身的 main 方法,這就是 premain 名字的來源。在 premain 方法中可以對加載前的 class 文件進行修改。這種機制可以認為是虛擬機級別的 AOP,無需對原有應用做任何修改,就可以實現(xiàn)類的動態(tài)修改和增強。
從 JDK 1.6 開始支持更加強大的動態(tài) Instrument,在JVM 啟動后通過 Attach API 遠程加載,后面會詳細介紹。
本文會分為 javaagent 和動態(tài) Attach 兩個部分來介紹
Java Instrumentation 核心方法
Instrumentation 是 java.lang.instrument 包下的一個接口,這個接口的方法提供了注冊類文件轉換器、獲取所有已加載的類等功能,允許我們在對已加載和未加載的類進行修改,實現(xiàn) AOP、性能監(jiān)控等功能。
常用的方法如下:
/** * 為 Instrumentation 注冊一個類文件轉換器,可以修改讀取類文件字節(jié)碼 */ void addTransformer(ClassFileTransformer transformer, boolean canRetransform); /** * 對JVM已經(jīng)加載的類重新觸發(fā)類加載 */ void retransformClasses(Class<?>... classes) throws UnmodifiableClassException; /** * 獲取當前 JVM 加載的所有類對象 */ Class[] getAllLoadedClasses()
它的 addTransformer 給 Instrumentation 注冊一個 transformer,transformer 是 ClassFileTransformer 接口的實例,這個接口就只有一個 transform 方法,調(diào)用 addTransformer 設置 transformer 以后,后續(xù)JVM 加載所有類之前都會被這個 transform 方法攔截,這個方法接收原類文件的字節(jié)數(shù)組,返回轉換過的字節(jié)數(shù)組,在這個方法中可以做任意的類文件改寫。
下面是一個空的 ClassFileTransformer 的實現(xiàn):
public class MyClassTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classBytes) throws IllegalClassFormatException {
// 在這里讀取、轉換類文件
return classBytes;
}
}接下來我們來介紹本文的主角之一 javaagent。
Javaagent 介紹
Javaagent 是一個特殊的 jar 包,它并不能單獨啟動的,而必須依附于一個 JVM 進程,可以看作是 JVM 的一個寄生插件,使用 Instrumentation 的 API 用來讀取和改寫當前 JVM 的類文件。
Agent 的兩種使用方式
它有兩種使用方式:
- 在 JVM 啟動的時候加載,通過 javaagent 啟動參數(shù) java -javaagent:myagent.jar MyMain,這種方式在程序 main 方法執(zhí)行之前執(zhí)行 agent 中的 premain 方法
在 JVM 啟動后 Attach,通過 Attach API 進行加載,這種方式會在 agent 加載以后執(zhí)行 agentmain 方法
premain 和 agentmain 方法簽名如下:
public static void premain(String agentArgument, Instrumentation instrumentation) throws Exception public static void agentmain(String agentArgument, Instrumentation instrumentation) throws Exception
這兩個方法都有兩個參數(shù)
- 第一個 agentArgument 是 agent 的啟動參數(shù),可以在 JVM 啟動命令行中設置,比如
java -javaagent:<jarfile>=appId:agent-demo,agentType:singleJar test.jar的情況下 agentArgument 的值為 "appId:agent-demo,agentType:singleJar"。 - 第二個 instrumentation 是 java.lang.instrument.Instrumentation 的實例,可以通過 addTransformer 方法設置一個 ClassFileTransformer。
第一種 premain 方式的加載時序如下:

Agent 打包
為了能夠以 javaagent 的方式運行 premain 和 agentmain 方法,我們需要將其打包成 jar 包,并在其中的 MANIFEST.MF 配置文件中,指定 Premain-class 等信息,一個典型的生成好的 MANIFEST.MF 內(nèi)容如下
為了能夠以 javaagent 的方式運行 premain 和 agentmain 方法,我們需要將其打包成 jar 包,并在其中的 MANIFEST.MF 配置文件中,指定 Premain-class 等信息,一個典型的生成好的 MANIFEST.MF 內(nèi)容如下
下面是一個可以幫助生成上面 MANIFEST.MF 的 maven 配置
<build>
<finalName>my-javaagent</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Agent-Class>me.geek01.javaagent.AgentMain</Agent-Class>
<Premain-Class>me.geek01.javaagent.AgentMain</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>Agent 使用方式一:JVM 啟動參數(shù)
下面使用 javaagent 實現(xiàn)簡單的函數(shù)調(diào)用棧跟蹤,以下面的代碼為例:
public class MyTest {
public static void main(String[] args) {
new MyTest().foo();
}
public void foo() {
bar1();
bar2();
}
public void bar1() {
}
public void bar2() {
}
}通過 javaagent 啟動參數(shù)的方式在每個函數(shù)進入和結束時都打印一行日志,實現(xiàn)調(diào)用過程的追蹤的效果。
核心的方法 instrument 的邏輯如下:
public static class MyMethodVisitor extends AdviceAdapter {
@Override
protected void onMethodEnter() {
// 在方法開始處插入 <<<enter xxx
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("<<<enter " + this.getName());
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
super.onMethodEnter();
}
@Override
protected void onMethodExit(int opcode) {
super.onMethodExit(opcode);
// 在方法結束處插入 <<<exit xxx
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn(">>>exit " + this.getName());
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
}把 agent 打包生成 my-trace-agent.jar,添加 agent 啟動 MyTest 類
java -javaagent:/path_to/my-trace-agent.jar MyTest
可以看到輸出結果如下:
<<<enter main
<<<enter foo
<<<enter bar1
>>>exit bar1
<<<enter bar2
>>>exit bar2
>>>exit foo
>>>exit main
通過上面的方式,我們在不修改 MyTest 類源碼的情況下實現(xiàn)了調(diào)用鏈跟蹤的效果。更加健壯和完善的調(diào)用鏈跟蹤實現(xiàn)會在后面的 APM 章節(jié)詳細介紹。
Agent 使用方式二:Attach API 使用
在 JDK5 中,開發(fā)者只能 JVM 啟動時指定一個 javaagent 在 premain 中操作字節(jié)碼,Instrumentation 也僅限于 main 函數(shù)執(zhí)行前,這樣的方式存在一定的局限性。從 JDK6 開始引入了動態(tài) Attach Agent 的方案,除了在命令行中指定 javaagent,現(xiàn)在可以通過 Attach API 遠程加載。我們常用的 jstack、arthas 等工具都是通過 Attach 機制實現(xiàn)的。
接下來我們會結合跨進程通信中的信號和 Unix 域套接字來看 JVM Attach API 的實現(xiàn)原理
JVM Attach API 基本使用
下面以一個實際的例子來演示動態(tài) Attach API 的使用,代碼中有一個 main 方法,每隔 3s 輸出 foo 方法的返回值 100,接下來動態(tài) Attach 上 MyTestMain 進程,修改 foo 的字節(jié)碼,讓 foo 方法返回 50。
public class MyTestMain {
public static void main(String[] args) throws InterruptedException {
while (true) {
System.out.println(foo());
TimeUnit.SECONDS.sleep(3);
}
}
public static int foo() {
return 100; // 修改后 return 50;
}
}步驟如下:
1、編寫 Attach Agent,對 foo 方法做注入,完整的代碼見:github.com/arthur-zhan…
動態(tài) Attach 的 agent 與通過 JVM 啟動 javaagent 參數(shù)指定的 agent jar 包的方式有所不同,動態(tài) Attach 的 agent 會執(zhí)行 agentmain 方法,而不是 premain 方法。
public class AgentMain {
public static void agentmain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException {
System.out.println("agentmain called");
inst.addTransformer(new MyClassFileTransformer(), true);
Class classes[] = inst.getAllLoadedClasses();
for (int i = 0; i < classes.length; i++) {
if (classes[i].getName().equals("MyTestMain")) {
System.out.println("Reloading: " + classes[i].getName());
inst.retransformClasses(classes[i]);
break;
}
}
}
}2、因為是跨進程通信,Attach 的發(fā)起端是一個獨立的 java 程序,這個 java 程序會調(diào)用 VirtualMachine.attach 方法開始和目標 JVM 進行跨進程通信。
public class MyAttachMain {
public static void main(String[] args) throws Exception {
VirtualMachine vm = VirtualMachine.attach(args[0]);
try {
vm.loadAgent("/path/to/agent.jar");
} finally {
vm.detach();
}
}
}使用 jps 查詢到 MyTestMain 的進程 id,
java -cp /path/to/your/tools.jar:. MyAttachMain pid
可以看到 MyTestMain 的輸出的 foo 方法已經(jīng)返回了 50。
java -cp . MyTestMain 100 100 100 agentmain called Reloading: MyTestMain 50 50 50
JVM Attach API 的底層原理
JVM Attach API 的實現(xiàn)主要基于信號和 Unix 域套接字,接下來詳細介紹這兩部分的內(nèi)容。
信號是什么
信號是某事件發(fā)生時對進程的通知機制,也被稱為“軟件中斷”。信號可以看做是一種非常輕量級的進程間通信,信號由一個進程發(fā)送給另外一個進程,只不過是經(jīng)由內(nèi)核作為一個中間人發(fā)出,信號最初的目的是用來指定殺死進程的不同方式。
每個信號都有一個名字,以 "SIG" 開頭,最熟知的信號應該是 SIGINT,我們在終端執(zhí)行某個應用程序的過程中按下 Ctrl+C 一般會終止正在執(zhí)行的進程,正是因為按下 Ctrl+C 會發(fā)送 SIGINT 信號給目標程序。
每個信號都有一個唯一的數(shù)字標識,從 1 開始,下面是常見的信號量列表:

在 Linux 中,一個前臺進程可以使用 Ctrl+C 進行終止,對于后臺進程需要使用 kill 加進程號的方式來終止,kill 命令是通過發(fā)送信號給目標進程來實現(xiàn)終止進程的功能。默認情況下,kill 命令發(fā)送的是編號為 15 的 SIGTERM 信號,這個信號可以被進程捕獲,選擇忽略或正常退出。目標進程如果沒有自定義處理這個信號,就會被終止。對于那些忽略 SIGTERM 信號的進程,則需要編號為 9 的 SIGKILL 信號強行殺死進程,SIGKILL 信號不能被忽略也不能被捕獲和自定義處理。
下面寫了一段 C 代碼,自定義處理了 SIGQUIT、SIGINT、SIGTERM 信號
signal.c
static void signal_handler(int signal_no) {
if (signal_no == SIGQUIT) {
printf("quit signal receive: %d\n", signal_no);
} else if (signal_no == SIGTERM) {
printf("term signal receive: %d\n", signal_no);
} else if (signal_no == SIGINT) {
printf("interrupt signal receive: %d\n", signal_no);
}
}
int main() {
signal(SIGQUIT, signal_handler);
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
for (int i = 0;; i++) {
printf("%d\n", i);
sleep(3);
}
}編譯運行上面的 signal.c 文件
gcc signal.c -o signal ./signal
這種情況下,在終端中Ctrl+C,kill -3,kill -15都沒有辦法殺掉這個進程,只能用kill -9
0 ^Cinterrupt signal receive: 2 // Ctrl+C 1 2 term signal receive: 15 // kill pid 3 4 5 quit signal receive: 3 // kill -3 6 7 8 [1] 46831 killed ./signal // kill -9 成功殺死進程
JVM 對 SIGQUIT 的默認行為是打印所有運行線程的堆棧信息,在類 Unix 系統(tǒng)中,可以通過使用命令 kill -3 pid 來發(fā)送 SIGQUIT 信號。運行上面的 MyTestMain,使用 jps 找到整個 JVM 的進程 id,執(zhí)行 kill -3 pid,在終端就可以看到打印了所有的線程的調(diào)用棧信息:
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.51-b03 mixed mode):
"Service Thread" #8 daemon prio=9 os_prio=31 tid=0x00007fe060821000 nid=0x4403 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
...
"Signal Dispatcher" #4 daemon prio=9 os_prio=31 tid=0x00007fe061008800 nid=0x3403 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
"main" #1 prio=5 os_prio=31 tid=0x00007fe060003800 nid=0x1003 waiting on condition [0x000070000d203000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(Native Method)
at java.lang.Thread.sleep(Thread.java:340)
at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
at MyTestMain.main(MyTestMain.java:10)Unix 域套接字(Unix Domain Socket)
使用 TCP 和 UDP 進行 socket 通信是一種廣為人知的 socket 使用方式,除了這種方式還有一種稱為 Unix 域套接字的方式,可以實現(xiàn)同一主機上的進程間通信。雖然使用 127.0.0.1 環(huán)回地址也可以通過網(wǎng)絡實現(xiàn)同一主機的進程間通信,但 Unix 域套接字更可靠、效率更高。Docker 守護進程(Docker daemon)使用了 Unix 域套接字,容器中的進程可以通過它與Docker 守護進程進行通信。MySQL 同樣提供了域套接字進行訪問的方式。
Unix 域套接字是什么?
Unix 域套接字是一個文件,通過 ls 命令可以看到
ls -l srwxrwxr-x. 1 ya ya 0 9月 8 00:26 tmp.sock
兩個進程通過讀寫這個文件就實現(xiàn)了進程間的信息傳遞。文件的擁有者和權限決定了誰可以讀寫這個套接字。
與普通套接字的區(qū)別是什么?
- Unix 域套接字更加高效,Unix 套接字不用進行協(xié)議處理,不需要計算序列號,也不需要發(fā)送確認報文,只需要復制數(shù)據(jù)即可
- Unix 域套接字是可靠的,不會丟失報文,普通套接字是為不可靠通信設計的
- Unix 域套接字的代碼可以非常簡單的修改轉為普通套接字
下面是一個簡單的 C 實現(xiàn)的域套接字的例子
. ├── client.c └── server.c
server.c 充當 Unix 域套接字服務器,啟動后會在當前目錄生成一個名為 tmp.sock 的 Unix 域套接字文件,它讀取客戶端寫入的內(nèi)容并輸出。
server.c
int main() {
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "tmp.sock");
int ret = bind(fd, (struct sockaddr *) &addr, sizeof(addr));
listen(fd, 5)
int accept_fd;
char buf[100];
while (1) {
accept_fd = accept(fd, NULL, NULL)) == -1);
while ((ret = read(accept_fd, buf, sizeof(buf))) > 0) {
// 輸出客戶端傳過來的數(shù)據(jù)
printf("receive %u bytes: %s\n", ret, buf);
}
}客戶端的代碼如下:
client.c
int main() {
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "tmp.sock");
connect(fd, (struct sockaddr *) &addr, sizeof(addr)) == -1
int rc;
char buf[100];
// 讀取終端標準輸入的內(nèi)容,寫入到 Unix 域套接字文件中
while ((rc = read(STDIN_FILENO, buf, sizeof(buf))) > 0) {
write(fd, buf, rc);
}
}在命令行中進行編譯和執(zhí)行
gcc server.c -o server gcc client.c -o client
啟動兩個終端,一個啟動 server 端,一個啟動 client 端
./server ./client
可以看到當前目錄生成了一個 "tmp.sock" 文件
ls -l srwxrwxr-x. 1 ya ya 0 9月 8 00:08 tmp.sock
在 client 輸入 hello,在 server 的終端就可以看到
./server receive 6 bytes: hello
JVM Attach 過程分析
執(zhí)行 MyAttachMain,當指定一個不存在的 JVM 進程時,會出現(xiàn)如下的錯誤:
java -cp /path/to/your/tools.jar:. MyAttachMain 1234
Exception in thread "main" java.io.IOException: No such process
at sun.tools.attach.LinuxVirtualMachine.sendQuitTo(Native Method)
at sun.tools.attach.LinuxVirtualMachine.<init>(LinuxVirtualMachine.java:91)
at sun.tools.attach.LinuxAttachProvider.attachVirtualMachine(LinuxAttachProvider.java:63)
at com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:208)
at MyAttachMain.main(MyAttachMain.java:8)
可以看到 VirtualMachine.attach 最終調(diào)用了 sendQuitTo 方法,這是一個 native 的方法,底層就是發(fā)送了 SIGQUIT 信號給目標 JVM 進程。
前面信號部分我們介紹過,JVM 對 SIGQUIT 的默認行為是 dump 當前的線程堆棧,那為什么調(diào)用 VirtualMachine.attach 沒有輸出調(diào)用棧堆棧呢?
對于 Attach 的發(fā)起方,假設目標進程為 12345,這部分的詳細的過程如下:
1、Attach 端檢查臨時文件目錄是否有 .java_pid12345 文件
這個文件是一個 UNIX 域套接字文件,由 Attach 成功以后的目標 JVM 進程生成。如果這個文件存在,說明正在 Attach 中,可以用這個 socket 進行下一步的通信。如果這個文件不存在則創(chuàng)建一個 .attach_pid12345 文件,這部分的偽代碼如下:
String tmpdir = "/tmp";
File socketFile = new File(tmpdir, ".java_pid" + pid);
if (socketFile.exists()) {
File attachFile = new File(tmpdir, ".attach_pid" + pid);
createAttachFile(attachFile.getPath());
}2、Attach 端檢查如果沒有 .java_pid12345 文件,創(chuàng)建完 .attach_pid12345 文件以后發(fā)送 SIGQUIT 信號給目標 JVM。然后每隔 200ms 檢查一次 socket 文件是否已經(jīng)生成,5s 以后還沒有生成則退出,如果有生成則進行 socket 通信
3、對于目標 JVM 進程而言,它的 Signal Dispatcher 線程收到 SIGQUIT 信號以后,會檢查 .attach_pid12345 文件是否存在。
- 目標 JVM 如果發(fā)現(xiàn) .attach_pid12345 不存在,則認為這不是一個 attach 操作,執(zhí)行默認行為,輸出當前所有線程的堆棧
目標 JVM 如果發(fā)現(xiàn) .attach_pid12345 存在,則認為這是一個 attach 操作,會啟動 Attach Listener 線程,負責處理 Attach 請求,同時創(chuàng)建名為 .java_pid12345 的 socket 文件,監(jiān)聽 socket。
源碼中 /hotspot/src/share/vm/runtime/os.cpp 這一部分處理的邏輯如下:
#define SIGBREAK SIGQUIT
static void signal_thread_entry(JavaThread* thread, TRAPS) {
while (true) {
int sig;
{
switch (sig) {
case SIGBREAK: {
// Check if the signal is a trigger to start the Attach Listener - in that
// case don't print stack traces.
if (!DisableAttachMechanism && AttachListener::is_init_trigger()) {
continue;
}
...
// Print stack traces
}
}AttachListener 的 is_init_trigger 在 .attach_pid12345 文件存在的情況下會新建 .java_pid12345 套接字文件,同時監(jiān)聽此套接字,準備 Attach 端發(fā)送數(shù)據(jù)。
那 Attach 端和目標進程用 socket 傳遞了什么信息呢?可以通過 strace 的方式看到 Attach 端究竟往 socket 里面寫了什么:
sudo strace -f java -cp /usr/local/jdk/lib/tools.jar:. MyAttachMain 12345 2> strace.out
...
5841 [pid 3869] socket(AF_LOCAL, SOCK_STREAM, 0) = 5
5842 [pid 3869] connect(5, {sa_family=AF_LOCAL, sun_path="/tmp/.java_pid12345"}, 110) = 0
5843 [pid 3869] write(5, "1", 1) = 1
5844 [pid 3869] write(5, "\0", 1) = 1
5845 [pid 3869] write(5, "load", 4) = 4
5846 [pid 3869] write(5, "\0", 1) = 1
5847 [pid 3869] write(5, "instrument", 10) = 10
5848 [pid 3869] write(5, "\0", 1) = 1
5849 [pid 3869] write(5, "false", 5) = 5
5850 [pid 3869] write(5, "\0", 1) = 1
5855 [pid 3869] write(5, "/home/ya/agent.jar"..., 18 <unfinished ...>可以看到往 socket 寫入的內(nèi)容如下:
1
\0
load
\0
instrument
\0
false
\0
/home/ya/agent.jar
\0
數(shù)據(jù)之間用 \0 字符分隔,第一行的 1 表示協(xié)議版本,接下來是發(fā)送指令 "load instrument false /home/ya/agent.jar" 給目標 JVM,目標 JVM 收到這些數(shù)據(jù)以后就可以加載相應的 agent jar 包進行字節(jié)碼的改寫。
如果從 socket 的角度來看,VirtualMachine.attach 方法相當于三次握手建連,VirtualMachine.loadAgent 則是握手成功之后發(fā)送數(shù)據(jù),VirtualMachine.detach 相當于四次揮手斷開連接。
這個過程如下圖所示:

小結
本文講解了 javaagent,一起來回顧一下要點:
- 第一,javaagent 是一個使用 instrumentation 的 API 用來改寫類文件的 jar 包,可以看作是 JVM 的一個寄生插件。
- 第二,javaagent 有兩個重要的入口類:Premain-Class 和 Agent-Class,分別對應入口函數(shù) premain 和 agentmain,其中 agentmain 可以采用遠程 attach API 的方式遠程掛載另一個 JVM 進程。
以上就是字節(jié)碼調(diào)教入口JVM 寄生插件javaagent的詳細內(nèi)容,更多關于JVM javaagent字節(jié)碼的資料請關注腳本之家其它相關文章!
相關文章
Java+opencv3.2.0實現(xiàn)hough直線檢測
這篇文章主要為大家詳細介紹了Java+opencv3.2.0之hough直線檢測,具有一定的參考價值,感興趣的小伙伴們可以參考一下2018-02-02
IDEA中Directory創(chuàng)建多級目錄的實現(xiàn)
本文主要介紹了IDEA中Directory創(chuàng)建多級目錄的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2023-06-06
SpringBoot獲取ApplicationContext的3種方式
這篇文章主要為大家詳細介紹了SpringBoot獲取ApplicationContext的3種方式,具有一定的參考價值,感興趣的小伙伴們可以參考一下2019-09-09

