HotSpot 运行时预览

前言

之前菜鱼我写了一篇搭建OpenJDK源码调试环境的文章,虽然环境搭建完毕了,但是对于代码的结构,以及重要的类等信息,还是存在一些阅读障碍。菜鱼我原计划写一篇介绍源码结构的文章,但思来想去,还是先翻译这篇文章,以供参考。

这篇文章源自于OpenJDK官网,对于JVM的运行机制做了一个大致的介绍,里面所涉及到的诸如VM的生命周期,异常处理,线程管理等信息,对研究JVM源码有概念性的作用。虽然没什么文章比官方的文档更权威,但菜鱼我的水平有限,菜鱼也不避讳,很多章节都是借助翻译软件来实现的。菜鱼我翻译的这篇文章也只可做一个借鉴,对于原版文章,请移步这里

极力推荐有能力的人,直接去看原版文档。文章中加了一些菜鱼我自己的理解与实际的运用,比如命令行参数处理VM生命周期这两节。


以下是翻译全文:

命令行参数处理(Command-Line Argument Processing)

有许多命令行参数和环境变量会影响Java HotSpot虚拟机的性能和特性。其中一些参数由启动器(即java命令)使用(例如-server-client);一些由启动器处理并传递给JVM;而大多数由JVM直接使用。

目前为止,主要有三种类型的参数:标准参数非标准参数开发者参数

  • 标准参数被所有JVM实现所接受,并且在发行版之间是稳定的(尽管它们可以被弃用)。例如前面提到的-server-client还有未提及的诸如:-agentlib:libname[=options]-version-help等。
  • -X开头的参数是非标准参数(不保证在所有JVM实现上都受支持),在Java SDK的后续版本中可以随意更改,无需通知。例如-Xmn-Xss-XshowSettings和最新的日志参数-Xlog等等,前面这些参数都是特定于HotSpot虚拟机的。
  • -XX开头的参数是开发者参数,通常对正确的操作有特定的系统要求,可能需要对系统配置参数的特权访问,不建议随意使用。这些参数也可随时更改,官方是不会特意发文告知的。例如-XX:ErrorFile-XX:MaxGCPauseMillis-XX:OnOutOfMemoryError等。

命令行参数控制JVM中内部变量的值,所有这些变量都有一个类型和一个默认值。对于布尔值,仅仅在命令行中出现或不出现参数就可以控制变量的值。对于-XX布尔参数,名称前的+-前缀分别表示truefalse值。对于需要额外数据的变量,有许多不同的机制用来传递数据。有些参数接受直接传递到参数名称后面的数据,不需要任何描述符,而对于其他参数,必须使用:=字符将参数名称与数据分开。不幸的是,这种方式依赖于特定的参数及其解析机制。开发者参数(-XX开头)只以三种不同的形式出现:-XX:+OptionName-XX:-OptionName-XX:OptionName=。大多数接受整数大小值的参数都接受kmg后缀,这些后缀用于千、百万或千兆乘数。这些最常用于控制内存大小的参数。

举个常用的例子,在生产环境上出现java.lang.OutOfMemoryError错误的时候,我们总是希望JVM能生成一份以.hprof结尾的堆内存分析文件,这时候就需要两个参数:-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath。这两个都是以-XX开头的参数,说明其都是开发者参数-XX:+HeapDumpOnOutOfMemoryError是一个布尔参数,其作用前面已经讲述过了,其默认值是false,也就是默认不会开启,当名称前出现一个+前缀,就表示开启这个参数的特性。 -XX:HeapDumpPath参数的作用:当生成.hprof文件以后,指定文件的存放路径。在Oracle Solaris、Linux和macOS操作系统中,这个参数的配置格式是这样的:-XX:HeapDumpPath=/path/java_heapdump.hprof,在Windows里面,这个参数的配置格式是这样的:-XX:HeapDumpPath=C:/path/java_heapdump.hprof。 再比如,我们在开发的过程中,想指定堆可使用的最大内存、堆初始化内存和年轻代堆初始化大小,就可以使用-Xmx-Xms-Xmn。这种参数就是前面所说的非标准参数。而且这种参数的赋值方式也很有意思,直接把数值放在参数后面:

-Xmx2g    # 堆可使用的最大内存为2g
-Xms2234k # 堆的初始化大小为2234k
-Xmn126M  # 年轻代堆的初始化大小为126M
-Xss2090  # 线程栈的大小设置为2090字节 没有后缀,默认单位就是字节。
复制代码

再说两个默认开启的参数:-XX:+UseTLAB-XX:+OptimizeStringConcat,这两个都是布尔值参数。-XX:+UseTLAB的作用是:在年轻代空间使用TLAB,如果在开发过程中,我们不想使用这个特性,就可以使用-XX:-UseTLAB来关闭这个参数的特性。-XX:+OptimizeStringConcat的作用是:优化String的连接操作,比如两个字符串用+符号连接的时候,这个参数就在起优化作用,这个特性也是默认开启的,且只在HotSpot虚拟机中存在,如果想禁用优化特性,就可以使用-XX:-OptimizeStringConcat来关闭这个参数的特性.

VM生命周期(VM Lifecycle)

下面的部分概述了与HotSpot VM的生命周期相关的通用java启动器。

Launcher

