4

Java监控与性能优化(三) JVM与调优

 2 years ago
source link: https://zhouj000.github.io/2021/10/10/java-monitoring-optimization-3/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Java性能优化01-程序优化
Java性能优化02-并行优化
Java性能优化03-JVM调优
Java性能优化04-调优工具

Java监控与性能优化(一) 概况
Java监控与性能优化(二) 性能分析
Java监控与性能优化(三) JVM与调优

JVM概览

HotSpot VM有3个主要组件:VM运行时(Runtime)、JIT编译器(JIT Compiler)、内存管理器(Memory Manager)

JIT编译器(Client或Server)和垃圾收集器(Serial、Throughput、CMS或G1)都是可插拔的。HotSpot VM运行时系统为HotSpot JIT编译器和垃圾收集器提供服务和通用API,还为VM提供启动、线程管理、JNI(Java本地接口)等基本功能

64位HotSpot VM可以使用更多的CPU寄存器,这有助于程序性能的改善。更多的CPU寄存器可以避免寄存器卸载:当活跃状态数超过CPU寄存器数,多出的活跃状态只能存放在内存中时,就会发生寄存器卸载。寄存器卸载时,某些活跃状态必须从CPU寄存器卸载到内存中,因此避免寄存器卸载可以让程序执行得更快
64位HotSpot VM使用了压缩指针改善性能。通过对齐、偏移量(Offset)将64位指针压缩成32位。通过更小更节省空间的压缩指针,而不是完整长度的64位指针,CPU缓存使用率得到改善

VM运行时

HotSpot VM运行时环境责任:命令行选项解析、VM生命周期管理、类加载、字节码解释、异常处理、同步、线程管理、Java本地接口、VM致命错误处理、C++(非Java)堆管理

命令行选项

HotSpot VM运行时系统解析命令行选项,并据此配置HotSpot VM

  • 供HotSpot VM启动器使用:指定选择哪个JIT编译器、选择何种垃圾收集器
  • 经启动器处理后传给完成启动的HotSpot VM:指定Java堆大小等

  • 标准选项(Standard Option)
    • 要求所有Java虚拟机都必须实现的选项,它们在发行版之间保持稳定,但也可能在后续的发行版中被废除
  • 非标准选项(Nonstandard Option):以-X为前缀
    • 不保证、也不强制所有JVM实现都必须支持,它可能未经通知就在Java SDK发行版之间发生更改
  • 非稳定选项(Developer Option):以-XX为前缀
    • 为了特定需要而对JVM的运行进行校正,并且可能需要有系统配置参数的访问权限,也可能不经通知就在发行版之间发生变动

命令行选项用于控制HotSpot VM的内部变量,每个变量都有类型和默认值。对于内部变量为布尔类型的选项来说,只要在HotSpot VM命令行上添加或去掉就可以控制这些变量。对于带有布尔标记的非稳定选项来说,选项名前的+或-表示true或false,用于开启或关闭特定的HotSpot VM特性或参数

VM生命周期

  1. 解析命令行选项
    • 启动器会直接处理一些命令行选项,例如-client或-server,它们决定加载那个JIT编译器,其他参数则传给HotSpot VM。
  2. 设置堆的大小和JIT编译器
    • 如果命令行没有明确设置堆的大小和JIT编译器0client或server),启动器则通过自动优化进行设置,自动优化的默认设定因底层系统配置和操作系统而有所不同
  3. 设定环境变量
    • 如LD_LIBRARY_PATH
  4. 如果命令行有-jar选项,启动器则从指定JAR的manifest中查找Main-Class,否则从命令行读取Main-Class
  5. 使用标准Java本地接口(Java Native Interface,JNI)方法JNI_CreateJavaVM在新创建的线程中创建HotSpot VM
    • 与后创建的线程相比,初始线程是启动新进程时操作系统内核分配的第一个线程
    • 不在初始线程中创建HotSpot VM,是为了可以对它进行定制
  6. 一旦创建并初始化好HotSpot VM,就会加载Java Main-Class,启动器也会从Java Main-Class中得到Java main方法的参数
  7. HotSpot VM通过JNI方法CallStaticVoidMethod调用Java Main方法,并将命令行选项传给它

至此,HotSpot VM开始正式执行命令行指定的Java程序了。一旦Java程序或Java Main方法执行结束,HotSpot VM就必须检查和清理所有程序或者方法执行过程中生成的未处理异常。此外,方法的退出状态和程序的退出状态也必须返回给它们的调用者。调用Java本地接口方法DetachCurrentThread将Java main方法与HotSpot VM脱离(Detached)。每次HotSpot VM调用DetachCurrentThread时,线程数就会减1,因此Java本地接口知道何时可以安全地关闭HotSpot VM,并能确保当时HotSpot VM中没有正在执行的操作,Java栈中也没有激活的Java帧

