Wednesday, November 12, 2008

Java命令之下

        运行一个Java的Hello World程序,简单的"java Hello"命令究竟做了什么?为什么打印一个字符串需要这么长时间?javac是怎么实现的?它和java有什么关系?javadoc呢?Java虚拟机到底是个什么东西?它是如何运行起来的?本文试图解答这个问题。为简单起见,这里将Java虚拟机视作黑盒子,仅关注于Java命令。
        首先来看看虚拟机这个黑盒子。虚拟机实际上就是jvm.dll,在正式的JDK中有两种版本的虚拟机,对应于Java命令行的-client和-server参数,分别用来运行普通Java程序和服务器程序。-server版本采用了更强大的JIT技术,编译出的代码效率更高;相应地,编译时间也长,所以适合长期运行的服务器程序。实际上从源码还可以编译出另外一个被称为core的版本,这个版本不包括JIT,只有解释器,可以用来学习虚拟机的基本机制。用dumpbin /exports可以看到jvm.dll输出的函数,大约256个输出函数,它们的原型都可以在两个头文件javavm/export/jni.h和javavm/export/jvm.h中找到(写过Java本地库的应该对这两个头文件很熟悉)。我们只需关心下面几个函数,都定义在jni.h中:
               JNI_GetDefaultJavaVMInitArgs
               JNI_CreateJavaVM
               JNI_GetCreatedJavaVMs
函数的用途从名字一目了然,不必赘述。一个典型的创建虚拟机的过程如下:
               JDK1_1InitArgs args1_1;
               JNI_GetDefaultJavaVMInitArgs(&args1_1);
               Pseudo Code: Create a thread with stack size of args1_1.javaStackSize
               In the new thread:
               JavaVMInitArgs args;
               args.version = JNI_VERSION_1_2;
               args.nOptions = numOptions;
               args.options = options; 
                    // 从命令行和环境变量换取的传给虚拟机的信息
               JavaVM* vm; 
                    // 可以将它看作创建的虚拟机的标志
               JNIEnv* jni;
                    // 这个指针包括了很多函数指针,称为JNI(Java Native
                    // API)。接口它是Java和本地代码(C/C++等)的接口。
               JNI_CreateJavaVM(&vm,  &jni, &args);
               mainID = (*jni)->GetStaticMethodID(jni, mainClass, "main", "([Ljava/lang/String;)V");
                    // 获取Java main方法
               (*jni)->CallStaticVoidMethod(jni, mainClass, mainID, mainArgs);
                    // 运行Java main方法
               (*vm)->DestroyJavaVM();
很简单?这就是java、javac、javadoc等工具的主体!
        JavaVM里包括了5个函数的指针:
               DestroyJavaVM
               AttachCurrentThread
               DetachCurrentThread
               GetEnv
               AttachCurrentThreadAsDaemon
这5个函数和之前提到的三个函数在一起被称为Java Invocation API,具体信息可以在这里看到,不再详述。
        那么为什么需要JavaVM和JNIEnv两个结构呢?简单的说,是为了解决多线程的问题。Java Invocation API是在一个进程内可以被多个线程同时使用了,它只包括了一些简单的功能。JNIEnv从表面上看来只是一些函数指针,实际上内部有很多数据结构,其中部分数据必须为每个线程保存一份。所以JNIEnv指针是不能在线程之间共享的。这意味着你必须在每个使用JNI的线程里获取该线程的JNIEnv指针。(这就是GetEnv的目的)。
        现在来看java.exe等工具的main函数(见jdk/src/share/bin/main.c):
int main(int argc, char ** argv) {
    int margc;
    char** margv;
    const jboolean const_javaw = JNI_FALSE;

    margc = argc;
    margv = argv;

    return JLI_Launch(margc, margv,
           sizeof(const_jargs) / sizeof(char *), const_jargs,  
                    // 这是一种常用的获取数组大小的方法。通常用
                    // sizeof(const_jargs)/sizeof(const_jargs[0])更合适
                    // 另外一个获取数组大小的办法是用C++模板。
           sizeof(const_appclasspath) / sizeof(char *), const_appclasspath,
           FULL_VERSION, DOT_VERSION,
           (const_progname != NULL) ? const_progname : *margv,
           (const_launcher != NULL) ? const_launcher : *margv,
           (const_jargs != NULL) ? JNI_TRUE : JNI_FALSE,
           const_cpwildcard, const_javaw, const_ergo_class);
}
        可以看到,就是简单地调用JLI_Launch,这个函数属于JLI(Java Launcher Interface?)库,它完成了实际的运行Java虚拟机的任务。不同的工具通过两个参数const_jargs和const_progname来区分。可以在jdk/src/share/bin/defines.h中看到这两个常量的定义:
         static const char* const_jargs[] = JAVA_ARGS;
         static const char* const_progname = PROGNAME;
