HotSpot运行时概览

原文地址:http://openjdk.java.net/groups/hotspot/docs/RuntimeOverview.html


本文将介绍与HotSpot运行时的重要子系统相关的核心概念。将会涉及的主题如下:

命令行参数处理

有许多的命令行选项和环境变量可以影响到HotSpot虚拟机的性能。其中有些选项直接由启动器处理(例如-server-client),有些则是启动器先加工一下再交给虚拟机处理,但大部分选项还是由启动器直接交给虚拟机来处理。

主要有三类选项:标准选项非标准选项开发者选项。所有的JVM实现都要支持标准选项,即使不同的版本也要稳定支持(不管选项是否被弃用)。以-X开头的是非标准选项(并不能保证所有的JVM实现都支持该选项),非标准选项在后续的Java SDK版本有可能在你不知情的情况下就被修改。以-XX开头的是开发者选项,这些选项通常需要特定的系统环境支持,并且可能需要访问系统配置参数的权限;一般用户并不推荐使用。开发者选项也可能在你不知情的情况下被修改。

命令行标记可以设置虚拟机内部变量,这些变量都有默认值。对于布尔类型的变量,命令行标记出现与否就可以控制该变量的值。对于-XX选项控制的布尔变量,在变量名前面加上+或者-分别可以设置该变量的值为true或者false。对于那些需要额外参数的变量,有许多不同的方式进行参数传递。有些标记可以直接将参数放在标记名后面, 有些则需要用:或者=将标记名与参数隔开。很不幸,使用哪种传递方式要看具体是哪个标记。开发者标记(-XX标记)只有三种格式:-XX:+OptionName-XX:-OptionName,和-XX:OptionName=

大部分用整数来表示大小的选项都可以使用km或者g作后缀来表示多少K,多少M或者多少G。通常是用在控制内存大小的参数上。

虚拟机生命周期

下面来看下通用的java启动器与HotSpot虚拟机生命周期相关的东西。

启动器

在JavaSE中有几个HotSpot虚拟机的启动器,通常使用的是,Unix平台上的java命令,Windows平台上的javajavaw命令,不要和javaws混淆起来,javaws是一个基于网络的启动器。

虚拟机的启动操作如下:

  1. 解析命令行选项,一些选项由启动器自己处理,如-client-server,它们被用来选择具体要加载的虚拟机库,其他的选项则包装在JavaVMInitArgs再传给虚拟机处理。
  2. 如果没有在命令行显示指定堆的大小和编译器类型(client或者server)则需要计算一下。
  3. 设置环境变量,例如LD_LIBRARY_PATHCLASSPATH
  4. 如果命令行没有指定Main-Class则从JAR包的manifest读取。
  5. 启动一个新线程并调用JNI_CreateJavaVM来创建虚拟机。注意:如果在原来的线程创建虚拟机将会大大降低定制虚拟机的能力,例如在Windows平台上栈的大小会受限制。
  6. 一旦虚拟机被创建并初始化Main-Class就会被加载,并且启动器也能拿到Main-Classmain方法了。
  7. 通过CallStaticVoidMethod调用main方法。
  8. 执行完main方法后需要检查下是否有产生阻塞的异常,如果有则清除掉,并返回退出状态。通过调用ExceptionOccurred来进行清除,方法返回值为0表示成功,其他都表示失败。
  9. 通过调用DetachCurrentThread来detach main线程(译者注:main线程即当前线程,也就是第5步中启动的新线程,另,本地线程需要attach到VM才能由VM管理并且成为Java层面的线程),DetachCurrentThread方法会减小线程计数,可以确保main线程不会再在虚拟机中执行操作并且没有活动的Java栈帧。之后就可以安全地调用DestroyJavaVM了。

其中最重要的是JNI_CreateJavaVMDestroyJavaVM,下面我们就来看看这两个方法。

JNI_CreateJavaVM