在Java SE中有几个HotSpot VM启动器,通用的启动器通常使用的是Unix和Windows上的java命令和javaw命令,不要与基于网络的启动器javaws混淆。 与虚拟机启动相关的启动程序操作如下:

  1. 解析命令行参数,一些命令行参数由启动器本身使用,例如-client-server用于确定和加载适当的VM库,其他的命令行参数将会被封装成JavaVMInitArgs传递给VM。
  2. 如果没有在命令行上显式指定,则虚拟机会根据运行环境来自动推断堆大小和编译器类型(clientserver)。
  3. 确定环境变量,如LD_LIBRARY_PATHCLASSPATH
  4. 如果Java Main-Class(main方法所在的类)没有在命令行中指定,它将从JAR的manifest中获取Main-Class名称。
  5. 在一个新创建的线程(非原始线程)中使用JNI_CreateJavaVM创建VM。注意:在原始线程中创建虚拟机大大降低了自定义虚拟机的能力,例如Windows上的堆栈大小和许多其他限制。
  6. 一旦VM被创建和初始化,Main-Class就会被加载,启动程序从Main-Class中获取main方法的属性。
  7. 然后调用CallStaticVoidMethod函数,使用来自命令行中封装传送进来的参数,在VM中调用Java main方法。
  8. Java main方法完成后,检查并清除可能发生的任何未决异常并返回退出状态非常重要,通过调用ExceptionOccurred清除异常,如果成功,该方法的返回值为0,否则返回任何其他值,此值将传递回调用进程。
  9. 主线程是使用DetachCurrentThread分离的,通过这样做,可以减少线程数量,这样DestroyJavaVM可以被安全地调用,同时也确保线程不在虚拟机中执行操作,并且在它的堆栈上没有活跃的Java帧。

有一点需要注意:对于命令行参数,分为两个部分,给虚拟机使用的参数和给Java中main方法的参数。比如菜鱼我有下面这样一个Main.java文件:

public class Main {
    public static void main(String[] args) {
        System.out.println(args.length);
    }
}
复制代码

现在执行下面这条命令:

java -XX:-OptimizeStringConcat -Xmx10m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath  Main.java firstParam -Xms2048K
复制代码

JVM会以Main.java为分界线,把前面的4个参数封装进JavaVMInitArgs中,以供VM使用,具体解析方式在arguments.cpp#Arguments::parse。会把包括Main.java在内之后的所有参数,封装成JavaMainArgs,在最后调用main方法之前,剥离出Main.java本身,其余的参数供main方法使用,详见java.c#JavaMain

最重要的阶段是JNI_CreateJavaVMDestroyJavaVM

JNI_CreateJavaVM

JNI调用方法执行以下操作:

  1. 确保不会有两个线程同时调用此方法,并且不会在同一进程中创建两个VM实例。请注意,一旦到达初始化点,即"point of no return",就无法在同一进程空间中创建VM。这是因为VM创建的静态数据结构此时无法重新初始化。
  2. 检查以确保JNI版本被支持,并且为GC日志记录初始化了ostream。OS模块被初始化,例如随机数生成器、当前pid、high-resolution时间、memory page size和 guard pages。
  3. 传入的参数和属性被解析并存储起来以备后用。标准Java系统属性被初始化。
  4. OS模块进一步创建和初始化,基于已经解析的参数和属性,针对同步、堆栈、内存和安全点页面进行初始化。此时其他库如libzip、libhpi、libjava、libthread已被加载,信号处理程序被初始化和设置,线程库被初始化。
  5. output stream logger已初始化。任何需要的代理库(hprof、jdi)都被初始化并启动。
  6. 线程状态和线程本地存储(TLS)被初始化,其中保存了线程操作所需的几个线程特定数据。
  7. 全局数据作为第一阶段的一部分进行初始化,例如事件日志、操作系统同步原语、perfMemory(performance memory)、chunkPool(memory allocator)。
  8. 此时,我们可以创建Threads。主线程的Java版本被创建并附加到当前的操作系统线程。但是,此线程尚未添加到Threads的已知列表中。Java层级的同步已初始化并启用。
  9. 其余的全局模块被初始化,例如BootClassLoaderCodeCacheInterpreterCompiler、JNI、SystemDictionaryUniverse。请注意,我们已经达到了“point of no return”,即我们不能再在同一个进程地址空间中创建另一个VM.
  10. 通过首先锁定Thread_Lock,将主线程添加到列表中。Universe(一组必需的全局数据结构)进行了完整性检查。创建了执行所有VM关键功能的VMThread。此时将发布适当的JVMTI事件以通知当前状态。
  11. 以下类:java.lang.Stringjava.lang.Systemjava.lang.Threadjava.lang.ThreadGroupjava.lang.reflect.Methodjava.lang.ref.Finalizerjava.lang.Class和其余的System类被加载和初始化。
  12. Signal Handler线程启动,编译器初始化,CompileBroker线程启动。其他辅助线程StatSamplerWatcherThreads启动,此时VM功能齐全,填充JNIEnv并返回给调用者,VM准备好服务新的JNI请求。

JNI_CreateJavaVM的实现在jni.cpp#JNI_CreateJavaVM_inner中,这里面会调用Threads::create_vm以完成上述初始化。

DestroyJavaVM

这个方法可以从启动器中调用来关闭VM,也可以在发生非常严重的错误时由VM本身调用。VM的销毁需要以下步骤:

  1. 等到我们是最后一个执行的非守护线程,注意此时VM仍然正常工作。
  2. 调用java.lang.Shutdown.shutdown(),它将调用Java级别的shutdown钩子,如果finalization-on-exit则运行终结器。
  3. 调用before_exit(),准备VM退出运行VM级别的关闭钩子(它们通过JVM_OnExit()注册),停止ProfilerStatSamplerWatcherGC线程。将状态事件发布到JVMTI/PI,禁用JVMPI,并停止Signal线程。
  4. 调用JavaThread::exit(),释放JNI句柄块,删除栈的guard pages,并从Threads列表中删除该线程。从现在开始,我们不能再执行任何Java代码。
  5. 停止VM线程,它将把剩余的VM带到一个安全点,并停止编译器线程。在安全点,我们应该注意不要使用任何可能被安全点阻塞的东西。
  6. 在JNI/JVM/JVMPI屏障处禁用跟踪。
  7. 为仍在运行本机代码的线程设置_vm_exited标志。
  8. 删除这个线程。
  9. 调用exit_globals(),删除IO和PerfMemory资源。
  10. 返回到调用者。

