Java性能调优实战》笔记(二)JVM优化、设计模式优化

文章目录

一、JVM优化

1.1 即时编译器JIT

  类编译加载执行过程:

  初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译。

  最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。

  为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中。

即时编译器类型

  在 HotSpot 虚拟机中,内置了两个 JIT,分别为 C1 编译器和 C2 编译器,这两个编译器的编译过程是不一样的。

  C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI 应用对界面启动速度就有一定要求。

  C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这两种即时编译也被称为 Client Compiler和 Server Compiler。

  在 Java7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,可以通过参数 “-client”“-server” 强制指定虚拟机的即时编译模式。

  在 Java8 中,默认开启分层编译,-client 和 -server 的设置已经是无效的了。如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1。

  通过 java -version 命令行可以直接查看到当前系统使用的编译模式。示例:

热点探测

  在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法” 。

  虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。

方法调用计数器 

用于统计方法被调用的次数,方法调用计数器的默认阈值在 C1 模式下是1500 次,在 C2 模式在是 10000 次,可通过 -XX: CompileThreshold 来设定;而在分层

编译的情况下,-XX: CompileThreshold 指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发 JIT 编译器。

回边计数器 

用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,C1 默认为 13995,C2 默认为 10700,可通过 -XX:

OnStackReplacePercentage=N 来设置;而在分层编译的情况下,-XX:

OnStackReplacePercentage 指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。

编译优化技术

  JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。

方法内联 

方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。示例:

//原有代码private int add1(int x1, int x2, int x3, int x4) {

returnadd2(x1,x2)+add2(x3,x4);}private int add2(int x1, int x2) {

returnx1+x2;}//优化后代码private int add1(int x1, int x2, int x3, int x4) {

returnx1+x2+x3+x4;}

  JVM 会自动识别热点方法,并对它们使用方法内联进行优化。可以通过 -XX:CompileThreshold 来设置热点方法的阈值。但要强调一点,热点方法不一定会被 JVM做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。而方法体的大小阈值,我们也可以通过参数设置来优化:

经常执行的方法,默认情况下,方法体大小小于 325 字节的都会进行内联,我们可以通过 -XX:MaxFreqInlineSize=N 来设置大小值;

不是经常执行的方法,默认情况下,方法大小小于 35 字节才会进行内联,我们也可以通过 -XX:MaxInlineSize=N 来重置大小值。

  热点方法的优化可以有效提高系统性能,一般可以通过以下几种方式来提高方法内联:

通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;

在编程中,避免在一个方法中写大量代码,习惯使用小方法体;

尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。

逃逸分析

  逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。

栈上分配 

在 Java 中默认创建一个对象是在堆中分配内存的,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对分配在栈中的对象的创建和销毁来说,更消耗时间和性能。这个时候,逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上。

标量替换 

逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换。

总结 

可以通过设置 JVM 参数来开关逃逸分析,还可以单独开关同步消除和标量替换,在JDK1.8 中 JVM 是默认开启这些操作:

-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启,其它版本未测试)-XX:-DoEscapeAnalysis关闭逃逸分析-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试)-XX:-EliminateLocks关闭锁消除-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启,其它版本未测试)-XX:-EliminateAllocations关闭就可以了

1.2 优化垃圾回收机制

  可以通过 JVM 工具查询当前 JVM 使用的垃圾收集器类型,首先通过 ps 命令查询出经常 ID,再通过 jmap -heap ID 查询出 JVM 的配置信息,其中就包括垃圾收集器的设置类型。示例:

  GC 性能衡量指标的一些指标:

1、吞吐量 

这里的吞吐量是指应用程序所花费的时间和系统总运行时间的比值。我们可以按照这个公式来计算 GC 的吞吐量:系统总运行时间 = 应用程序耗时 +GC 耗时。如果系统运行了 100 分钟,GC 耗时 1 分钟,则系统吞吐量为 99%。GC 的吞吐量一般不能低于95%。

2、停顿时间 

指垃圾收集器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。

3、垃圾回收频率 

多久发生一次指垃圾回收呢?通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。

  查看JVM日志的几种参数设置:

-XX:+PrintGC 输出 GC 日志-XX:+PrintGCDetails 输出 GC 的详细日志-XX:+PrintGCTimeStamps 输出 GC 的时间戳(以基准时间的形式)-XX:+PrintGCDateStamps 输出 GC 的时间戳(以日期的形式,如2013-05-04T21:53:59.234+0800)-XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息-Xloggc:../logs/gc.log 日志文件的输出路径

  几种常用的 GC 调优策略:

1、降低 Minor GC 频率 

通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此可以通过增大新生代空间来降低 Minor GC 的频率。

单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么正常情况下,Minor GC 的时间为 :T1+T2。

当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生Minor GC 的时间为:两次扫描新生代,即 2T1。可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。通常在虚拟机中,复制对象的成本要远高于扫描成本。

如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC的时间。如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。

2、降低 Full GC 的频率 

通常情况下,由于堆内存空间不足或老年代对象太多,会触发 Full GC,频繁的 Full GC 会带来上下文切换,增加系统的性能开销。常见的方法:

减少创建大对象 

例如,可能有一个一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老年代。这种大对象很容易产生较多

的 Full GC。

此时,可以将这种大对象拆解出来,首次只查询一些比较重要的字段,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段。

增大堆内存空间 

在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率。

3、选择合适的 GC 回收器

  假设有这样一个需求,要求每次操作的响应时间必须在 500ms 以内。这个时候我们一般会选择响应速度较快的 GC 回收器,CMS(Concurrent Mark Sweep)回收器和 G1 回收器都是不错的选择。

  而当需求对系统吞吐量有要求时,就可以选择 Parallel Scavenge 回收器来提高系统的吞吐量。

总结 :垃圾收集器的种类很多,我们可以将其分成两种类型,一种是响应速度快,一种是吞吐量高。通常情况下,CMS 和 G1 回收器的响应速度快,Parallel Scavenge 回收器的吞吐量高。

  在 JDK1.8 环境下,默认使用的是 Parallel Scavenge(年轻代)+Serial Old(老年代)垃圾收集器,你可以通过文中介绍的查询 JVM 的 GC 默认配置方法进行查看。

  通常情况,JVM 是默认垃圾回收优化的,在没有性能衡量标准的前提下,尽量避免修改GC 的一些性能配置参数。如果一定要改,那就必须基于大量的测试结果或线上的具体性能来进行调整。

1.3 优化JVM内存分配

  JVM 内存分配不合理最直接的表现就是频繁的 GC,这会导致上下文切换等性能问题,从而降低系统的吞吐量、增加系统的响应时间。因此,如果你在线上环境或性能测试时,发现频繁的 GC,且是正常的对象创建和回收,这个时候就需要考虑调整 JVM 内存分配了,从而减少 GC 所带来的性能开销。

  当新建一个对象时,对象会被优先分配到新生代的 Eden 区中,这时虚拟机会给对象定义一个对象年龄计数器(通过参数 -XX:MaxTenuringThreshold 设置)。

  同时,也有另外一种情况,当 Eden 空间不足时,虚拟机将会执行一个新生代的垃圾回收(Minor GC)。这时 JVM 会把存活的对象转移到 Survivor 中,并给对象的年龄 +1。对象在 Survivor 中同样也会经历 MinorGC,每经过一次 MinorGC,对象的年龄将会 +1。

  当然了,内存空间也是有设置阈值的,可以通过参数 -XX:PetenureSizeThreshold 设置直接被分配到老年代的最大对象,这时如果分配的对象超过了设置的阀值,对象就会直接被分配到老年代,这样做的好处就是可以减少新生代的垃圾回收。

  在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小,示例:

  可以得知在这台机器上启动的 JVM 默认最大堆内存为 1953MB,初始化大小为 124MB。

  在 JDK1.7 中,默认情况下年轻代和老年代的比例是 1:2,我们可以通过–XX:NewRatio 重置该配置项。年轻代中的 Eden 和 To Survivor、From Survivor 的比例是 8:1:1,可以通过 -XX:SurvivorRatio 重置该配置项。

  在 JDK1.7 中如果开启了 -XX:+UseAdaptiveSizePolicy 配置项,JVM 将会动态调整 Java堆中各个区域的大小以及进入老年代的年龄,–XX:NewRatio 和 -XX:SurvivorRatio 将会失效,而 JDK1.8 是默认开启 -XX:+UseAdaptiveSizePolicy 配置项的。

  可以提供一些具体的调优方向的指标:

1、GC 频率 

高频的 FullGC 会给系统带来非常大的性能消耗,虽然 MinorGC 相对 FullGC 来说好了许多,但过多的 MinorGC 仍会给系统带来压力。

2、内存 