JNI_CreateJavaVM

  1. 确保只有一个线程调用这个方法,并且确保只创建一个HotSpot VM实例
    • HotSpot VM创建的静态数据结构无法再次初始化,所以一旦初始到达到某个确定点后,进程空间里就只能有一个HotSpot VM。HotSpot VM启动至此已经是无法逆转了
  2. 检查并确保支持当前的JNI版本,初始化垃圾收集日志的输出流
  3. 初始化OS模块,如随机数生成器,当前进程id、高精度计时器、内存页尺寸、保护页
    • 保护页是不可访问的内存页,用做内存访问区域的边界。例如操作系统常在线程栈顶压入一个保护页以保证引用不会超出栈的边界
  4. 解析传入JNI_CreateJavaVM的命令行选项,保存以备将来使用
  5. 初始化标准的Java系统属性,例如java.version、java.vendor、os.name等
  6. 初始化支持同步、栈、内存和安全点页的模块
  7. 加载libzip、libhpi、libjava及libthread等
  8. 初始化并设置信号处理器(Signal Handler)
  9. 初始化线程库
  10. 初始化输出流日志记录器(Logger)
  11. 如果用到Agent库(hprof、jdi),则初始化并启动
  12. 初始化线程状态(Thread State)和线程本地存储(Thread Local Storage),它们存储了线程私有数据
  13. 初始化部分HotSpot VM全局数据,例如事件日志(Event Log),OS同步原语、perfMemory(性能统计数据内存),以及chunkPool(内存分配器)
  14. 至此,HotSpot VM可以创建线程了。创建出来的Java版main线程被关联到当前操作系统的线程,只不过还没有添加到已知线程列表中
  15. 初始化并激活Java级别的同步
  16. 初始化启动类加载器(Bootclassloader)、代码缓存、解释器JIT编译器JNI、系统词典(System Dictionary)及universe(一种必备的全局数据结构集)
  17. 现在,添加Java主线程到已知线程列表中。检查universe是否正常。创建HotSpot VMThread,它执行HotSpot VM所有的关键功能。同时发出适当的JVMTI事件,报告HotSpot VM的当前状态
  18. 加载和初始化以下Java类:java.lang.String、java.lang.System、java.lang.Thread、java.lang.ThreadGroup、java.lang.reflect.Method、java.lang.ref.Finalizer、java.lang.Class以及余下的Java系统类。此时,HotSpot已经初始化完毕并可使用,只是功能还不完备
  19. 启动HotSpot VM的信号处理线程,初始化JIT编译器并启动HotSpot编译代理线程。启动HotSpot VM辅助线程(如监控线程和统计抽样器)。至此,HotSpot VM已功能完备
  20. 最后,生成JNIEnv对象返回给调用者,HotSpot则准备响应新的JNI请求

DestroyJavaVM

如果HotSpot VM启动过程中发生错误,或启动后的执行过程中发生很严重的错误, 启动器则调用DestroyJavaVM方法关闭HotSpot VM:

  1. 一直等待, 直到只有一个非守护的线程执行(此时HotSpot VM仍然可用)
  2. 调用java.1ang.Shutdown.shutdown(),它会调用Java上的shutdown钩子方法,如果finalization-on-exit为true,则运行Java对象的finalizer
  3. 运行HotSpot VM上的shutdown钩子(通过VM_On Exit()注册),停止以下线程。然后发出状态事件通知JVMTI,然后关闭JVMTI、停止信号线程
    • 性能分析器、统计数据抽样器、监控线程及垃圾收集器线程
  4. 调用HotSpot的JavaThread::exit()释放JNI处理块,移除保护页,并将当前线程从已知线程队列中移除。从这时起,HotSpot VM就无法执行任何Java代码了
  5. 停止HotSpot VM线程, 将遗留的HotSpot VM线程带到安全点并停止JIT编译器线程
  6. 停止追踪JNI,HotSpot VM及JVM TI屏障
  7. 为那些仍然以本地代码运行的线程设置标记“vm exited”
  8. 删除当前线程
  9. 删除或移除所有的输入/输出流,释放Perf Memory(性能统计内存)资源
  10. 最后返回到调用者

VM类加载

