从java线程到内存空间

来源:互联网 发布:网络大电影的运作流程 编辑:程序博客网 时间:2024/04/28 17:09

1、实现线程的三种方式


 

    使用内核线程实现

    内核线程(KernelThread KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操作调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(LightWeight ProcessLWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间11的关系称为一对一的线程模型。轻量级进程要消耗一定的内核资源(如内核线程的栈空间),而且系统调用的代价相对较高,因此一个系统支持轻量级进程的数量是有限的。

   使用用户线程实现

    广义上来讲,一个线程只要不是内核线程,那就可以认为是用户线程(UserThreadUT),而狭义的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现,用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1N的关系称为一对多的线程模型。

    使用用户线程的优势在于不需要系统内核的支援,劣势在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理,因而使用用户线程实现的程序一般都比较复杂,现在使用用户线程的程序越来越少了。JavaRuby层语言都曾经使用过用户线程,最终又都放弃了使用它。

     混合实现

      混合环境下,既存在用户线程,又存在轻量级进程。用户线程还是完全建立在用户空间中,而操作系统所支持的轻量级进程则作为用户线程和内核线程之间的桥梁。这种混合模式下,用户线程与轻量级进程的数量比是不定的,是MN的关系。许多Unix系列的系统,都提供了MN的线程模型实现。

 

2java线程与操作系统之间的调度关系

 

      以前的老java自己实现了线程库,也就是说java的线程并不和操作系统的线程对应,jvm在操作系统上面是一个进程,当这个进程被操作系统调度到后,jvm内部实现的线程库再调度java线程,这就是最初的多对一的关系(使用用户线程实现),为什么是这样呢?考虑到以前的操作系统内核,比如linux,在以前都不直接支持线程,用户线程和内核线程是多对一的关系,solaris一度也是这样,所以java当然心有余而力不足了,你操作系统都不能完美支持线程,你让我实现不是难为我吗?在那个年代,java多线程的调度完全是自主的,操作系统根本不知道java是多线程的,调度策略完全自己实现,单cpu下肯定是分时的,多cpu下就看jvm会不会建立多cpu上的多jvm实例了。
     
到了后来,操作系统内核纷纷都支持了多线程(windows开始就支持),那么java也要考虑推卸一些责任了,这样java线程就和操作系统线程一一对应(操作系统调度,使用内核线程实现)或多多对应了(操作系统和JVM一起调度,混合实现),这个时候,如果是一一对应,那么线程的调度完全交给了操作系统内核。Linux从内核2.6开始使用NPTLNative POSIX ThreadLibrary)支持,但这时线程本质上还轻量级进程。 Java里的线程是由JVM来管理的,它如何对应到操作系统的线程是由JVM的实现来确定的。Linux 2.6上的HotSpot使用了NPTL机制,JVM线程跟内核轻量级进程(LWP)有一一对应的关系线程的调度完全交给了操作系统内核,当然jvm还保留一些策略足以影响到其内部的线程调度,举个例子,在linux下,只要一个Thread.run就会调用一个fork产生一个线程。Java线程在WindowsLinux平台上的实现方式,现在看来,是内核线程的实现方式。这种方式实现的线程,是直接由操作系统内核支持的——由内核完成线程切换,内核通过操纵调度器(Thread Scheduler)实现线程调度,并将线程任务反映到各个处理器上。内核线程是内核的一个分身,程序一般不直接使用该内核线程,而是使用其高级接口,即轻量级进程(LWP),也即线程。这看起来可能很拗口。其调度关系如下图所示

 

(说明:KLT即内核线程Kernel Thread,是内核分身。每一个KLT对应到进程P中的某一个轻量级进程LWP(也即线程),期间要经过用户态、内核态的切换,并在Thread Scheduler 下反应到处理器CPU上。)

 

      现在的Java使用的线程调度方式就是抢占式调度。每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获得执行时间的话,线程本身是没有办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会因为一个线程而导致整个进程阻塞。另外,抢占式线程调度也与线程优先级有关,不过线程优先级并不是很靠谱,原因是Java的线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算,虽然现在好多操作系统都有提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应。因此,不能太依赖优先级。

       JDK1.5中开始引入了Java.lang.Process相关的类,从而让JVM可以通过调用对应操作系统平台的api来创建进程。一般而言,使用进程是为了执行某项任务,而现代操作系统对于执行任务的计算资源的配置调度一般是以线程为对象。创建一个进程,操作系统实际上还是会为此创建相应的线程以运行一系列指令。当需要执行一个比较庞大的的复杂任务时,就可能需要创建多个线程以实现逻辑上并发执行的时候,线程的作用更加明显。

        概念上来说,一个 Java线程的创建根本上就对应了一个本地线程(nativethread)的创建,两者是一一对应的。问题是,本地线程执行的应该是本地代码,而 Java 线程提供的线程函数是 Java方法,编译出的是 Java字节码,所以可以想象的是, Java线程其实提供了一个统一的线程函数,该线程函数通过 Java虚拟机调用 Java线程方法 ,这是通过 Java本地方法调用来实现的。

 

3、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的源代码。

·        

·           

·            首先Java线程的start方法会创建一个本地线程(通过调用JVM_StartThread),该线程的线程函数是定义在jvm.cpp中thread_entry,由其创建真正的平台相关的本地线程,并进一步调用run方法。可以看到Java线程的run方法和普通方法没有本质区别,直接调用run方法不会报错,但是却是在当前线程执行,而不会创建一个新的线程。

·                    从上我们知道,Java线程是建立在系统本地线程之上的,是另一层封装,其面向 Java开发者提供的接口存在以下的局限性:

线程返回值

Java 没有提供方法来获取线程的退出返回值。实际上,线程可以有退出返回值,它一般被操作系统存储在线程控制结构中TCB),调用者可以通过检测该值来确定线程是正常退出还是异常终止。

