转载

JAVA运行时简述(HotSpot)

本文简单介绍HotSpot虚拟机运行时子系统,内容来自不同的版本,因此可能会与最新版本之间(当前为JDK12)存在一些误差。

  • 1.命令行参数处理

    HotSpot虚拟机中有大量的可影响性能的命令行属性,可根据他们的消费者进行简单分类:执行器消费(如-server -client选项),执行器处理并传递给JVM,直接由JVM消费(大多)。

    这些选项可分为三个主要的类别:标准选项,非标准选项,开发者选项。标准选项是指所有的JVM不同实现均可以处理且在不同版本之间稳定可用的选项(但是也可以deprecated)。

    以-X开头的选项是非标准选项(不保证所有JVM虚拟机的实现均支持),后续的JAVA SDK更新也不保证会对它进行通知。使用-XX开头的为开发者选项,它一般需要特定的系统环境(以保证实现正确的操作)和足量的权限(以访问系统配置参数),这些实现应当慎重使用,相应的选项在更新后也并不保证通知到用户。

    命令行参数控制了JVM内部变量的属性值,这些参数同时具备"类型"与"值"。对于布尔型的属性值,以+或-置于参数之前,可分别表示该属性的值为true或false。对于需要其他数据的变量,同样有许多机制可以设置其值数据(很遗憾并不统一),一部分参数在格式上要求在属性名称后直接跟随属性值,有些不需要分隔符,有些又不得不加上分隔符,分隔符又可能是":","="等,如-XX:+OptionName, -XX:-OptionName, and -XX:OptionName=。

    大多整型的参数(如内存大小)可接受'k','m','g'分别代表kb,mb,gb这种简写形式。

  • 2.虚拟机运行周期

    执行器

    HotSpot虚拟机有几种Java标准版的执行器,在unix系统上即java命令,在windows系统下即java和javaw(javaw其实是指基于网络的执行器)。从属于虚拟机启动过程的执行器操作有:

    a.解析命令行选项,部分选项直接由执行器自身消费,如-client和-sever属性被用来决断加载合适的vm库,其他的属性则作为虚拟机初始化参数(JavaVMInitArgs)传递给vm。

    b.如果未明确指定选项,执行器来确定堆的大小和编译器类型(是client还是server)。

    c.确立如LD_LIBRARY_PATH 和 CLASSPATH等环境变量。

    d.如果未在命令行中明确指定主类,执行器会从jar文件清单中找出主类名称。

    e.执行器会在一个新创建的线程(非原生线程)中使用JNI_CreateJavaVM来创建虚拟机实例。 注意,在原生线程中创建vm会极大的减少定制vm的可能性,如windows中的栈大小等。

    f.一旦vm创建并初始化成功,加载主类成功,执行器可从主类中得到main方法的属性,然后使用CallStaticVoidMethod执行主方法并以命令行参数为它的方法入参。

    g.当java主方法执行完成时,检查和清理任何可能已发生的挂起的异常,返回退出状态。它会使用ExceptionOccurred来清理异常,方法如果执行成功,它会给调用进程返回一个0值,否则为其他值。

    h.使用DetachCurrentThread解除主线程的关联,这样减少了线程的数量,保证可安全调用DestroyJavaVM,它也能保证线程不在vm中执行操作,栈中不再有存活的栈桢。

    最重要的两个阶段是JNI_CreateJavaVM以及DestroyJavaVM,下面详述。

    JNI_CreateJavaVM执行步骤:

    首先保证没有两个线程同时调用此方法,从而保证不在同一进程中出现两个vm实例。当在一个进程空间中达到一个初始化点时,该进程空间中不能再创建vm,该点也被称为“不返回的点”(point of no return)。原因在于此时vm已创建的静态数据结构不能够重新初始化。

    接下来,要对JNI的版本支持进行检查,检测gc日志的ostream是否初始化。此时会初始化一些操作系统模块,如随机数生成器,当前进程号,高分辨率的时间,内存页大小和保护页等。

    解析传入的参数和属性值,并存放留用。初始化java标准系统属性。

    基于上一步解析的参数和属性进一步创建和初始化系统模块,这一次的初始化是为同步,栈内存,安全点页做准备。在此时如libzip,libhpi,libjava,libthread等库也完成了加载,同时完成信号句柄的初始化和设定,并初始化线程库。

    接下来初始化输出流日志,任何必需的代理库(hprof jdi等)均于此时完成初始化和开启。

    完成线程状态的初始化以及持有了线程操作所需的指定的数据的线程本地存储(TLS Thread Local Storage)的初始化。

    全局数据的初始化,如事件日志,操作系统同步,性能内存(perfMemory),内存分配器(chunkPool)等。

    到此时开始创建线程,会创建java版本的主线程并绑定到一个当前操作系统线程上。然而这个线程还不能被Threads线程列表感知,完成java级别的线程初始化和启用。

    紧接着进行余下部分的全局模块的初始化,它们包括启动类加载器(BootClassLoader),代码缓存(CodeCache),解释器(Interpreter),编译器(Compiler),JNI,系统字典(SystemDictionary),Universe。此时便已到达前述的“不返回的点”,也就是说,我们此时已不能在进程的地址空间中再创建一个vm实例了。

    主线程会在此时被加入到线程列表中,这一步首先要对Thread_Lock进行加锁操作。此处会对Universe(戏称为小宇宙,即所需的全局数据结构)进行健全检查。此时创建执行所有重要vm函数的VMThread,创建完成后,即达到一个合适的点,在这个点,可发出适当的JVMTI事件通知当前jvm的状态。

    加载并初始化一些类,包含java.lang.String,java.lang.System,java.lang.Thread,java.lang.ThreadGroup,java.lang.reflect.Method,java.lang.ref.Finalizer,java.lang.Class,以及系统类中的其他成员。在这一刻,vm已经完成初始化并且可操作,但是并未具备完整的功能。

    到这一步,信号处理器线程也被开启,同时也完成了启动编译器线程和CompileBroker线程,以及StatSampler和WatcherThreads等辅助线程,此时vm具备了完整的功能,生成JNIEnv信息并返回给调用者,此时的vm已经准备就绪,可服务新的JNI请求。

    DestroyJavaVM执行步骤

    DestroyJavaVM的调用有两种情况:执行器调用它拆解vm,或vm自身在出现严重错误时调用。拆解虚拟机的基本步聚如下:

    首先,要等待到自身成为唯一一个正在运行的非守护线程时,在整个等待过程中,虚拟机仍旧是可工作的。

    调用java.lang.Shutdown.shutdown()方法,它会执行java级别的关闭勾子方法,如果有退出终结器可用,运行相应的终结器(finalizer)。

    调用before_exit()为vm退出做出准备,运行vm级别的关闭勾子(它们是用JVM_OnExit()注册的),停止剖析器(Profiler),采样器(StatSampler),Watcher和GC线程。将相应的事件发送给JVMTI/PI,禁用JVMPI,并终止信号线程。

    调用JavaThread的exit方法,释放JNI句柄块,移除栈保护页,把此线程从线程列表中移除,从这个点起,任何java代码不可被执行。

    终止vm线程,它会把当前的vm带到安全点并终止编译器线程。在安全点,应注意任何可能会在安全点阻塞的功能都不可使用。

    禁用JNI/JVM/JVMPI屏障的追踪。

    给native代码中依旧在运行的线程设置_vm_existed标记。

    删除这个线程。

    调用exit_globals删除IO和PerfMemory资源。

    返回调用者。

  • 3.虚拟机类加载(class loading)

    虚拟机要负责常量池符号的解析,它需要对有关的类和接口先后进行装载(loading),链接(linking)然后初始化。一般用“类加载机制”来描述把一个类或接口的名称映射到一个class对象的过程,相应的,JVMS定义了详细的装载,链接和初始化阶段的协议。

    类的加载是在字节码解析过程中完成的,典型是当一个类文件中的常量池符号需要被解析时。有一些JAVA的api会触发这个过程,如Class.forName(),classLoader.loadClass(),反射api,以及JNI_FindClass均能初始化类的加载。虚拟机自身也能初始化类加载。虚拟机会在启动时加载如Object,Thread等核心类。装载一个类需要装载所有的超类和超接口。且对于链接阶段的类文件验证过程,可能需要装载额外的类。

    虚拟机和JAVA SE类加载库共同承担了类的加载,虚拟机执行了常量池的解析,类和接口的链接和初始化。加载阶段是vm和特定的类加载器(java.lang.ClassLoader)之间的一个协作过程。

    类加载阶段

    装载阶段,根据类或接口的名称,在类文件中找出二进制语义,定义类并创建java.lang.Class对象。如果在类文件中找不到二进制表示,则抛出NoClassDefFound错误。此外装载阶段也做了一些类文件的在语法上的格式检查,检查不通过会抛出ClassFormatError或UnsupportedClassVersionError。在完成装载之前,vm必须载入所有的超类和超接口。如果类继承树存在问题,如类直接或间接地自己继承或实现自己,则vm会抛出ClassCircularityError。若vm发现类的直接接口不是接口,或者直接父类是一个接口,则会抛出IncompatibleClassChangeError。

    类加载的链接阶段首先做一些校验,它会检测类文件的语义,常量池符号以及类型检测,这个过程可能会抛出VerifyError。链接阶段接下来进行一些准备工作,它会为静态字段进行创建和初始化标准默认值,并分配方法表。注意,到此步为止不会进行任何java代码的执行。之后链接阶段还有一个可选的步骤,即符号引用的解析。

    接下来是类的初始化阶段,它会运行类的静态初始化器,初始化类的静态字段。这是类的java代码的第一次执行。注意类的初始化需要超类的初始化,但不包含超接口的初始化。

    JAVA虚拟机规范(JVMS)规定了类的初始化发生在类的第一次“活化使用”,java语言规范(JLS)允许链接阶段的符号解析过程在不破坏java语义前提下的灵活性,装载,链接和初始化的每一个步骤都要在前一步骤完成后进行。为了性能考虑,HotSpot虚拟机一般会等到要去初始化一个类时才会去进行类的装载和链接。所以举个简单的例子,如果类A引用了类B,那么加载类A将不会必然导致B的加载,除非在验证阶段必需。当执行了第一个引用B的指令时,将会导致B的初始化,而这又需要先对类B进行装载和链接。

    类加载的委托机制

    当一个加载器被要求查找和加载一个class时,它可以请求另一个类加载器去做实际的加载工作。这个机制被称为加载委托。第一个类加载器是一个“初始化加载器”,而最终定义了该类的类加载器被称为“定义加载器”,在字节码解析的例子中,初始化加载器负责该类的常量池符号的解析。

    类加载器是分层定义的,每个类加载器可有委托的双亲。委托机制定义了二进制类表示的检索顺序。JAVASE类加载器按层序检索启动类加载器,扩展类加载器和系统类加载器。系统类加载器同时也是默认的应用类加载器,它会运行main方法并从类路径下加载类。应用类加载器可以是JAVASE 类加载器库中的实现,也可以由应用开发人员实现。JAVASE类库实现了扩展类加载器,它负责加载jre下lib/ext目录中的类。

    作者在 “54个JAVA官方文档术语” 一文中曾说过,这一机制已经不适用于JAVA9以上版本的描述,如果去查询有关文章,可以发现这个经典的类加载委托机制其实已经历过三次破坏(委托机制出厂时晚于加载器本身,破坏一;线程上下文类加载器,破坏二;热部署的后门,破坏三),而作者个人认为类加载器支持JAVA9之后的模块路径的加载也是一种破坏,它们之间不再是简单的委托加载,也不仅从类路径下加载,不同路径加载到的模块也有不同的处理机制,详细描述见该文。

    启动类加载器是由vm实现的,它从BOOTPATH下加载类,包含rt.jar中的类定义。为了快速启动,vm也会通过类数据共享(cds)来处来类的预加载。关于cds,在最新的几版jdk中有所更新,我们在稍后的章节中简述。

    类型安全

    类或者接口名是由包含包名称的全限定名定义的。一个类的类型由该全限定名和类加载器所唯一定义,所以类加载器其实可以理解为一个名称空间,两个不同类加载器定义的同一个类实际上会是两个class类型。

    vm会对自定义类加载器进行限定,保证不能与类型安全发生冲突。当类A中调用类B的方法时,vm通过追踪和检查加载器约束保证两个类的加载器在方法参数和返回值上协商一致。

    HotSpot中的类元数据

    类加载的结果是在永久代(旧版)创建一个instanceKlass或者arrayKlass。instanceKlass指向一个java.lang.Class的实例,虚拟机c++代码通过klassOop访问instanceClass。

    HotSpot内部类加载数据

    HotSpot虚拟机为了追踪类加载而维护了三张主哈希表。分别是SystemDictionary表,它包含被加载的类,它们映射键为一个类名/类加载器对,值为一个klassOop,它同时包含了类名/初始化加载器对和类名/定义加载器对,目前只有在安全点才可以移除它们;PlaceholderTable表,它包含当前正在被载器的类,它被用于前述ClassCircularityError检查和支持多线程类加载的加载器进行并行加载;LoaderConstraintTable,它追踪类型安全检查约束。这些哈希表都由一个锁SystemDictionary_lock来保护,一般情况下vm中的类加载阶段是使用类加载器对象锁串行执行的。

  • 4.字节码验证和格式检查

    JAVA语言是类型安全的,标准的java编译器会生产可用的类文件和类型安全的代码,但是jvm不能保证代码是由可信任的编译器生成的,因此它必须在链接时进行字节码校验(bytecode verification)重建类型安全。

    字节码校验的规范详见java虚拟机规范的4.8节。规范中规定了JVM校验的代码动态和静态约束。如果发现了任何与约束冲突的地方,虚拟机将会抛出VerifyError并阻断类的链接。

    可静态检查的字节码约束有很多,'ldc'码(Low Disparity Code 低差别编码)的操作数必须为一个可用的常量池索引,它的类型是CONSTANT_Integer, CONSTANT_String 或 CONSTANT_Float。其他指令需要的检查参数类型和个数的约束需要对代码动态分析,这样来决定执行时哪个操作数可出现在表达式栈。

    目前,有两种办法(截止1.6)分析字节码并决定在每一条指定中出现的操作数类型和个数。传统的办法被称作“类型推断”,它通过对每个字节码进行抽象解释,在代码的分支处或异常句柄处进行类型状态的合并。整个分析过程会迭代全部的字节码,直到发现这些类型的“稳态”。如果不能达到稳态,或者结果类型与一些字节码的约束冲突,那么抛出VerifyError。这一步的验证代码位于外部库libverify.so中,它使用JNI去收集所需的类和类型的信息。

    在JDK6中出现了第二种被称为“类型验证“的方法,在这种方法中,java编译器通过代码属性,StackMapTable来提供每一个分支和异常目标的稳态类型信息。StackMapTable包含大量的栈图桢,每一个桢表示方法的某一个偏移量的表达式栈和局部变量表中的条目类型。jvm接下来只需要遍历字节码并验证字其中的类型正确性。这是一个已经在JAVAME CLDC中使用的技术。因为它小而快,此验证方法vm自身即可构建。

    对于所有版本号低于50,创建早于JDK6的类文件,jvm会使用传统的类型推荐方式验证类文件,否则会使用新办法。

  • 5.类数据共享(cds)

    类数据共享是一个JDK5引入的功能,旨在提高java程序语言应用的启动时间,尤其是小型应用,同时,它也能减少内存占用。当jre安装在32位系统时并且使用sun提供的安装器时,安装器会从系统jar中载入一组类并生成一种内部的格式,然后转储为一个文件,这个文件被称作”共享存档“。如果没有使用sun提供的jre安装器,也可以手动执行。在后续的jvm执行时,这个共享存档文件被映射进内存,节省了其他jvm装载类和元数据的时间。

    目前官方对于cds的文档未整理完善,在截止到JAVA8的有关文档中,仍可以见到这样一句描述:Class data sharing is supported only with the Java HotSpot Client VM, and only with the serial garbage collector,即类共享目前只在HotSpot client虚拟机中支持,且只能使用serial垃圾收集器。而在JAVA9-12的若干新特性中,也对cds有过一些更新描述,如JAVA10中对类数据的共享包含了应用程序的类,在JAVA11中模块路径也支持了cds。但作者并未在专门的垃圾收集器中找到大篇幅的详述,不过根据jdk12的jvm文档中介绍,cds已经在 G1, serial, parallel, 和 parallelOldGC 几种垃圾收集器中支持,且默认使用G1的128M堆内存。且G1在JDK7中已出现,在JDK9中已经成为默认的垃圾收集器,parallel 出现的相对更早,因此作者严重怀疑JAVA8中相应文档描述的准确性,好在我们可以直接去看最新版。

    cds可以减少启动时间,因为它减少了装载固定的类库的开销,应用程序相对于使用的核心类越小,cds就相当节省了越多的启动时间。cds同时也有两种方式减少了jvm实例的内存占用。首先,一部分共享存档文件被映射进内存并作为只读的库,多个jvm进程不需要重复占用进程的内存空间;其次,因为共享存档文件中包含的类数据已经是jvm使用的格式,处理rt.jar(低于9的版本)所需的额外内存开销也可以省去了,这使得多个应用在同一机器上能够更优的并发执行。

    在HotSpot虚拟机中,类共享的实现实际是在永久代(元空间)中开辟了新的内存区域存放共享数据。存档文件名为”classes.jsa“,它会在vm启动时映射进这个空间。后续的管理由vm内存管理子系统负责。

    共享数据是只读的,它包含常量方法对象(constMethodOops),符号对象(symbolOops),基本类型数组,多数字符数组。可读写的共享数据包含可变的方法对象(methodOops),常量池对象(constantPoolOops),vm内部的java类和数组实现(instanceKlasses和arrayKlasses), 以及大量的String,Class和Exception对象。

    作者看来,近几版的jdk关于cds的几处更新明显借鉴了一些如tomcat等服务器的机制,适配越来越多的云生产环境,减少内存开销和启动开销都是为云用户省钱的方式。

  • 6.解释器

    当前HotSpot解释器是一个基于模板的解释器,它被用来执行字节码。HotSpot在启动时运行时用InterpreterGenerator在内存中利用TemplateTable(每个字节码有关的汇编代码)中的信息生成一个解释器实例。模板是每个字节码的描述,模板表定义了所有模板并提供了获取指定字节码的访问方法。在jvm启动时,可使用-XX:+PrintInterpreter打印有关的模板表信息。

    执行效果上看,模板好于经典的switch语句循环的方式,原因也很简单,首先switch语句执行重复的比较操作来得到目标字节码,最极端情况它可能需要对一个给定的指定比较所有的字节码;第二,模板使用共享的栈来传递java参数,同时本地c方法栈被vm自身来使用,大量的jvm内部变量是用c变量存放的(如线程的程序计数器或栈指针),它们不保证永久存放在硬件寄存器中,管理这些软件的解释结构会消耗总执行时间中的相当可观的一部分。

    从全局来看,HotSpot解释器大幅弥合了虚拟机和实体机器之间的裂缝,它大大加快了解释的时间,但是牺牲了很多代码的机器块,同时也增大了代码大小和复杂度,也需要一些代码的动态生成。很明显,debug机器动态生成的代码要比静态代码更加困难。

    对于一些对汇编语言来说过于复杂的操作,如常量池的查找,解释器会运行时调用vm来完成。

    HotSpot解释器也是整个HotSpot自适应优化历史中重要的一部分,自适应优化解决了JIT编译的问题,大部分情况下,几乎所有的程序都是用大量时间执行极少量的代码,因此运行时不需要逐方法编译,vm仅使用解释器来立即运行程序,分析代码在程序中运行的次数,避免编译不频繁运行的程序代码(大多数),这样HotSpot编译器可以专注于程序中最需要性能优化的部分,并不增加全局的编译时间,在程序持续运行期间进行动态的监控,达到最适应用户需要的目的。

  • 7.JAVA异常处理

    jvm使用异常作为一个信号,它说明程序中出现了与java语言语义相冲突的事件,数组越界是一个极简的案例。异常会导致控制流从异常发生或抛出的点转到程序指定的处理点或捕获点的一次非本地转换。HotSpot解释器和动态编译器在运行时协作实现了异常的处理。异常处理有两种简单案例,异常抛出并由同一方法捕获,异常抛出并由调用者捕获。后一种情况稍微复杂一些,因为需要展开栈来找出恰当的处理者。

    要初始化一个异常有多个方式,如throw字节码,从vm内部调用中返回,JNI调用中返回,或java调用中返回,最后一个情况其实是前三者的后一阶段。当vm意识到有异常抛出时,执行运行时系统去找出该异常最近的处理器,这一过程会用到三片信息:当前方法,当前字节码,异常对象。如果当前方法没有找到处理器,如上面提到的,将当前活化的栈桢出栈,进程将在此前的栈桢中迭代重复上述步骤。一旦找到了合适的处理器,vm更新执行状态,跳转到相应的处理器,java代码在相应位置继续执行。

  • 8.同步

    广泛来讲,可以把“同步”定义为一个阻止或恢复不恰当的并发交互(一般称为竞态)的一个机制。在java中,并发通过线程来表示,锁排他是java中常见的一个同步案例,这一过程中,只有一个线程同时被允许访问一段保护的代码或数据。

    HotSpot提供了java监视器的概念,线程可通过监视器来排它的运行应用代码。监视器只有两个状态:锁或者未锁,一个线程可以在任何时间持有(锁住)监视器。只有在获取了监视器后,线程才能进入被监视器保护的代码块。在java中这类被监视器保护的代码块称为同步代码块。

    无竞态的同步包含了大多的同步情况,它由常量时技术实现。java对于同步机制做了大量的优化,偏向锁技术是其中之一,因为大多数的对象一生只被最多一个线程持有锁,因此允许该线程将监视器偏向给自己,一旦偏向,该线程后续锁和解锁不再需要额外又昂贵的原子指令开销。

    对于有竞态的同步操作场景,使用高级自适应自旋技术提高吞吐量。即使此时应用中有大量的竞态,在经历这些优化后,同步操作性能已经大幅提升,从jdk6开始,它不再是现在的real-world程序中的重大问题。

    在HotSpot中,大多的同步操作是由一种被称作“fast-path”(快路)代码的调用完成的。有两种即时编译器(JIT)和一个解释器,它们都可以产生快路代码。两种编译器分别是C1,即-client编译器,以及C2,即-server编译器。C1和C2均直接在同步点生成快路代码。在一般没有竞态的情况下,同步操作将会完全在快路中执行,然而当发现需要去阻塞或者唤醒一个线程时(如monitorenter monitorexit),将会进入slow-path执行,它由本地C++代码实现。

    单个对象的同步状态是在对象中的第一个word中编码存放的(mark word,详见前面的文章“54个JAVA官方文档术语”)。mark word对同步状态元数据来说是多用的(其实mark word本身也是多用的,它还包含gc分代数据,对象的hash码值)。这些状态包含:

    Neutral(中立): 未锁

    Biased(偏向): 锁/未锁+非共享

    Stack-Locked(栈锁): 锁+共享 无竞态

    Inflated(膨胀锁): 锁/未锁+共享和竞态

  • 9.线程管理

    线程管理覆盖线程从创建到销毁的整个生命周期,并负责在vm内协调各个线程。这个过程包含java代码创建的线程(应用代码或库代码),绑定到vm的本地线程,出于各种目的创建的vm内部线程。线程管理在绝大多数情况下是独立于运行平台的,但仍有一些细节与所运行的操作系统有所关联。

    线程模型

    在hotspot虚拟机中,java线程和操作系统线程是一对一映射的关系,java线程即一个java.lang.Thread实例,当它被开启(start)后,本地线程也随之创建,当它终止(terminated)时,本地线程回收。操作系统负责调度所有的线程以及派发可用的cpu资源。java线程的优先级以及操作系统线程的优先级机制非常复杂,在不同的操作系统中表现也差异极大,此处略。

    线程创建和销毁

    有两种办地可以向虚拟机中引入一个线程:执行java.lang.Thread对象的start方法;或使用JNI将一个已存在的本地线程绑定到vm。出于一些目的,vm内部也有一些办法创建线程,本处不予讨论。

    在vm的一个线程上实际上关联了若干个对象(HotSpot虚拟机是由面向对象的c++实现),具体有:

    a.java.lang.Thread实例表现java代码中的一个线程。

    b.JavaThread实例表示vm中的一个java.lang.Thread,JavaThread是Thread的子类,它包含额外的用以追踪线程状态的信息。一个JavaThread实例持有关联的java.lang.Thread对象的引用(指针),同时持有OSThread实例的引用。java.lang.Thread也持有JavaThread的引用(以一个整数表示)。

    c.OSThread(直译为操作系统线程)实例表示了一个操作系统的线程,它包含额外的可用于追踪线程状态的操作系统级别的信息。OSThread包含了一个平台指定的可用于定位真实操作系统线程的句柄。

    当java.lang.Thread实例启动,vm创建关联的JavaThread和OSThread对象,并最终创建了一个本地线程。在准备好所有vm状态后(如线程本地存储,分配缓存,同步对象等)之后,本地线程得以启动。本地线程完成初始化并执行一个start-up方法,它会导向java.lang.Thread对象的run方法。随后,在该方法返回或抛出未捕获的异常时终止线程,并且在终止时与vm交互,这个过程是用以判断是否它此时也需要终止vm。线程终止会释放掉所有关联的资源,从已知线程集中移除掉JavaThread实例,执行OSThread实例和JavaThread实例的销毁过程,并最终停止startup 方法的执行。

    可使用JNI调用AttachCurrentThread把本地线程绑定到虚拟机。作为此方法的响应,OSThread和JavaThread实例会被创建并进行基本的初始化。接下来会使用绑定线程命令提供的参数及Thread类的构造器反射初始化一个java线程。绑定完成后,线程可以通过可用的JNI方法调用所需的java代码。当本地线程不希望继续在vm中进行执行时,可使用JNI调用DetachCurrentThread来解除与vm的关联(会释放资源,丢弃指向java.lang.Thread实例的引用,销毁JavaThread和OSThread对象等)。

    使用JNI调用CreateJavaVM创建vm是一个特殊的绑定本地线程的例子,它会由执行器(java.c)完成或通过一个本地应用来完成。这件事会造成一系列的初始化操作,也会在接下来出现类似执行AttachCurrentThread的行为。随后线程继续执行所需的java代码(此例中即反射执行main方法)

    线程状态

    vm维护了一组内部的线程状态来标识各线程的工作。协调各线程的交互,或当线程执行错误进行debug时均需要用到这些状态标识。当执行了不同的动作时,线程的状态可以发生改变,可即时使用这些转换点检测相应的线程是否具备执行将要执行的动作的客观条件,安全点即是一个典型的例子。

    以虚拟机的视图来看,线程有以下几个状态:

    _thread_new:表示一个新线程处于初始化的过程中。

    _thread_in_Java:表示一个线程正在执行java代码。

    _thread_in_vm:表示一个线程正在vm内部执行。

    _thread_blocked:表示线程因某些原因阻塞(原因可能是正在获取一个锁,等待一个条件,sleep,执行阻塞io等)。

    出于debug的目的,可能需要一些额外的信息。如对于一些工具,或者用户需要进行线程栈转储或栈迹追踪等操作时,均需要额外的信息,OSThread维护了相应的一些信息,但部分信息现在已经不再使用了,在线程转储时,可报告的状态额外包含:

    MONITOR_WAIT:表示线程正在等待获取竞态锁。

    CONDVAR_WAIT:表示线程正在等待vm使用的内部条件变量(与java级别的对象无关联)。

    OBJECT_WAIT:线程执行了Object.wait方法。

    虚拟机的其他子系统和库可能维护了自己的状态信息,如JVMTI工具,Thread类本身维护的ThreadState等。这些状态一般不被其他组件使用。

    虚拟机内部线程

    JAVA的执行有着严格的步骤,不同于某些脚本语言,java即使运行简单的Hello World也需要相应的资源准备,因此,对于最简单的Hello World,也可以发现系统中其实创建了若干个线程,它们主要是由vm中的线程和有关代码库中使用的线程(包含引用处理器,终结者线程等)组成。主要的虚拟机线程有以下几种:

    a.vm线程:它是VMThread的单例,负责执行虚拟机操作。

    b.周期任务线程:它是在vm内部执行周期操作的线程,是WatcherThread的实例。

    c.GC线程:顾名思义。

    d.编译器线程:负责运行时执行字节码到本地代码的编译。

    e.信号派发线程:负责等待进程信号并派发给java级别的信号处理方法。

    以上所有线程是Thread类的实例,且所有执行java代码的线程均为JavaThread实例。vm内部维护了一个Threads_list的数据结构,它是一个追踪所有线程的链表,在vm内部有一个核心的同步锁Threads_lock,该锁就用于保护Threads_list。

  • 10.虚拟机操作和安全点

    VMThread会监测一个VMOperationQueue队列,该队列中存放的成员全部为“操作”,等待相应的操作入队后,它会执行相应的操作。这些操作被交给VMThread来执行,因为它们需要vm到达安全点才可执行。简单来说,当vm在到达安全点时,所有vm内运行的线程均会阻塞,所有在本地代码中执行的线程在安全点期间被禁止返回vm执行。这意味着虚拟机操作可以在已知无线程处于正在更改java堆的前提下进行运行,且此时所有的线程处在一个特殊的,不改变java栈的可检视状态。

    最著名的虚拟机操作之一即gc,或者更精确一点是很多gc算法中的“stop the world”阶段,但也存在很多基于安全点的其他操作,作者在“54个java官方文档术语”一文中简单列举了这些操作。

    很多虚拟机操作是同步阻塞的,请求者会阻塞到操作完成,但也有一些异步并发的操作,请求者可以和VMThread并行执行。

    安全点是使用协作轮询的机制初始化的。简单来说,线程会去询问“我是否要为一个安全点阻塞”。这个询问机制的实现并不简单。当发生线程的状态转换时会常见询问这个问题,但并是所有的状态转换都会询问,如当一个线程离开vm并进入native代码块时。当从编译的代码返回时,或在循环迭代的阶段,线程也会询问这个问题。对于执行解释代码的线程来说是不常询问的,但在安全点,它也有相应的方案,当请求安全点时,解释器会切换到一个包含了该询问的代码的转发表,当安全点结束后,从派发表切回。一旦请求了安全点,VMThread必须等到所有已知线程均处于安全点-安全状态,然后才可执行虚拟机操作。在安全点期间,使用Threads_lock来block住那些正在运行的线程,虚拟机操作完成后,VMThread释放该锁。

  • 11.C++堆管理

    除了由JAVA堆管理者和gc维护的JAVA堆以外,HotSpot虚拟机也使用一个c/c++堆(即所谓的分配堆)来存放虚拟机的内部对象和数据。这些用来管理C++堆操作的类都由一个基类Arena(竞技场)派生而来。

    Arena和它的子类提供了位于分配/释放机制顶层的一个快速分配层。每一个Arena在3个全局的块池(ChunkPools)中进行内存块(Chunk)的分配。不同的块池满足不同大小区间的分配,举例说明,如果请求分配1k的内存,那么会用“small”块池分配,如果请求分配10k内存,则使用“medium”块池,这样可以避免内存碎片浪费。

    Arena系统也提供了比纯粹的分配/释放机制更佳的性能。因为后者可能需要获取一个操作系统的全局锁,它会严重影响扩展性并伤害系统性能。Arena是一些缓存了指定内存数量的线程本地对象,这样的设计使得它可以在分配时使用“快路”分配而不用获取该全局锁,对于释放内存的操作,通常情况下Arena不需要获得锁。

    Arena的两个子类,ResourceArena应用于线程本地资源管理,HandleArena用于句柄管理,在client和server编译器中均用到了这两种arena。

  • 12.JAVA本地接口(JNI)

    JNI代表本地程序接口。它允许运行在jvm中的java代码与使用其他语言(如c/c++)实现的应用或库进行交互。JNI本地方法可以用来做很多事情,如创建对象,检视对象,更新对象,调用java方法,捕获抛出的异常,加载类和获取类信息,执行运行时类型检测等。JNI也可以使用Invocation api来启用jvm中嵌入的任意native应用,通过它,我们可以轻易地让已有应用可以用java运行而不用去链接vm源码。

    但有重要的一点,一旦使用了JNI,便失去了使用java平台的两个重要的好处。

    第一,依赖jni的java应用不保证能在多平台上可用,尽管基于java实现的部分是可以跨宿主机环境的,使用本地程序语言实现的部分仍旧需要重新编译。

    第二,使用java语言编写的程序是类型安全的,C或者C++则不是。结果就是使用了JNI的程序员必须额外注意这部分代码,行为不端的本地方法可能扰乱整个应用,出于这个原因考虑,在执行jni功能前,相应使用到jni的应用一定要负责它的安全性检查。

    原则上讲,应尽可能少地使用本地方法,并做好这部分代码与java应用的隔离,作者看来,unsafe后门包是一个典型的案例。

    在HotSpot虚拟机中,jni方法的实现相对直接,它使用各种vm内部原生规则来执行诸如对象创建方法调用等行为,通常情况,相应的如解释器等子系统也使用了这些运行时规则。

    可使用命令行选项-Xcheck:jni来帮助debug那些使用了本地方法的应用,该选项会使得JNI调用时用到一组debug接口。这些接口会更加严格地进行JNI调用的参数验证,同时还会做一些额外的内部一致性检查。

    HotSpot对于执行本地方法的线程进行了额外“照顾”,对于一些vm的工作,比如gc过程中,一部分线程必须保证在安全点阻塞,从而保证java堆在这些敏感过程中不会再次更改。当我们希望把一个安全点上的线程带入到本地代码执行时,它会被允许进入本地方法,但是禁止从该方法返回java代码或者执行JNI调用。

  • 13.虚拟机致命故障处理

    毫无疑问,提供致命故障的处理对jvm来说是非常之必需的。以oom为例,它是一个典型的致命错误。当发生这类错误时,一定要给用户提供一些合理且友好的方式来理解致命错误成因,从而能快速修复问题,这方面的问题不仅包含应用本身,也包含jvm本身。

    第一,一般当jvm在致命故障发生时crash掉,它会转储一个hotspot的错误日志文件,格式为:hs_err_pid<pid>.log。从JDK6开始大幅提升了这些致命错误的可诊断性,当发生crash,错误日志文件中会包含当前的内存图像,因此可以很容易搞清楚发生crash时的内存布局。

    第二,也可以使用-XX:ErrorFile=选项来指定错误日志的位置。

    第三,发生oom时,也会触发生成该错误文件。

    还有一个重要的功能,可以指定一个选项:-XX:OnError="cmd1 args...;com2 ...",这样当发生了crash时会执行这些指令,相应的指令就比较自由,比如我们可以指定此时执行一些诸如dbx或Windbg之类的debugger执行相应的操作。早于jdk6的应用可使用-XX:+ShowMessageBoxOnError来指定发生crash时使用的debugger。以下是jvm内部处理致命错误的一些摘要:

    首先,用VMError类聚合和转储hs_err_pid<pid>.log文件,当发现未识别的信号/异常时,由操作系统指定的代码调用它生成该文件。

    第二,vm使用信号来进行内部的交流,当出现未识别的信号,致命错误处理器被执行。而这个信号可能源自一个应用的jni代码,操作系统本地库,jre本地库,甚至是jvm本身。

    第三,致命错误处理器是慎重编写的,这也是为了避免它自己也出现错误,比如在出现StackOverFlow时,或在持有重要的锁期间发生crash(如持有分配锁)。

    死锁是一种常见的错误,一般发生在应用程序在申请多个锁时顺序不正确的情况。当死锁发生时,找出相应的点也是比较困难的,此时可以抓出java进程id,发送SIGQUIT到该进程(Solaris/Linux),会在标准输出中输出java级别的栈信息,这对分析死锁帮助极大,不过在jdk6之上的版本,已经可以使用Jconsole来轻松处理该问题。

    顺便简单提一提除了Jconsole/VisualVM等集成工具之外,一些单一目的的自带工具。

    jps:jvm进程工具,可以查看各jvm进程,名称和编号。

    jstat:虚拟机统计信息。比如发生了多少次full gc等。

    jinfo:java配置信息工具,运行时查看jvm进程的配置。

    jmap:内存映像工具,可以将当前内存情况转储一个快照文件。

    jhat:堆转储快照分析工具。

    jstack:java堆栈跟踪工具。

  • 总结

    本文简述了包含运行时参数处理,线程管理,类加载,类数据共享,运行时编译,异常处理,重大错误处理等java运行时技术。参考资料主要源自官方的若干文档,一部分资料是专属性的,如专门描述JVM或JIT,但根本无法确定成作于哪个版本(关于cds作者判断与JAVA8中的描述相同,但显然早已不适用),一部分资料是依托于较新版本的,因为新旧版本的文档并未保持同一目录结构,有些组件未能在新版中找到详尽的文档,因此难免会有不准确或过时的内容,作者争取在后面找到最新且更加权威的资料以修正。

    作者个人认为有两点重要的收获,一是宏观上了解了官方出品的HotSpot虚拟机在运行时的框架设计,理解java在运行时为我们竭尽全力做了哪些事;二是了解某些具体模块在新版中的优化和取舍,从而间接了解接下来java的使用趋势。如“云友好”,“多适应”,“开放”等。

    这三点是作者个人不成熟的简单总结,写到这里,也顺便对这三点进行一个“简单总结”。

    云友好其实体现的方面很多,cds就是重要一点,它在最新几个版本的更新用一句俗化表示:帮用户省钱。G1定时释放无用内存的新特性也体现了这一点。

    多适应和开放也很好理解,不止是gc方面,前面简单提过的zgc等针对超大堆的gc,以及G1这种放权让用户指定目标的gc,综合此前的各种gc,基本涵盖了我们所有可能的应用环境。同样的,模块化系统也天然匹配了中小型到大型项目的需求,一个项目从初创到逐渐壮大,或许最终就是模块不断扩充的过程,模块化系统甚至允许对jdk本身进行按需定制,对于小型设备用户也无益于是一个福音。JIT本身就具备自适应的编译思想,最优化最常执行的代码,graal是新出的基于java的JIT编译器。同步机制也引入了“自适应”自旋锁,G1中对cs的选择也具备自适应性等。

    再一次,膜拜前辈。

原文  https://segmentfault.com/a/1190000019435442
正文到此结束
Loading...