第一部分 走近Java
世界上并没有完美的程序,但我们并不因此而沮丧,因为写程序本来就是一个不断追求完美的过程。
第1章 走进Java
1.Java优点
- 结构严谨、面向对象。
- 摆脱了硬件平台的束缚,实现“一次编写,到处运行”的理想
- 提供了相对安全的内存管理和访问机制
- 实现了热点代码监测和运行时编译及优化
- 有一套完善的应用程序接口以及无数来自商业机构和开源社区的第三方类库来帮助它实现各种各样的功能
认识这些技术运作的本质,是自己思考“程序这样写好不好”的基础和前提。
2.Java技术体系
Sun官方所定义的Java技术体系包括:
Java程序设计语言
各种硬件平台上的Java虚拟机
Class文件格式
Java API类库
来自商业机构和开源社区的第三方Java类库
其中,其中第1、2、4点统称为JDK(Java Development Kit),JDK是用于支持Java开发的最小环境,JRE是支持Java程序运行的标准环境,整个Java体系如下所示:
3.Java发展史
- JDK 1.0:Java虚拟机、Applet、AWT等
- JDK 1.1:JAR文件格式、JDBC、JavaBeans、RMI、内部类、反射
- JDK 1.2:拆分为J2SE/J2EE/J2ME、内置JIT编译器、一系列Collections集合类
- JDK 1.3:JNDI服务、使用CORBA IIOP实现RMI通信协议、Java 2D改进
- JDK 1.4:正则表达式、异常链、NIO、日志类、XML解析器和XSLT转换器
- JDK 1.5:自动装箱、泛型、动态注解、枚举、可变参数、遍历循环(foreach循环)、改进了Java内存模型、提供了java.util.concurrent并发包
- JDK 1.6:提供动态语言支持、提供编译API和微型HTTP服务器API、虚拟机优化(锁与同步、垃圾收集、类加载等)
- JDK 1.7:G1收集器、加强对Java语言的调用支持、升级类加载架构
- JDK 1.8:Lambda表达式等
4.Java虚拟机发展史
Sun Classic/Exact VM:Classic VM是第一款商用虚拟机,纯解析器方式来执行Java代码,如果要使用JIT编译器就必须进行外挂,解析器和编译器不能配合工作,编译器执行效率非常差;Exact VM是Sun虚拟机团队曾在Solaris平台发布的虚拟机,支持两级即时编译器、编译器和解释器混合工作、使用准确内存管理(虚拟机可以知道内存中某个位置的数据具体是什么类型),但很快就被HotSpot VM所取代
Sun HotSpot VM:Sun JDK和OpenJDK所带的虚拟机,目前使用范围最广;继承了前两款虚拟机的优点,还支持热点代码探测技术(通过计数器找出最具编译价值的代码);2006年Sun公司宣布JDK包括HotSpot VM开源,在此基础上建立OpenJDK
Sun Mobile-Embedded VM/Meta-Circular VM:还有一些Sun开发的面对移动和嵌入式发布的和实验性质的虚拟机
BEA JRockit/IBM J9 VM:JRockit VM号称是世界上最快的Java虚拟机,专注于服务器端应用,不包含解析器实现,全部靠即时编译器编译执行;J9 VM定位于HotSpot比较接近,主要目的是作为IBM公司各种Java产品的执行平台
Azul VM/BEA Liquid VM:特定硬件平台专有的高性能虚拟机
Apache Harmony/Google Android Dalvik VM:Apache Harmony包含自己的虚拟机和Java库,但没有通过TCK认证;Dalvik VM是Android平台的核心组成部分,其并没有遵循Java虚拟机规范,不能直接执行Class文件,使用的是寄存器架构而不是JVM常见的栈架构
Microsoft JVM及其他:微软曾经是Java技术的铁杆支持者,开发过Windows下性能最好的Java虚拟机,但后来被Sun起诉终止其发展
5.展望Java技术的未来
模块化、混合语言、多核并行、进一步丰富语法、64位虚拟机
第二部分 自动内存管理机制
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。
第2章 Java内存区域与内存溢出异常
1.概述
对于Java程序员来说,在虚拟机自动内存管理机制下,不需要为new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题。由虚拟机管理内存这一切看起来都很美好,但是一旦出现内存泄漏问题,如果不了解虚拟机的是怎样使用内存的,便难以定位,排查错误将会成为一项异常艰难的工作。
2.运行时数据区域
2.1 程序计数器(Program Counter Register)
一块较小的内存,可以看作是当前线程所执行的字节码的行号指示器。
在虚拟机概念模型里(各种虚拟机实现可能不一样),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
程序计数器是属于“线程私有”的内存:Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,因此为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。
如果执行的是Java方法,该计数器记录的是正在执行的虚拟机字节码指令的地址;如果是Native方法则为空(Undefined)。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
2.2 Java虚拟机栈(Java Virtual Machine Stacks)
Java虚拟机栈也是线程私有的;
描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程;
局部变量表存放了编译器可知的各种基本数据类型、对象引用和returnAddress类型;其所需的内存空间在编辑期完成分配,不会再运行期改变;
可能存在两种异常:StackOverflowError和OutOfMemoryError;
2.3 本地方法栈(Native Method Stack)
- 与虚拟机栈非常相似,只不过是为虚拟机使用到的Native方法服务;
- 可能存在两种异常:StackOverflowError和OutOfMemoryError;
2.4 Java堆(Java Heap)
- Java堆是被所有线程共享的,在虚拟机启动时创建;
- 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这分配;
- 是垃圾收集器管理的主要区域,可以分为新生代和老年代:一个Eden空间、两个Survivor空间——From Survivor空间、To Survivor空间;
- 可以物理不连续,只要逻辑上是连续的即可;
- 如果堆中没有内存完成实例分配也无法再扩展时,会抛出OutOfMemoryError异常;
2.5 方法区(Method Area)
- 是线程共享的区域;
- 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;
- 该区域对于垃圾收集来说条件比较苛刻,但是还是非常有必要要进行回收处理。相对而言,垃圾收集行为在这个区域是比较少出现的,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载;
- 当无法满足内存分配需求时,将抛出OutOfMemoryError异常;
对于HotSpot虚拟机来说,很多人把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。JDK1.7中的HotSpot中,已经移除永久代。
2.6 运行时常量池(Runtime Constant Pool)
- 是方法区的一部分;
- Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放;
- Java虚拟机规范要求较少,通常还会把翻译出来的直接引用也存储在此;
- 另外一个重要特征是具备动态性,可以在运行期间将新的常量放入池中,如String类的intern()方法;
- 可能存在的异常:OutOfMemoryError;
2.7 直接内存(Direct Memory)
- 并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域;
- JDK 1.4的NIO(New Input/Output)类引入了基于通道(Channel)和缓冲区(Buffer)的IO方法,可以使用Native函数库直接分配对外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作以提升性能;
3.HotSpot虚拟机对象探秘
进一步了解虚拟机内存中数据的其他细节,比如它们是如何创建、如何布局以及如何访问的。下面以虚拟机HotSpot和常用的内存区域Java堆为例,深入探讨HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。
3.1 对象的创建
类加载检查:虚拟机遇到一条new指令时,先检查指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
为新生对象分配内存:接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便完全确定,为对象分配空间等同于把一块确定大小的内存从Java堆中划分出来。在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞(内存绝对规整,只要通过指针作为分界点标识);而使用CMS这种基于Mark-Sweep算法收集器时,通常使用空闲列表(内存不规整,通过维护一个列表记录那块内存是可用的)。
并发情况下处理对象创建的线程安全问题:另外一个需要考虑的并发下的线程安全问题,有两种方案:一是分配内存空间的动作进行同步处理(实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性);二是为每个线程分配一小块内存(称为本地线程分配缓冲,TLAB),各个线程独立分配,只有TLAB用完需要分配新的才需要同步锁定,虚拟机通过-XX:+/-UseTLAB参数来设定。
为分配到的内存空间初始化为零值:内存分配完后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头),这保证了对象的实例字段在Java代码中可以不赋值就直接使用,程序能访问到这些字段数据类型对应的零值(P220)。
虚拟机设置对象的对象头(Object Header)信息:包括对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象GC分代年龄等。
执行
方法 :把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
- HotSpot解释器的代码片段:略
3.2 对象的内存布局
对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头包括两部分信息:第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;官方称它为“Mark Word”。Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例(并不是所有虚拟机都必须在对象数据上保留类型指针)。另外如果对象是一个Java数组,对象头中还必须有一块用于记录数组长度的数据。
实例数据部分是真正存储的有效信息,也是在代码中所定义的各种类型的字段内容。无论是父类继承的还是子类中定义的都需要记录下来。这部分存储的顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
对齐填充不是必然存在的,主要是由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍。
3.3 对象的访问定位
Java程序需要通过栈上的reference数据来操作堆上的具体对象。而栈上的reference类型在虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆栈对象的具体位置,目前主流的访问方式有句柄和直接指针两种。
通过句柄:Java堆中划出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。其最大好处就是reference存储的是稳定的句柄地址,在对象被移到(垃圾收集时移到)只改变实例数据指针,而reference不需要修改;
- 通过直接指针:Java堆对象的布局中必须考虑如果放置访问类型数据的相关信息,而reference中存在的直接就是对象地址。其最大好处在于速度更快,节省了一次指针定位的时机开销。HotSpot采用该方式进行对象访问,但其他语言和框架采用句柄的也非常常见。
4.本章小结
学习了虚拟机的内存是如何划分的,对象是如何创建、布局和访问的,哪部分区域、什么样的代码和操作可能导致内存的溢出异常。
第3章 垃圾收集器与内存分配策略
1.概述
思考GC需要完成的3件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
再回头看看第二章介绍的Java内存运行时区域的各个部分:
- 程序计时器、虚拟机栈、本地方法栈:随线程而生,随线程而灭。栈帧随方法而进行出栈和入栈,每一个栈帧分配的内存在类结构确定就已知,因此这几个区域不需要考虑回收
- 对于Java堆和方法区,只有程序运行期间才知道会创建哪些对象,内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存
2.对象已死吗
在垃圾收集器进行回收前,第一件事就是确定这些对象哪些还存活,哪些已经死去。
2.1 引用计数法
给对象添加引用计数器,当有地方引用它时就加1,引用失效就减1,为0时就认为对象不再被使用可回收。该算法实现简单,判断高效,但并不被主流虚拟机采用,主要原因是它很难解决对象之间相互循环引用的问题。
2.2 可达性分析算法
通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),如果一个对象到GC Roots没有引用链相连,则该对象是不可用的。
在Java语言中,可作为GC Roots的对象包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象; 执行上下文
- 方法区中类静态属性引用的对象; 全局性引用
- 方法区中常量引用的对象; 全局性引用
- 本地方法栈中JNI(即一般说的Native方法)引用的对象;
2.3 再谈引用
在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,这4种引用强度依次减弱。
2.4 生存还是死亡
要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法(如没有重写finalize方法或者已经被调用过则认为没有必要执行)。如果有必要执行则将该对象放置在F-Queue队列中,并在稍后由一个由虚拟机自己建立的、低优先级的Finalizer线程去执行它;稍后GC将对F-Queue中的对象进行第二次标记,如果对象还是没有被引用,则会被回收。
但是作者不建议通过finalize方法“拯救”对象,因为它运行代价高、不确定性大、无法保证各个对象的调用顺序。
2.5 回收方法区
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
一个无用的类需要满足以下三个条件:
- 该类的所有实例都已经被回收;
- 加载该类的ClassLoader已经被回收;
- 该类对象的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;
在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能(HotSpot提供-Xnoclassgc参数控制),以保证永久代不会溢出。
3.垃圾收集算法
- 标记-清除(Mark-Sweep)算法:首先标记出所有需要回收的对象,然后统一回收所有被标记的对象;缺点是效率不高且容易产生大量不连续的内存碎片;
- 复制(Copying)算法:将可用内存分为大小相等的两块,每次只使用其中一块;当这一块用完了,就将还活着的对象复制到另一块上,然后把已使用过的内存清理掉。在HotSpot里,考虑到大部分对象存活时间很短将内存分为Eden和两块Survivor,默认比例为8:1:1。代价是存在部分内存空间浪费,适合在新生代使用;
- 标记-整理(Mark-Compact)算法:首先标记出所有需要回收的对象,然后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。适用于老年代。
- 分代收集算法:一般把Java堆分新生代和老年代,在新生代用复制算法,在老年代用标记-清理或标记-整理算法,是现代虚拟机通常采用的算法。
4.HotSpot的算法实现
4.1 枚举根节点
- 由于要确保在一致性的快照中进行可达性分析,从而导致GC进行时必须要停顿所有Java执行线程(“Stop The World”)
- 在HotSpot里通过一组OopMap数据结构来知道哪些地方存放着对象引用
4.2 安全点(Safepoint)
- HotSpot只在特定的位置记录了OopMap,这些位置称为安全点(SafePoint);
- 即程序执行时并非在所有地方都能停顿下来开始GC,只有到达安全点时才能暂停;
- 对于安全点基本上是以程序“是否具有让程序长时间执行的特征”(比如方法调用、循环跳转、异常跳转等)为标准进行选定的;
- 另外还需要考虑如果在GC时让所有线程都跑到最近的安全点上,有两种方案:抢先式中断和主动式中断(主流选择)。
4.3 安全区域
- 如果程序没有分配CPU时间(如线程处于Sleep或Blocked),此时就需要安全区域(Safe Region),其是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。
- 线程执行到安全区域时,首先标识自己已经进入了安全区域,这样JVM在GC时就不管这些线程了;
5.垃圾收集器
- 垃圾收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。
- 这里讨论JDK 1.7 Update 14之后的HotSpot虚拟机(此时G1仍处于实验状态),包含的虚拟机如下图所示(存在连线的表示可以搭配使用):
5.1 Serial收集器
- 最基本、发展历史最悠久,在JDK 1.3之前是新生代收集的唯一选择;
- 是一个单线程的收集器(单线程并非指只会使用一个CPU或者一条收集线程,更重要的是在它进行垃圾收集时必须其他所有的工作线程,直到它收集结束);
- 现在依然是虚拟机运行在Client模式下的默认新生代收集器,主要就是因为它简单而高效(没有线程交互的开销);
5.2 ParNew收集器
- 其实就是Serial收集器的多线程版本;
- ParNew收集器在单CPU环境中绝对不会有比Serial收集器更好的效果;
- 是许多运行在Server模式下虚拟机首选的新生代收集器,重要原因就是除了Serial收集器外,只有它能与CMS收集器配合工作;
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态;
并发(Concurrent):指用户线程与垃圾收集线程同时执行,用户线程在继续执行而垃圾收集程序运行在另外一个CPU上;
5.3 Parallel Scavenge收集器
- 新生代收集器,使用复制算法,并行的多线程收集器;
- 与其他收集器关注于尽可能缩短垃圾收集时用户线程停顿时间不同,它的目标是达到一个可控制的吞吐量;
高吞吐量可以高效率利用CPU时间,适合在后台运算而不需要太多交互的任务,Parallel Scavenge收集器也经常称为“吞吐量优先”收集器;
-XX:MaxGCPauseMillis参数可以设置最大停顿时间,而停顿时间缩短是以牺牲吞吐量和新生代空间来换取的;
另外它还支持GC自适应的调节策略,这是与ParNew收集器的一个重要区别。
5.4 Serial Old收集器
- 是Serial收集器的老年代版本,同样是单线程,使用标记-整理算法;
- 主要是给Client模式下的虚拟机使用的;
- 在Server模式下主要是给JDK 1.5及之前配合Parallel Scavenge使用或作为CMS收集器的后备预案;
5.5 Parallel Old收集器
- 是Parallel Scavenge的老年代版本,使用多线程和标记-整理算法;
- 是JDK 1.6中才开始提供的;
5.6 CMS收集器
- 是一种以获取最短回收停顿时间为目标的收集器,特别适合互联网站或者B/S的服务端;
- 主要包括4个步骤:初始标记、并发标记、重新标记和并发清除;
- 优点:并发收集、低停顿。并发低停顿收集器
- 还有3个明显的缺点:CMS收集器对CPU非常敏感、无法处理浮动垃圾、大量内存碎片产生;
5.7 G1收集器
一款面向服务端应用的垃圾收集器,后续会替换掉CMS垃圾收集器;
特点:并行与并发(充分利用多核多CPU缩短Stop-The-World时间)、分代收集(独立管理整个Java堆,但针对不同年龄的对象采取不同的策略)、空间整合(基于标记-整理)、可预测的停顿(将堆分为大小相等的独立区域,避免全区域的垃圾收集);
关于Region:新生代和老年代不再物理隔离,只是部分Region的集合;G1跟踪各个Region垃圾堆积的价值大小,在后台维护一个优先列表,根据允许的收集时间优先回收价值最大的Region;Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,采用Remembered Set来避免全堆扫描;
G1收集器运作大致分为几个步骤:初始标记(标记一下GC Roots能直接关联的对象并修改TAMS值,需要STW但耗时很短)、并发标记(从GC Root从堆中对象进行可达性分析找存活的对象,耗时较长但可以与用户线程并发执行)、最终标记(为了修正并发标记期间产生变动的那一部分标记记录,这一期间的变化记录在Remembered Set Log里,然后合并到Remembered Set里,该阶段需要STW但是可并行执行)、筛选回收(对各个Region回收价值排序,根据用户期望的GC停顿时间制定回收计划来回收);
6.内存分配与回收策略
- 对象优先在新生代分配
- 大对象直接进入老年代
- 长期存活的对象将进入老年代
- 动态对象年龄判断:如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,大于或等于该年龄的对象直接进入老年代
- 空间分配担保:发生Minor GC前,虚拟机会先检查老年代最大可用连续空间是否大于新生代所有对象总空间;如果不成立,虚拟机会查看HandlePromotionFailure设置值是否允许担保失败;如果允许继续检查老年代最大可用的连续空间是否大于历次晋升到老年代的平均大小,如果大于会尝试进行一次Minor GC;如果小于或者不允许冒险,会进行一次Full GC;
7.本章小结
本章介绍了垃圾回收算法、几款JDK 1.7中提供的垃圾收集器特点以及运作原理。内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一。然而没有固定收集器和参数组合,也没有最优的调优方法,需要根据实践了解各自的行为、优势和劣势。
第4章 虚拟机性能监控与故障处理工具
1.概述
定位问题时,知识和经验是关键基础、数据(运行日志、异常堆栈、GC日志、线程快照、堆转储快照heapdump)是依据、工具是运用知识处理数据的手段。
2.jps: 虚拟机进程状况工具
功能:可以列出正在运行的虚拟机进程,并线上虚拟机执行的主类名称及其本地虚拟机唯一ID(LVMID);
对于本地虚拟机来说,LVMID和操作系统的进程ID是一致的;
其他的工具通常都需要依赖jps获取LVMID;
主要选项:-q(只输出LVMID)、-m(输出传给main函数的参数)、-l(输出主类的全名)、-v(输出虚拟机启动JVM参数);
3.jstat:虚拟机统计信息监视工具
- 功能:监视虚拟机各种运行状态信息,包括类装载、内存、垃圾收集、JIT等;
- 纯文本监控首选;
4.jinfo:Java配置信息工具
- 功能:实时地查看虚拟机各项参数。虽然jps -v可以查看虚拟机启动参数,但是无法查看一些系统默认的参数。
- 支持运行期修改参数的能力,格式为“jinfo -flag name=value pid”;
5.jmap:Java内存映像工具
功能:用于生成堆转储快照(一般称为heapdump或dump文件);
其他可生成heapdump的方式:使用参数-XX:+HeapDumpOnOutOfMemoryError;使用参数-XX:+HeapDumpOnCtrlBreak然后使用Ctrl+Break生成;Linux系统使用kill -3生成;
- 另外它还可以查询finalize执行队列、Java堆和永久代的详细信息;
6.jhat:虚拟机堆转储快照分析工具
- 功能:用于分析jmap生成的heapdump。其内置了一个微型的HTTP服务器,可以在浏览器查看分析结果;
- 实际很少用jhat,主要有两个原因:一是分析工程会耗用服务器资源;二是功能相对BisualVM、IBM HeapAnalyzer较为简陋;
7.jstack:Java堆栈跟踪工具
功能:用于生成虚拟机当前时刻的线程快照(一般称为threaddump或javacore文件)。javacore主要目的是定位线程出现长时间停顿的原因,比如死锁、死循环、请求外部资源响应长时间等待等;
另外JDK 1.5后Thread类新增了getAllStackTraces()方法,可以基于此自己增加管理页面来分析;
8.HSDIS:JIT生成代码反编译
现代虚拟机的实现慢慢地和虚拟机规范产生差距,如果要分析程序如何执行,最常见的就是通过软件调试工具(GDB、Windbg等)断点调试。但是对于Java来说,很多执行代码是通过JIT动态生成到CodeBuffer中的;
功能:HSDIS是官方推荐的HotSpot虚拟机JIT编译代码的反汇编工具,它包含在HotSpot虚拟机的源码中但没有提供编译后的程序,可以自己下载放到JDK的相关目录里。
9.JDK的可视化工具
9.1 JConsole:Java监视与管理控制台
是一种基于JMX的可视化监控和管理工具,它管理部分的功能是针对MBean进行管理,由于MBean可以使用代码、中间件服务器或者所有符合JMX规范的软件进行访问,因此这里着重介绍JConsole的监控功能;
通过jconsole命令启动JConsole后,会自动搜索本机所有虚拟机进程。另外还支持远程进程的监控;
进入主界面,支持查看以下标签页:概述、内存、线程、类、VM摘要和MBean。
9.2 VisualVM:多合一故障处理工具
- 目前为止JDK发布的功能最强调的运行监控和故障处理程序,另外还支持性能分析;
- VisualVM还有一个很大的优点:不需要被监视的程序基于特殊Agent运行,对应用程序的实际性能影响很小,可直接应用在生成环境中;
- VisualVM基于NetBeans平台开发,具备插件扩展功能的特性,基于插件可以做到:显示虚拟机进程以及进程配置、环境信息(jps、jinfo)、监视应用程序的CPU、GC、堆、方法区以及线程的信息(jstat、jstack)、dump以及分析堆转储快照(jmap、jhat)、方法级的程序运行性能分析,找出被调用最多运行时间最长的方法、离线程序快照(收集运行时配置、线程dump、内存dump等信息建立快照)、其他plugins的无限可能。
- 使用jvisualvm首次启动时需要在线自动安装插件(也可手工安装);
- 特色功能:生成浏览堆转储快照(摘要、类、实例标签页、OQL控制台)、分析程序性能(Profiler页签可以录制一段时间程序每个方法执行次数和耗时)、BTrace动态日志跟踪(不停止目标程序运行的前提下通过HotSwap技术动态加入调试代码)。
10.本章小结
本章介绍了随JDK发布的6个命令行工具以及两个可视化的故障处理工具,灵活运行这些工具可以给问题处理带来很多便利。我的总体感觉是可视化工具虽然强大,但是加载速度相比命令行工具慢很多,这个时候专注于某个功能的命令行工具是更优的选择。
第5章 调优案例分析与实践
1.概述
除了第四章介绍的知识和工具外,在处理实际问题时,经验同样很重要。
2.案例分析
以下的案例大部分来源作者处理过的一些问题,还有小部分是网络上笔记有代表的案例总结。
2.1 高性能硬件上的程序部署策略
问题描述
- 一个每天15万PV左右的在线文档网站升级了硬件,4个CPU,16GB物理内存,操作系统为64位CentOS 5.4,使用Resin作为Web服务器,没有部署其他的应用。
- 管理员选用了64位的JDK 1.5,并通过-Xmx和-Xms参数将Java堆固定在12GB。
使用一段时间不定期出现长时间失去响应的情况;
问题分析
升级前使用32位系统,Java堆设置为1.5GB,只是感觉运行缓慢没有明显的卡顿;
- 通过监控发现是由于GC停顿导致的,虚拟机运行在Server模式,默认使用吞吐量优先收集器,回收12GB的堆,一次Full GC的停顿时间高达14秒;
- 并且由于程序设计的原因,很多文档从磁盘加载到内存中,导致内存中出现很多由文档序列化生成的大对象,这些大对象进入了老年代,没有在Minor GC中清理掉;
解决办法
- 在虚拟机上建立5个32位的JDK逻辑集群,每个进程按2GB内存计算(其中堆固定为1.5GB),另外建议一个Apache服务作为前端均衡代理访问门户;
- 另外考虑服务压力主要在磁盘和内存访问,CPU资源敏感度较低,因此改为CMS收集器;
- 最终服务没有再出现长时间停顿,速度比硬件升级前有较大提升;
2.2 集群间同步导致的内存溢出
问题描述
- 一个基于B/S的MIS系统,硬件为两台2个CPU、8GB内存的HP小型机,服务器为WebLogic 9.2,每台机器启动了3个WebLogic实例,构建一个6台节点的亲和式集群(一个固定的用户请求永远分配到固定的节点处理)。
- 由于有部分数据需要共享,原先采用数据库,后因为读写性能问题使用了JBossCache构建了一个全局缓存;
- 正常使用一段较长的时间,最近不定期出现了多次的内存溢出问题;
问题分析
- 监控发现,服务内存回收状况一直正常,每次内存回收后都能恢复到一个稳定的可用空间
- 此次未升级业务代码,排除新修改代码引入的内存泄漏问题;
- 服务增加-XX:+HeapDumpOnOutOfMemoryError参数,在最近一次内存溢出时,分析heapdump文件发现存在大量的org.jgroups.protocols.pbcast,NAKACK对象;
- 最终分析发现是由于JBossCache的NAKACK栈在页面产生大量请求时,有个负责安全校验的全局Filter导致集群各个节点之间网络交互非常频繁,当网络情况不能满足传输要求时,大量的需要失败重发的数据在内存中不断堆积导致内存溢出。
解决办法
- JBossCache版本改进;
- 程序设计优化,JBossCahce集群缓存同步,不大适合有频繁写操作的情况;
2.3 堆外内存导致的溢出错误
问题描述
- 一个学校的小型项目,基于B/S的电子考试系统,服务器是Jetty 7.1.4,硬件是一台普通PC机,Core i5 CPU,4GB内存,运行32位Windows操作系统;
- 为了实现客户端能实时地从服务器端接收考试数据,使用了逆向AJAX技术(也称为Comet或Server Side Push),选用CometD 1.1.1作为服务端推送框架;
- 测试期间发现服务端不定期抛出内存溢出;加入-XX:+HeapDumpOnOutOfMemoryError后抛出内存溢出时什么问题都没有,采用jstat观察GC并不频繁且GC回收正常;最后在内存溢出后从系统日志发现如下异常堆栈:
堆外内存溢出日志
问题分析
- 在第二章里曾经说过直接内存溢出的场景,垃圾收集时,虚拟机虽然会对直接内存进行回收,但它只能等老年代满了触发Full GC时顺便清理,否则只能等内存溢出时catch住然后调用System.gc(),如果虚拟机还是不听(比如打开了-XX:+DisableExplictGC)则只能看着堆中还有许多空闲内存而溢出;
- 本案例中的CometD框架正好有大量的NIO操作需要使用直接内存;
2.4 外部命令导致系统缓慢
问题描述
- 一个数字校园应用系统,运行在一个4个CPU的Solaris 10操作系统上,中间件为GlassFish服务器;
- 系统在做大并发压力测试时,发现请求响应时间比较慢,通过监控工具发现CPU使用率很高,并且系统占用绝大多数的CPU资源的程序并不是应用系统本身;
- 通过Dtrace脚本发现最消耗CPU的竟然是fork系统调用(Linux用来产生新进程的);
问题分析
- 最终发现是每个用户请求需要执行一个外部的shell脚本来获取一些系统信息,是通过Runtime.getRuntime().exec()方法调用的;
- Java虚拟机在执行这个命令时先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新进程去执行外部命令,如果频繁地执行这个操作,系统消耗会很大;
- 最终修改时改用Java的API去获取这些信息,系统恢复了正常;
2.5 服务器JVM进程奔溃
问题描述
- 一个基于B/S的MIS系统,硬件为两台2个CPU、8GB内存的HP系统,服务器是WebLogic 9.2(和案例”集群间同步导致的内存溢出”相同的系统);
- 正常运行一段时间后发现运行期间频繁出现集群节点的虚拟机进程自动关闭的现象,留下一个hs_err_pid###.log,奔溃前不久都发生大量相同的异常,日志如下所示:
JVM进程奔溃日志
问题分析
- 这是一个远端断开连接的异常,得知在MIS系统工作流的待办事项变化时需要通过Web服务通知OA门户系统;
- 通过SoapUI测试发现调用后竟然需要长达3分钟才能返回,并且返回结果都是连接中断;
- 由于MIS使用异步方式调用,两边处理速度不对等,导致在等待的线程和Socket连接越来越多,最终在超过虚拟机承受能力后进场奔溃;
- 解决方法:将异步调用修改为生产者/消费者模型的消息队列处理,系统恢复正常;
2.6 不恰当数据结构导致内存占用过大
问题描述
- 有一个后台RPC服务器,使用64位虚拟机,内存配置为-Xms4g -Xmx8g -Xmn1g,使用ParNew + CMS的收集器组合;
- 平时Minor GC时间约在20毫秒内,但业务需要每10分钟加载一个约80MB的数据文件到内存进行数据分析,这些数据会在内存中形成超过100万个HashMap<Long, Long> Entry,在这段时间里Minor GC会超过500毫秒,这个时间过长,GC日志如下:
不恰当数据结构GC日志1
不恰当数据结构GC日志2
问题分析
- 在分析数据文件期间,800M的Eden空间在Minor GC后对象还是存活的,而ParNew垃圾收集器使用的是复制算法,把这些对象复制到Survivor并维持这些对象引用成为沉重的负担,导致GC时间变长;
- 从GC可以将Survivor空间去掉(加入参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0或者-XX:AlwaysTenure),让新生代存活的对象第一次Minor GC后立即进入老年代,等到Major GC再清理。这种方式可以治标,但也有很大的副作用。
- 另外一种是从程序设计的角度看,HashMap<Long, Long>结构中,只有key和value所存放的两个长整形数据是有效数据,共16B(2 * 8B),而实际耗费的内存位88B(长整形包装为Long对象需要多8B的MarkWord、8B的Klass指针,Map.Entry多了16B的对象头、8B的next字段和4B的int型hash字段、为对齐添加的4B空白填充,另外还有8B的Entry引用),内存空间效率(18%)太低。
2.7 由Windows虚拟内存导致的长时间停顿
问题描述
- 有一个带心跳检测功能的GUI桌面程序,每15秒发送一次心跳检查信号,如果对方30秒内都没有信信号返回,则认为和对方已断开连接;
- 程序上线后发现有误报,查询日志发现误报是因为程序会偶尔出现间隔约1分钟左右的时间完全无日志输出,处于停顿状态;
- 另外观察到GUI程序最小化时,资源管理中显示的占用内存大幅减小,但虚拟内存没变化;
- 因为是桌面程序,所需内存不大(-Xmx256m),加入参数-XX:+PrintGCApplicationStoppedTime -XX:PrintGCDateStamps -Xloggc:gclog.log后,从日志文件确认是GC导致的,大部分的GC时间在100ms以内,但偶尔会出现一次接近1min的GC;
- 加入参数-XX:PrintReferenceGC参数查看GC的具体日志信息,发现执行GC动作的时间并不长,但从准备开始GC到真正GC直接却消耗了大部分时间,如下所示:
虚拟内存案例日志
问题分析
- 初步怀疑是最小化时工作内存被自动交换到磁盘的页面文件中,这样发生GC时就有可能因为恢复页面文件的操作而导致不正常的GC停顿;
- 在MSDN查证确认了这种猜想,加入参数-Dsun.awt.keepWorkingSetOnMinimize=true来解决;这个参数在很多AWT程序如VisualVM都有应用。
3.实战:Eclipse运行速度调优
- 升级JDK;
- 设置-XX:MaxPermSize=256M解决Eclipse判断虚拟机版本的bug;
- 加入参数-Xverfify:none禁止字节码验证;
- 虚拟机运行在client模式,采用C1轻量级编译器;
- 把-Xms和-XX:PermSize参数设置为-Xmx和-XX:MaxPermSize一样,这样强制虚拟机启动时把老年代和永久代的容量固定下来,避免运行时自动扩展;
- 增加参数-XX:DisableExplicitGC屏蔽掉显式GC触发;
- 采用ParNew+CMS的垃圾收集器组合;
- 最终从Eclipse启动耗时15秒到7秒左右, eclipse.ini配置如下:
Eclipse调优
4.本章小结
Java虚拟机的内存管理和垃圾收集是虚拟机结构体系最重要的组成部分,对程序的性能和稳定性有非常大的影响。通过案例和实战部分,加深了对前面理论知识和工具的理解。