HotSpot VM和Java SE类加载库共同负责类加载。术语”类加载”用以描述类名或接口名映射到类(Class)对象的整个过程,类加载的3个阶段:加载、链接和初始化。类加载的最佳时机是在解析Java字节码类文件中常量池符号的时候。Java API如Class.forName()、ClassLoader.loadClass()、反射API和JNI-FindClass都可以引发类加载。HotSpot VM自身也可以引发类加载。此外,作为链接阶段的一部分,类文件验证也需要加载一些其他类。实际上,加载阶段是HotSpot VM和特定类加载器如java.lang.ClassLoader之间相互协作的过程

  1. 类加载阶段
    • 加载。对于给定的Java类或接口,类加载时会依据它的名字找到Java类的二进制类文件,定义Java类,然后创建代表这个类或者接口的java.lang.Class对象。如果没有找到Java类或接口的二进制表示,就会抛出NoClassDefFound
    • 链接。第一步是验证,检查类文件的语义、常量池符号以及类型。如果检查有错,就会抛出VerifyError。第二步是准备,它会创建静态字段,初始化为标准默认值,以及分配方法表。请注意,此时还没有执行任何Java代码。第三步是解析符号引用,这一步是可选的
    • 初始化。运行类构造器。这是迄今为止,类中运行的第一段Java代码。值得注意的是,初始化类需要首先初始化超类(不会初始化超接口)
    • 出于性能优化的考虑,通常直到类初始化时HotSpotVM才会加载和链接类。这意味着,类A引用类B,加载A不一定导致加载B (除非B需要验证)。执行B的第一条指令会导致初始化B,从而加载和链接B
  2. 类加载器委派
    • 当请求类加载器查找和加载某个类时,该类加载器可以转而请求别的类加载器来加载。这被称为类加载器委派。类的首个类加载器称为初始类加载器(Initiating Class Loader),最终定义类的类加载器称为定义类加载器(Defining Class Loader)。就字节码解析而言,某个类的初始类加载器是指对该类进行常量池符号解析的类加载器
    • 类加载器之间是层级化关系,每个类加载器都可以委派给上一级类加载器。这种委派关系定义了二进制类的查找顺序。Java SE类加载器的层级查找顺序为启动类加载器、扩展类加载器及系统类加载器。系统类加载器是默认的应用程序类加载器,它加载Java类的main方法并从classpath上加载类。应用程序类加载器可以是Java SE系统自带的类加载器,或者由应用程序开发人员提供。扩展类加载器则由Java SE系统实现,它负责从JRE(Java Runtime Environment,Java运行环境)的lib/ext目录下加载类
  3. 启动类加载器
    • 启动类加载器由HotSpot VM实现,负责加载BOOTCLASSPATH路径中的类。为了加快启动速度,Client模式的HotSpot VM可以通过称为类数据共享(Class DataSharing)的特性使用已经预加载的类。这个特性默认为开启,可由HotSpot VM命令行开关-Xshare:on开启,-Xshare:off关闭
  4. 类型安全
    • Java类或接口的名字为全限定名(包括包名),Java的类型由全限定名和类加载器唯一确定。换言之,类加载器定义了命名空间,这意味着两个不同的类加载器加载的类,即便全限定名相同,仍然是两个不同的类型
  5. HotSpot类元数据
    • 类加载时,HotSpot VM会在永久代创建类的内部表示instanceklass或arrayklass。HotSpot VM内部使用称为klass0op的数据结构访问instanceklass。后缀”Oop”表示普通对象指针,所以klass0op是引用java.lang.Class的HotSpot内部抽象,它是指向Klass (与Java类对应的内部表示)的普通对象指针
  6. 内部的类加载数据
    • 类加载过程中, HotSpot VM维护了3张散列表。SystemDictionary包含已加载的类。PlaceholderTable包含当前正在加载的类。LoaderConstraintTable用于追踪类型安全检查的约束条件。这些散列表都需要加锁以保证访问安全,在HotSpot VM中,这个锁称为SystemDictionary_lock。通常,HotSpot VM借助类加载器对象锁对加载类的过程进行序列化

字节码验证

Java是一门类型安全语言,在链接时必须进行字节码验证以保障类型安全。字节码验证规定了Java虚拟机需要进行字节码的静态和动态约束验证。如果发现任何冲突,Java虚拟机就会抛出VerifyError并且阻止链接该类

许多字节码约束都可以进行静态检查,另外有些指令的参数类型和个数约束检查需要在执行过程中动态分析代码。目前有两种判断指令操作数类型和个数的字节码分析方法。常用的方法称为类型推导(TypeInference),第二种方法是类型检查(Type Verification)

对于字节码验证,类型检查比常用的类型推导来得快也更为轻巧,如果类型检查验证出错, HotSpot VM就会切换成类型推导进行验证,如果类型推导失败,则抛出VerifyError

类数据共享

类数据共享是Java 5引入的特性,可以缩短Java程序(特别是小程序)的启动时间,同时也能减少它们的内存占用。JRE安装程序会加载系统jar中的部分类,变成私有的内部表示并转储成文件,称为共享文档(Shared Archive)。之后调用Java虚拟机时,共享文档会映射到JVM内存中,从而减少加载这些类的开销,也使得这些类的大部分JVM元数据能在多个JVM进程间共享

HotSpot VM解释器是一种基于模板的解释器。JVM启动时,HotSpot VM运行时系统利用内部TemplateTable中的信息在内存中生成解释器。TemplateTable包含与每个字节码对应的机器代码,每个模板描述一个字节码。HotSpot VM TemplateTable定义了所有的模板,并提供了获得字节码模板的访问函数。结合命令行选项-XX:+UnlockDi agnosticVMOptions和-XX:+PrintInterpreter就可以查看生成在内存中的模板表

HotSpot VM解释器基于模板的设计要好于传统的switch语句循环方式

解释器使得HotSpot VM运行时系统能够执行复杂的操作

HotSpot VM解释器也是整个HotSpot VM自适应优化的重要部分。事实上,对所有程序来说,大量时间主要花费在一小部分代码的执行上。HotSpot VM没有逐个方法进行”即时”或”提前”编译,而是直接用解释器运行程序,并在运行中分析代码并监测程序中的重要热点( Hot Spot ),然后用全局机器代码优化器(Global Machine Code Optimizer )集中优化这些热点。避免编译那些很少执行的代码, JIT编译器可以在与程序性能密切相关的部分集中更多注意力,还不用增加总体编译时间

术语”JIT编译器”并没有很好地描述HotSpot VM编译器如何生成优化的机器代码。它实际上是通过研究程序的运行行为动态生成机器代码,而不是”即时”或者”提前”编译程序

在程序运行时,JVM会持续动态监控热点,及时调整性能,从而完全适应程序的运行和用户的需求

