Java 離 Linux 內核有多遠?
點擊上方藍色“程序猿DD”,選擇“設為星標”
回復“資源”獲取獨家整理的學習資料!?
在往期的文章中,給大家分享了內核中的重要功能 —— 容器底層 cgroup 的相關知識,不少讀者表示內核實在太高深,代碼也較難理解。本期內容我們將站在非內核開發(fā)者的角度,給大家介紹應用和系統(tǒng)工程師如何梳理 Linux 內核代碼。
Java 離內核有多遠?
測試環(huán)境版本信息:

玩內核的人怎么也懂 Java?這主要得益于我學校的 Java 課程和畢業(yè)那會在華為做 Android 手機的經歷,幾個模塊從 APP/Framework/Service/HAL/Driver 掃過一遍,自然對 Java 有所了解。
每次提起 Java,我都會想到一段有趣的經歷。剛畢業(yè)到部門報到第一個星期,部門領導(在華為算是 Manager)安排我們熟悉 Android。我花了幾天寫了個 Android 游戲,有些類似連連看那種。開周會的時候,領導看到我的演示后,一臉不悅,質疑我的直接領導(在華為叫 PL,Project Leader)沒有給我們講明白部門的方向。
emm,我當時確實沒明白所謂的熟悉 Android 是該干啥,后來 PL 說,是要熟悉 xxx 模塊,APP 只是其中一部分。話說如果當時得到的是肯定,也許我現在就是一枚 Java 工程師了(哈哈手動狗頭)。
從 launcher 說起
世界上最遠的距離,是咱倆坐隔壁,我在看底層協(xié)議,而你在研究 spring……如果想拉近咱倆的距離,先下載 openjdk 源碼,然后下載 glibc,再下載內核源碼。
Java 程序到 JVM,這個大家肯定比我熟悉,就不班門弄斧了。
我們就從 JVM 的入口為例,分析 JVM 到內核的流程,入口就是 main 函數了(java.base/share/native/launcher/main.c):
JNIEXPORT?int
main(int?argc,?char?**argv)
{
????//中間省略一萬行參數處理代碼
????return?JLI_Launch(margc,?margv,
???????????????????jargc,?(const?char**)?jargv,
???????????????????0,?NULL,
???????????????????VERSION_STRING,
???????????????????DOT_VERSION,
???????????????????(const_progname?!=?NULL)???const_progname?:?*margv,
???????????????????(const_launcher?!=?NULL)???const_launcher?:?*margv,
???????????????????jargc?>?0,
???????????????????const_cpwildcard,?const_javaw,?0);
}
JLI_Launch 做了三件我們關心的事。
首先,調用 CreateExecutionEnvironment 查找設置環(huán)境變量,比如 JVM 的路徑(下面的變量 jvmpath),以我的平臺為例,就是 /usr/lib/jvm/java-14-openjdk-amd64/lib/server/libjvm.so,window 平臺可能就是 libjvm.dll。
其次,調用 LoadJavaVM 加載 JVM,就是 libjvm.so 文件,然后找到創(chuàng)建 JVM 的函數賦值給 InvocationFunctions 的對應字段:
jboolean?LoadJavaVM(const?char?*jvmpath,?InvocationFunctions?*ifn)
{
void?*libjvm;
//省略出錯處理
????libjvm?=?dlopen(jvmpath,?RTLD_NOW?+?RTLD_GLOBAL);
????ifn->CreateJavaVM?=?(CreateJavaVM_t)
????????dlsym(libjvm,?"JNI_CreateJavaVM");
????ifn->GetDefaultJavaVMInitArgs?=?(GetDefaultJavaVMInitArgs_t)
????????dlsym(libjvm,?"JNI_GetDefaultJavaVMInitArgs");
????ifn->GetCreatedJavaVMs?=?(GetCreatedJavaVMs_t)
????????dlsym(libjvm,?"JNI_GetCreatedJavaVMs");
????return?JNI_TRUE;
}
dlopen 和 dlsym 涉及動態(tài)鏈接,簡單理解就是 libjvm.so 包含 JNI_CreateJavaVM、JNI_GetDefaultJavaVMInitArgs 和 JNI_GetCreatedJavaVMs 的定義,動態(tài)鏈接完成后,ifn->CreateJavaVM、ifn->GetDefaultJavaVMInitArgs 和 ifn->GetCreatedJavaVMs 就是這些函數的地址。
不妨確認下 libjvm.so 有這三個函數。
objdump?-D?/usr/lib/jvm/java-14-openjdk-amd64/lib/server/libjvm.so?|?grep?-E
"CreateJavaVM|GetDefaultJavaVMInitArgs|GetCreatedJavaVMs"?|?grep?":$"
00000000008fa9d0?@SUNWprivate_1.1>:
00000000008faa20?@SUNWprivate_1.1>:
00000000009098e0?@SUNWprivate_1.1>:
openjdk 源碼里有這些實現的(hotspot/share/prims/下),有興趣的同學可以繼續(xù)鉆研。
最后,調用 JVMInit 初始化 JVM,load Java 程序。
JVMInit 調用 ContinueInNewThread,后者調用 CallJavaMainInNewThread。插一句,我是真的不喜歡按照函數調用的方式講述問題,a 調用 b,b 又調用 c,簡直是在浪費篇幅,但是有些地方跨度太大又怕引起誤會(尤其對初學者而言)。相信我,注水,是真沒有,我不需要經驗+3 哈哈。
CallJavaMainInNewThread 的主要邏輯如下:
int?CallJavaMainInNewThread(jlong?stack_size,?void*?args)?{
????int?rslt;
????pthread_t?tid;
????pthread_attr_t?attr;
????pthread_attr_init(&attr);
????pthread_attr_setdetachstate(&attr,?PTHREAD_CREATE_JOINABLE);
????if?(stack_size?>?0)?{
????????pthread_attr_setstacksize(&attr,?stack_size);
????}
????pthread_attr_setguardsize(&attr,?0);?//?no?pthread?guard?page?on?java?threads
????if?(pthread_create(&tid,?&attr,?ThreadJavaMain,?args)?==?0)?{
????????void*?tmp;
????????pthread_join(tid,?&tmp);
????????rslt?=?(int)(intptr_t)tmp;
????}
???else?{
????????rslt?=?JavaMain(args);
????}
????pthread_attr_destroy(&attr);
????return?rslt;
}
看到 pthread_create 了吧,破案了,Java 的線程就是通過 pthread 實現的。此處就可以進入內核了,但是我們還是先繼續(xù)看看 JVM。ThreadJavaMain 直接調用了 JavaMain,所以這里的邏輯就是,如果創(chuàng)建線程成功,就由新線程執(zhí)行 JavaMain,否則就知道在當前進程執(zhí)行JavaMain。
JavaMain 是我們關注的重點,核心邏輯如下:
int?JavaMain(void*?_args)
{
????JavaMainArgs?*args?=?(JavaMainArgs?*)_args;
????int?argc?=?args->argc;
????char?**argv?=?args->argv;
????int?mode?=?args->mode;
????char?*what?=?args->what;
????InvocationFunctions?ifn?=?args->ifn;
????JavaVM?*vm?=?0;
????JNIEnv?*env?=?0;
????jclass?mainClass?=?NULL;
????jclass?appClass?=?NULL;?//?actual?application?class?being?launched
????jmethodID?mainID;
????jobjectArray?mainArgs;
????int?ret?=?0;
????jlong?start,?end;
????/*?Initialize?the?virtual?machine?*/
????if?(!InitializeJVM(&vm,?&env,?&ifn))?{????//1
????????JLI_ReportErrorMessage(JVM_ERROR1);
????????exit(1);
????}
????mainClass?=?LoadMainClass(env,?mode,?what);????//2
????CHECK_EXCEPTION_NULL_LEAVE(mainClass);
????mainArgs?=?CreateApplicationArgs(env,?argv,?argc);
????CHECK_EXCEPTION_NULL_LEAVE(mainArgs);
????mainID?=?(*env)->GetStaticMethodID(env,?mainClass,?"main",
???????????????????????????????????????"([Ljava/lang/String;)V");????//3
????CHECK_EXCEPTION_NULL_LEAVE(mainID);
????/*?Invoke?main?method.?*/
????(*env)->CallStaticVoidMethod(env,?mainClass,?mainID,?mainArgs);????//4
????ret?=?(*env)->ExceptionOccurred(env)?==?NULL???0?:?1;
????LEAVE();
}
第 1 步,調用 InitializeJVM 初始化 JVM。InitializeJVM 會調用 ifn->CreateJavaVM,也就是libjvm.so 中的 JNI_CreateJavaVM。
第 2 步,LoadMainClass,最終調用的是 JVM_FindClassFromBootLoader,也是通過動態(tài)鏈接找到函數(定義在 hotspot/share/prims/ 下),然后調用它。
第 3 和第 4 步,Java 的同學應該知道,這就是調用 main 函數。
有點跑題了……我們繼續(xù)以 pthread_create 為例看看內核吧。
其實,pthread_create 離內核還有一小段距離,就是 glibc(nptl/pthread_create.c)。創(chuàng)建線程最終是通過 clone 系統(tǒng)調用實現的,我們不關心 glibc 的細節(jié)(否則又跑偏了),就看看它跟直接 clone 的不同。
以下關于線程的討論從書里摘抄過來。
const?int?clone_flags?=?(CLONE_VM?|?CLONE_FS?|?CLONE_FILES?|?CLONE_SYSVSEM
???|?CLONE_SIGHAND?|?CLONE_THREAD
???|?CLONE_SETTLS?|?CLONE_PARENT_SETTID
???|?CLONE_CHILD_CLEARTID
???|?0);
__clone?(&start_thread,?stackaddr,?clone_flags,?pd,?&pd->tid,?tp,?&pd->tid);
各個標志的說明如下表(這句話不是摘抄的。。。)。

