歡迎您光臨本站 註冊首頁

Java 離 Linux 核心有多遠?

←手機掃碼閱讀     admin @ 2020-07-14 , reply:0
作者:姜亞華(@二如公子 ),《精通 Linux 核心——智慧裝置開發核心技術》的作者,一直從事與 Linux 內核和 Linux 程式設計相關的工作,研究核心程式碼十多年,對多數模組的細節如數家珍。曾負責華為手機 Touch、Sensor 的驅動和軟體最佳化(包括 Mate、榮耀等系列),以及 Intel 安卓平臺 Camera 和 Sensor 的驅動開發(包括 Baytrail、Cherrytrail、Cherrytrail CR、Sofia 等)。現負責 DMA、Interrupt、Semaphore 等模組的最佳化與驗證(包括 Vega、Navi 系列和多款 APU 產品)。

往期回顧:

雲端計算時代,容器底層 cgroup 如何實現資源分組?

雲端計算時代,容器底層 cgroup 的程式碼實現分析

雲端計算時代,容器底層 cgroup 如何使用

在往期的文章中,姜亞華老師給大家分享了核心中的重要功能 —— 容器底層 cgroup 的相關知識,不少網友表示核心實在太高深,程式碼也較難理解。本期內容我們將站在非核心開發者的角度,給大家介紹應用和系統工程師如何梳理 Linux 核心程式碼。希望大家讀完之後能有所收穫,也希望更多的開發者能夠關注到核心開發領域,畢竟連祖師爺 Linus 都表示核心維護者要後繼無人了呀!

Java 離內核有多遠?

測試環境版本資訊:

Ubuntu

(lsb_release -a)

Distributor ID: Ubuntu

Description:    Ubuntu 19.10

Release:        19.10

Linux

(uname -a)

Linux yahua 5.5.5 #1 SMP … x86_64 x86_64 x86_64 GNU/Linux

Java

Openjdk jdk14

玩內核的人怎麼也懂 Java?這主要得益於我學校的 Java 課程和畢業那會在華為做 Android 手機的經歷,幾個模組從 APP/Framework/Service/HAL/Driver 掃過一遍,自然對 Java 有所瞭解。

每次提起 Java,我都會想到一段有趣的經歷。剛畢業到部門報到第一個星期,部門領導(在華為算是 Manager)安排我們熟悉 Android。我花了幾天寫了個 Android 遊戲,有些類似連連看那種。開週會的時候,領導看到我的演示後,一臉不悅,質疑我的直接領導(在華為叫 PL,Project Leader)沒有給我們講明白部門的方向。

emm,我當時確實沒明白所謂的熟悉 Android 是該幹啥,後來 PL 說,是要熟悉 xxx 模組,APP 只是其中一部分。話說如果當時得到的是肯定,也許我現在就是一枚 Java 工程師了(哈哈手動狗頭)。

從 launcher 說起

世界上最遠的距離,是咱倆坐隔壁,我在看底層協議,而你在研究 spring……如果想拉近咱倆的距離,先下載 openjdk 原始碼(openjdk),然後下載 glibc(glibc),再下載核心原始碼(kernel)。

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 查詢設定環境變數,比如 JVM 的路徑(下面的變數 jvmpath),以我的平臺為例,就是 /usr/lib/jvm/java-14-openjdk-amd64/lib/server/libjvm.so,window 平臺可能就是 libjvm.dll。

其次,呼叫 LoadJavaVM 載入 JVM,就是 libjvm.so 檔案,然後找到建立 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 涉及動態連結,簡單理解就是 libjvm.so 包含 JNI_CreateJavaVM、JNI_GetDefaultJavaVMInitArgs 和 JNI_GetCreatedJavaVMs 的定義,動態連結完成後,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 <JNI_GetDefaultJavaVMInitArgs@@SUNWprivate_1.1>:
 00000000008faa20 <JNI_GetCreatedJavaVMs@@SUNWprivate_1.1>:
 00000000009098e0 <JNI_CreateJavaVM@@SUNWprivate_1.1>:

openjdk 原始碼裡有這些實現的(hotspot/share/prims/下),有興趣的同學可以繼續鑽研。

最後,呼叫 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 實現的。此處就可以進入核心了,但是我們還是先繼續看看 JVM。ThreadJavaMain 直接呼叫了 JavaMain,所以這裡的邏輯就是,如果建立執行緒成功,就由新執行緒執行 JavaMain,否則就知道在當前程序執行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,也是透過動態連結找到函式(定義在 hotspot/share/prims/ 下),然後呼叫它。

第 3 和第 4 步,Java 的同學應該知道,這就是呼叫 main 函式。

有點跑題了……我們繼續以 pthread_create 為例看看核心吧。

其實,pthread_create 離核心還有一小段距離,就是 glibc(nptl/pthread_create.c)。建立執行緒最終是透過 clone 系統呼叫實現的,我們不關心 glibc 的細節(否則又跑偏了),就看看它跟直接 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);