这个本地方法所做的事情如下:

  1. 确保这个方法不会同时被两个线程调用,并且在同一个进程中不存在两个虚拟机实例。注意,在同一进程当中,如果虚拟机的初始化进行到了某一个点,那么也已经不能再创建另一个虚拟机了。这是因为目前的虚拟机会创建一些静态的数据结构,这数据结构不能被重复初始化。
  2. 检查JNI版本,初始化GC日志的输出流。一些OS模块初始化,例如随机数生成器,当前的pid,高精度的时间,内存页大小,保护页。
  3. 解析并存储传进来的参数和属性。初始化标准的Java系统属性。
  4. 使用解析后的参数和属性进一步初始化OS模块,包括同步,栈,内存,安全点页。然后会加载一些其它的库,例如libziplibhpilibjavalibthread,初始化信号处理器,初始化线程库。
  5. 日志输出流初始化。agent库(hprofjdi)初始化与启动。
  6. 线程状态与线程本地存储(TLS)初始化。TLS保存了许多线程操作需要的数据。
  7. 全局数据初始化。例如事件日志,OS同步原语,perfMemory(性能数据),chunkPool(内存分配器)。
  8. 现在我们可以创建线程了。Java版的main线程被创建并attach到当前这个OS线程。现在还不能将该线程添加到线程列表中。初始化Java层面的同步
  9. 初始化其余的全局模块,例如BootClassLoaderCodeCacheInterpreterCompilerJNISystemDictionaryUniverse。这时候已经没有退路了,不能在相同的进程地址空间再创建一个虚拟机了。
  10. main线程添加到线程列表中,这个操作需要第一次使用Thread_Lock。检查Universe中的一些全局数据结构。创建VMThread,所有重要的虚拟机操作都在这个线程执行。发送JVMTI事件通知当前状态。
  11. 加载并初始化java.lang.Stringjava.lang.Systemjava.lang.Threadjava.lang.ThreadGroupjava.lang.reflect.Methodjava.lang.ref.Finalizerjava.lang.Class和其他的系统类。这时候虚拟机已经完成初始化并且可以开始使用,但还没有开启全部功能。
  12. 启动信号处理器线程,初始化编译器并启动CompileBroker线程。启动StatSamplerWatcherThreads线程。这时候虚拟机已经打开所有功能,JNIEnv设置完成,虚拟机可以开始接收JNI请求了。

DestroyJavaVM

可以通过启动器调用这个方法来关闭虚拟机。当发生了严重的错误时,虚拟机自身也可以调用这个方法。

关闭虚拟机要经过以下步骤:

  1. 一直等待直到main线程是最后一个非守护线程。此时虚拟机仍然是可以使用的。
  2. 调用java.lang.Shutdown.shutdown()。这个方法会调用Java层面的关闭钩子,如果设置了finalization-on-exit就执行finalizer。
  3. 调用before_exit(),为执行虚拟机层面的关闭钩子(通过JVM_OnExit()注册)做准备,停止ProfilerStatSamplerWatcher和GC线程。发送JVMTI/PI状态事件,关闭JVMPI,停止信号处理器线程。
  4. 调用JavaThread::exit(),释放JNIHandleBlock,清除栈保护页,从线程列表删除main线程。此时已经不能再执行任何Java代码了。
  5. 关闭对JNI/JVM/JVMPI调用的跟踪。
  6. 对那些还在运行本地代码的线程设置_vm_exited
  7. 删除Java版的main线程。
  8. 调用exit_globals(),该方法会删除IO与PerfMemory等资源。
  9. 返回。

类加载

HotSpot虚拟机实现了由Java语言规范第三版Java虚拟机规范第二版所定义,更新后的Java虚拟机规范第五章所修正的类加载机制。

虚拟机要负责解析常量池符号,这其中涉及到类与接口的加载,链接和初始化。下面我们将使用类加载这个词来描述将类名或接口名映射到类对象的整个过程,而加载链接初始化将用于描述具体的由虚拟机规范所定义的类加载过程。

在字节码解析过程中,当遇到常量池符号时就会牵扯到类加载。Java API,例如Class.forName()classLoader.loadClass(),反射的API,和JNI_FindClass都可以启动类加载。虚拟机自身也可以。在虚拟机启动阶段,虚拟机就会去加载java.lang.Objectjava.lang.Thread等核心类。加载一个类之前需要先加载它所有的父类或者父接口。如果有需要,类文件验证(链接阶段的一部分)也会触发类加载。

虚拟机与JavaSE的类加载库共同完成类加载。虚拟机执行常量池解析,链接初始化加载阶段由虚拟机和特定的类加载器(java.lang.classLoader)共同完成。

类加载过程

加载阶段首先会拿到类名或接口名,找到相应的类文件,定义这个类,生成java.lang.Class对象。如果找不到类的二进制表示会抛出NoClassDefFound。此外,进行类文件语法格式检查也可能抛出ClassFormatError或者UnsupportedClassVersionError。完成一个类的加载之前需要先加载它所有的父类或者父接口。如果这个类的层次结构有问题,例如它是它自己的父类或者父接口(递归了),虚拟机将会抛出ClassCircularityError。如果父接口不是一个接口或者父类是一个接口,那么虚拟机会抛出IncompatibleClassChangeError

链接阶段首先做的事情是验证,包括类文件语法检查,常量池符号检查,还有类型检查。这些检查都可能抛出VerifyError。然后是准备,包括创建并初始化static字段成标准默认值,还有分配方法表。注意此时还不会执行任何Java代码。链接阶段的最后是可选的符号引用解析

初始化阶段会执行static代码块和static字段的初始化。这些是这个类中最先被执行的Java代码。注意,父类的初始化要先执行,父接口则不需要。

Java虚拟机规范规定了在类第一次“活跃使用”时执行初始化。对于链接阶段中的符号解析这一步需要在什么时候执行,Java语言规范没有定死,只要我们遵循语言的语法,加载,链接和初始化这三个阶段严格按顺序执行并且准确地抛出异常就可以。出于性能考虑,HotSpot虚拟机会等到需要初始化时才进行加载与链接。所以如果类A引用了类B,A的加载并不会触发B的加载(除非验证需要)。只有当执行到第一条引用到B的指令才会触发B的加载,链接与初始化。