VM类加载(VM Class Loading)

Java Hotspot VM支持Java语言规范第三版1,Java虚拟机规范(JVMS)第二版2和更新的JVMS第5章Loading, Linking and Initializing3所定义的类加载.

VM负责解析常量池符号,这需要加载、链接然后初始化类和接口。我们将使用术语"类加载"来描述将类或接口名称映射到类对象的整个过程,以及JVMS所定义的类加载阶段中更具体的术语加载、链接和初始化。

类加载的最常见原因是在字节码解析期间,此时类文件中的常量池符号需要解析。Java API诸如Class.forName()classLoader.loadClass()、反射API和JNI_FindClass等都可以启动类加载。 VM本身可以启动类加载。VM在JVM启动时加载java.lang.Objectjava.lang.Thread等核心类。加载一个类需要加载其所有父类和父接口。类文件验证是链接阶段的一部分,也可能需要加载额外的类。

VM和Java SE类加载库共同负责类加载。VM为类和接口执行常量池解析、链接和初始化。加载阶段是VM和特定类加载器(java.lang.ClassLoader)之间的协作。

类加载阶段(Class Loading Phases)

加载类阶段采用类名或接口名,查找类文件格式的二进制文件(即.class文件),定义类并创建java.lang.Class对象。如果找不到二进制表示(表述没有问题,二进制表示不仅仅存在于磁盘上的文件中,为避免歧义,后面使用.class文件来表示),则加载类阶段可能会抛出NoClassDefFound错误。此外,加载类阶段会对.class文件的语法进行格式检查,这可能会抛出ClassFormatErrorUnsupportedClassVersionError。在完成类的加载之前,VM必须加载它的所有父类和父接口。如果类层次结构存在问题,例如此类是其自己的父类或父接口(递归extendsimplements),则VM将抛出ClassCircularityError。如果直接父接口不是接口,或者直接父类是接口,VM也会抛出IncompatibleClassChangeError

链接类阶段首先进行验证,检查class文件语义、检查常量池符号并进行类型检查。这些检查可能会抛出VerifyError。链接然后进行准备,创建静态字段并将其初始化为标准默认值,并分配方法表。 请注意,此时尚未运行任何Java代码。 然后链接视需要解析符号引用。

类初始化运行静态初始化器(类里面的static{},静态代码段)和静态字段的初始化器(类里面static修饰的字段)。这是这个类运行的第一个Java代码。注意,类初始化需要父类初始化,但不需要父接口初始化。

JVMS规定类初始化发生在第一次"主动使用"类时。只要我们尊重语言的语义,在执行下一步之前先完成加载、链接和初始化的每个步骤,并在程序预期的时候抛出错误,JLS就允许在链接的符号解析步骤发生时具有灵活性。出于性能考虑,HotSpot VM 通常会等到类初始化后再加载和链接一个类。所以如果类A引用类B,加载类A不一定会导致类B的加载(除非需要进行验证)。执行引用B的第一条指令将导致B的初始化,这时才需要加载和链接类B

类加载器委托

当一个类加载器被要求查找并加载一个类时,它可以请求另一个类加载器来进行实际的加载。这称为类加载器委托。第一个加载器是初始化加载器,最终定义类的类加载称为定义加载器。在字节码解析的情况下,初始化加载器是我们正在解析其常量池符号的类的类加载器。

类加载器是分层定义的,每个类加载器都有一个委托父类。委托定义了二进制类表示的搜索顺序。Java SE类加载器层次结构按顺序搜索bootstrap类加载器、extension类加载器和system类加载器。system类加载器是默认的应用程序类加载器,它运行"main"并从类路径加载类。应用程序类加载器可以是来自Java SE类加载器库的类加载器,也可以由应用程序开发人员提供。Java SE类加载器库实现了从JRE的lib/ext目录加载类的extension类加载器。

Bootstrap类加载器

VM实现了Bootstrap类加载器,它从BOOTPATH加载类,包括例如rt.jar。为了更快的启动,VM还可以通过Class Data Sharing处理预加载的类。

类型安全

类或接口名被定义为包含包名称的完全限定名。类类型由该完全限定名和类加载器唯一确定。 因此,一个类加载器定义了一个命名空间,并且由两个不同的定义类加载器加载的同一个类名会导致两个不同的类类型。

鉴于自定义类加载器的存在,VM负责确保表现不佳的类加载器不会违反类型安全。请参阅Java虚拟机4和JVMS5.3.42中的动态类加载。VM通过跟踪和检查加载器约束,来确保当类A调用B .foo() 时,A的类加载器和B的类加载器对foo的参数和返回值达成一致。

HotSpot中的类元数据

类加载会在GC永久代中创建一个instanceKlassarrayKlass。instanceKlass指的是一个java镜像,就是java.lang.Class镜像这个类的实例。 VM C++对instanceKlass的访问是通过klassOop进行的。

ps:JDK1.8之后,已不存在永久代的概念。

HotSpot内部类加载数据

HotSpot VM维护三个主要哈希表来跟踪类的加载。SystemDictionary包含已经加载的类,它将类名/类加载器的配对映射到klassOopSystemDictionary包含类名/启动加载器的配对和类名/定义加载器的配对。已配对的记录目前只在安全点被删除。PlaceholderTable包含当前正在加载的类。它用于ClassCircularityError检查和支持多线程加载类的的类加载器执行并行类加载。LoaderConstraintTable跟踪用于类型安全检查的约束。