编译javac.exe的cl参数包括:
/DJAVA_ARGS="{ \"-J-ms8m\", \"com.sun.tools.javac.Main\",  }"
/DPROGNAME="\"javac\""
也就是说javac实际上是在启动Java虚拟机后,运行com.sun.tools.javac.Main。可以预料,javadoc的相应参数为com.sun.tools.javadoc.Main,其它可以此类推。这些工具的代码在langtools\src\share\classes\com\sun\tools下(openjdk没有这部分代码,请看JRL版本的JDK7),均为纯Java代码。
         好的,开始分析JLI的代码。
         JNI_Launch的实现可以在jdk/src/share/bin/java.[h|cc]中找到。JLI的另外一部分代码是平台相关的,在文件jdk/src/windows/bin/java_md.[h|cc]中。以后我们可以看到很多这种代码组织方式,即平台无关的代码在share目录下,平台相关的在平台名字(如windows,solarias等)的目录下,并在相应的文件名后加上_md后缀。这种模式清晰易读,值得学习。把JNI_Launcher的代码浓缩一下:
         JLI_Launch(int argc, char ** argv,        /* main argc, argc */
               int jargc, const char** jargv,              /* java args */
               int appclassc, const char** appclassv,  /* app classpath */
               const char* fullversion,                /* full version defined */
               const char* dotversion,                 /* dot version defined */
               const char* pname,                      /* program name */
               const char* lname,                      /* launcher name */
               jboolean javaargs,                      /* JAVA_ARGS */
               jboolean cpwildcard,                    /* classpath wildcard*/
               jboolean javaw,                         /* windows-only javaw */
               jint ergo                               /* ergonomics class policy */
         ) {
              InitLauncher(javaw);    
                    // 调用InitCommonControlsEx和JLI_SetTraceLauncher
              CreateExecutionEnvironment(&argc, &argv,
                               jrepath, sizeof(jrepath),
                               jvmpath, sizeof(jvmpath),
                               original_argv);
                    // 1. 首先查找包括java.dll的目录,即jrepath。
                    //     假设java.exe所在的上一层目录为JAVA_HOME,
                    //     那么jrepath可能为JAVA_HOME或者
                    //     JAVA_HOME\jre。
                    // 2. 打开jrepath\lib\i386\jvm.cfg,这个文件包括了所有
                    //     支持的虚拟机列表。一个典型的配置为:
                    //              -client KNOWN
                    //              -server KNOWN
                    //      你可以猜到他们的含义了,:)
                    // 3. 根据当前命令行参数(-client和-server)决定应该
                    //     载入的虚拟机(jvm.dll)的全路径,通过jvmpath返回。
              LoadJavaVM(jvmpath, &ifn); 
                    // 载入jvm.dll库,并取得JNI_CreateJavaVM和
                    // JNI_GetDefaultJavaVMInitArgs的指针。
              ParseArguments(&argc, &argv, &jarfile, &classname, &ret, jvmpath);
                    // 顾名思义,解析命令行参数。将命令行参数转换为
                    // 虚拟机可以理解的JavaVMOption(定义见
                    // hotspot\src\share\vm\prims\jni.h
              ContinueInNewThread(&ifn, argc, argv, jarfile, classname, ret);
                    // 调用jvm.dll的JNI_GetDefaultJavaVMInitArgs获取
                    // javaStackSize参数,然后创建一个初始栈大小为
                    // javaStackSize的线程,其主函数为JavaMain。主线程的
                    // 使命到此就结束了,其等待新线程结束后退出。
         }
        在钻入JavaMain的代码之前,先来看看JLI_SetTraceLauncher,它定义在
jdk\src\share\bin\jli_util.[h|cc]中。相关的还有两个函数:JLI_IsTraceLauncher和JLI_TraceLauncher。JLI_SetTraceLauncher检查环境变量_JAVA_LAUNCHER_DEBUG是否被定义,如是设置全局变量_launcher_debug为JNI_TRUE,表明输出各种调试信息。JLI_TraceLauncher实际上就是printf,在_launcher_debug不为true时直接返回,否则在标准输出上打印调试信息。尝试运行命令"set _JAVA_LAUNCHER_DEBUG=1"后运行"java Hello"。
        JavaMain的代码框架如下:
        JavaMain(JavaMainArgs *args) {
            InitializeJVM(&vm, &env, &ifn);
                     // 调用JNI_CreateJavaVM创建虚拟机,
                     // 返回相应的vm和env值。
            mainClassName = NewPlatformString(env, classname);
                     // 调用env的接口构建一个Java String对象。
            classname = (char *)(*env)->GetStringUTFChars(env, mainClassName, 0);
                     // 转换为UTF8编码
            mainClass = LoadClass(env, classname);
                     // 调用env的FindClass载入main class。
            mainID = (*env)->GetStaticMethodID(env, mainClass, "main",
                                       "([Ljava/lang/String;)V");
                     // 找到main方法。
            (*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);
                     // 调用main方法。
        }
     在结束本篇之前,让我们来看看NewPlatformString的实现,即如何在Java虚拟机中创建一个Java String对象。这并不是一个简单的任务:
        static jstring NewPlatformString(JNIEnv *env, char *s) {
            enc = getPlatformEncoding(env);
            ary = (*env)->NewByteArray(env, len);
            cls = (*env)->FindClass(env, "java/lang/String");
            mid = (*env)->GetMethodID(env, cls, "",
                                          "([BLjava/lang/String;)V");
                      // 这是找到String(Byte[] ba)方法。
            str = (*env)->NewObject(env, cls, mid, ary, enc);
            return str;
        }
     你可以看到这里调用了多个JNI接口来完成创建Java String这个“简单的任务”。下一篇我们将讲述JNI接口的一些细节。

No comments: