软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

时间:2021-12-09 17:11:47

Java编程语言自从诞生起,就成为了一门非常流行的编程语言,覆盖了互联网、安卓应用、后端应用、大数据等很多技术领域,因此Java应用程序的性能分析和调优也是一门非常重要的课题。Java应用程序的性能直接关系到了很多大型电商网站的访问承载能力、大数据的数据处理量等,它的性能分析和调优往往还可以节省很多的硬件成本。

5.1  JVM基础知识

5.1.1  JVM简介

JVM是Java Virtual Machine(Java虚拟机)的英文简写,通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java编程语言在引入了Java虚拟机后,使得Java应用程序可以在不同操作系统平台上运行,而不需要再次重新编译。Java编程语言通过使用Java虚拟机屏蔽了与具体操作系统平台相关的信息,保证了编译后的应用程序的平台兼容性,使得Java应用程序只需编译生成在Java虚拟机上运行的目标代码(字节码),就可以在不同的操作系统上部署和运行。Java虚拟机本质上可以认为是运行在操作系统上的一个程序、一个进程。Java虚拟机在启动后就开始执行保存在字节码文件中的指令,其内部组成结构如图5-1-1所示。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-1

在JDK1.8(Java 8)及以后的版本中,JVM的内部组成结构发生了一些小的变化,如图5-1-2所示。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-2

5.1.2  类加载器

类加载器(Class Loader)负责将编译好的.class字节码文件装载到内存中,使得JVM可以实例化或以其他方式使用加载后的类。类加载器支持在运行时的动态加载,动态加载可以节省内存空间,灵活地从网络上加载类,可以通过命名空间的分隔来实现类的隔离,增强了整个系统的安全性等。类加载器分为如下几种:

l启动类加载器(BootStrap Class Loader): 启动类加载器是最底层的加载器,负责加载JDK中的rt.jar文件中所有的Java字节码文件。如图5-1-3所示,rt.jar文件一般位于JDK的jre目录下,里面存放中Java语言自身的核心字节码文件。Java自身的核心字节码文件一般都是由启动类加载器进行加载。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-3

l扩展类加载器(Extension Class Loader):负责加载一些扩展功能的jar包到内存中。一般负责加载<Java_Runtime_Home >/lib/ext目录或者由系统变量-Djava.ext.dir指定位置中的字节码文件。

l系统类加载器(System Class Loader):负责将系统类路径java -classpath或-Djava.class.path参数所指定的目录下的字节码类库加载到内存中。通常程序员自己编写的Java程序也是由该类加载器进行加载。

类加载器加载类的过程如图5-1-4所示,该图同时也描述了一个class字节码文件的整个生命周期。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-4

本文作者:张永清,转载请注明:https://www.cnblogs.com/laoqing/p/15950682.html 来源于博客园

类加载器加载过程详细描述如表5-1所示。

表5-1 类加载器加载过程详细描述

步骤

说明

加载

将指定的.calss字节码文件加载到JVM中

连接

将加载到JVM中的二进制字节流的类数据信息,合并到JVM的运行时状态中,加载过程包括验证、准备、解析三个步骤

验证

校验.class字节码文件的正确性,确保该文件是符合规范定义的,并且适合当前JVM版本的使用。一般包含如下4个子步骤:

(1)文件格式校验:校验字节码文件的格式是否符合规范、版本号是否正确并且对应的版本是否是当前JVM可以支持的、常量池中的常量是否有不被支持的类型等。

(2)元数据校验:对字节码描述的信息进行语义分析,以确保其描述的信息符合Java语言的规范。

(3)字节码校验:通过对字节码文件的数据流和控制流进行分析,验证代码的语义是合法的、符合Java规范的。

(4)符号引用校验:符号引用是指以一组符号来描述所引用的目标,校验符号引用转化成为真正的内存地址是否正确

准备

为加载到JVM中的类分配内存,同时初始化类中的静态变量的初始值

解析

将符号引用转换为直接引用,一般主要是把类的常量池中的符号引用解析为直接引用

初始化

初始化类中的静态变量,并执行类中的static代码、构造函数等。如果没有构造函数,系统添加默认的无参构造函数。如果类的构造函数中没有显示的调用父类的构造函数,编译器会自动生成一个父类的无参构造函数

被调用

指在运行时被使用

卸载

指将类从JVM中移除

5.1.3  Java虚拟机栈和本地方法栈

Java虚拟机栈是Java方法执行的内存模型,是线程私有的,和线程直接相关。每创建一个新的线程,JVM就会为该线程分配一个对应的Java栈。各个线程的Java栈的内存区域是不能互相直接被访问的,以保证在并发运行时线程的安全性。每调用一个方法,Java虚拟机栈就会为每个方法生成一个栈帧(Stack Frame),调用方法时压入栈帧(通常叫入栈),方法返回时弹出栈帧并抛弃(通常叫出栈)。栈帧中存储局部变量、操作数栈、动态链接、中间运算结果、方法返回值等信息。每个方法被调用和完成的过程,都对应一个栈帧从虚拟机栈上入栈和出栈的过程。虚拟机栈的生命周期和线程是一样的,栈帧中存储的局部变量随着线程运行的结束而结束。

本地方法栈类似于Java虚拟机栈,主要存储了本地方法调用的状态和信息,是为了方便JVM去调用本地方法(native method)和接口的栈区。

和栈相关的常见异常如下:

l*Error:俗称栈溢出。一般当栈深度超过JVM虚拟机分配给线程的栈大小时,就会出现这个错误。在循环调用方法而无法退出的情况下,容易出现栈溢出错误。

lOutOfMemoryError:详细错误信息一般为“Exception in thread "main" java.lang.OutOfMemoryError:unable to create new native thread”。Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。

5.1.4  方法区与元数据区

本文作者:张永清,转载请注明:https://www.cnblogs.com/laoqing/p/15950682.html 来源于博客园

方法区也就是我们常说的永久代区域,里面存储着Java 类信息、常量池、静态变量等数据,方法区占用的内存区域在JVM中是线程共享的。在JDK1.8及以后的版本中,方法区已经被移除,取而代之的是元数据区和本地内存,类的元数据信息直接存放到JVM管理的本地内存中。需要注意的是,本地内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。常量池、静态变量等数据则存放到了Java堆(Heap)中。这样做的目的主要是为了减少加载的类过多时容易造成Full GC问题。

5.1.5  堆区

Java是一门面向对象的开发语言,而JVM堆区是真正存储Java对象实例的内存区域,并且是所有线程共享的。所以Java程序在进行实例化对象等操作时,需要解决同步和线程安全问题。Java堆区可以细分为新生代区域和老年代区域。新生代还可以再细分为Eden空间区域、From Survivor空间区域、To Survivor空间区域,如图5-1-5所示。堆区是发生GC垃圾回收最频繁的内存区域,因此也是JVM性能调优的关键区域。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-5

Java堆区内部结构说明如表5-2所示。

表5-2 Java堆区内部结构说明

区域

说明

新生代区

又称年轻代区域,由Eden空间区域和Survivor空间区域共同组成。在新生代区域中JVM默认内存分配比例为Eden : From Survivo : To Survivor = 8 : 1 : 1

Eden空间区域

新生对象存放的内存区域,存放着首次创建的对象实例

Survivor空间区域

由From Survivor空间区域和To Survivor空间区域共同组成,并且这两个区域中总是有一个是空的

From Survivor空间区域

存储Eden空间区域发生GC垃圾回收后幸存的对象实例。From Survivor空间区域和To Survivor空间区域的作用是等价的,并且默认情况下这两个区域的大小是一样大的

To Survivor空间区域

存储Eden空间区域发生GC垃圾回收后幸存的对象实例。当一个Survivor(幸存者)空间饱和,依旧存活的对象会被移动到另一个Survivor(幸存者)空间,然后会清空已经饱和的那个Survivor(幸存者)空间

老年代区域

JVM的垃圾回收器分代进行垃圾回收。在回收到一定次数(可以通过JVM参数设定)后,依然存活的新生代对象实例将会进入老年代区域

上图5-1-5中的箭头指示的方向就代表JVM堆区分代进行垃圾回收时数据的移动过程。对象在刚刚被创建之后是保存在Eden空间区域的,那些长期存活的对象会经由Survivor(幸存者)空间转存到老年代空间区域(Old generation)。当然对于一些比较大的对象(需要分配一块比较大的连续内存空间),则直接进入到老年代区域,这种情况一般在Survivor 空间区域内存不足的时候下会发生。

在JDK1.7以及之前的版本中,JVM的共享内存区域组成如图5-1-6所示。

本文作者:张永清,转载请注明:https://www.cnblogs.com/laoqing/p/15950682.html 来源于博客园

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-6

在JDK1.8以及之后的版本中,JVM的共享内存区域组成如图5-1-7所示。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-7

5.1.6  程序计数器

程序计数器是一个记录着线程所执行的字节码指令位置的指示器,装载入JVM内存中的.class字节码文件通过字节码解释器进行解释执行,按照顺序读取字节码指令。每读取一个指令后,将该指令转换成对应的操作,并根据这些操作进行分支、循环、条件判断等流程处理。由于程序一般是多线程来协同执行的,并且JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配CPU执行时间)算法来实现的,这样就存在着某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到CPU时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置了,在JVM中就是通过程序计数器来记录某个线程的字节码指令的执行位置。因此,程序计数器是线程私有的、是线程隔离的,每个线程在运行时都有属于自己的程序计数器。另外,如果是执行native方法,程序计数器的值为空,因为native方法是Java通过JNI(Java Native Interface)直接调用Java本地C/C++语言库执行的,而C/C++语言实现的方法自然无法产生相应的.class字节码(C/C++语言是按照C/C++语言的方式来执行的),因此Java的程序计数器此时是无值的。

5.1.7  垃圾回收

Java语言和别的编程语言不一样,程序运行时的内存回收不需要开发者自己在代码中进行手动回收和释放,而是JVM自动进行内存回收。内存回收时会将已经不再使用的对象实例等从内存中移除掉,以释放出更多的内存空间,这个过程就是常说的JVM垃圾回收机制。

垃圾回收一般也叫GC,新生代的垃圾回收一般称作Minor GC,老年代的垃圾回收一般称作Major GC或者Full GC。垃圾回收之所以如此重要,是因为发生垃圾回收时一般会伴随着应用程序的暂停运行。一般发生垃圾回收时除GC所需的线程外,所有的其他线程都进入等待状态,直到GC执行完成。GC调优最主要目标就是减少应用程序的暂停执行时间。

JVM垃圾回收的常见算法有根搜索算法、标记-清除算法、复制算法标记-整理算法。

1. 根搜索算法

根搜索算法把垃圾回收线程把应用程序的所有引用关系看作一张图,从一个节点GC ROOT(英文解释为A garbage collection root is an object that is accessible from outside the heap,即一个可以从堆外访问的对象) 开始,寻找对应的引用节点,找到这个节点后,继续寻找这个节点的引用节点。当所有的引用节点寻找完毕后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,然后对这些节点执行垃圾回收。

如图5-1-8所示,颜色较深的节点(实例对象6、实例对象7、实例对象8)就是可以被垃圾回收的节点,因为这些节点已经被引用了。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-8

本文作者:张永清,转载请注明:https://www.cnblogs.com/laoqing/p/15950682.html 来源于博客园

IBM网站页面https://www.ibm.com/support/knowledgecenter/en/SS3KLZ/com.ibm.java. diagnostics.memory.analyzer.doc/gcroots.html介绍中认为JVM中可以作为GC ROOT节点的对象包括:

System[z8] class[u9]

A class that was loaded by the bootstrap loader, or the system class loader. For example, this category includes all classes in the rt.jar file (part of the Java runtime environment), such as those in the java.util.* package.

JNI local

A local variable in native code, for example user-defined JNI code or JVM internal code.

JNI global

A global variable in native code, for example user-defined JNI code or JVM internal code.

Thread block

An object that was referenced from an active thread block.

Thread

A running thread.

Busy monitor

Everything that called the wait() or notify() methods, or that is synchronized, for example by calling the synchronized(Object) method or by entering a synchronized method. If the method was static, the root is a class, otherwise it is an object.

Java local

A local variable. For example, input parameters, or locally created objects of methods that are still in the stack of a thread.

Native stack

Input or output parameters in native code, for example user-defined JNI code or JVM internal code. Many methods have native parts, and the objects that are handled as method parameters become garbage collection roots. For example, parameters used for file, network, I/O, or reflection operations.

Finalizer

An object that is in a queue, waiting for a finalizer to run.

Unfinalized

An object that has a finalize method, but was not finalized, and is not yet on the finalizer queue.

Unreachable

An object that is unreachable from any other root, but was marked as a root by Memory Analyzer so that the object can be included in an analysis.

Unreachable objects are often the result of optimizations in the garbage collection algorithm. For example, an object might be a candidate for garbage collection, but be so small that the garbage collection process would be too expensive. In this case, the object might not be garbage collected, and might remain as an unreachable object.

By default, unreachable objects are excluded when Memory Analyzer parses the heap dump. These objects are therefore not shown in the histogram, dominator tree, or query results. You can change this behavior by clicking File > Preferences... > IBM Diagnostic Tools for Java - Memory Analyzer, then selecting the Keep unreachable objects check box.

而https://www.dynatrace.com/resources/ebooks/javabook/how-garbage-collection-works/网站中给出的解释如图5-1-9所示。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-9

最终我们总结归纳如下:

(1)JVM虚拟机栈中引用的实例对象。

(2)方法区中静态属性引用的对象(仅针对JDK1.8之前的JVM,JDK1.8及之后由于不存在方法区,静态属性直接存于Heap中)。

(3)方法区中静态常量引用的对象(仅针对JDK1.8之前的JVM,JDK1.8及之后由于不存在方法区,静态常量直接存于Heap中)。

(4)本地方法(native method,多用在JNI接口调用中)栈中引用的对象。

(5)JVM自身持有的对象,比如启动类加载器、系统类加载器等。

下面讲的其他GC算法基本都会引用根搜索算法这种概念。

本文作者:张永清,转载请注明:https://www.cnblogs.com/laoqing/p/15950682.html 来源于博客园

2. 标记-清除算法

如图5-1-10所示,标记-清除算法采用从GC ROOT进行扫描,对存活的对象节点进行标记,标记完成后再扫描整个内存区域中未被标记的对象进行直接回收。由于标记-清除算法标记完毕后不会对存活的对象进行移动和整理,因此很容易导致内存碎片。但是由于仅对不存活的对象进行处理,在存活的对象较多、不存活的对象较少的情况下,标记清除-算法的性能极高。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-10

3. 复制算法

复制算法采用从根集合扫描,将存活的对象复制到空闲区间,当扫描完活动区间后,会将活动区间内存一次性全部回收,此时原来的活动区间就变成了空闲区域,如图5-1-11所示。复制算法会将内存分为两个区间,所有动态分配的实例对象都只能分配在其中一个区间(此时该区间就变成了活动区间),而另外一个区间则是空闲的,每次GC时都重复这样的操作,每次总是会有一个区域是空闲的。

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-11

4. 标记-整理算法

采用标记-清除算法一样的方式进行对象的标记、清除,但在回收不存活的对象占用的空间后,会将所有存活的对象往左端空闲空间移动,并更新对应的内存节点指针,如图5-1-12所示。标记-整理算法是在标记-清除算法之上,又进行了对象的移动排序整理,虽然性能成本更高了,但却解决了内存碎片的问题。如果不解决内存碎片的问题,一旦出现需要创建一个大的对象实例时,JVM可能无法给这个大的实例对象分配连续的大内存,从而导致发生Full GC。在垃圾回收中,Full GC 应该是需要尽量去避免的,因为一旦出现Full GC,一般会导致应用程序暂停很久以等待Full GC完成。

本文作者:张永清,转载请注明:https://www.cnblogs.com/laoqing/p/15950682.html 来源于博客园

软件性能测试分析与调优实践之路-Java应用程序的性能分析与调优-手稿节选

图5-1-12

JVM为了优化垃圾回收的性能,使用了分代回收的方式。它对于新生代内存的回收(Minor GC)主要采用复制算法,而对于老年代的回收(Major GC/Full GC),大多采用标记-整理算法。在做垃圾回收优化时,最重要的一点就是减少老年代垃圾回收的次数,因为老年代垃圾回收耗时长,性能成本非常高,对应用程序的运行影响非常大。

5.1.8  并行与并发

本文作者:张永清,转载请注明:https://www.cnblogs.com/laoqing/p/15950682.html 来源于博客园

在并发程序开发中经常会提到并行与并发。在垃圾回收中并行和并发的区别如下:

l并行:JVM启动多个垃圾回收线程并行工作,但此时用户线程(应用程序的工作线程)需要一直处于等待状态。

l并发:指用户线程(应用程序的工作线程)与垃圾回收线程同时执行(但并不一定是并行的,可能会交替执行),用户线程此时可以继续运行,而垃圾回收线程运行于另一个CPU核上,彼此可以互不干扰。

未完待续,本文作者:张永清,转载请注明:https://www.cnblogs.com/laoqing/p/15950682.html 来源于博客园。 本文摘选自《软件性能测试分析与调优实践之路》