解释器
(译者注:解释器、编译器坑太大,翻译太水请见谅:)
当前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
)。这些状态是:
- Neutral:未锁住的。
- Biased:锁住/未锁住的 + 非共享的。
- Stack-Locked:锁住的 + 共享但无竞争的。标记位指向锁持有线程栈上面的锁记录。
- Inflated:锁住/未锁住的 + 共享并且有竞争的。线程阻塞在
monitorenter
或者wait()
中。标记位指向重量级的monitor对象。
(译者注:)
// - 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
,虚拟机就会创建相关联的JavaThread
和OSThread
对象,然后才创建本地线程。在一些相关的虚拟机准备工作 (例如线程本地存储(TLS),分配缓冲区,同步对象等等)后本地线程才会启动。本地线程先完成初始化,然后执行一个start-up方法,这个方法会调用java.lang.Thread
的run()
方法,当方法返回后,先处理未捕获的异常如果有的话,然后终止该线程,并且与虚拟机交互,确定此线程终止后是否需要关闭整个虚拟机。终止线程会释放所有分配的资源,从线程列表中删除JavaThread
,调用JavaThread
和OSThread
的析构函数,最后结束执行。
本地线程通过JNI调用AttachCurrentThread
来attach到虚拟机。这个调用会创建相关联的OSThread
和JavaThread
实例,并执行基本的初始化。然后会通过反射调用java.lang.Thread
的构造方法来创建一个java.lang.Thread
对象,参数由本地线程提供。一旦attach到虚拟机,本地线程就可以通过其他可用的JNI方法来调用它所需要的任意的Java代码。如果本地线程不再希望由虚拟机管理,可以调用DetachCurrentThread
来解除关联(释放资源,删除java.lang.Thread
实例的引用,销毁JavaThread
和OSThread
对象等等)。
本地线程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 handler和finalizer线程)。虚拟机线程主要有以下几种:
- VM线程:
VMThread
单例,负责执行虚拟机操作,下面会讨论 - 周期性任务线程:
WatcherThread
单例,模拟时钟中断,用于在虚拟机内部执行周期性的操作 - GC线程:这些线程用于支持并行和并发的垃圾回收,有不同的类型
- 编译器线程:这些线程用于执行运行时编译,将字节码编译成本地代码
- 信号分发线程:这个线程用于接收发送给虚拟机进程的信号,并且分发给一个Java级别的信号处理方法
所有的线程都是Thread
实例,并且所有执行Java代码的线程都是JavaThread
(Thread
的子类)实例。虚拟机使用了Threads_list
这个链表来记录所有的线程,Threads_list
由一个重要的同步锁,Threads_lock
,保护。
(译者注:)
// Class hierarchy
// - Thread
// - NamedThread
// - VMThread
// - ConcurrentGCThread
// - WorkerThread
// - GangWorker
// - GCTaskThread
// - JavaThread
// - WatcherThread
虚拟机操作与安全点
VMThread
从VMOperationQueue
获取操作并执行。比较典型的做法是,当这些操作在执行前需要虚拟机来到安全点时,它们就会被交给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
。
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:jni
,JNI会使用一些可替换的调试接口。这些可替换的接口会更加严格的验证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”,里面包含了许多对不可恢复错误诊断有用的信息。