JVM

Java , 发表评论

1 JVM发展历史

1.1 Sun Classic VM 第一款商用Java虚拟机

1996年1月,Sun公司发布JDK1.0,Java首次拥有了商用的正式运行环境——Sun Classic VM。

这款虚拟机十分缓慢,原因是它不能是解释器和编译器混合工作,也就是要么只使用纯解释器,这样运行时速度会很慢,要么使用纯编译器,这样必须对每一个方法、每一行代码进行编译,编译耗时会很高。“Java语言很慢”的形象就是在这时候在用户心中树立起来的。

1.2 Exact VM 现代高性能虚拟机的雏形

为了解决Sun Classic VM所面临的各种问而诞生的。两级即时编译器、编译器和解释器混合工作模式等,同时Exact VM采用准确式内存管理,即虚拟机可以知道内存某个位置的数据具体是什么类型,样在垃圾收集时可以准确判断这些数据是否可用,大大提高了垃圾回收的效率。

1.3 Sun HotSpot VM JDK默认虚拟机

HotSpot  VM的最大特点,正如其名,就是热点代码探测能力,这项能力,可以通过执行计数器,找出最具有编译价值的代码,然后通知JIT编译器进行编译,通过编译器和解释器的协同合作,在最优程序响应时间和最佳执行性能中取得平衡。

HotSpot最初并非Sun公司开发的,而是由一家名为“Longview Technologies”的小公司设计的,甚至这个虚拟机最初并非是为Java语言而开发的,而这款虚拟机中相当多的技术又是来源于一款支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的虚拟机,

Sun公司注意到了这款虚拟机在JIT编译上有许多优秀的理念和实际效果,在1997年收购了Longview Technologies公司,从而获得了HotSpot VM。

2006年的JavaOne大会上,Sun公司宣布最终会把Java开源。并在JDK基础上建立了OpenJDK。

在2008年和2009年,Oracle公司分别收购了BEA公司和Sun公司,这样Oracle就同时拥有了两款优秀的Java虚拟机:JRockit VM和HotSpot VM。

JDK8整合的方式大致上是在HotSpot的基础上,移植JRockit的优秀特性,譬如使用JRockit的垃圾回收器与MissionControl服务,使用HotSpot的JIT编译器与混合的运行时系统。

2 主流JVM

2.1 HotSpot VM

JDK8的HotSpot VM已经是以前的HotSpot VM与JRockit VM的合并版,把前者一些有价值的功能在后者里重新实现一遍。移除PermGen、Java Flight Recorder、jcmd等都属于合并项目的一部分。

2.2 J9 VM

J9是IBM开发的一个高度模块化的JVM。在许多平台上,IBM J9 VM都只能跟IBM产品一起使用,是许可证限制。J9 VM的性能水平大致跟HotSpot VM是一个档次的。

2.3 Zing VM

Zing VM是一个从Sun HoSpot VM fork出来的一个高性能JVM,可以运行在Linux/x86-64平台上。Azul为它重新写了一套GC,也修改了VM内的许多实现细节,与其说它是HotSpot VM的一个变种。

在要求低延迟、快速预热等的场景里,Zing VM都会比HotSpot VM表现更好。Zing自带的ZVision / ZVRobot功能可以方便用户监控JVM的运行状态,从找出代码热点到对象分配监控、锁竞争监控等都可以做到。

2.4其他VM

JRockit: 跟HotSpot与J9一起并称三大主流JVM

IKVM.NET: .NET上运行完整的Java

DRLVM: Apache

Dalvik VM:Android

3 JVM工作原理

Java由四方面组成:Java编程语言、Java类文件格式、Java虚拟机和Java应用程序接口(Java API)

3.1 JVM体系结构

3.2JAVA代码编译和执行过程

 

JAVA代码编译和执行的整个过程包含了以下三个重要的机制:

3.2.1Java源码编译机制

(1)分析和输入到符号表

(2)注解处理

(3)语义分析和生成class文件

Class文件组成:

结构信:包括class文件格式版本号及各部分的数量与大小的信息

元数据:对应于Java源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池

方法信息:对应Java源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息

3.2.2类加载机制

把加载动作放到JVM外部实现的目的让应用程序决定如何获取所需的类

 

3.3.3类执行机制

JVM是基于栈的体系结构来执行class字节码的。JVM栈只对栈帧进行存储,压栈和出栈操作。线程创建后,都会产生程序计数器(PC)和栈(Stack),程序计数器存放下一条要执行的指令在方法内的偏移量,栈中存放一个个栈帧,每个栈帧对应着每个方法的每次调用,而栈帧又是有局部变量区和操作数栈两部分组成,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果。

4 JVM和系统调用关系