类加载代理

当一个类加载器收到类加载请求,它可以请求其他类加载器去完成真正的加载操作。这就是类加载的代理机制。第一个加载器称为初始加载器,后一个真正完成加载操作的称为定义加载器。字节码解析时,解析类常量池符号的一定是初始加载器。

类加载器是有层次结构的,并且每个加载器都有一个父加载器。代理机制决定了类的二进制表示的搜索顺序。引导类加载器扩展类加载器系统类加载器,按这个顺序来。系统类加载器是默认的应用类加载器,应用类加载器是用来加载Main-Class和其他类路径上面的类的。可以使用JavaSE类库中的类加载器作为应用类加载器,也可以自己开发应用类加载器。JavaSE所实现的扩展类加载器会从JRE的lib/ext目录加载类文件。

引导类加载器

由虚拟机实现的引导类加载器会从BOOTPATH加载类文件,其中包括rt.jar。为了加快启动速度,虚拟机可以通过类数据共享来执行类的预加载。

类型安全

类名或接口名用全限定名来表示,全限定名包含了类的包名。一个类的类型由全限定名与类加载器唯一确定。所以一个类加载器相当于定义了一个命名空间,相同的类名由不同的定义加载器加载便是两种不同的类型。

由于存在定制的类加载器,虚拟机有必要来保证所有的类加载器都遵循类型安全的约束。参考Dynamic Class Loading in the Java Virtual MachineJava虚拟机规范 5.3.4小节。通过执行加载约束检查,虚拟机保证了当类A调用了B.foo(),foo方法的方法签名对于A的类加载器与B的类加载器是一样的。(译者注:具体一点,假设在A中有这么一句,C c = B.foo();,那么要求这个C不论是由A的类加载器还是由B的类加载器来加载都是一样的,不过大多数情况下A和B的类加载器会是同一个。当然,这里说的类加载器是定义加载器)

HotSpot中类的元数据

类加载会在GC永久代创建一个instanceKlass或者arrayKlassinstanceKlass持有一个自己的Java镜像的引用,这个Java镜像是一个java.lang.Class实例。虚拟机通过klassOop访问instanceKlass

HotSpot中类加载的数据

HotSpot虚拟机主要维护了3个哈希表来跟踪类加载情况。SystemDictionary保存了已加载的类,将类名/类加载器对映射到一个klassOopSystemDictionary既保存了类名/初始加载器也保存了类名/定义加载器的映射。所保存的映射只有在安全点才能被删除。PlaceholderTable保存了当前正在被加载的类,用于ClassCircularityError检查和支持并行类加载。LoaderConstraintTable用于跟踪类型安全检查的约束。

这几个哈希表都被SystemDictionary_lock保护。在虚拟机内部通常使用类加载器对象锁来保证类加载串行进行。

字节码验证与格式检查

Java是一门类型安全的语言,标准的Java编译器会输出有效的类文件和类型安全的代码,但是Java虚拟机仍然需要通过链接时进行字节码验证来保证类型安全,因为不能确保所有的代码都是由可信赖的编译器产生。

字节码验证在Java虚拟机规范4.8小节(译者注:现在应该是在4.10小结,可能是文档没更新)中描述。其中规定了虚拟机需要验证的两种代码约束,即静态约束和动态约束。如果违反了这些约束,虚拟机将会抛出VerifyError并且阻止链接继续进行。

字节码的约束有许多都是静态的,例如ldc指令的操作数必须是一个有效的常量池索引,该常量必须是CONSTANT_IntegerCONSTANT_String或者CONSTANT_Float类型。有些指令需要检查参数类型和数量,由于要等到运行时才能确定在表达式栈上面有哪些操作数,因此这样的约束只能动态分析了。

目前有两种方法可以确定运行时每条指令将会接收到的操作数类型和数量。类型推导是比较传统的一种做法。类型推导需要对每一个字节码执行抽象解释,并且合并目标分支或者异常处理的类型声明。这个过程会一直迭代直到类型达到稳定状态。如果无法达到稳定状态,或者最终结果的类型违反了字节码约束就会抛出VerifyError。目前执行这一步验证的代码放在libverify.so这个外部库当中,并且使用JNI来获取必要的类和类型信息。

在JDK6中开始使用第二种方法,这种方法称为类型验证在这种方法中,Java编译器通过字节码属性StackMapTable提供了每一个目标分支或者异常处理的稳定状态的类型信息。StackMapTable由许多的栈映射帧组成,每一帧表示了方法中特定位置上,操作数栈和局部变量的类型(译者注:可参考R大的这个讲解。另,这篇抨击StackMapTable的文章说的还是有点道理的)。这样虚拟机只需要遍历一遍字节码,验证类型的正确性就可以了。这个方法已经被JavaME CLDC所采用。这种方式既小又快,因此直接嵌入到虚拟机内部了。