当与Java的语义约束冲突时,Java虚拟机会用异常通知程序。异常处理由HotSpot VM解释器、JT编译器和其他HotSpot VM组件一起协作实现。异常处理主要有两种情形,同一方法中抛出和捕获异常,或由调用方法捕获异常。后一种情况更为复杂,需要退栈才能找到合适的异常处理器。当VM遇到抛出的异常时,就会调用HotSpot VM运行时系统查找该异常最近的处理器(Handler)。如前所述,如果在当前方法中没有找到异常处理器,当前的活动栈帧就会退栈,重复这个过程直至找到异常处理器。一旦发现适当的异常处理器,HotSpot VM的执行状态就会更新,并跳转到该异常处理器继续执行Java代码

广义上说,同步是一种并发操作机制,用来预防、避免对资源不适当的交替使用(通常称为竞争),保障交替使用资源的安全。Java用称为线程的结构来实现并发。互斥(Mutual Exclusion)是同步的特殊情况,即同一时间最多只允许一个线程访问受保护的代码或数据。HotSpot VM用monitor对象来保障线程运行代码之间的互斥。Java的monitor对象可以锁定或者解锁,但任何时刻只能有一个线程拥有该monitor对象。只有获得monitor对象的所有权后,线程才可以进入它所保护的临界区。Java中临界区由同步块(Synchronized Block)表示,代码中用synchronized语句表示

偏向锁最好情况下成本甚至为零。既然大多数对象在其生命期中最多只会被一个线程锁住,那就可以开启-XX:+UseBiasedLocking允许线程自身使用偏向锁。一旦开启偏向锁,该线程不需要借助昂贵的原子指令就可以对该对象进行锁定和解锁了

大多数HotSpot VM同步操作使用称为fast-path代码(快速路径代码)的方法。如果需要阻塞或者唤醒线程(分别是monitor-enter或者monitor-exit状态),fast-path代码将调用slow-path代码。slow-path代码由C++实现,而fast-path代码则是IT编译器产生的机器代码

HotSpot VM的标记字(Mark Word)中可能存放以下对象同步状态

线程管理涉及从线程创建到终止的整个生命周期,以及HotSpot VM线程间的协调

HotSpot VM的线程模型中,Java线程( java.lang.Thread实例)被一对一映射为本地操作系统线程

线程创建和销毁

HotSpot VM有两种引入线程的方式,执行Java代码时调用java.lang.Thread对象的start0方法,或者用JNI将已存在的本地线程关联到HotSpot VM上

  • java.lang.Thread实例以Java代码形式表示线程
  • HotSpot VM内部以C++类JavaThread的实例表示java.lang.Thread实例。JavaThread也保存了它所关联的OSThread实例的引用
  • OSThread实例代表操作系统线程

当java.lang.Thread启动时,HotSpot VM创建与之相关联的JavaThread和OSThread对象,最后是本地线程。所有的HotSpot VM状态(如线程本地存储和分配缓存、同步对象等)准备好后,启动本地线程

终止线程会释放所有已分配的资源,并从已知线程列表中移除JavaThread,然后调用OSThread和JavaThread的析构函数,当它的初始启动方法完成时,最终停止运行

HotSpot VM使用JNI的AttachCurrentThread与本地线程关联,并创建与之关联的OSThread和JavaThread实例,然后执行基本的初始化。一旦关联,线程就可以通过其他JNI方法调用任何它所需要的Java代码

  • 新线程:线程正在初始化的过程中
  • 线程在Java中:线程正在执行Java代码
  • 线程在VM中:线程正在HotSpot VM中执行
  • 线程阻塞:线程因某种原因(获取锁、等待条件满足、休眠和执行阻塞式1/0操作等)而被阻塞

为了便于调试,用工具报告线程转储、栈追踪等信息时,还需要包括其他的状态信息。这些信息由HotSpot内部的C++对象OSThread维护

  • MONITOR_WAIT:线程正在等待获取竞争的监视锁
  • CONDVAR_WAIT:线程正在等待HotSpot VM使用的内部条件变量(没有和任何Java对象关联)
  • OBJECT_WAIT: Java线程正在执行java.lang.object.wait()

VM内部线程

  • VM线程:是C++单例对象,负责执行VM操作
  • 周期任务线程:是C++单例对象:也称为WatcherThread,模拟计时器中断使得在HotSpotVM内可以执行周期性操作
  • 垃圾收集线程:这些线程有不同类型,支持串行、并行和并发垃圾收集
  • JIT编译器线程:这些线程进行运行时编译,将字节码编译成机器码

VM操作和安全点

HotSpot VM内部的VMThread监控称为VMOperationQueue的C++对象,等待该对象中出现VM操作,然后执行这些操作。因为这些操作通常需要HotSpot VM达到安全点后才能执行,所以它们会直接传递给VMThread。简单来说,当HotSpot VM到达安全点时,所有的Java执行线程都会被阻塞,在安全点时,任何执行本地代码的线程都不能返回Java代码

垃圾收集是最为人所知的HotSpot VM安全点操作,更明确地说是垃圾收集的Stop-The-World阶段。JVM会阻塞或停止所有Java执行线程执行Java代码。如果程序线程正在执行本地代码(如JNI),可以继续执行,不过一旦跨过本地代码边界进入Java代码时就会被阻塞