4.1 Java堆(Heap)

这块区域也是线程共享的,也是 gc 主要的回收区,一个 JVM 实例只存在一个堆类存。该区域的唯一目的就是存放对象实例。堆的大小可以通过-Xmx和-Xms来控制。堆内存分配:

 

新生代:新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命

旧生代:养老区用于保存从新生区筛选出来的 JAVA 对象,一般池对象都在这个区域活跃。

永久代:用于存放JDK自身所携带的 Class,Interface 的元数据。此区域的数据是不会被垃圾回收器回收掉的。产生java.lang.OutOfMemoryError: PermGen space的原因:

  1. 程序启动需要加载大量的第三方jar包。
  2. 大量动态反射生成的类不断被加载,最终导致Perm区被占满。

说明:

Jdk1.6及之前:常量池分配在永久代 。

    Jdk1.7:有,但已经逐步“去永久代” 。

Jdk1.8及之后:无(java.lang.OutOfMemoryError: PermGen space,这种错误将不会出现在JDK1.8中)。

 

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。,理论上取决于32位/64位系统可虚拟的内存大小。可见也不是无限制的,需要配置参数。 

 

常量池:

4.2 Stack 栈

栈也叫栈内存,主管Java程序的运行,是在线程创建时创建。对于栈来说不存在垃圾回收问题。基本类型的变量和对象的引用变量都是在函数的栈内存中分配。

栈帧中主要保存3类数据:

          本地变量(Local Variables):输入参数和输出参数以及方法内的变量;

          栈操作(Operand Stack):记录出栈、入栈的操作;

          栈帧数据(Frame Data):包括类文件、方法等等。

栈运行原理:遵循“先进后出”/“后进先出”原则。

4.3 Native Method Stack本地方法栈

任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。

    1. Method Area方法区

存放了要加载的类信息、静态变量、final类型的常量、属性和方法信息。JVM用持久代(Permanet Generation)来存放方法区,可通过-XX:PermSize和-XX:MaxPermSize来指定最小值和最大值。

    1. PC Register程序计数器

每个线程都有一个程序计算器,就是一个指针,指向方法区中的方法字节码(下一个将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。

 

    1. 执行引擎

主要包含解释器、JIT编译器和GC。

虚拟机是如何执行方法里面的字节码指令的?

解释执行(通过解释器执行)

编译执行(通过即时编译器JIT产生本地代码)

      1. JIT 编译过程

当JVM 执行某一方法或遍历循环的次数越多,就会更加了解代码结构,那么 JVM 在编译代码的时候就做出相应的优化。

      1. JIT优化

https://www.ibm.com/developerworks/cn/java/j-lo-just-in-time/

(1) 客户模式或服务器模式

(2) 

a.优化代码缓存

b.编译阈值: 在 JVM 中,编译是基于两个计数器的:一个是方法被调用的次数,另一个是方法中循环被回弹执行的次数。当 JVM 执行一个 Java 方法,它会检查这两个计数器的总和以决定这个方法是否有资格被编译。

c. 编译线程: 当一个方法(或循环)拥有编译资格时,它就会排队并等待编译。这个队列是由一个或很多个后台线程组成。

 

 

从优化的角度讲,最简单的选择就是使用 server 编译器的分层编译技术,这将解决大约 90%左右的与编译器直接相关的性能问题。最后,请保证代码缓存的大小设置的足够大,这样编译器将会提供最高的编译性能。

5 GC

5.1回收策略

5.1.1引用计数(Reference Counting)

原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数。垃圾回收时,只用收集计数为0的对象。此算法最致命的是无法处理循环引用的问题。

5.1.2标记-清除(Mark-Sweep)

此算法执行分两阶段。第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,把未标记的对象清除。此算法需要暂停整个应用,同时,会产生内存碎片。

5.1.3复制(Copying)

此算法把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。当然,此算法的缺点也是很明显的,就是需要两倍内存空间。

5.1.4标记-整理(Mark-Compact)

此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。

5.2 JVM垃圾回收分代收集算法

JVM分别对新生代和旧生代采用不同的垃圾回收机制。

5.2.1新生代的GC

采用了GC的复制算法,速度快,因为新生代一般是新对象,都是瞬态的用了可能很快被释放的对象。

在执行机制上JVM提供了串行GC(Serial GC)、并行回收GC(Parallel Scavenge)和并行GC(ParNew)

1)串行GC

    在整个扫描和复制过程采用单线程的方式来进行,适用于单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定

2)并行回收GC

    在整个扫描和复制过程采用多线程的方式来进行,适用于多CPU、对暂停时间要求较短的应用上,是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数

3)并行GC

与旧生代的并发GC配合使用