这些哈希表都受SystemDictionary_lock保护。通常,VM中的加载类阶段使用类加载器对象锁进行序列化。

字节码验证器和格式检查器

Java语言是一种类型安全的语言,标准的Java编译器生成有效的类文件和类型安全的代码,但是JVM不能保证代码是由可信任的编译器生成的,因此它必须在链接时通过一个称为字节码验证的机制重新建立类型安全。

字节码验证在Java虚拟机规范的4.8节中指定。规范对JVM验证的代码规定了静态和动态约束。如果发现任何违规,VM将抛出VerifyError并阻止类被链接。

字节码上的许多约束可以进行静态检查,例如ldc代码的操作数必须是一个有效的常量池索引,其类型为CONSTANT_IntegerCONSTANT_StringCONSTANT_Float。检查其他指令的参数类型和数量的其他约束需要对代码进行动态分析,以确定在执行期间哪些操作数将出现在表达式堆栈上。

目前有两种分析字节码的方法,可以用来确定将出现的操作数的类型和数量。传统方法称为“类型推断”,通过对每个字节码进行抽象解释,并在分支目标或异常句柄处合并类型状态来进行操作。分析遍历字节码,直到找到类型的稳定状态。 如果找不到稳定状态,或者结果类型违反了某些字节码约束,则抛出VerifyError。此验证步骤的代码存在于libverify.so扩展库中,并使用JNI收集有关类和类型所需的任何信息。

JDK6 中新增了第二种验证方法,称为“类型验证”。在此方法中,Java编译器通过代码属性StackMapTable为每个分支或异常目标提供稳态类型信息。StackMapTable由许多堆栈映射帧组成,每个帧表示表达式堆栈上和方法中某个偏移处的局部变量中项的类型。然后,JVM只需对字节码执行一次传递,以验证字节码的类型的正确性。这是JavaME CLDC已经使用的方法。由于它更小、更快,这种验证方法直接构建在VM本身中。

对于版本号小于50的所有.class文件,比如那些在JDK6之前创建的.class文件,JVM将使用传统的类型推断方法来验证.class文件。对于大于或等于50的.class文件,将出现StackMapTable属性,并使用新的验证器。 由于较旧的外部工具可能会检测字节码,但忽略了更新的StackMapTable属性,因此在类型检查验证期间发生的某些验证错误可能会转移到类型推断方法。如果成功,将对.class文件进行验证。

Class Data Sharing

类数据共享(CDS)是J2SE 5.0中引入的一个特性,旨在减少Java编程语言应用程序(特别是较小的应用程序)的启动时间,并减少程序占用的存储空间。 当使用Sun提供的安装程序在32位平台上安装JRE时,安装程序将系统jar文件中的一组类加载到私有内部表示中,并将该表示转储到称为“共享存档(shared archive)”的文件中。 如果未使用Sun提供的JRE安装程序,也可以手动完成,如下所述。 在随后的JVM调用期间,共享存档被映射到内存中,从而节省了加载这些类的成本,并允许这些类的大部分JVM元数据在多个JVM进程之间共享。

类数据共享只支持Java HotSpot Client VM,并且只支持串行垃圾收集器。

包含CDS的主要动机是为了减少它的启动时间。 CDS为较小的应用程序生成了更好的结果,因为它消除了固定成本:加载某些核心类的成本。 应用程序相对于它使用的核心类的数量越小,节省的启动时间就越大。

新JVM实例的占用成本已通过两种方式降低。首先,共享存档的一部分,目前在5到6M之间,被映射为只读,因此在可以在多个JVM进程之间共享,且无线程安全问题。 以前,该数据在每个JVM实例中都被复制一份。 其次,由于共享存档文件包含Java HotSpot VM所使用的类数据的形式,因此节省了访问rt.jar中原始类信息所需的内存。 这些节省下来的空间允许更多应用程序在同一台机器上同时运行。 在Microsoft Windows上,根据各种工具的测量,随着时间的推移,进程的占用空间可能会增加,因为大量的页面被映射到进程的地址空间中。 这被减少存储rt.jar部分类数据所需的内存量(在 Microsoft Windows 内)所抵消。减少存储空间占用仍然是重中之重。

在HotSpot中,类数据共享实现在包含共享数据的永久代中引入的新Spaces。classes.jsa共享存档在VM启动时被映射到这些空间。随后,共享区域由现有的VM内存管理子系统管理。

只读共享数据包括常量方法对象(constMethodOops)、符号对象(symbolOops)和原语数组(主要是字符数组)。

读写共享数据由可变方法对象(methodOops)、常量池对象(constantPoolOops)、Java类和数组的VM内部表示(instanceKlassarrayKlass),以及各种StringClassException对象组成。

解释器

当前用于执行字节码的HotSpot解释器是一个基于模板的解释器。 HotSpot运行时,InterpreterGenerator在启动时使用TemplateTable中的信息(对应于每个字节码的汇编代码)在内存中生成一个解释器。 模板是每个字节码的描述。TemplateTable定义了所有的模板,并提供了访问函数来获取给定字节码的模板。 不推荐在生产环境使用的参数-XX:+PrintInterpreter,用于查看虚拟机启动过程中在内存中生成的模板表。

模板设计比经典的switch语句循环执行得更好,原因有如下几个。 switch语句执行重复的比较操作,在最坏的情况下,可能需要将给定的字节码与目标字节码之外的所有字节码进行比较,以定位到所需的目标字节码。 其次,它使用一个独立的软件堆栈来传递Java参数,而本地C堆栈由VM本身使用。许多JVM内部变量,比如程序计数器或Java线程的堆栈指针,都存储在C变量中,且不能保证这些变量总是保存在硬件寄存器中。 这些软件解释器结构的管理消耗了相当大的一部分总执行时间。5