在安全点时,VMThread用Threads_lock阻塞所有正在运行的线程,VM操作完成后,VMThread释放Threads_lock

C++堆管理

除了HotSpot VM内存管理器和垃圾收集器所维护的Java堆以外,HotSpot VM还用C/C++堆存储HotSpot VM的内部对象和数据。从基类Arena衍生出来的一组C++类负责管理HotSpot VM C++堆的操作,这些类只供HotSpot VM使用,并不会暴露给HotSpot VM的使用者

Arena是线程本地对象,会预先保留一定量的内存,这使得fast-path分配不需要全局共享锁。与此类似,当Arena的free操作将内存释放同Chunk时,也不需要通常释放内存时所用的锁。在HotSpot VM的内部实现中,线程本地资源管理所用的Arena是它的C++子类ResourceArea。此外,句柄管理所用的Arena是它的C++子类Handl eArea。在JT编译过程中,HotSpot的client和server JIT编译器也都会使用Arena

Java本地接口(JNI)

Java本地接口(本文后面称为JNI)是本地编程接口,它允许在Java虚拟机中运行的Java代码和用其他语言(例如C、 C++和汇编语言)编写的程序和库进行协作

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

切记,一旦在应用中使用JNI,就意味着丧失了Java平台的两个好处。第一:一旦使用JNI就失去了Java承诺的特性,即”一次编写,到处运行”。第二:Java是强类型和安全的语言,本地语言如C或C++则不是。在调用JNI方法前,Java应用常常需要安全检查。额外的安全检查的数据复制会降低应用的性能。作为一般性准则,开发人员应该设计好应用的架构,将本地方法限定在尽可能少的类中

HotSpot VM的命令行选项(-Xcheck:jni)可以辅助调试使用JNI的本地方法

HotSpot VM内部JNI函数的实现比较简单

HotSpot VM追踪正在执行本地方法的线程时必须特别小心

VM致命错误处理

当HotSpot VM因致命错误而崩溃时,会生成HotSpot错误日志文件,名为hs_err_pid<pid>.log,这里<pid>是崩溃HotSpot VM进程的id,hs_err_pid<pid>.log文件生成在HotSpot VM的启动目录下

  • hs_err_pid<pid>.log错误日志文件中包括内存镜像,可以很容易地看到VM崩溃时的内存布局
  • 提供命令行选项-XX:ErrorFile,可以设置hs_err_pid<pid>.log错误日志文件的路径名
  • OutOfMemoryError还可以触发生成hs_err_pid<pid>.log文件

另一种常用于诊断VM致命错误根源的做法是,添加HotSpot VM命令行选项-XX:OnError=cmd1 args...; cmd2...。当HotSpot VM崩溃时,就会执行这个HotSpot VM命令行选项传递给它的命令列表。这个特性常用于立即调用调试器(如Linux/Solaris dbx或Windows WinDbg)检查这次崩溃。对于那些不支持-XX:OnError的Java发行版来说,可以用HotSpot VM命令行选项-XX:+ShowMessageBoxOnError来替代。这个参数会使VM退出前显示对话框以表示VM遇到了致命错误。这使得HotSpot VM在退出前有机会连到调试器

当HotSpot VM遇到致命错误时,内部使用VMError类收集信息并导出成hs_err_pid<pid>.log

-XX:OnOutOfMemoryError=<cmd>当抛出第一个OutOfMemoryError时,可以执行一条命令。另一个值得提及的有用特性是,当OutOfMemoryError出现时可以生成堆的转储信息,指定-XX:+ HeapDumpOnOutOfMemory可以开启这个特性,-XX: HeapDump-Path=<pathname>可让用户指定堆转储的存放路径

当发生死锁时,在Windows平台上可以用Ctrl+Break生成Java级别的线程栈追踪信息并打印到标准输出。在Solaris和Linux平台上。发送SIGOUIT信号给Java进程id也可以得到同样效果。基于线程的栈迫踪信息,可以分析死锁根源。从Java6开始,自带的JConsole工具添加了一项功能,可以关联到一个挂起的Java进程并分析死锁的根源。多数情况下,死锁是由于获取锁的顺序错误所导致的

JVM 垃圾回收

弱分代假设:大多数分配对象的存活时间很短;存活时间久的对象很少引用存活时间短的对象

  • 新生代:一个Eden、两个Survivor。Minor GC 效率很
  • 老年代:Full GC 执行频率低,执行时间长
  • 永久代:存储元数据,例如类的数据结构、保留字符串等

过早提升:Survivor中的存活对象溢出,多余的对象将被移到老年代,导致老年代中短期存活对象的增长,可能造成严重的性能问题

提升失败:在Minor GC 中,如果老年代满了而无法容纳更多的对象,Minor GC 后将发生 Full GC

垃圾收集器

  • Servial收集器
    • 新生代用复制算法,老年代使用标记-压缩算法
  • Parallel收集器
    • 吞吐量为先
    • 新生代用复制算法,老年代使用标记-压缩算法
    • 适用于批处理引擎、科学计算等
  • CMS收集器 - 低延迟为先
    • 老年代使用标记-清理算法
    • 分为阶段:初始标记、并发标记、重新标记、并发预清除、并发清除
    • CMS的缺点
      • 空间不连续,需要一个空闲列表记录
      • 需要更大的Heap处理浮动垃圾
      • 缺乏压缩,形成空间碎片化
    • 如果CMS 失败,将采用Stop-The-Worl收集方法
  • G1收集器
    • CMS的替代者
    • G1中Heap不再划分成新生代、老年代,而是分为相同尺寸的区,优先收集垃圾最多的区