线程的同步

Java 提供方法 Thread#Join()来等待一个线程结束,一般情况这就足够了,但一种可能的情况是,需要等待在多个线程上(比如任意一个线程结束或者所有线程结束才会返回),循环调用每个线程的 Join 方法是不可行的,这可能导致很奇怪的同步问题。

线程的 ID

Java 提供的方法 Thread#getID()返回的是一个简单的计数 ID,其实和操作系统线程的 ID 没有任何关系。

线程运行时间统计

Java 没有提供方法来获取线程中某段代码的运行时间的统计结果。虽然可以自行使用计时的方法来实现(获取运行开始和结束的时间,然后相减),但由于存在多线程调度方法的原因,无法获取线程实际使用的 CPU运算时间,因而必然是不准确的。

·         

·               通过对 Java进程和线程的分析,可以看出 Java对这两种操作系统资源进行了封装,使得开发人员只需关注如何使用这两种资源,而不必过多的关心细节。这样的封装一方面降低了开发人员的工作复杂度,提高了工作效率;另一方面由于封装屏蔽了操作系统本身的一些特性,因而在使用 Java进程线程时有了某些限制,这是封装不可避免的问题。语言的演化本就是决定需要什么不需要什么的过程,相信随着 Java的不断发展,封装的功能子集必然越来越完善。

·         

·        4、运行java应用的机器内存结构

·         

·        当机器运行java应用时,机器内存从逻辑上可以分为java堆和本地非java堆,下图是32位机器上内存的布局

·        

·        从图中可以看出,操作系统和C运行环境使用大约1GB空间,java堆使用了2GBJVM与本地推使用了1GB

·         

 

当我们new一个java对象时其所占的内存空间,比我们预想的要多,因为除了对象本身的信息外,jvm还会给对象分配一个元数据,用来描述对象的相关信息。

这个元数据包括三个部分:

Class:一个指向Class信息的地址,用来描述对象的类型。

Flags:一些标志,用来描述对象的状态,包括对象的hashcode,及对象是否为数组。

Lock:对象的同步信息,用来表明当前对象是否被同步。

 

下图是一个32位机器上一个Integer对象内存布局

 

对于数组对象,它的元数据里面多一个字段Size,用来表示数组的长度

其内存布局如下

 

 

对于更复杂一些的对象,比如对象内部又引用了其它对象,下面看下String对象的内存布局

 

·        

·        从上图中可以看出,一个包含8个字符(16个字节)的字符串对象,需要用224bits来描述String对象,用256bits来描述对象内的字符数组,从而一个占用480bits,60个字节。

·         

·        对于64位机器,对象的内存结构还是一样的,但是其所占的内存空间更大,如下图所示:

·        

·        下图为不同类型数据在32位机器和64位机器上的字段占用

·        

·        通过压缩算法(OOPs),64位机器上的field size可以压缩到32bits,从而使得对象头缩短到12字节。

0 0
原创粉丝点击