总的来说,通过HotSpot解释器,显著缩小了VM和真实机器之间的差距,这使得解释速度大大提高。 然而,这需要付出一些代价,例如大量特定于机器的代码块(大约10 KLOC特定于intel的代码和14 KLOC特定于SPARC的代码)。 与此同时,整体的代码大小和复杂度也会显著提高,因为需要支持动态代码生成的代码。显然,调试动态生成的机器码比静态代码要困难得多。 这些属性当然不会促进运行时演化的实现,但它们也不会使其不可行。 5

解释器调用VM运行时进行复杂的操作(基本上是任何复杂到无法用汇编语言完成的操作),比如常量池查找。

HotSpot解释器也是整个HotSpot自适应优化过程的关键部分。自适应优化通过利用一个有趣的程序属性来解决JIT编译的问题。 几乎所有程序都将绝大多数时间花在执行少数代码上。Java HotSpot VM的编译方式不是按方法编译,而是及时地使用解释器立即运行程序,并在运行时分析代码以检测程序中的关键热点。 然后它将全局本地代码优化器的注意力集中在热点上。 通过避免编译不经常执行的代码(大部分程序),Java HotSpot 编译器可以将更多注意力放在程序的性能关键部分,而不必增加整体编译时间。 这种热点监控在程序运行时动态地持续进行,因此它可以根据用户的需要即时调整其性能。

Java异常处理

Java虚拟机使用异常来表示程序违反了Java语言的语义约束。例如,试图在数组边界外建立索引将导致异常。异常导致控制从发生(或引发)异常的点到程序员指定的点(或捕获异常的点)进行非本地转移。6

HotSpot解释器、动态编译器和运行时都协同实现异常处理。 异常处理一般有两种情况:一种情况是,异常在同一个方法中被抛出或捕获;另一种情况是,异常被调用者捕获。 后一种情况更复杂,需要展开堆栈以找到合适的处理程序。

异常可以由throw字节码、VM内部调用的结果、JNI调用的结果或Java调用的结果引发。(最后一种情况实际上只是前3种情况的后续体现。)当VM意识到抛出了一个异常时,将调用运行时系统以查找该异常最近的处理程序。三个信息用于查找处理程序:当前方法、当前字节码和异常对象。 如果当前方法中没有找到处理程序,如上所述,将弹出当前已激活的堆栈帧,并对前面的帧重复此查找过程。

一旦找到正确的处理程序,VM执行状态就会更新,当Java代码执行恢复时,我们会跳转到处理程序。

同步

从广义上讲,我们可以将“同步”定义为一种防止、避免或从并发操作的不适当顺序(通常称为“竞争”)中恢复的机制。 在Java中,并发是通过线程构造来表达的。 互斥是同步的一种特殊情况,最多允许单个线程访问受保护的代码或数据。

HotSpot提供Java监视器,运行应用程序代码的线程可以通过这些监视器参与互斥协议。监视器要么被锁定,要么被解锁,并且任何时候有且只有一个线程可以拥有监视器。 只有在获得了监视器的所有权后,线程才能进入监视器保护的临界区。在Java中,临界区被称为“同步块”,并由同步语句在代码中描述。

如果一个线程试图锁定一个监视器,而该监视器处于无锁状态,该线程将立即获得该监视器的所有权。如果后续线程在监视器被锁定时试图获得该监视器的所有权,则该线程将不被允许进入临界区,直到监视器的所有者释放该锁,并且第二个线程设法获得(或被授予)该锁的独占所有权。

一些额外的术语:“进入”监视器意味着获得监视器的独占所有权,并进入相关的临界区。同样,“退出”监视器意味着释放监视器的所有权,并退出临界区。我们还说,锁定了监视器的线程现在“拥有”该监视器。“无竞争”是指仅由单个线程在其他无主的监视器上进行同步操作。

HotSpot VM在无竞争和竞争的同步操作中都集成了前沿技术,大大提高了同步性能。

无竞争的同步操作占了大部分的同步操作,它们是用常量时间技术实现的。 使用偏向锁,在最好的情况下,这些操作基本上是自由的。 由于大多数对象在其生命周期内最多被一个线程锁定,因此我们允许该线程将对象偏向自身。 一旦有偏差,该线程随后就可以锁定和解锁对象,而无需求助于昂贵的原子指令。7

竞争的同步操作使用先进的自适应旋转技术来提高吞吐量,即使对于具有大量锁争用的应用程序也是如此。因此,同步性能变得如此之快,以至于对于绝大多数现实世界的程序来说,这不是一个显著的性能问题。

在HotSpot中,大多数的同步都是通过我们所谓的“快速路径(fast-path)”代码来处理的。 我们有两个即时编译器 (JIT) 和一个解释器,所有这些都将发出快速路径代码。这两个JIT是“C1”,它是-client编译器,以及“C2”,它是-server编译器。 C1和C2都直接在同步点生成快速路径代码。在没有争用的正常情况下,同步操作将完全在快速路径中完成。 但是,如果我们需要阻塞或唤醒线程(分别在monitorentermonitorexit中),快速路径代码将调用"慢速路径(slow-path)"。 慢速路径实现在本地C++代码中,而快速路径由JIT发出。