应用程序对垃圾收集器的影响

内存分配:Eden满,发生Minor GC;老年代满,发生Full GC;内存分配速率越高、垃圾收集触发越频繁

存活数据的多少:Heap 中存活对象越多,收集器需要做的工作越多

老年代中的引用更新:若老年代中引用发生了更新,将创建一个Old-to-Young的引用,导致在预清除或重新标记阶段产生一个需要遍历的对象。一些不好的编程习惯会造成这个问题:对象池化(对象长期存活)、容器类的初始尺寸太小(内部数组需要长期调整尺寸)等

扩展:《java性能优化权威指南》 3.3 HotSpot VM垃圾收集器

HotSpot VM JIT编译器

  • Client JIT: 启动快、编译快
  • Server JIT: 启动慢,但是启动后性能好,吞吐量高

所有编译器的结构大体相同。首先它们必须要有前端接受源代码,然后转换成中间代码(Intermediate Representation, IR),中间代码有许多种形式。常见的IR风格是静态单赋值(Static Single Assignment,SSA),特点是每个变量只能赋值一次,指令要直接使用这些值。这种做法的好处是指令所用的值对它来说是直接可见的。另一种常用的是命名形式,概念上类似于源语言将值赋给变量或名字,而指令使用这些名字。它带来一定程度的灵活性,可以简化某此操作(例如复制代码),但削弱了指令和所用值之间的直接联系

前端生成的IR通常是编译器优化最集中的地方。最基本的优化有简单恒等变换、常量折叠、公共子表达式消除以及函数内联。更复杂的优化通常集中在改善循环的执行上,包括范围检查消除、展开以及循环不变代码迁移

经过这些高级优化之后的IR,会被编译器后端接收,并转换成机器代码。这阶段包括指令选择和寄存器分配。一旦完成了指令选择,就必须将寄存器指派给程序中的所有变量,并依据机器的调用约定生成代码。大多数情况下,存活变量的数目会超过机器寄存器的个数,所以生成代码只能将一部分变量同时分配给寄存器,通过在寄存器和栈之间来问移动变量,腾出寄存器从而容纳其他的变量。将值移动到栈中称为值卸载(Spilling)或寄存器卸载。经典的寄存器分配策略是图着色算法,通常可以使机器寄存器的使用率达到最高,而且多余的值很少会卸载到栈中。图表示的是同时有哪些变量在使用,以及哪些寄存器可以存放这些变量。比较简单的策略是线性扫描寄存器分配。它的目标是在单趟扫描所有指令时指派寄存器,并且指派的还不错。它维护变量存活范围的列表,但不能保证在变量的生命期里,它都留在同一个寄存器中

类型继承关系分析(CHA)

默认情况下所有实例方法都可以被子类覆盖,所以只看局部类型信息并不足以了解哪个方法可以内联。HotSpot VM解决这个问题的办法是类型继承关系分析(Class Hierarchy Analysis,以下简写为CHA),编译器利用CHA进行即时分析,判断加载的子类是否覆盖了特定方法:这种分析方法的关键在于,HotSpot VM只考虑已经加载的子类,而不关心任何其他还不可见的子类。此外,CHA也被用来在已加载的类中识别只有一个接口或抽象类实现的情况

它(CHA)用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类且子类是否为抽象类等信息

由于JIT没有时间编译程序中的所有方法,因此所有代码最初都是在解释器中运行。一旦方法被调用的次数变多,就可能变成编译。这个过程是由HotSpot VM中与每个方法关联的计数器来控制的。每个方法都有两个计数器:方法调用计数器和回边计数器。方法调用计数器在每次进人方法时加一。回边计数器在控制流每次从行号靠后的字节码回跳到靠前的字节码时加一

与仅用方法调用计数器相比,用回边计数器可以检测包含循环的方法,能使这些方法更早地转为编译。每次解释器递增这两种计数器时,都会与阈值进行比较,一旦超过,解释器就会请求编译这个方法

当发起编译请求时,它会进入被一个或多个编译器线程监视的队列。如果编译器线程不忙,就会从队列中移出一个编译请求并开始编译。通常解释器不会等编译结束,相反,它会重置方法调用计数器,然后继续在解释器中执行该方法。一旦编译完成,编译代码就会和该方法关联,然后下次调用时就会使用该编译代码。通常来说,不等编译完成仍然继续执行是个好方法,因为执行和编译可以继续并行。如果你想让解释器等编译完成,可以用HotSpot VM命令行选项-Xbatch或-XX:-BackgroundCompilation阻塞执行,等待编译完成

当解释器执行长期运行的Java循环时, HotSpot VM会选择一种称为栈上替换(On StackReplacement, OSR)的特殊编译。通常Java代码最后进入编译代码的方式是,解释器在调用方法时发现,该方法有已经编译的代码,那该方法就会分派到编译代码,而不是停留在解释器中。但这个方法对在解释器里开始长时间运行的循环来说没有什么帮助,因为它们不会被再次调用