对于版本号小于50的类文件,例如由JDK6之前版本生成的,Java虚拟机会使用传统的类型推导方式进行验证。而版本号大于等于50的,会使用StackMapTable进行类型验证。由于存在一些旧的外部工具会去修改字节码,但不会更新StackMapTable属性,因此当使用类型验证遇到一些错误时,可能会换成使用类型推导的方式来进行

类数据共享

类数据共享(CDS)是J2SE 5.0引进的一个特性,目的是为了减少Java应用,特别是小应用,的启动时间和内存占用。当你使用Sun提供的安装器在32位平台安装JRE时,安装器会从系统jar包加载一系列的类并将这些类表示成私有的内部格式,再将这些格式的类数据dump成一个文件,这个文件称为共享归档。如果没有使用Sun提供的安装器,那你也可以手动去创建这个文件。这个文件会以内存映射的方式打开,这样就可以降低加载那些类的成本,并且可以在多个虚拟机进程中共享这些类的元数据。

只有HotSpot客户端虚拟机支持类数据共享,并且只能使用串行垃圾收集器。

加入CDS的主要动机是它可以减少启动时间。CDS更适合小应用,因为它消除了一些固定开销:核心类的加载。使用的核心类越少,减少的启动时间就越多。

有两种方式可以减少新的JVM实例的内存占用。首先,共享归档其中一部分是以只读形式在多个JVM实例间共享的,大概5-6M。之前这部分数据在各个JVM实例都会冗余一份。其次,共享归档的数据格式已经是HotSpot虚拟机所使用的格式,因此访问rt.jar里面原始的类信息所需要的内存空间也省了。省下这些空间,同一台机器就可以有更多的应用并发执行。在Windows平台上,通过不同的工具观测,单个进程的内存占用可能会增加,因为有大量的页要映射到进程地址空间。 这部分通过减少rt.jar所占用的内存可以抵消。减少内存占用仍然是需要优先考虑的问题。

HotSpot实现的类数据共享在永久代中引入了新的共享数据的空间。共享归档classes.jsa在虚拟机启动时被映射到内存当中。后续这些共享区域的管理由虚拟机内存管理子系统执行。

只读的共享数据包括不变方法对象(constMethodOops),符号对象(symbolOops),原生类型数组,大部分字符数组。

可读写的共享数据包括可变方法对象(methodOops),常量池对象(constantPoolOops),Java类和数组的虚拟机内部表示(instanceKlassesarrayKlasses),可变的StringClassException对象。

解释器

(译者注:解释器、编译器坑太大,翻译太水请见谅:)

当前HotSpot用于执行字节码的解释器是一种基于模板的解释器。虚拟机启动时InterpreterGenerator会使用TemplateTable中的信息(每一个字节码对应的汇编代码)在内存中生成一个解释器。一个模板描述一个字节码。TemplateTable定义了所有字节码的模板,并提供了访问的方法。非生产标记-XX:+PrintInterpreter可以用来查看VM启动时在内存中生成的模板。

使用模板要优于传统的switch循环,原因有以下几个。首先,switch语句需要重复比较操作,最坏情况下可能需要与所有字节码进行对比。其次,使用switch语句需要单独的软件栈来传递Java参数,而本地C程序栈本身就是虚拟机在使用(原文:it uses a separate software stack to pass Java arguments, while the native C stack is used by the VM itself)。许多虚拟机内部变量,例如程序计数器或者Java线程的堆栈指针,都是存储在C变量当中,这些变量无法保证一直存放在寄存器当中。管理这些解释器软件结构占据了整个执行时间里面相当可观的一部分。

总体上来说,由于HotSpot解释器的高性能使得虚拟机与真实机器之间的距离变得非常的小。但是做到这点所要付出的代价是大量的机器相关的代码(大概有10,000行Intel的代码,14,000行SPARC的代码)。再加上还要支持动态代码生成,因此代码量及其复杂度都相当高。很明显,对动态生成的代码进行调试要远比静态代码难得多。这些特性虽然没有促进运行时实现的发展,但也没有让它们更糟:)

对于复杂的操作(基本上很难用汇编语言来实现的),解释器会交给虚拟机来处理,例如常量池的查找。

解释器也是HotSpot自适应优化(adaptive optimization)发展历程中关键的一部分。自适应优化利用了一个很有趣的程序特性来解决JIT编译的问题。几乎所有的程序都使用了大部分时间来执行小部分代码。方法一个接一个进行编译,这是JIT的方式。相比于这种方式,HotSpot虚拟机直接使用解释器来执行程序,一边跑一边分析程序中的热点代码。然后用一个全局的本地代码实现的优化器来优化这些热点代码。通过避免了对非热点代码(大部分代码)进行编译,HotSpot编译器可以更专注于解决性能问题,而不至于增加全局的编译时间。只要程序在跑,热点代码的监控就会一直进行下去,因此可以随时根据用户需求来调整程序性能。