每个对象的同步状态被编码在VM对象表示的第一个字(所谓的mark word)中。 对于同步状态,mark word被多路复用以指向额外的同步元数据(顺便说一句,除同步状态之外,mark word也被多路复用以包含GC年龄数据,以及对象的身份hashCode值)。这些同步状态是:

  • Neutral: Unlocked
  • Biased: Locked/Unlocked + Unshared
  • Stack-Locked: Locked + Shared but uncontended。The mark points to displaced mark word on the owner thread's stack.
  • Inflated: Locked/Unlocked + Shared and contended。Threads are blocked in monitorenter or wait().

The mark points to heavy-weight "objectmonitor" structure.8

线程管理

线程管理涵盖线程生命周期的所有方面,从创建到终止,以及VM内的线程协调。这涉及到管理从Java代码(无论是应用程序代码还是库代码)创建的线程、直接附加到VM的本地线程,或出于各种目的创建的内部VM线程。虽然线程管理从抽象的概念来讲,与平台无关,但细节必然会因底层操作系统而异。

线程模型(Threading Model)

HotSpot中的基本线程模型是Java线程(java.lang.Thread的一个实例)和本地操作系统线程之间的1:1映射。本地线程在Java线程启动时创建,在它终止时回收。操作系统负责调度所有线程并将其分派到任何可用的CPU。

Java线程优先级和操作系统线程优先级之间的关系是复杂的,在不同的系统中会有所不同。稍后将讨论这些细节。

线程创建和销毁(Thread Creation and Destruction)

将线程引入VM有两种基本方法:执行Java代码,在java.lang.Thread对象上调用start()方法; 或使用JNI将现有的本地线程附加到VM。 VM因内部目的创建的其他线程将在下面讨论。

在VM中,有许多对象与一个给定的线程相关联(请记住,HotSpot是用C++面向对象的编程语言编写的):

  • 在Java代码中,java.lang.Thread实例表示线程。

  • 在VM内部,一个JavaThread实例表示java.lang.Thread的一个实例。它包含用于跟踪线程状态的附加信息。JavaThread实例持有与其关联的java.lang.Thread对象的引用(作为一个oop),而java.lang.Thread对象也存储与其关联的JavaThread实例的引用(作为一个原始int)。JavaThread还持有与其关联的OSThread实例的引用。

  • 一个OSThread实例对应一个操作系统线程,并包含跟踪线程状态所需的其他操作系统级信息。OSThread包含一个特定于平台的“句柄”,用来标识操作系统的实际线程。

本地线程完成初始化,然后执行一个启动方法,该方法导致java.lang.Thread对象的run()方法执行,然后,在它返回时,在处理任何未捕获的异常后终止线程,并与VM交互,检查该线程的终止是否需要终止整个VM。线程终止释放所有已分配的资源,从已知的线程集合中删除JavaThread,调用OSThreadJavaThread的析构函数,并最终在初始启动方法完成时停止执行。

本地线程使用JNI调用AttachCurrentThread附加到VM。作为对此的响应,将创建关联的OSThreadJavaThread实例并执行基本初始化。接下来,必须为附加的线程创建一个java.lang.Thread对象,这是通过根据线程附加时提供的参数反射调用Thread类构造函数的Java代码来完成的。一旦附加成功,线程就可以通过其他可用的JNI方法调用它需要的任何Java代码。最后,当本机线程不再希望与虚拟机有关联时,它可以调用JNI DetachCurrentThread方法来将其与VM解除关联(释放资源,删除对java.lang.Thread实例的引用,销毁JavaThreadOSThread对象,等等)。

附加本地线程的一种特殊情况是通过JNI CreateJavaVM调用初始创建VM,这可以由本地应用程序或启动程序(java.c)完成。这会导致一系列初始化操作发生,然后像调用AttachCurrentThread一样有效地运行。接下来。线程可以根据需要调用Java代码,例如反射调用应用程序的main方法。

线程状态(Thread States)

VM使用许多不同的内部线程状态来描述每个线程正在做什么。这对于协调线程的交互以及在出现问题时提供有用的调试信息都是必要的。线程在执行不同操作时的状态转换,这些转换点用于检查线程在该时间点继续执行请求的行为是否合适——请参阅下面对安全点的讨论。

从虚拟机的角度来看,主线程的状态如下:

  • _thread_new:正在初始化中的新线程

  • _thread_in_Java:现在正在执行Java代码

  • _thread_in_vm:在虚拟机内部执行的线程

  • _thread_blocked:线程由于某种原因被阻塞(获取锁,等待条件,休眠,执行阻塞的I/O操作,等等)

出于调试目的,还维护了额外的状态信息以供工具,在线程转储、堆栈跟踪等使用。这些状态信息在OSThread中维护,其中一些已停用,但在线程转储中报告的状态包括:

  • MONITOR_WAIT:线程正在等待获取一个争用的监视器锁
  • CONDVAR_WAIT:线程正在等待VM使用的内部条件变量(不与任何Java级对象关联)
  • OBJECT_WAIT:线程正在执行Object.wait()调用

其他子系统和库有它们自己的状态信息,比如JVMTI系统和java.lang.Thread类自身暴露出的ThreadState。这些信息通常无法访问,也与VM内部的线程管理无关。

内部虚拟机线程(Internal VM Threads)

人们常常惊讶地发现,即使执行一个简单的“Hello World”程序,也可能导致在系统中创建十几个或更多的线程。这些线程由内部VM线程和库相关线程(例如引用处理程序和终结程序线程)组合而成。虚拟机线程的主要类型如下:

  • VM thread:VMThread的这个单实例负责执行VM操作,下面将讨论这些操作

  • Periodic task thread:WatcherThread的这个单实例模拟了用于在VM中执行周期性操作的计时器中断

  • GC threads:这些不同类型的线程支持并行和并发垃圾收集

  • Compiler threads:这些线程将字节码在运行时编译为本地机器码

  • Signal dispatcher thread:此线程等待进程定向信号,并将它们分发到Java级别的信号处理方法