與當前進程共享 VM、共享文件系統(tǒng)信息、共享打開的文件……看到這些我們就懂了,所謂的線程是這么回事。
Linux 實際上并沒有從本質上將進程和線程分開,線程又被稱為輕量級進程(Low Weight Process, LWP),區(qū)別就在于線程與創(chuàng)建它的進程(線程)共享內存、文件等資源。
完整的段落如下(雙引號擴起來的幾個段落),有興趣的同學可以詳細閱讀:
“?fork 傳遞至 _do_fork 的 clone_flags 參數是固定的,所以它只能用來創(chuàng)建進程,內核提供了另一個系統(tǒng)調用 clone,clone 最終也調用 _do_fork 實現,與 fork 不同的是用戶可以根據需要確定 clone_flags,我們可以使用它創(chuàng)建線程,如下(不同平臺下 clone 的參數可能不同):
SYSCALL_DEFINE5(clone,?unsigned?long,?clone_flags,?unsigned?long,?newsp,
?int?__user?*,?parent_tidptr,?int,?tls_val,?int?__user?*,?child_tidptr)
{
return?_do_fork(clone_flags,?newsp,?0,?parent_tidptr,?child_tidptr);
}
Linux 將線程當作輕量級進程,但線程的特性并不是由 Linux 隨意決定的,應該盡量與其他操作系統(tǒng)兼容,為此它遵循 POSIX 標準對線程的要求。所以,要創(chuàng)建線程,傳遞給 clone 系統(tǒng)調用的參數也應該是基本固定的。
創(chuàng)建線程的參數比較復雜,慶幸的是 pthread(POSIX thread)為我們提供了函數,調用pthread_create 即可,函數原型(用戶空間)如下。
int?pthread_create(pthread_t?*thread,?const?pthread_attr_t?*attr,
??????????????????????????void?*(*start_routine)?(void?*),?void?*arg);
第一個參數 thread 是一個輸出參數,線程創(chuàng)建成功后,線程的 id 存入其中,第二個參數用來定制新線程的屬性。新線程創(chuàng)建成功會執(zhí)行 start_routine 指向的函數,傳遞至該函數的參數就是arg。
pthread_create 究竟如何調用 clone 的呢,大致如下:
//來源:?glibc
const?int?clone_flags?=?(CLONE_VM?|?CLONE_FS?|?CLONE_FILES?|?CLONE_SYSVSEM
???|?CLONE_SIGHAND?|?CLONE_THREAD
???|?CLONE_SETTLS?|?CLONE_PARENT_SETTID
???|?CLONE_CHILD_CLEARTID
???|?0);
__clone?(&start_thread,?stackaddr,?clone_flags,?pd,?&pd->tid,?tp,?&pd->tid);
clone_flags 置位的標志較多,前幾個標志表示線程與當前進程(有可能也是線程)共享資源,CLONE_THREAD 意味著新線程和當前進程并不是父子關系。
clone 系統(tǒng)調用最終也通過 _do_fork 實現,所以它與創(chuàng)建進程的 fork 的區(qū)別僅限于因參數不同而導致的差異,有以下兩個疑問需要解釋。
首先,vfork 置位了 CLONE_VM 標志,導致新進程對局部變量的修改會影響當前進程。那么同樣置位了 CLONE_VM 的 clone,也存在這個隱患嗎?答案是沒有,因為新線程指定了自己的用戶棧,由 stackaddr 指定。copy_thread 函數的 sp 參數就是 stackaddr,childregs->sp = sp 修改了新線程的 pt_regs,所以新線程在用戶空間執(zhí)行的時候,使用的棧與當前進程的不同,不會造成干擾。那為什么 vfork 不這么做,請參考 vfork 的設計意圖。
其次,fork 返回了兩次,clone 也是一樣,但它們都是返回到系統(tǒng)調用后開始執(zhí)行,pthread_create 如何讓新線程執(zhí)行 start_routine 的?start_routine 是由 start_thread 函數間接執(zhí)行的,所以我們只需要清楚 start_thread 是如何被調用的。start_thread 并沒有傳遞給 clone 系統(tǒng)調用,所以它的調用與內核無關,答案就在 __clone 函數中。
為了徹底明白新進程是如何使用它的用戶棧和 start_thread 的調用過程,有必要分析 __clone 函數了,即使它是平臺相關的,而且還是由匯編語言寫的。
/*i386*/
ENTRY?(__clone)
movl?$-EINVAL,%eax
movl?FUNC(%esp),%ecx?/*?no?NULL?function?pointers?*/
testl?%ecx,%ecx
jz?SYSCALL_ERROR_LABEL
movl?STACK(%esp),%ecx?/*?no?NULL?stack?pointers?*/????//1
testl?%ecx,%ecx
jz?SYSCALL_ERROR_LABEL
andl?$0xfffffff0,?%ecx??/*對齊*/????//2
subl?$28,%ecx
movl?ARG(%esp),%eax?/*?no?negative?argument?counts?*/
movl?%eax,12(%ecx)
movl?FUNC(%esp),%eax
movl?%eax,8(%ecx)
movl?$0,4(%ecx)
pushl?%ebx????//3
pushl?%esi
pushl?%edi
movl?TLS+12(%esp),%esi????//4
movl?PTID+12(%esp),%edx
movl?FLAGS+12(%esp),%ebx
movl?CTID+12(%esp),%edi
movl?$SYS_ify(clone),%eax
movl?%ebx,?(%ecx)????//5
int?$0x80????//6
popl?%edi????//7
popl?%esi
popl?%ebx
test?%eax,%eax????//8
jl?SYSCALL_ERROR_LABEL
jz?L(thread_start)
ret????//9
L(thread_start):????//10
movl?%esi,%ebp?/*?terminate?the?stack?frame?*/
testl?$CLONE_VM,?%edi
je?L(newpid)
L(haspid):
call?*%ebx
/*…*/
以 __clone (&start_thread, stackaddr, clone_flags, pd, &pd->tid, tp, &pd->tid) 為例,
FUNC(%esp) 對應 &start_thread,
STACK(%esp) 對應 stackaddr,
ARG(%esp) 對應 pd(新進程傳遞給 start_thread 的參數)。
第 1 步,將新進程的棧 stackaddr 賦值給 ecx,確保它的值不為 0。 第 2 步,將 pd、&start_thread 和 0 存入新線程的棧,對當前進程的棧無影響。 第 3 步,將當前進程的三個寄存器的值入棧,esp寄存器的值相應減12。 第 4 步,準備系統(tǒng)調用,其中將 FLAGS+12(%esp) 存入 ebx,對應 clone_flags,將clone 的系統(tǒng)調用號存入 eax。 第 5 步,將 clone_flags 存入新進程的棧中。 第 6 步,使用 int 指令發(fā)起系統(tǒng)調用,交給內核創(chuàng)建新線程。截止到此處,所有的代碼都是當前進程執(zhí)行的,新線程并沒有執(zhí)行。 從第 7 步開始的代碼,當前進程和新線程都會執(zhí)行。對當前進程而言,程序將它第 3 步入棧的寄存器出棧。但對新線程而言,它是從內核的 ret_from_fork 執(zhí)行的,切換到用戶態(tài)后,它的棧已經成為 stackaddr 了,所以它的 edi 等于 clone_flags,esi 等于 0,ebx 等于&start_thread。 系統(tǒng)調用的結果由 eax 返回,第 8 步判斷 clone 系統(tǒng)調用的結果,對當前進程而言,clone 系統(tǒng)調用如果成功返回的是新線程在它的 pid namespace 中的 id,大于 0,所以它執(zhí)行 ret 退出 __clone 函數。對新線程而言,clone 系統(tǒng)調用的返回值等于 0,所以它執(zhí)行L(thread_start) 處的代碼。clone_flags 的 CLONE_VM 標志被置位的情況下,會執(zhí)行 call *%ebx,ebx 等于 &start_thread,至此 start_thread 得到了執(zhí)行,它又調用了提供給pthread_create 的 start_routine,結束。”
如此看來,Java?→?JVM?→?glibc?→?內核,好像也沒有多遠。
作者介紹
姜亞華,《精通 Linux 內核——智能設備開發(fā)核心技術》的作者,一直從事與 Linux 內核和 Linux 編程相關的工作,研究內核代碼十多年,對多數模塊的細節(jié)如數家珍。曾負責華為手機 Touch、Sensor 的驅動和軟件優(yōu)化(包括 Mate、榮耀等系列),以及 Intel 安卓平臺 Camera 和 Sensor 的驅動開發(fā)(包括 Baytrail、Cherrytrail、Cherrytrail CR、Sofia 等)?,F負責 DMA、Interrupt、Semaphore 等模塊的優(yōu)化與驗證(包括 Vega、Navi 系列和多款 APU 產品)。
往期推薦
???????