异常处理

当程序违反了Java的语义约束,虚拟机就会抛出异常。例如,数组越界访问就会抛出异常。异常会引起非本地的控制转移,从产生(或者说是抛出)异常的地方,到由程序员指定(或者说是捕获异常)的地方。

HotSpot的解释器,动态编译器,还有运行时需要相互协作来完成异常处理。异常处理通常有两种情况:异常在同一个方法里面抛出并被捕获,异常被上层调用者捕获。第二种情况要复杂得多,需要栈展开(stack unwinding)来找到正确的异常处理代码。

throw字节码,虚拟机内部调用返回,JNI调用返回,Java调用返回,都可以初始化异常(前三种包含了最后一种)。当虚拟机发现一个异常被抛出,就会调用运行时来找到最近的异常处理代码。这里需要用到三种信息:当前方法,当前字节码,异常对象。如果在当前方法找不到异常处理代码,当前活动栈帧就会被弹出,然后在前一个栈帧重复这个过程。

一旦找到正确的异常处理代码,虚拟机执行状态会被更新,并且跳转到异常处理代码处继续执行。

同步

广义上来讲,同步可以被定义成是一个机制,用来阻止、避免或者恢复并发操作不适当的交叉执行(一般称作竞争)。Java中,通过线程来阐述并发这个概念。互斥是同步的一种特殊情况,最多只允许一个线程访问被保护的代码或数据。

HotSpot提供了Java监视器——线程运行时可能需要互斥。监视器既可以是锁住的也可以是未锁住的,并且任何时候都只能被一个线程所持有。只有持有了监视器后才能进入由监视器保护的临界区。Java中,临界区被称为“同步块”,在代码中用synchronized来修饰。如果一个线程尝试去锁住一个监视器并且这个监视器是处于未锁住的状态,那么这个线程将会直接持有这个监视器。只有当这个线程释放了监视器,后续的线程才能够访问临界区。

补充一些术语:“进入”一个监视器意味着独占这个监视器,并进入关联的临界区。类似的,“退出”一个监视器意味着释放这个监视器,并退出关联的临界区。当一个线程锁住了一个监视器,我们也称之为“持有”了这个监视器。单个线程在未被持有的监视器上执行同步操作是“无竞争”的。

不管是有竞争的还是无竞争的同步操作,HotSpot都使用了前沿的技术来大大提升同步的性能。

大部分的同步操作都是无竞争的,HotSpot使用了复杂度为常数时间的技术来实现。使用偏向锁(biased locking),最好的情况下这些操作基本都是零开销的。由于大部分对象在自己的生命周期里最多只会被一个线程锁住,因此我们允许对象偏向于这个线程。一旦偏向某个线程,这个线程后续的加锁与解锁操作就不需要再使用昂贵的原子操作了。

有竞争的同步操作则使用了高级的自适应自旋技术,为的是提高应用的吞吐量,即使是存在许多锁竞争的应用。因此,在我们所见到大部分程序中同步操作已经不存在严重的性能问题。

在HotSpot中,大部分同步操作都通过"快速路径"(fast-path)代码来处理。我们有两个即时编译器(JIT)和一个解释器,他们都可以生成快速路径代码。其中一个JIT编译器叫做"C1",用-client指定,另一个是"C2",用-server指定。对同步调用点C1和C2都会直接生成快速路径代码。在没有竞争的情况下,整个同步操作都会在快速路径中完成。如果出现竞争,就需要阻塞或者唤醒某个线程(对应的是执行monitorenter或者monitorexit),快速路径代码会调用慢速路径(slow-path)代码。慢速路径是用C++代码来实现的,而快速路径则是JIT编译器生成的。

每个对象的同步状态都保存在对象结构的第一个字(称之为标记字)里面。有几种状态下标记字还保存了一些额外的信息(GC年龄,对象hashCode)。这些状态是:

(译者注:)

//  - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
//    [ptr             | 00]  locked             ptr points to real header on stack
//    [header      | 0 | 01]  unlocked           regular object header
//    [ptr             | 10]  monitor            inflated lock (header is wapped out)
//    [ptr             | 11]  marked             used by markSweep to mark an object
//                                               not valid at any other time

线程管理

线程管理包括了线程生命周期的各个方面,从创建到终止,还有线程之间的协作。这些线程包括了Java代码(应用代码或者库代码)创建的线程,attach到虚拟机的本地线程,还有出于不同目的而创建的虚拟机内部线程。线程管理依赖于具体的操作系统。

线程模型

HotSpot中,基础的线程模型是Java线程(java.lang.Thread实例)与操作系统本地线程的1:1映射。Java线程启动时会创建本地线程,Java线程终止时本地线程会被回收。线程调度与CPU分配交由操作系统来负责。

Java线程优先级与本地线程优先级的关系非常复杂,要看具体的操作系统。详细情况待会说明。

线程创建与销毁