所有线程都是Thread类的实例,所有执行Java代码的线程都是JavaThread实例(Thread的一个子类)。VM跟踪一个名为Threads_list的链表中的所有线程,该链表受Threads_lock(VM中使用的关键同步锁之一)保护。

VM Operations and Safepoints

VMThread花时间等待VMOperationQueue中的操作出现,然后执行这些操作。通常,这些操作被传递给VMThread,因为它们要求VM在执行之前到达一个安全点。简单地说,当虚拟机在安全点时,虚拟机内部的所有线程都被阻塞了,在安全点进行期间,任何在本地代码中执行的线程都被阻止返回虚拟机。这意味着可以执行VM操作,因为知道此时没有线程正在修改Java堆,并且所有线程都处于Java堆栈不变的状态,可以对其进行检查。

最常见的VM操作是用于垃圾收集,或者更具体地说,是用于垃圾收集的“stop-the-world”阶段,这是许多垃圾收集算法所常见的。但是存在许多其他基于安全点的VM操作,例如:偏向锁撤销、线程堆栈转储、线程暂停或停止(即java.lang.Thread.stop()方法被调用)以及通过JVMTI请求的大量检查/修改操作。

许多VM操作是同步的,即请求者阻塞直到操作完成,但有些是异步或并发的,这意味着请求者可以与VMThread并行进行(当然假设没有启动安全点)。

安全点是使用一种合作的、基于轮询的机制开创的。 简单来说,每隔一段时间就会有一个线程问“我应该为安全点阻塞吗?”。 有效地提出这个问题并不是那么简单。 这个问题经常出现在线程状态转换期间。 并不是所有的状态转换都这样做,例如一个线程离开VM转到本地代码,但大多数的状态转换,线程都会产生这样一个询问。 当从方法返回或在循环迭代的某些阶段返回时,线程请求的其他位置在已编译的代码中。 执行解释代码的线程通常不会提问题,相反,当安全点被请求时,解释器会切换到一个不同的调度表,这个调度表包含在询问问题的代码中;当安全点结束时,调度表再次切换回。 一旦请求了安全点,VMThread必须等待直到所有线程都处于安全点安全状态,然后才能继续执行VM操作。在安全点期间,Threads_lock被用来阻塞任何正在运行的线程,VMThread最终会在VM操作完成后释放Threads_lock

C++ 堆管理

除了由Java堆管理器和垃圾收集器维护的Java堆之外,HotSpot还使用C/C++堆(也称为 malloc 堆)来存储VM内部对象和数据。从基类Arena派生的一组C++类用于管理C++堆操作。

Arena及其子类提供了一个位于malloc/free之上的快速分配层。每个Arena从3个全局ChunkPool中分配内存块(或Chunks)。每个ChunkPool满足不同分配大小范围的分配请求。例如,1k的内存请求将从“小型”ChunkPool分配,而10K内存分配将从“中型”ChunkPool进行。这样做是为了避免浪费的内存碎片。

Arena系统还提供了比单纯使用malloc/free更好的性能。后一种操作可能需要获取全局操作系统锁,这将影响可伸缩性,并可能损害性能。Arena是缓存一定数量存储的线程本地对象,因此在快速路径分配情况下不需要锁。同样,Arena free操作在一般情况下不需要锁。

Arena用于线程本地资源管理(ResourceArea)和句柄管理(HandleArea)。它们也被客户端和服务器编译器在编译期间使用。

Java本地接口(Java Native Interface (JNI))

JNI是一个本地编程接口。它允许在Java虚拟机中运行的Java代码与用其他编程语言(如C/C++和汇编)编写的应用程序和库进行互操作。

虽然应用程序可以完全用Java编写,但在某些情况下,仅Java并不能满足应用程序的需求。 当应用程序不能完全用Java编写时,程序员可以使用JNI编写Java本地方法来处理这些情况。

JNI本地方法可用于创建、检查和更新Java对象、调用Java方法、捕获和抛出异常、加载类和获取类信息,以及执行运行时类型检查。

JNI还可以与Invocation API一起使用,使任意本地应用程序能够嵌入Java VM。这使得程序员可以轻松地使他们现有的应用程序启用Java,而不必链接到VM源代码9

重要的是要记住,一旦应用程序使用JNI,就有可能失去Java平台的两个好处。

首先,依赖于JNI的Java应用程序不能再轻易的一次编译,到处运行。即使应用程序中使用Java编程语言编写的部分可以移植到多个主机环境中,仍然需要重新编译应用程序中使用本地编程语言编写的部分。

其次,虽然Java语言是类型安全和可靠的,但C/C++等本地语言则不是。因此,Java开发人员在使用JNI编写应用程序时必须格外小心。暗藏隐患的本地方法可能会破坏整个应用程序。由于这个原因,Java应用程序在调用JNI功能之前要接受安全检查。

作为一条共用规则,开发人员应该在构建应用程序时,尽量在较少的类中定义本地方法。 这需要在本地代码和应用程序的其余部分之间进行更清晰的隔离。10

在HotSpot中,JNI函数的实现相对简单。它使用各种VM内部原语来执行诸如对象创建、方法调用等活动。通常,这些与解释器等其他子系统使用的运行时原语相同。

VM提供了一个虚拟机参数-Xcheck:jni,以帮助调试本地方法使用JNI时出现的问题。指定-Xcheck:jni会导致JNI调用使用一组备用的调试接口。备用接口更严格地验证JNI调用的参数,并执行额外的内部一致性检查。

