Java 离 Linux 内核有多远?
点击上方蓝色“程序猿DD”,选择“设为星标”
回复“资源”获取独家整理的学习资料!
在往期的文章中,给大家分享了内核中的重要功能 —— 容器底层 cgroup 的相关知识,不少读者表示内核实在太高深,代码也较难理解。本期内容我们将站在非内核开发者的角度,给大家介绍应用和系统工程师如何梳理 Linux 内核代码。
Java 离内核有多远?
测试环境版本信息:
玩内核的人怎么也懂 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 源码,然后下载 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 查找设置环境变量,比如 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 @SUNWprivate_1.1>:
00000000008faa20 @SUNWprivate_1.1>:
00000000009098e0 @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);
各个标志的说明如下表(这句话不是摘抄的。。。)。
与当前进程共享 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 → 内核,好像也没有多远。
作者介绍
姜亚华,《精通 Linux 内核——智能设备开发核心技术》的作者,一直从事与 Linux 内核和 Linux 编程相关的工作,研究内核代码十多年,对多数模块的细节如数家珍。曾负责华为手机 Touch、Sensor 的驱动和软件优化(包括 Mate、荣耀等系列),以及 Intel 安卓平台 Camera 和 Sensor 的驱动开发(包括 Baytrail、Cherrytrail、Cherrytrail CR、Sofia 等)。现负责 DMA、Interrupt、Semaphore 等模块的优化与验证(包括 Vega、Navi 系列和多款 APU 产品)。
往期推荐
? ? ? ?