各個標誌的說明如下表(這句話不是摘抄的。。。)。

標誌

描述

CLONE_VM

與當前程序共享VM

CLONE_FS

共享檔案系統資訊

CLONE_FILES

共享開啟的檔案

CLONE_PARENT

與當前程序共有同樣的父程序

CLONE_THREAD

與當前程序同屬一個執行緒組,也意味著建立的是執行緒

CLONE_SYSVSEM

共享sem_undo_list

……

……

與當前程序共享 VM、共享檔案系統資訊、共享開啟的檔案……看到這些我們就懂了,所謂的執行緒是這麼回事。

Linux 實際上並沒有從本質上將程序和執行緒分開,執行緒又被稱為輕量級程序(Low Weight Process, LWP),區別就在於執行緒與建立它的程序(執行緒)共享記憶體、檔案等資源。

完整的段落如下(雙引號擴起來的幾個段落),有興趣的同學可以詳細閱讀:

fork 傳遞至 _do_fork 的 clone_flags 引數是固定的,所以它只能用來建立程序,核心提供了另一個系統呼叫 clone,clone 最終也呼叫 _do_fork 實現,與 fork 不同的是使用者可以根據需要確定 clone_flags,我們可以使用它建立執行緒,如下(不同平臺下 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 隨意決定的,應該儘量與其他作業系統相容,為此它遵循 POSIX 標準對執行緒的要求。所以,要建立執行緒,傳遞給 clone 系統呼叫的引數也應該是基本固定的。

建立執行緒的引數比較複雜,慶幸的是 pthread(POSIX thread)為我們提供了函式,呼叫pthread_create 即可,函式原型(使用者空間)如下。

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                           void *(*start_routine) (void *), void *arg);

第一個引數 thread 是一個輸出引數,執行緒建立成功後,執行緒的 id 存入其中,第二個引數用來定製新執行緒的屬性。新執行緒建立成功會執行 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 系統呼叫最終也透過 _do_fork 實現,所以它與建立程序的 fork 的區別僅限於因引數不同而導致的差異,有以下兩個疑問需要解釋。

首先,vfork 置位了 CLONE_VM 標誌,導致新程序對區域性變數的修改會影響當前程序。那麼同樣置位了 CLONE_VM 的 clone,也存在這個隱患嗎?答案是沒有,因為新執行緒指定了自己的使用者棧,由 stackaddr 指定。copy_thread 函式的 sp 引數就是 stackaddr,childregs->sp = sp 修改了新執行緒的 pt_regs,所以新執行緒在使用者空間執行的時候,使用的棧與當前程序的不同,不會造成幹擾。那為什麼 vfork 不這麼做,請參考 vfork 的設計意圖。

其次,fork 返回了兩次,clone 也是一樣,但它們都是返回到系統呼叫後開始執行,pthread_create 如何讓新執行緒執行 start_routine 的?start_routine 是由 start_thread 函式間接執行的,所以我們只需要清楚 start_thread 是如何被呼叫的。start_thread 並沒有傳遞給 clone 系統呼叫,所以它的呼叫與核心無關,答案就在 __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 步,準備系統呼叫,其中將 FLAGS+12(%esp) 存入 ebx,對應 clone_flags,將clone 的系統呼叫號存入 eax。
  • 第 5 步,將 clone_flags 存入新程序的棧中。
  • 第 6 步,使用 int 指令發起系統呼叫,交給核心建立新執行緒。截止到此處,所有的程式碼都是當前程序執行的,新執行緒並沒有執行。
  • 第 7 步開始的程式碼,當前程序和新執行緒都會執行。對當前程序而言,程式將它第 3 步入棧的暫存器出棧。但對新執行緒而言,它是從內核的 ret_from_fork 執行的,切換到使用者態後,它的棧已經成為 stackaddr 了,所以它的 edi 等於 clone_flags,esi 等於 0,ebx 等於&start_thread。
  • 系統呼叫的結果由 eax 返回,第 8 步判斷 clone 系統呼叫的結果,對當前程序而言,clone 系統呼叫如果成功返回的是新執行緒在它的 pid namespace 中的 id,大於 0,所以它執行 ret 退出 __clone 函式。對新執行緒而言,clone 系統呼叫的返回值等於 0,所以它執行L(thread_start) 處的程式碼。clone_flags 的 CLONE_VM 標誌被置位的情況下,會執行 call *%ebx,ebx 等於 &start_thread,至此 start_thread 得到了執行,它又呼叫了提供給pthread_create 的 start_routine,結束。

如此看來,Java JVM glibc 核心,好像也沒有多遠。

 

往期回顧:

雲端計算時代,容器底層 cgroup 如何實現資源分組?

雲端計算時代,容器底層 cgroup 的程式碼實現分析

雲端計算時代,容器底層 cgroup 如何使用


[admin ]

來源:OsChina
連結:https://www.oschina.net/question/2918182_2317611
Java 離 Linux 核心有多遠?已經有47次圍觀

http://coctec.com/news/soft/show-post-242993.html