5.2.2旧生代的GC

旧生代与新生代不同,对象存活的时间比较长,比较稳定,因此采用标记(Mark)算法来进行回收,减少内存碎片带来的效率损耗。

6 JVM内存调优

首先需要注意的是在对JVM内存调优的时候不能只看操作系统级别Java进程所占用的内存,这个数值不能准确的反应堆内存的真实占用情况,因为GC过后这个值是不会变化的,因此内存调优的时候要更多地使用JDK提供的内存查看工具,比如JConsole和Java VisualVM。

对JVM内存的系统级的调优主要的目的是减少GC的频率和Full GC的次数,过多的GC和Full GC是会占用很多的系统资源(主要是CPU),影响系统的吞吐量。

 

导致Full GC一般由于以下几种情况:

  1. 旧生代空间不足: 尽量让对象在新生代GC时被回收、让对象在新生代多存活一段时间和不要创建过大的对象及数组避免直接在旧生代创建对象
  2. Pemanet Generation空间不足: 增大Perm Gen空间,避免太多静态对象.  控制好新生代和旧生代的比例。

调优手段

主要是通过控制堆内存的各个部分的比例和GC策略来实现。

  1. 新生代设置过小

一是新生代GC次数非常频繁,增大系统消耗;二是导致大对象直接进入旧生代,占据了旧生代剩余空间,诱发Full GC

  1. 新生代设置过大

一是新生代设置过大会导致旧生代过小(堆总量一定),从而诱发Full GC;二是新生代GC耗时大幅度增加

一般说来新生代占整个堆1/3比较合适

  1. Survivor设置过小

导致对象从eden直接到达旧生代,降低了在新生代的存活时间

  1. Survivor设置过大

导致eden过小,增加了GC频率

另外,通过-XX:MaxTenuringThreshold=n来控制新生代存活时间,尽量让对象在新生代被回收

 

GC策略的设置方式

1)吞吐量优先

    JVM以吞吐量为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,来达到吞吐量指标。这个值可由-XX:GCTimeRatio=n来设置

2)暂停时间优先

JVM以暂停时间为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,尽量保证每次GC造成的应用停止时间都在指定的数值范围内完成。这个值可由-XX:MaxGCPauseRatio=n来设置

 

JVM常见配置

 

堆设置

 

-Xms:初始堆大小

 

-Xmx:最大堆大小

 

-XX:NewSize=n:设置年轻代大小

 

-XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4

 

-XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5

 

-XX:MaxPermSize=n:设置持久代大小

 

收集器设置

 

-XX:+UseSerialGC:设置串行收集器

 

-XX:+UseParallelGC:设置并行收集器

 

-XX:+UseParalledlOldGC:设置并行年老代收集器

 

-XX:+UseConcMarkSweepGC:设置并发收集器

 

垃圾回收统计信息

 

-XX:+PrintGC

 

-XX:+PrintGCDetails

 

-XX:+PrintGCTimeStamps

 

-Xloggc:filename

 

并行收集器设置

 

-XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。

 

-XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间

 

-XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)

 

并发收集器设置

 

-XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。

 

-XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。

 

 

调优总结

年轻代大小选择

 

响应时间优先的应用:尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择)。在此种情况下,年轻代收集发生的频率也是最小的。同时,减少到达年老代的对象。

 

吞吐量优先的应用:尽可能的设置大,可能到达Gbit的程度。因为对响应时间没有要求,垃圾收集可以并行进行,一般适合8CPU以上的应用。

 

年老代大小选择

 

响应时间优先的应用:年老代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,可以会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。最优化的方案,一般需要参考以下数据获得:

 

1. 并发垃圾收集信息

 

2. 持久代并发收集次数

 

3. 传统GC信息

 

4. 花在年轻代和年老代回收上的时间比例

 

减少年轻代和年老代花费的时间,一般会提高应用的效率

 

吞吐量优先的应用

 

一般吞吐量优先的应用都有一个很大的年轻代和一个较小的年老代。原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而年老代尽存放长期存活对象。

 

较小堆引起的碎片问题

 

因为年老代的并发收集器使用标记、清除算法,所以不会对堆进行压缩。当收集器回收时,他会把相邻的空间进行合并,这样可以分配给较大的对象。但是,当堆空间较小时,运行一段时间以后,就会出现“碎片”,如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记、清除方式进行回收。如果出现“碎片”,可能需要进行如下配置:

 

1. -XX:+UseCMSCompactAtFullCollection:使用并发收集器时,开启对年老代的压缩。

 

2. -XX:CMSFullGCsBeforeCompaction=0:上面配置开启的情况下,这里设置多少次Full GC后,对年老代进行压缩

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注

昵称 *