当回边计数器溢出时,解释器会发起编译请求,这次编译从回边的字节码开始而不是从方法的首个字节码开始。然后以解释器帧作为输入生成代码,并从此状态开始执行。在这种情况下,长时间运行的循环可以充分利用编译代码。这种以解释器帧作为输入执行的代码生成技术称为栈上替换

HotSpot VM中的术语”逆优化”是指将那些经过若干级内联而来的编译帧转换为等价的解释器顿的过程。它可以将编译代码从多种乐观优化中问退回来,特别是从类型继承关系分析假设中回退回来。Server编译器在遇到”罕见陷阱”(Uncommon Trap)时也会使用逆优化。逆优化是已生成代码中的特殊点,编译器在这些点上选择解释器处理某些执行路径。这多半是因为编译时某些类还未加载或者某路径从没执行过。某些类型的异常也用这种方式处理

JIT编译器的逆优化会在每个安全点上记录一些元数据,这些元数据描述了当时字节码的执行状态。对于逆优化,编译器还会记录局部变量和表达式栈中引用值的位置以及获得的锁。这是解释器帧状态在当时的抽象展现,足以构建一组解释器帧使得程序可以在解释器中继续执行

乍一看,逆优化似乎需要额外保存许多值,不过有一此技巧可以减少保存的值。实践中,JIT编译器不会因为逆优化而单独保存许多存活的值

生成好的编译代码可能会因某些原因失效,例如类加载会使CHA优化失效或者代码所引用的类被卸载了

为了支持运行时系统的不同特性,JIT编译的代码关联了若干种元数据。因为系统必须在安全点时暂停,所以编译代码的任意位置都需要所有的调用点和分配可能发生的地方。每个安全点也都包含联方法链的所有信息和逆优化所需要的Java帧的信息

静态单赋值

Server JIT编译器的中间代码(IR,编译器内部称为”ideal”),是一个基于SSA(Static SingleAssignment)的IR,但它使用不同的方式展现控制流,称为程序依赖图(Program DependencyGraph)。这种方法试图捕获每次操作执行过程中的最小约束,使得可以对操作进行激进重排和全局值计数,以此减少冗余计算。它有一个富类型系统可以捕获Java类型系统的所有细节,并能将这些知识反馈给优化器

Server JIT编译器也会利用解释器执行过程中搜集到的性能分析信息。所有这些信息都被Server JIT编译器用来寻找基于常见类型的内联机会,以及计算控制流的频率,这会影响块的布局和寄存器分配

所有基于Java字节码的JIT编译器都需要处理卸载或未初始化的类。Server JIT编译器的处理方式是,当它包含无法解析的常量池条目时,就会把路径标记成不可达。这种情况下,它会为这段字节码生成罕见陷阱并停止解析通过该方法的路径。罕见陷阱请求HotSpot VM运行时系统对当前已经编译好的方法采取逆优化,退回到解释器中继续执行

“罕见陷阱”也用来处理不可达的路径。ServerJT编译器还用”罕见陷阱”实现某些乐观优化(Optimistic Optimizations),Server编译器会把某些较可能发生的情况当作唯一的情况来优化,不过也会留下一手,在代码中插入动态检查以确保这些假设是成立的。如果动态检查失败,代码就会栽进”罕见陷阱”,而改由解释器继续处理

Server JIT编译器的生成代码对循环做了大量优化,包括循环判断外提(Loop Unswitching)、循环展开(Loop Unrolling)以及用迭代分离(Iteration Splitting)进行的范围检查消除(Range CheckElimination)。迭代分离将循环转换成3个:预循环、主循环及后循环。预循环和后循环处理迭代的边界条件,需要进行范围检查。绝大多数情况下,预循环和后循环运行的次数不多,甚至多数情况下后循环可以完全消除。这使得主循环的运行压根就不需要任何范围检查

一旦移除了循环的范围检查,就有可能将它展开。循环展开是用相对简单的循环体,在循环中创建多份副本,从而减少循环的迭代次数。

循环展开使用的另外一种优化,称为超字(Superword),它是循环向量化的一种形式。循环展开在循环体中创建可并行的操作,如果这些是在连续内存之上的操作,就可以被合并为一个矢量上的操作,使得单条指令在同样的时间内可以执行多个操作

一旦运行了所有的高级优化,IR就会被转换成机器相关的形式,可以利用处理器所有的指令和寻址模式。依据节点输入、块的期望执行频率,机器相关的节点就被安排到基本块中。然后图着色分配器就会为所有指令分配寄存器,并插入必要的寄存器卸载。最后代码被转换成nmethod,这是HotSpot VM编译字节码的内部表示,包括所有的代码以及HotSpot VM运行时系统所需的元数据

HotSpot VM自适应优化

重要垃圾收集数据:

  • 当前使用的垃圾收集器
  • Java堆大小
  • 新生代和老年代大小
  • 永久代大小
  • Minor GC持续时间
  • Minor GC频率
  • Minor GC空间回收量
  • Full GC持续时间
  • Full GC频率
  • 每个并发垃圾收集器周期内的空间回收量
  • 垃圾收集前后Java堆的占用量
  • 垃圾收集前后新生代和老年代的占用量
  • 垃圾收集前后永久代的占用量
  • 是否老年代或永久代的占用触发了Full GC
  • 应用中是否显示调用了System.gc()