有两种方式来将线程交由虚拟机管理:对java.lang.Thread对象执行start()方法;使用JNI将已存在的本地线程attach到虚拟机。由虚拟机创建的用于其他目的的内部线程稍后讨论。

在虚拟机中有许多对象与线程相关联(记住HotSpot是用面向对象的C++语言写的):

  • java.lang.Thread实例表示了一个Java线程
  • 虚拟机内部用JavaThread表示一个java.lang.Thread实例。它包含了一些附加信息来追踪线程的状态。JavaThread持有一个与之相关联的java.lang.Thread对象(oop表示)的引用java.lang.Thread对象也保存了对应的JavaThread(原生int类型表示)的引用JavaThread同时也持有了相关联的OSThread实例的引用
  • OSThread实例表示了一个操作系统线程,它包含了一些操作系统级别的附加信息,用于追踪线程状态。OSThread还包含了一个平台相关的"handle"用来找出真正的操作系统线程。

当启动一个java.lang.Thread,虚拟机就会创建相关联的JavaThreadOSThread对象,然后才创建本地线程。在一些相关的虚拟机准备工作 (例如线程本地存储(TLS)分配缓冲区,同步对象等等)后本地线程才会启动。本地线程先完成初始化,然后执行一个start-up方法,这个方法会调用java.lang.Threadrun()方法,当方法返回后,先处理未捕获的异常如果有的话,然后终止该线程,并且与虚拟机交互,确定此线程终止后是否需要关闭整个虚拟机。终止线程会释放所有分配的资源,从线程列表中删除JavaThread,调用JavaThreadOSThread的析构函数,最后结束执行。

本地线程通过JNI调用AttachCurrentThread来attach到虚拟机。这个调用会创建相关联的OSThreadJavaThread实例,并执行基本的初始化。然后会通过反射调用java.lang.Thread的构造方法来创建一个java.lang.Thread对象,参数由本地线程提供。一旦attach到虚拟机,本地线程就可以通过其他可用的JNI方法来调用它所需要的任意的Java代码。如果本地线程不再希望由虚拟机管理,可以调用DetachCurrentThread来解除关联(释放资源,删除java.lang.Thread实例的引用,销毁JavaThreadOSThread对象等等)。

本地线程attach到虚拟机的一个特殊例子是,一开始通过JNI调用CreateJavaVM来创建虚拟机,本地应用或者启动器(java.c)都可以这么做。这个调用会执行一系列初始化操作,并且会表现得就像是调用了AttachCurrentThread一样。然后线程就可以调用Java代码了,例如反射调用应用程序的main方法。详细信息请看下面的JNI小节。

线程状态

虚拟机使用了许多线程内部状态来确定每个线程正在忙啥。这对线程协作以及提供有用的调试信息是非常有必要的。执行不同的操作会伴随着线程状态的转移,状态转移时需要校验当下执行这些操作是否合适,参见下文对安全点的讨论。

从虚拟机的视角来看,主要有以下线程状态

  • _thread_new:线程正在初始化
  • _thread_in_Java:线程正在执行Java代码
  • _thread_in_vm:线程正在虚拟机内部执行
  • _thread_blocked:由于某些原因(正在获取锁,等待条件被满足,休眠,执行阻塞的I/O操作等等)线程被阻塞了

虚拟机还维护了一些用于调试的状态信息。报告工具,线程转储,栈追踪等等都会用到这些信息。这些信息保存在OSThread中,其中有一些信息已经停止使用了,线程转储所包括的状态有:

  • MONITOR_WAIT:线程正在等待获取一个有竞争的监视器锁
  • CONDVAR_WAIT:线程正在等待一个虚拟机使用的内部条件变量(没有关联的Java级别对象)
  • OBJECT_WAIT:线程正在执行Object.wait()方法

其他子系统和库有他们自己的状态信息,例如JVMTI系统和java.lang.Thread暴露出来的ThreadState。这些信息对于虚拟机内部的线程管理是不可见的,而且也没有任何关系。

虚拟机内部线程

当大家发现即便只是执行一个简单的"Hello World"程序,虚拟机都会创建一堆的线程时,通常都会非常惊讶。这堆线程包括了虚拟机内部线程和库相关的线程(例如reference handlerfinalizer线程)。虚拟机线程主要有以下几种:

  • VM线程:VMThread单例,负责执行虚拟机操作,下面会讨论
  • 周期性任务线程:WatcherThread单例,模拟时钟中断,用于在虚拟机内部执行周期性的操作
  • GC线程:这些线程用于支持并行和并发的垃圾回收,有不同的类型
  • 编译器线程:这些线程用于执行运行时编译,将字节码编译成本地代码
  • 信号分发线程:这个线程用于接收发送给虚拟机进程的信号,并且分发给一个Java级别的信号处理方法

所有的线程都是Thread实例,并且所有执行Java代码的线程都是JavaThreadThread的子类)实例。虚拟机使用了Threads_list这个链表来记录所有的线程,Threads_list由一个重要的同步锁,Threads_lock,保护。