这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存。首先我们要分析堆内存大小是否合适,其实是分析年轻代和老年代的比例是否合适。如果内存不足或分配不均匀,会增加 FullGC,严重的将导致 CPU 持续爆满,影响系统性能。

3、吞吐量 

频繁的 FullGC 将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降。

4、延时 

JVM 的 GC 持续时间也会影响到每次请求的响应时间。

  具体调优方法:

1、调整堆内存空间减少 FullGC

  如果堆内存基本被用完了,而且存在大量FullGC,这意味着堆内存严重不足,这个时候需要调大堆内存空间:

java-jar-Xms4g-Xmx4gheapTest-0.0.1-SNAPSHOT.jar

2、调整年轻代减少 MinorGC

  可以将年轻代设置得大一些,从而减少一些MinorGC:

java-jar-Xms4g-Xmx4g-Xmn3gheapTest-0.0.1-SNAPSHOT.jar

3、设置 Eden、Survivor 区比例

  在 JVM 中,如果开启 AdaptiveSizePolicy,则每次 GC后都会重新计算 Eden、From Survivor 和 To Survivor 区的大小,计算依据是 GC 过程中统计的 GC 时间、吞吐量、内存占用量。这个时候 SurvivorRatio 默认设置的比例会失效。

  在 JDK1.8 中,默认是开启 AdaptiveSizePolicy 的,我们可以通过 -XX:-UseAdaptiveSizePolicy 关闭该项配置,或显示运行 -XX:SurvivorRatio=8 将 Eden、Survivor 的比例设置为 8:2。大部分新对象都是在 Eden 区创建的,我们可以固定 Eden 区的占用比例,来调优 JVM 的内存分配性能。

总结 :JVM 内存调优通常和 GC 调优是互补的。

1.4 如何内存持续上升问题

  平时排查内存性能瓶颈时,往往需要用到一些 Linux 命令行或者 JDK 工具来辅助我们监测系统或者虚拟机内存的使用情况。

1、top 

top 命令可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。示例:

除了简单的 top 之外,还可以通过 top -Hp pid 查看具体线程使用系统资源情况:

2、vmstat 

vmstat 是一款指定采样周期和次数的功能性监测工具,它不仅可以统计内存的使用情况,还可以观测到 CPU 的使用率、swap 的使用情况。但 vmstat 一般很少用来查看内存的使用情况,而是经常被用来观察进程的上下文切换。示例:

r:等待运行的进程数;

b:处于非中断睡眠状态的进程数;

swpd:虚拟内存使用情况;

free:空闲的内存;

buff:用来作为缓冲的内存数;

si:从磁盘交换到内存的交换页数量;

so:从内存交换到磁盘的交换页数量;

bi:发送到块设备的块数;

bo:从块设备接收到的块数;

in:每秒中断数;

cs:每秒上下文切换次数;

us:用户 CPU 使用时间;

sy:内核 CPU 系统使用时间;

id:空闲时间;

wa:等待 I/O 时间;

st:运行虚拟机窃取的时间。

3、pidstat

3、pidstat

  之前的 top 和 vmstat 两个命令都是监测进程的内存、CPU 以及 I/O 使用情况,而 pidstat 命令则是深入到线程级别。

  通过 pidstat -help 命令,可以查看到有以下几个常用的参数来监测线程的性能:

  常用参数:

-u:默认的参数,显示各个进程的 cpu 使用情况;

-r:显示各个进程的内存使用情况;

-d:显示各个进程的 I/O 使用情况;

-w:显示每个进程的上下文切换情况;

-p:指定进程号;

-t:显示进程中线程的统计信息。

  可以通过相关命令(例如 ps 或 jps)查询到相关进程 ID,再运行以下命令来监测该进程的内存使用情况:

  pidstat 的参数 -p 用于指定进程 ID,-r 表示监控内存的使用情况,1 表示每秒的意思,3 则表示采样次数。

  上图中几个结果的意义:

Minflt/s:任务每秒发生的次要错误,不需要从磁盘中加载页;

Majflt/s:任务每秒发生的主要错误,需要从磁盘中加载页;

VSZ:虚拟地址大小,虚拟内存使用 KB;

RSS:常驻集合大小,非交换区内存使用 KB。

4、jstat

4、jstat

  jstat 可以监测 Java 应用程序的实时运行情况,包括堆内存信息以及垃圾回收信息。

  一个常用功能,如何使用 jstat 查看堆内存的使用情况。可以用 jstat -gc pid 查看:

S0C:年轻代中 To Survivor 的容量(单位 KB);

S1C:年轻代中 From Survivor 的容量(单位 KB);

S0U:年轻代中 To Survivor 目前已使用空间(单位 KB);

S1U:年轻代中 From Survivor 目前已使用空间(单位 KB);

EC:年轻代中 Eden 的容量(单位 KB);

EU:年轻代中 Eden 目前已使用空间(单位 KB);

OC:Old 代的容量(单位 KB);

OU:Old 代目前已使用空间(单位 KB);

MC:Metaspace 的容量(单位 KB);

MU:Metaspace 目前已使用空间(单位 KB);

YGC:从应用程序启动到采样时年轻代中 gc 次数;

YGCT:从应用程序启动到采样时年轻代中 gc 所用时间 (s);

FGC:从应用程序启动到采样时 old 代(全 gc)gc 次数;

FGCT:从应用程序启动到采样时 old 代(全 gc)gc 所用时间 (s);

GCT:从应用程序启动到采样时 gc 用的总时间 (s)。

5、jstack 

最常用的功能就是使用 jstack pid 命令查看线程的堆栈信息,通常会结合 top -Hp pid 或 pidstat -p pid -t 一起查看具体线程的状态,也经常用来排查一些死锁的异常。

每个线程堆栈的信息中,都可以查看到线程 ID、线程的状态(wait、sleep、running 等状态)以及是否持有锁等。

6、jmap 

可以使用 jmap 输出堆内存中的对象信息,包括产生了哪些对象,对象数量多少等。

可以用 jmap 来查看堆内存初始化配置信息以及堆内存的使用情况:

可以使用 jmap -histo[:live] pid 查看堆内存中的对象数目、大小统计直方图,如果带上 live 则只统计活对象:

可以通过 jmap 命令把堆内存的使用情况 dump 到文件中:

  平时遇到的内存溢出问题一般分为两种,一种是由于大峰值下没有限流,瞬间创建大量对象而导致的内存溢出;另一种则是由于内存泄漏而导致的内存溢出。使用限流,一般就可以解决第一种内存溢出问题,但其实很多时候,内存溢出往往是内存泄漏导致的,这种问题就是程序的 BUG,需要及时找到问题代码。

二、设计模式优化

2.1 原型模式与享元模式优化

  原型模式和享元模式,前者是在创建多个实例时,对创建过程的性能进行调优;后者是用减少创建实例的方式,来调优系统性能。

  它们的使用是分场景的。在有些场景下,我们需要重复创建多个实例,例如在循环体中赋值一个对象,此时我们就可以采用原型模式来优化对象的创建过程;而在有些场景下,我们则可以避免重复创建多个实例,在内存中共享对象就好了

  要实现一个原型类,需要具备三个条件:

实现 Cloneable 接口重写 Object 类中的clone方法在重写的clone方法中调用 super.clone()

  其实深拷贝就是基于浅拷贝来递归实现具体的每个对象。

  在一些重复创建对象的场景下,我们就可以使用原型模式来提高对象的创建性能。如循环体内创建对象时,我们就可以考虑用 clone 的方式来实现,示例:

for(inti=0;i

Studentstu=newStudent();...}//可以优化为:Studentstu=newStudent();for(inti=0;i

Studentstu1=(Student)stu.clone();...}

  享元模式是运用共享技术有效地最大限度地复用细粒度对象的一种模式。该模式中,以对象的信息状态划分,可以分为内部数据和外部数据。内部数据是对象可以共享出来的信息,这些信息不会随着系统的运行而改变;外部数据则是在不同运行时被标记了不同的值。

  享元模式在实际开发中的应用也非常广泛。例如 Java 的 String 字符串,在一些字符串常量中,会共享常量池中字符串对象,从而减少重复创建相同值对象,占用内存空间。

2.2 装饰器模式优化电商系统中复杂的商品价格策略

  装饰器模式主要用来优化业务的复杂度,它不仅简化了业务代码,还优化了业务代码的结构设计,使得整个业务逻辑清晰、易读易懂。通常,装饰器模式用于扩展一个类的功能,且支持动态添加和删除类的功能。在装饰器模式中,装饰类和被装饰类都只关心自身的业务,不相互干扰,真正实现了解耦。

你可能感兴趣的:(Java性能调优实战》笔记(二)JVM优化、设计模式优化)