简述Java中进程与线程的关系_动力节点Java学院整理
概述
进程与线程,本质意义上说, 是操作系统的调度单位,可以看成是一种操作系统 “资源” 。java 作为与平台无关的编程语言,必然会对底层(操作系统)提供的功能进行进一步的封装,以平台无关的编程接口供程序员使用,进程与线程作为操作系统核心概念的一部分无疑亦是如此。在 java 语言中,对进程和线程的封装,分别提供了 process 和 thread 相关的一些类。本文首先简单的介绍如何使用这些类来创建进程和线程,然后着重介绍这些类是如何和操作系统本地进程线程相对应的,给出了 java 虚拟机对于这些封装类的概要性的实现;同时由于 java 的封装也隐藏了底层的一些概念和可操作性,本文还对 java 进程线程和本地进程线程做了一些简单的比较,列出了使用 java 进程、线程的一些限制和需要注意的问题。
java 进程的建立方法
在 jdk 中,与进程有直接关系的类为 java.lang.process,它是一个抽象类。在 jdk 中也提供了一个实现该抽象类的 processimpl 类,如果用户创建了一个进程,那么肯定会伴随着一个新的 processimpl 实例。同时和进程创建密切相关的还有 processbuilder,它是在 jdk1.5 中才开始出现的,相对于 process 类来说,提供了便捷的配置新建进程的环境,目录以及是否合并错误流和输出流的方式。
java.lang.runtime.exec 方法和 java.lang.processbuilder.start 方法都可以创建一个本地的进程,然后返回代表这个进程的 java.lang.process 引用。
runtime.exec 方法建立一个本地进程
该方法在 jdk1.5 中,可以接受 6 种不同形式的参数传入。
process exec(string command) process exec(string [] cmdarray) process exec(string [] cmdarrag, string [] envp) process exec(string [] cmdarrag, string [] envp, file dir) process exec(string cmd, string [] envp) process exec(string command, string [] envp, file dir)
他们主要的不同在于传入命令参数的形式,提供的环境变量以及定义执行目录。
processbuilder.start 方法来建立一个本地的进程
如果希望在新创建的进程中使用当前的目录和环境变量,则不需要任何配置,直接将命令行和参数传入 processbuilder 中,然后调用 start 方法,就可以获得进程的引用。
process p = new processbuilder("command", "param").start();
也可以先配置环境变量和工作目录,然后创建进程。
processbuilder pb = new processbuilder("command", "param1", "param2"); map<string, string> env = pb.environment(); env.put("var", "value"); pb.directory("dir"); process p = pb.start();
可以预先配置 processbuilder 的属性是通过 processbuilder 创建进程的最大优点。而且可以在后面的使用中随着需要去改变代码中 pb 变量的属性。如果后续代码修改了其属性,那么会影响到修改后用 start 方法创建的进程,对修改之前创建的进程实例没有影响。
jvm 对进程的实现
在 jdk 的代码中,只提供了 processimpl 类来实现 process 抽象类。其中引用了 native 的 create, close, waitfor, destory 和 exitvalue 方法。在 java 中,native 方法是依赖于操作系统平台的本地方法,它的实现是用 c/c++ 等类似的底层语言实现。我们可以在 jvm 的源代码中找到对应的本地方法,然后对其进行分析。jvm 对进程的实现相对比较简单,以 windows 下的 jvm 为例。在 jvm 中,将 java 中调用方法时的传入的参数传递给操作系统对应的方法来实现相应的功能。如表 1
表 1. jdk 中 native 方法与 windows api 的对应关系
以 create 方法为例,我们看一下它是如何和系统 api 进行连接的。
在 processimple 类中,存在 native 的 create 方法,其参数如下:
private native long create(string cmdstr, string envblock, string dir, boolean redirecterrorstream, filedescriptor in_fd, filedescriptor out_fd, filedescriptor err_fd) throws ioexception;
在 jvm 中对应的本地方法如代码清单 1 所示 。
清单 1
jniexport jlong jnicall java_java_lang_processimpl_create(jnienv *env, jobject process, jstring cmd, jstring envblock, jstring dir, jboolean redirecterrorstream, jobject in_fd, jobject out_fd, jobject err_fd) { /* 设置内部变量值 */ …… /* 建立输入、输出以及错误流管道 */ if (!(createpipe(&inread, &inwrite, &sa, pipe_size) && createpipe(&outread, &outwrite, &sa, pipe_size) && createpipe(&errread, &errwrite, &sa, pipe_size))) { throwioexception(env, "createpipe failed"); goto catch; } /* 进行参数格式的转换 */ pcmd = (lptstr) jnu_getstringplatformchars(env, cmd, null); …… /* 调用系统提供的方法,建立一个 windows 的进程 */ ret = createprocess( 0, /* executable name */ pcmd, /* command line */ 0, /* process security attribute */ 0, /* thread security attribute */ true, /* inherits system handles */ processflag, /* selected based on exe type */ penvblock, /* environment block */ pdir, /* change to the new current directory */ &si, /* (in) startup information */ &pi); /* (out) process information */ … /* 拿到新进程的句柄 */ ret = (jlong)pi.hprocess; … /* 最后返回该句柄 */ return ret; }
可以看到在创建一个进程的时候,调用 windows 提供的 createpipe 方法建立输入,输出和错误管道,同时将用户通过 java 传入的参数转换为操作系统可以识别的 c 语言的格式,然后调用 windows 提供的创建系统进程的方式,创建一个进程,同时在 java 虚拟机中保存了这个进程对应的句柄,然后返回给了 processimpl 类,但是该类将返回句柄进行了隐藏。也正是 java 跨平台的特性体现,jvm 尽可能的将和操作系统相关的实现细节进行了封装,并隐藏了起来。
同样,在用户调用 close、waitfor、destory 以及 exitvalue 方法以后, jvm 会首先取得之前保存的该进程在操作系统中的句柄,然后通过调用操作系统提供的接口对该进程进行操作。通过这种方式来实现对进程的操作。
在其它平台下也是用类似的方式实现的,不同的是调用的对应平台的 api 会有所不同。
java 进程与操作系统进程
通过上面对 java 进程的分析,其实它在实现上就是创建了操作系统的一个进程,也就是每个 jvm 中创建的进程都对应了操作系统中的一个进程。但是,java 为了给用户更好的更方便的使用,向用户屏蔽了一些与平台相关的信息,这为用户需要使用的时候,带来了些许不便。
在使用 c/c++ 创建系统进程的时候,是可以获得进程的 pid 值的,可以直接通过该 pid 去操作相应进程。但是在 java 中,用户只能通过实例的引用去进行操作,当该引用丢失或者无法取得的时候,就无法了解任何该进程的信息。
当然,java 进程在使用的时候还有些要注意的事情:
1. java 提供的输入输出的管道容量是十分有限的,如果不及时读取会导致进程挂起甚至引起死锁。
2. 当创建进程去执行 windows 下的系统命令时,如:dir、copy 等。需要运行 windows 的命令解释器,command.exe/cmd.exe,这依赖于 windows 的版本,这样才可以运行系统的命令。
3. 对于 shell 中的管道 ‘ | '命令,各平台下的重定向命令符 ‘ > ',都无法通过命令参数直接传入进行实现,而需要在 java 代码中做一些处理,如定义新的流来存储标准输出,等等问题。
总之,java 中对操作系统的进程进行了封装,屏蔽了操作系统进程相关的信息。同时,在使用 java 提供创建进程运行本地命令的时候,需要小心使用。
一般而言,使用进程是为了执行某项任务,而现代操作系统对于执行任务的计算资源的配置调度一般是以线程为对象(早期的类 unix 系统因为不支持线程,所以进程也是调度单位,但那是比较轻量级的进程,在此不做深入讨论)。创建一个进程,操作系统实际上还是会为此创建相应的线程以运行一系列指令。特别地,当一个任务比较庞大复杂,可能需要创建多个线程以实现逻辑上并发执行的时候,线程的作用更为明显。因而我们有必要深入了解 java 中的线程,以避免可能出现的问题。本文下面的内容即是呈现 java 线程的创建方式以及它与操作系统线程的联系与区别。
java 创建线程的方法
实际上,创建线程最重要的是提供线程函数(回调函数),该函数作为新创建线程的入口函数,实现自己想要的功能。java 提供了两种方法来创建一个线程:
1. 继承 thread 类
class mythread extends thread{ public void run() { system.out.println("my thread is started."); } }
实现该继承类的 run 方法,然后就可以创建这个子类的对象,调用 start 方法即可创建一个新的线程:
mythread mythread = new mythread(); mythread.start();
2. 实现 runnable 接口
class myrunnable implements runnable{ public void run() { system.out.println("my runnable is invoked."); } }
实现 runnable 接口的类的对象可以作为一个参数传递到创建的 thread 对象中,同样调用 thread#start 方法就可以在一个新的线程中运行 run 方法中的代码了。
thread mythread = new thread( new myrunnable()); mythread.start();
可以看到,不管是用哪种方法,实际上都是要实现一个 run 方法的。 该方法本质是上一个回调方法。由 start 方法新创建的线程会调用这个方法从而执行需要的代码。 从后面可以看到,run 方法并不是真正的线程函数,只是被线程函数调用的一个 java 方法而已,和其他的 java 方法没有什么本质的不同。
java 线程的实现
从概念上来说,一个 java 线程的创建根本上就对应了一个本地线程(native thread)的创建,两者是一一对应的。 问题是,本地线程执行的应该是本地代码,而 java 线程提供的线程函数是 java 方法,编译出的是 java 字节码,所以可以想象的是, java 线程其实提供了一个统一的线程函数,该线程函数通过 java 虚拟机调用 java 线程方法 , 这是通过 java 本地方法调用来实现的。
以下是 thread#start 方法的示例:
public synchronized void start() { … start0(); … }
可以看到它实际上调用了本地方法 start0, 该方法的声明如下:
private native void start0();
thread 类有个 registernatives 本地方法,该方法主要的作用就是注册一些本地方法供 thread 类使用,如 start0(),stop0() 等等,可以说,所有操作本地线程的本地方法都是由它注册的 . 这个方法放在一个 static 语句块中,这就表明,当该类被加载到 jvm 中的时候,它就会被调用,进而注册相应的本地方法。
private static native void registernatives(); static{ registernatives(); }
本地方法 registernatives 是定义在 thread.c 文件中的。thread.c 是个很小的文件,定义了各个操作系统平台都要用到的关于线程的公用数据和操作,如代码清单 2 所示。
清单 2
jniexport void jnicall java_java_lang_thread_registernatives (jnienv *env, jclass cls){ (*env)->registernatives(env, cls, methods, array_length(methods)); } static jninativemethod methods[] = { {"start0", "()v",(void *)&jvm_startthread}, {"stop0", "(" obj ")v", (void *)&jvm_stopthread}, {"isalive","()z",(void *)&jvm_isthreadalive}, {"suspend0","()v",(void *)&jvm_suspendthread}, {"resume0","()v",(void *)&jvm_resumethread}, {"setpriority0","(i)v",(void *)&jvm_setthreadpriority}, {"yield", "()v",(void *)&jvm_yield}, {"sleep","(j)v",(void *)&jvm_sleep}, {"currentthread","()" thd,(void *)&jvm_currentthread}, {"countstackframes","()i",(void *)&jvm_countstackframes}, {"interrupt0","()v",(void *)&jvm_interrupt}, {"isinterrupted","(z)z",(void *)&jvm_isinterrupted}, {"holdslock","(" obj ")z",(void *)&jvm_holdslock}, {"getthreads","()[" thd,(void *)&jvm_getallthreads}, {"dumpthreads","([" thd ")[[" ste, (void *)&jvm_dumpthreads}, };
到此,可以容易的看出 java 线程调用 start 的方法,实际上会调用到 jvm_startthread 方法,那这个方法又是怎样的逻辑呢。实际上,我们需要的是(或者说 java 表现行为)该方法最终要调用 java 线程的 run 方法,事实的确如此。 在 jvm.cpp 中,有如下代码段:
jvm_entry(void, jvm_startthread(jnienv* env, jobject jthread)) … native_thread = new javathread(&thread_entry, sz); …
这里jvm_entry是一个宏,用来定义jvm_startthread 函数,可以看到函数内创建了真正的平台相关的本地线程,其线程函数是 thread_entry,如清单 3 所示。
清单 3
static void thread_entry(javathread* thread, traps) { handlemark hm(thread); handle obj(thread, thread->threadobj()); javavalue result(t_void); javacalls::call_virtual(&result,obj, klasshandle(thread,systemdictionary::thread_klass()), vmsymbolhandles::run_method_name(), vmsymbolhandles::void_method_signature(),thread); }
可以看到调用了 vmsymbolhandles::run_method_name 方法,这是在 vmsymbols.hpp 用宏定义的:
class vmsymbolhandles: allstatic { … template(run_method_name,"run") … }
至于 run_method_name 是如何声明定义的,因为涉及到很繁琐的代码细节,本文不做赘述。感兴趣的读者可以自行查看 jvm 的源代码。
图 1. java 线程创建调用关系图
综上所述,java 线程的创建调用过程如 图 1 所示,首先 , java 线程的 start 方法会创建一个本地线程(通过调用 jvm_startthread),该线程的线程函数是定义在 jvm.cpp 中的 thread_entry,由其再进一步调用 run 方法。可以看到 java 线程的 run 方法和普通方法其实没有本质区别,直接调用 run 方法不会报错,但是却是在当前线程执行,而不会创建一个新的线程。
java 线程与操作系统线程
从上我们知道,java 线程是建立在系统本地线程之上的,是另一层封装,其面向 java 开发者提供的接口存在以下的局限性:
线程返回值
java 没有提供方法来获取线程的退出返回值。实际上,线程可以有退出返回值,它一般被操作系统存储在线程控制结构中 (tcb),调用者可以通过检测该值来确定线程是正常退出还是异常终止。
线程的同步
java 提供方法 thread#join()来等待一个线程结束,一般情况这就足够了,但一种可能的情况是,需要等待在多个线程上(比如任意一个线程结束或者所有线程结束才会返回),循环调用每个线程的 join 方法是不可行的,这可能导致很奇怪的同步问题。
线程的 id
java 提供的方法 thread#getid()返回的是一个简单的计数 id,其实和操作系统线程的 id 没有任何关系。
线程运行时间统计,java 没有提供方法来获取线程中某段代码的运行时间的统计结果。虽然可以自行使用计时的方法来实现(获取运行开始和结束的时间,然后相减 ),但由于存在多线程调度方法的原因,无法获取线程实际使用的 cpu 运算时间,因而必然是不准确的。
总结
本文通过对 java 进程和线程的分析,可以看出 java 对这两种操作系统 “资源” 进行了封装,使得开发人员只需关注如何使用这两种 “资源” ,而不必过多的关心细节。这样的封装一方面降低了开发人员的工作复杂度,提高了工作效率;另一方面由于封装屏蔽了操作系统本身的一些特性,因而在使用 java 进程线程时有了某些限制,这是封装不可避免的问题。语言的演化本就是决定需要什么不需要什么的过程,相信随着 java 的不断发展,封装的功能子集的必然越来越完善。
推荐阅读
-
Java 中的Printstream介绍_动力节点Java学院整理
-
简述Java中进程与线程的关系_动力节点Java学院整理
-
Java多线程的调度_动力节点Java学院整理
-
Java Thread中start()和run()的区别_动力节点Java学院整理
-
Java中实现线程的三种方式及对比_动力节点Java学院整理
-
Java类之间的关系图_动力节点Java学院整理
-
Java的动态绑定与双分派_动力节点Java学院整理
-
Java 中的CharArrayReader 介绍_动力节点Java学院整理
-
Java 中的 BufferedWriter 介绍_动力节点Java学院整理
-
Java 中的FileReader和FileWriter源码分析_动力节点Java学院整理