(译者注:)

// Class hierarchy
// - Thread
//   - NamedThread
//     - VMThread
//     - ConcurrentGCThread
//     - WorkerThread
//       - GangWorker
//       - GCTaskThread
//   - JavaThread
//   - WatcherThread

虚拟机操作与安全点

VMThreadVMOperationQueue获取操作并执行。比较典型的做法是,当这些操作在执行前需要虚拟机来到安全点时,它们就会被交给VMThread执行。简单来讲,当处于安全点时,虚拟机内部的所有线程都会被阻塞,并且正在执行本地代码的线程也不允许返回到虚拟机。这就意味着虚拟机操作可以在这样的情况下执行:Java堆暂时不会被某个线程修改,所有线程的Java栈不会改变并且可以检查。

最熟悉的虚拟机操作就是垃圾回收了,具体一点就是许多垃圾回收算法中都有的"stop-the-world"阶段。还有其他许多基于安全点的虚拟机操作,比如偏向锁的消除线程栈转储线程挂起或暂停(也就是java.lang.Thread.stop()方法),还有很多通过JVMTI请求的读写操作。

许多虚拟机操作都是同步的,也就是说操作请求方会阻塞直到操作完成,有些操作则是异步或者并发的,那么请求方就可以和VMThread并行执行(当然必须是没有触发安全点的)。

安全点通过一种基于轮询的协作机制来初始化。线程经常会问,“我需不需要因为现在是安全点而阻塞?”(译者注:safepoint check)。有效的提出这个问题并不简单。有个地方会经常提出这个问题,就是线程状态转移的时候。并不是所有的状态转移都会,例如线程要从虚拟机转移到本地代码,但大部分都会。另一个地方是线程在执行JIT编译后的代码时, 在方法返回或者循环中的一些确定点(译者注:“at back jump of loop”?)会进行安全点检查(译者注:这些安全点检查代码由JIT编译器插入)。解释器通常不会进行安全点检查, 当收到安全点请求时,解释器会切换到不同的dispatch table,里面包含了安全点检查的代码,安全点结束后dispatch table又会切换回去。一旦发出安全点请求,VMThread就必须等到所有线程都进入safepoint-safe的状态才能继续执行虚拟机操作。安全点期间,Threads_lock会用于阻塞任何运行中的线程,当虚拟机操作执行完毕VMThread就会释放Threads_lock
(译者注:安全点类似中断机制,由VMThread触发这个中断,支持安全点的线程需要检查安全点,类似中断检查。解释器的dispatch table切换类似中断处理器,但是需要主动触发:)

堆管理

除了由Java堆管理器和垃圾收集器维护的Java堆之外,HotSpot还使用了C/C++堆(也称作malloc堆),用于存储虚拟机内部对象和数据。继承自Arena的一系列C++类用于管理C++堆操作。

Arena及其子类提供了基于malloc/free的快速分配层。Arena从3个全局的ChunkPool分配内存块(Chunk)。每个ChunkPool满足不同范围大小的分配请求。例如,1k的分配请求会从小的ChunkPool分配,而10k的请求则会从中的ChunkPool分配。这么做是为了避免内存碎片的浪费。

Arena系统也提供了比原生的malloc/free更好的性能。malloc/free可能需要获取全局的操作系统锁,这会影响扩展性和性能。Arena都是线程本地对象,并且缓存了一部分固定的存储,所以在快速分配路径上是不需要加锁的。类似的,Arena的释放操作通常也不需要加锁。

Arena被用于线程本地资源管理(ResourceArea)和handle管理(HandleArea)。C1和C2在进行编译时也会用到Arena

Java Native Interface (JNI)

JNI是本地编程接口。它允许跑在虚拟机内部的Java代码与其他语言,例如C、C++和汇编,实现的应用程序和库进行相互操作。

虽然应用程序可以全都使用Java语言来实现,但是仍然存在一些情况,单靠Java语言是满足不了的。程序猿可以使用JNI写Java本地方法来应付这些情况。

JNI本地方法可以用来创建,检查和修改Java对象,可以调用Java方法,捕获和抛出异常,进行类加载,还有获取类信息,并执行运行时类型检查。

JNI通过与Invocation API一起使用,可以允许任何的本地应用嵌入Java虚拟机。这使得程序猿可以很轻松的让已经存在的应用支持Java而不需要去链接虚拟机的代码。

必须记住很重要的一点,一旦应用程序使用了JNI那么有可能会失去Java平台的两个优势。

首先,依赖于JNI的Java应用将无法再轻松的跑在多主机环境。尽管使用Java语言实现的部分仍然是可移植的,但使用本地编程语言实现的部分则是需要重新编译的。

其次,尽管Java语言是类型安全并且可靠的,本地语言例如C或者C++则不是。因此Java开发者在使用JNI开发应用时必须要额外的小心。操作不当的本地方法可能会拖垮整个应用。出于这个原因考虑,Java应用在调用JNI之前必须接受安全检查。

