Java 线程实现原理
Linux 操作系统中创建线程的方式
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
| 参数名 | 参数定义 | 参数解释 |
|---|---|---|
pthread_t *thread |
传出参数,调用之后会传出被创建线程的id | |
const pthread_attr_t *attr |
线程属性 | 一般传 NULL,保持默认属性 |
void *(*start_routine) (void *) |
线程启动后的主体函数 | |
void *arg |
主体函数的参数 |
#include <pthread.h>
#include <stdio.h>
pthread_t pid;
void* thread_entity(void* arg) {
printf("run\n");
}
int main() {
pthread_create(&pid, NULL, thread_entity, NULL);
// 防止子线程还没启动,就结束了
usleep(100);
printf("main\n");
return 0;
}
Thread#start0 实现原理
package java.lang;
public class Thread implements Runnable {
private static native void registerNatives();
static {
registerNatives();
}
// JVM 中并没有 Java_java_lang_Thread_start0 方法,因此这个方法是由 registerNatives() 动态注册的
private native void start0();
}
注册的信息定义在这里src/java.base/share/native/libjava/Thread.c中:
static JNINativeMethod methods[] = {
{"start0", "()V", (void *)&JVM_StartThread},
//......
};
jvm.cpp:
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
// ... 传给构造函数的 entry_point 是 thread_entry 静态方法
native_thread = new JavaThread(&thread_entry, sz);
// ...
}
构造方法在 Thread.cpp:
JavaThread::JavaThread(ThreadFunction entry_point, size_t stack_sz) : JavaThread() {
// ... 传进来的 thread_entry 静态方法设置为 entry_point
set_entry_point(entry_point);
// ... 启动线程
os::create_thread(this, thr_type, stack_sz);
}
os_linux.cpp:
// 实现 run() 方法回调
static void *thread_native_entry(Thread *thread) {
// ...
thread->call_run();
// ...
}
// 启动线程
bool os::create_thread(Thread* thread, ThreadType thr_type,
size_t req_stack_size) {
// ... pthread 创建线程
int ret = pthread_create(&tid, &attr, (void* (*)(void*)) thread_native_entry, thread);
// ...
}
thread.cpp:
void Thread::call_run() {
// ... 实际类型是 JavaThread,所以调用 JavaThread::run()
this->run();
// ...
}
void JavaThread::run() {
// ...
thread_main_inner();
}
void JavaThread::thread_main_inner() {
// ... entry_point 是 thread_entry 静态方法 (构造方法中传进来的)
this->entry_point()(this, this);
// ...
}
jvm.cpp:
static void thread_entry(JavaThread* thread, TRAPS) {
HandleMark hm(THREAD);
Handle obj(THREAD, thread->threadObj());
JavaValue result(T_VOID);
// 回调 Java 方法
JavaCalls::call_virtual(&result, // 返回对象
obj, // 实例对象
vmClasses::Thread_klass(), // 类
vmSymbols::run_method_name(), // 方法名
vmSymbols::void_method_signature(), // 方法签名
THREAD);
}
vmSymbols.hpp :
// 回调的方法名写死为 "run"
template(run_method_name, "run")
// 方法签名写死为 无参无返回值
template(void_method_signature, "()V")
javaCalls.cpp中回调 Java 方法:
void JavaCalls::call_virtual(JavaValue* result, Klass* spec_klass, Symbol* name, Symbol* signature, JavaCallArguments* args, TRAPS) {
// ... 不在往下深入了
JavaCalls::call(result, method, args, CHECK);
}
线程模型
所以 Java 是一对一的线程模型。
在 Java中,基本我们说的线程(Thread)实际上应该叫作“用户线程”,而对应到操作系统,还有另外一种线程叫作“内核线程”。 用户线程和内核线程之间必然存在某种关系,多对一模型、一对一模型和多对多模型
多对一线程模型
多个用户线程对应到同一个内核线程上,线程的创建、调度、同步的所有细节全部由进程的用户空间线程库来处理。
-
优点: 用户线程的很多操作对内核来说都是透明的,不需要用户态和内核态的频繁切换,使线程的创建、 调度、同步等非常快;
-
缺点: 由于多个用户线程对应到同一个内核线程,如果其中一个用户线程阻塞,那么该其他用户线程也无法执行; 内核并不知道用户态有哪些线程,无法像内核线程一样实现较完整的调度、优先级等;虽然多对一模型允许开发人员创建任意多的用户线程,但是由于内核只能一次调度一个线程,所以并未增加并发性
一对一模型
即一个用户线程对应一个内核线程,内核负责每个线程的调度
- 优点: (比如JVM几乎把所有对线程的操作都交给了内核)实现线程模型的容器(jvm)简单,所以我们经常听到在 java 中使用线程一定要慎重就是这个原因;
- 缺点: 对用户线程的大部分操作都会映射到内核线程上,引起用户态和内核态的频繁切换; 内核为每个线程都映射调度实体,如果系统出现大量线程,会对系统性能有影响;
多对多模型
早期 JVM 尝试过,后来一律改成一对一模型
多路复用多个用户级线程到同样数量或更少数量的内核线程。内核线程的数量可能与特定应用程序或特定机器有关(应用程序在多处理器上比在单处理器上可能分配到更多数量的线程)。
- 优点:开发人员可以创建任意多的用户线程,并且相应内核线程能在多处理器系统上并发执行。而且,当一个线程执行阻塞系统调用时,内核可以调度另一个线程来执行。
多对多模型的一种变种仍然多路复用多个用户级线程到同样数量或更少数量的内核线程,但也允许绑定某个用户线程到一个内核线程。这个变种,有时称为双层模型。
内核态与用户态
物理地址空间
理解虚拟地址空间还得从物理地址空间开始说起。我们知道内存就像一个数组,每个存储单元被分配了 一个地址,这个地址就是物理地址,所有物理地址构成的集合就是物理地址空间。物理地址也就是真实的地址,对应真实的那个内存条。
虚拟地址空间
引入虚拟地址之后,对于每一个进程,操作系统提供一种假象,让每个进程感觉自己拥有一个巨大的连续的内存可以使用,这个虚拟的空间甚至还可以比内存的容量还大。这个“假象”就是虚拟地址空间。虚拟地址是面向每个进程的,只是一个“假象”罢了。 CPU 使用虚拟地址向内存寻址,通过专用的内存管理单元(MMU)硬件把虚拟地址转换为真实的物理地址 intel x86 CPU 有四种不同的执行级别 0-3,linux 只使用了其中的 0 级和 3 级分别来表示内核态和用户态, 所谓的内核态和用户态其实仅仅是 CPU 的一个权限而已
用户态切换到内核态的 3 种方式:1. 系统调用 2. 异常(这个异常不是java当中的异常) 3. 外围设备的中断
其实站在 java 程序员的角度只需要关注系统调用,因为系统调用可以认为是用户进程主动发起的,比如调用线程的 park() 方法会对应到一个 os 的一个函数,从而使当前线程进入了内核态;再比如遇到 synchronized 关键字如果是重量锁则会调用 pthread_mutex_lock() 这样我们的线程也会切换到内核态;当执行完系统调用切换到用户态; 所以,系统调用过程通常称为特权模式切换,而不是上下文切换。系统调用属于同进程内的 CPU 上下文切换
不是所有的系统函数都是系统调用(都会升级为内核态),是规定了一部分函数会产生系统调用,比如 MMAP 映射到系统区域(内存中有一部分区域专门划分出来——内核空间)时,为了安全 CPU 会升级到内核态。
切换
而在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先帮它设置好 CPU 寄存器和程序计数器。CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任 何任务前,必须的依赖环境
- CPU 寄存器是 CPU 内置的容量小、但速度极快的内存。
- 程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置
CPU 上下文切换
就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。 而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行
进程用户态和内核态的切换(系统调用,同进程内的 CPU 上下文切换)
进程既可以在用户空间运行,又可以在内核空间中运行。进程在用户空间运行时,被称为进程的用户 态,而陷入内核空间的时候,被称为进程的内核态。
从用户态到内核态的转变,需要通过系统调用来完成。比如,当我们查看文件内容时,就需要多次系统调用来完成:首先调用 open() 打开文件,然后调用 read() 读取文件内容,并调用 write() 将内容写到标 准输出,最后再调用 close()关闭文件
- 保存 CPU 寄存器里原来用户态的指令位
- 为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置。
- 跳转到内核态运行内核任务。
- 当系统调用结束后,CPU 寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程。
所以,一次系统调用的过程,其实是发生了两次 CPU 上下文切换。不过,需要注意的是,系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。
进程间的切换
进程上下文切换,是指从一个进程切换到另一个进程运行;而系统调用过程中一直是同一个进程在运行。
进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。 因此,进程的上下文切换就比系统调用时多了一步:在保存内核态资源(当前进程的内核状态和 CPU 寄存器)之前,需要先把该进程的用户态资源(虚拟内存、栈等)保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。
- 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流 分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行。
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行
进程内线程间的切换
前后两个线程属于不同进程。此时,因为资源不共享,所以切换过程就跟进程上下文切换是一样。
线程和进程外线程的切换
前后两个线程属于同一个进程。此时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
CAS 不会升级内核态,他仅仅是处理器提供的一个指令,速度非常快

浙公网安备 33010602011771号