HotSpot必须特别注意跟踪当前在本地方法中执行的线程。在某些VM活动期间,特别是在垃圾回收的某些阶段,必须在安全点暂停一个或多个线程,以确保Java内存堆在敏感活动期间不会被修改。当我们希望将在本地代码中执行的线程带到安全点时,允许它继续执行本机代码,但当线程试图返回到Java代码或进行JNI调用时,线程将被停止。

VM致命错误处理(VM Fatal Error Handling)

为任何软件提供处理致命错误的简单处理方法都是非常重要的。Java虚拟机,即JVM也不例外。一个典型的致命错误是OutOfMemoryError。 Windows上另一个常见的致命错误叫做Access Violation错误,它相当于Solaris/Linux平台上的Segmentation Fault。为了在应用程序中(有时在JVM本身中)修复这些致命错误,理解这些致命错误的原因是至关重要的。

通常当JVM因致命错误而崩溃时,它会将名为hs_err_pid<pid>.log的热点错误日志文件(其中<pid>替换为崩溃的java进程id)转储到Windows桌面,或 Solaris/Linux上的当前应用程序目录中。自JDK 6以来,已经进行了几项改进以提高此文件的可诊断性,其中许多改进项已经重新移植到JDK-1.4.2_09版本。以下是这些改进的一些亮点:

  • 内存映射包含在错误日志文件中,因此很容易看到内存在崩溃期间是如何布局的。
  • -XX:ErrorFile=参数,用户可以设置错误日志文件的路径名。
  • OutOfMemoryError也会触发生成文件。

另一个重要的特性是,你可以为java命令指定-XX:OnError="cmd1 args...;com2 ...",这样每当VM崩溃时,它将执行你在上面显示的引号中指定的命令列表。该特性的一个典型用法是,当崩溃发生时,可以调用dbx或Windbg之类的调试器来查看崩溃原因。对于早期版本,你可以指定-XX:+ShowMessageBoxOnError作为运行时的虚拟机参数,以便当VM崩溃时,你可以将正在运行的Java进程附加到你喜欢的调试器。

在讨论了HotSpot错误日志文件之后,下面简要总结一下JVM内部是如何处理致命错误的。

  • VMError类是为聚合和转储hs_err_pid<pid>.log文件而发明的。当看到无法识别的信号/异常时,它由特定于操作系统的代码调用。
  • VM在内部使用信号进行通信。当信号无法识别时,将调用致命错误处理程序。在无法识别的情况下,它可能来自应用程序JNI代码、OS本地库、JRE本地库或JVM自身的错误。
  • 致命错误处理程序是精心编写的,以避免在StackOverflow或持有关键锁(如malloc锁)时崩溃的情况下导致自身故障。

由于OutOfMemoryError在某些大型应用程序中非常常见,因此向用户提供有用的诊断信息,以便他们可以快速确定解决方案至关重要,比如有时只需指定更大的Java堆即可。当OutOfMemoryError发生时,错误消息将指示哪种类型的内存有问题。例如,它可能是Java堆空间或PermGen空间等。自JDK 6以来,错误消息中已将堆栈跟踪包含在内。同时,还发明了-XX:OnOutOfMemoryError="<cmd>"参数,以便在抛出第一个OutOfMemoryError时运行命令。另一个值得一提的好特性是OutOfMemoryError的内置堆转储。它通过设置-XX:+HeapDumpOnOutOfMemoryError参数启用,你还可以指定-XX:HeapDumpPath=<pathname>参数,以告诉VM将堆转储文件放在哪里。

即使应用程序经过仔细编写以避免死锁,有时它仍然会发生。当死锁发生时,你可以在Windows上键入“Ctrl+Break”或获取Java进程ID并将SIGQUIT发送到Solaris/Linux上的挂起进程。Java级别的堆栈跟踪将被转储到标准输出,以便你可以分析死锁的原因。从JDK 6开始,此功能已内置到jconsole中,这是JDK中非常有用的工具。 所以当应用程序死锁时,将Java进程附加到jconsole,它会分析哪个锁有问题。大多数情况下,死锁是由于获取锁的顺序错误造成的。

我们强烈建议你查看“故障排除和诊断指南”11。它包含许多信息,这些信息对于诊断致命错误可能非常有用。

引用


  1. Java Language Specification, Third Edition. Gosling, Joy, Steele, Bracha. java.sun.com/docs/books/…
  2. Java Virtual Machine Specification, Second Edition. Tim Lindholm, Frank Yellin. java.sun.com/docs/books/…
  3. Amendment to Java Virtual Machine Specification. Chapter 5: Loading, Linking and Initializing. java.sun.com/docs/books/…
  4. Dynamic Class Loading in the Java Virtual Machine. Shen Liang, Gilad Bracha. Proc. of the ACM Conf. on Object-Oriented Programming, Systems, Languages and Applications, October 1998 www.bracha.org/classloader…
  5. “Safe Clsss and Data Evolution in Large and Long-Lived Java Applications”, Mikhail Dmitriev, research.sun.com/techrep/200…
  6. Java Language Specification, Third Edition. Gosling, Joy, Steele, Bracha. java.sun.com/docs/books/…
  7. “Biased Locking in HotSpot”. blogs.oracle.com/dave/entry/…
  8. “Let’s say you’re interested in using HotSpot as a vehicle for synchronization research ...”. blogs.oracle.com/dave/entry/…
  9. “Java Native Interface Specifications” java.sun.com/javase/6/do…
  10. “The Java Native Interface Programmer’s Guide and Specification”, Sheng Liang, java.sun.com/docs/books/…
  11. “Trouble-Shooting and Diagnostic Guide” java.sun.com/javase/6/we…

猜你喜欢

转载自juejin.im/post/7053758215536214047