一般来讲,开发者应该好好考虑应用的架构以尽可能减少本地方法的使用。这样也能让本地代码与其他部分有一个清晰的界限。

HotSpot中,JNI函数的实现相对来说是比较简单的。直接使用了各种虚拟机内部原语来执行如对象创建,方法调用等操作。通常,这些原语与其他子系统,例如解释器,使用的运行时原语是相同的。

命令行选项,-Xcheck:jni,用来帮助使用本地方法的问题调试。指定了-Xcheck:jniJNI会使用一些可替换的调试接口。这些可替换的接口会更加严格的验证JNI调用的参数,也会进行一些额外的内部一致性校验。

HotSpot需要特别关注当下有哪些线程正在执行本地方法。在虚拟机执行一些操作时,尤其是垃圾回收的一些阶段,线程需要在安全点停止活动,为的是保证在执行这些敏感操作时Java堆不会被修改。当一个正在执行本地代码的线程来到安全点时,它可以继续执行本地代码,但不可以返回到Java代码或者发起JNI调用。

VM不可恢复错误的处理

对任何软件来说,提供简单的方式来处理不可恢复错误都是非常重要的。Java虚拟机并不例外。一个典型的不可恢复错误是OutOfMemoryError。Windows平台上另一个常见的不可恢复错误是Access Violation,类似Solaris/Linux平台上的Segmentation Fault。不管是在你的应用还是在虚拟机本身,搞清楚这类错误的起因然后修复它们都是非常关键的。

通常,当不可恢复错误导致虚拟机崩溃时都会dump一个HotSpot错误日志文件,hs_err_pid<pid>.log(pid是崩溃的Java进程ID),Windows下这个文件在桌面,Solaris/Linux下在当前应用目录。为了提高这个文件的可诊断性,从JDK6开始上了多个增强功能,其中有一些也被移植回了JDK-1.4.2_09版本。下面是这些改进的highlight:

  • 内存映射写进了错误日志文件中,这样就很容易知道崩溃时的内存布局了。
  • 提供了-XX:ErrorFile=选项便于用户设置错误日志文件保存的路径。
  • OutOfMemoryError也会触发错误日志文件dump。

另一个重要的特性是你可以指定-XX:OnError="cmd1 args...;cmd2 ..."选项,这样不管什么时候虚拟机崩溃了都会执行你在选项中指定的一系列命令。一个典型的应用场景是,你可以调用调试器,例如dbx或者windbg,来观察崩溃时的状况。对于较早的版本,可以指定-XX:+ShowMessageBoxOnError选项,这样当虚拟机崩溃时可以attach运行中的Java进程到你中意的调试器上。

下面是Java虚拟机内部如何处理不可恢复错误的一个简单总结:

  • 使用VMError来汇总并且dump到hs_err_pid<pid>.log。当产生了无法识别的信号/异常时,会由操作系统相关的代码来完成这个操作。
  • 虚拟机内部使用信号来进行通信。当信号无法识别时,不可恢复错误处理器会被调用。应用的JNI代码,操作系统本地库,JRE本地库,或者虚拟机自身所造成的错误都有可能产生无法识别的信号。
  • 不可恢复错误处理器的实现必须非常小心,要防止它自身又发生错误,StackOverflow,或者崩溃时关键锁(例如malloc锁)没有释放。

在一些大型应用中,OutOfMemoryError是很常见的,因此提供有用的诊断信息来帮助用户快速解决问题是非常重要的,有时候只需要扩大Java堆的大小即可。当发生OutOfMemoryError时,错误信息会指出是哪一种内存出了问题。例如,可能是Java堆空间或者是永久区等等。从JDK6开始,错误信息会包含栈的跟踪信息。同样的,增加了-XX:OnOutOfMemoryError="<cmd>"选项,这样当第一次抛出OutOfMemoryError时就可以执行指定的命令了。另一个值得一提的特性是,OutOfMemoryError时会执行内建的堆转储。通过指定-XX:+HeapDumpOnOutOfMemoryError打开这个功能,你还可以通过-XX:HeapDumpPath=<pathname>选项告诉虚拟机将堆转储文件存到哪。

尽管应用代码都很小心地去避免死锁,但有时候它还是会发生。当发生了死锁,Windows上你可以输入Ctrl+Break,Solaris/Linux上可以给Java进程发送SIGQUIT信号,这样来终止进程。Java级别的栈跟踪信息会输出到标准输出,这样你就可以分析死锁的原因了。从JDK6开始,这个特性被内建到jconsole当中,jconsole是JDK中一个非常有用的工具。当应用程序因为死锁而挂起了,使用jconsole attach到该进程,然后jconsole就会分析是哪一个锁出了问题。大部分情况下,死锁都是由于错误的加锁顺序引起的。

强烈推荐阅读“Trouble-Shooting and Diagnostic Guide”,里面包含了许多对不可恢复错误诊断有用的信息。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值