垃圾收集数据的离线分析工具:GCHisto

图形化工具:JConsole、VisualVM(包括插件)

  • 使用更高效的算法(和数据结构)
  • 减少锁竞争
  • 为算法生成更有效的代码

JVM性能优化

  1. 划分应用程序的系统需求优先级
    • 延迟及响应性
  2. 选择JVM部署模式
    • 单JVM部署模式
    • 多JVM部署模式
      • 使用多JVM时,可以将不同JVM绑定到不同的处理器集
      • 能够获得更好的可用性,以及更低延迟的可能性
      • 挑战在于监控、管理以及维护多JVM的代价较大
    • 一般情况下,使用的JVM数目越少越好。使用的JVM越少,监控及管理的成本就越低,消耗的总内存也更少
  3. 选择JVM运行时
    • client模式或server模式
    • 32位或64位JVM
    • 选择垃圾收集器
    • 垃圾收集调优
      • 性能属性:吞吐量、延迟、内存占用
      • 每次Minor GC都尽可能多地收集垃圾对象
      • 处理吞吐量和延迟问题时,垃圾处理器能使用的内存越大,即Java堆空间越大,垃圾收集的效果越好,应用程序运行也越流畅。我们称之为”GC内存最大化原则
      • 在这三个性能属性(吞叶量、延迟、内存占用)中任意选择两个进行JVM垃圾收集器调优。我们称之为”GC调优的3选2原则
      • 命令行选项及GC日志
        • -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc: <filename>
  4. 确定并调优应用程序内存使用
    • 约束:JVM可以使用的物理内存量,JVM部署模式也会影响,需要给操作系统预留一部分内存
    • 指定新生代和老年代空间大小的初始值和最大值
    • 不指定堆大小:HotSpot VM自适应调优
    • 计算活跃数据大小:活跃数据大小是应用程序运行于稳定态,Full GC之后Java堆中老年代和永久代占用的空间大小
    • 通用法则之一:将Java堆的初始值-Xms和最大值-Xmx设置为老年代活跃数据大小的3-4倍
    • 通用法则之二:永久代的初始值-XX:Permsize及最大值-XX:MaxPermSize应该比永久代活跃数据大1.2-1.5倍
    • 补充法则:新生代空间应该为老年代空间活跃数据的1-1.5倍
    • 补充:老年代空间大小不应该小于活跃数据大小的1.5倍
  5. 确定并调优应用程序延迟/响应性
    • 要求:应用程序可接受的平均停滞时间、可接受的Minor GC(会导致延迟)频率、可接受的应用程序的最大停顿时间
    • 根据垃圾收集的统计数据、Minor GC的持续时间和频率可以确定新生代空间的大小。同时在调整新生代空间大小时,尽量保持老年代空间大小恒定
    • 新生代空间至少应为Java堆大小的10%,通过-Xmx和-Xms可以设定该值。新生代过小可能适得其反,会导致频繁的Minor GC
    • 如果预期或观测到Full GC的频率已经远远不能达到应用程序的最差Full GC频率要求,就应该增大老年代空间的大小。这个方法可以帮助降低Full GC的频率。同时增加老年代空间的大小时注意保持新生代空间大小恒定
    • 观察到新的晋升阈值持续小于最大晋升阈值,或者观察到Survivor空间大小小于总的存活对象大小都表明Survivor空间过小。每当Survivor空间增加时,新生代空间都应该增大。保持Eden空间大小恒定,Minor GC的频率就不会由于Survivor空间增大而发生变化
  6. 确定并调优应用程序吞吐量
    • 增加新生代空间大小、增加老年代空间的大小、调整新生代中Eden空间和Survivor空间的大小
  • 逃逸分析
    • 对象展开。这是一种在可能直接回收的空间而非Java堆上分配对象字段的技术
    • 标量替换。这是一种减少内存访问的优化技术
    • 栈上分配。这是一种在线程的栈帧上而非Java堆上分配对象的优化技术。非逃逸对象由于不会被其他线程访问可以直接在线程栈帧上分配。线程栈帧上的分配可以减少对象在Java堆上分配的数目,从而减少垃圾收集的频率
    • 消除同步。如果线程分配的对象不会发生逃逸,且该线程持有了该对象上的锁,由于其他线程不会访问该对象,这个锁可以通过IT编译器移除
    • 消除垃圾收集的读/写屏障。如果线程分配的对象不发生逃逸,该对象只能从线程本地的根节点访问,因此在其他对象中存储其地址时不需要执行读或写屏障。只有在对象可以被另一个线程访问时,才需要读/写屏障。这常常发生在分配的对象被赋给了另一对象中的字段,并因此能被另一线程访问时,也就是发生了”逃逸”
  • 偏向锁
    • 偏向锁是一种偏向于最后获得对象锁的线程的优化技术。当只有一个线程锁定该对象,没有锁冲突的情况下,其锁开销可以接近lock-free
  • 大页面支持
    • 使用大页面的好处是其减小了TLB失效的几率

扩展:
Java HotSpot VM Options

参考:
《Java性能优化权威指南》



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK