Java教程

《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化

本文主要是介绍《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

文章目录

    • 一、Java性能调优概述
      • 1.1 性能调优标准
      • 1.2 制定性能调优策略
    • 二、Java编程性能调优
      • 2.1 字符串
      • 2.2 正则表达式
      • 2.3 ArrayList和LinkedList的选择
      • 2.4 使用Stream提高遍历集合效率
      • 2.5 HashMap优化
      • 2.6 高并发下I/O优化
      • 2.7 避免使用Java序列化
      • 2.8 优化RPC网络通信
      • 2.9 NIO优化
    • 三、多线程性能优化
      • 3.1 Synchronized优化
      • 3.2 Lock优化
      • 3.3 乐观锁优化
      • 3.4 上下文切换优化
      • 3.5 并发容器的使用
      • 3.6 线程池大小的设置
      • 3.7 用协程来优化多线程业务

一、Java性能调优概述

1.1 性能调优标准

  在项目开发的初期,有必要过于在意性能优化,这样反而会疲于性能优化,不仅不会给系统性能带来提升,还会影响到开发进度,甚至获得相反的效果,给系统带来新的问题。
  只需要在代码层面保证有效的编码,比如,减少磁盘 I/O 操作、降低竞争锁的使用以及使用高效的算法等等。遇到比较复杂的业务,我们可以充分利用设计模式来优化业务代码。
  在系统编码完成之后,我们就可以对系统进行性能测试了。这时候,产品经理一般会提供线上预期数据,我们在提供的参考平台上进行压测,通过性能分析、统计工具来统计各项性能指标,看是否在预期范围之内。
  在项目成功上线后,我们还需要根据线上的实际情况,依照日志监控以及性能统计日志,来观测系统性能问题,一旦发现问题,就要对日志进行分析并及时修复问题。
  可能成为系统的性能瓶颈的计算机资源:

  • 1、CPU
      有的应用需要大量计算,他们会长时间、不间断地占用 CPU 资源,导致其他资源无法争夺到 CPU 而响应缓慢,从而带来系统性能问题。例如,代码递归导致的无限循环,正则表达式引起的回溯,JVM 频繁的 FULL GC,以及多线程编程造成的大量上下文切换等,这些都有可能导致 CPU 资源繁忙。
  • 2、内存
      Java 程序一般通过 JVM 对内存进行分配管理,主要是用 JVM 中的堆内存来存储Java 创建的对象。系统堆内存的读写速度非常快,所以基本不存在读写性能瓶颈。但是由于内存成本要比磁盘高,相比磁盘,内存的存储空间又非常有限。所以当内存空间被占满,对象无法回收时,就会导致内存溢出、内存泄露等问题。
  • 3、磁盘 I/O
      磁盘 I/O 读写的速度要比内存慢。
  • 4、网络
      网络对于系统性能来说,也起着至关重要的作用。带宽过低的话,对于传输数据比较大,或者是并发量比较大的系统,网络就很容易成为性能瓶颈。
  • 5、异常
      如果在高并发的情况下引发异常,持续地进行异常处理,那么系统的性能就会明显地受到影响。
  • 6、数据库
      大部分系统都会用到数据库,而数据库的操作往往是涉及到磁盘 I/O 的读写。大量的数据库读写操作,会导致磁盘 I/O 性能瓶颈,进而导致数据库操作的延迟性。对于有大量数据库读写操作的系统来说,数据库的性能优化是整个系统的核心。
  • 7、锁竞争
      锁的使用可能会带来上下文切换,从而给系统带来性能开销。如何合理地使用锁资源,优化锁资源,就需要你了解更多的操作系统知识、Java 多线程编程基础,积累项目经验,并结合实际场景去处理相关问题。

  衡量一般系统的性能的指标:

  • 1、响应时间
      响应时间是衡量系统性能的重要指标之一,响应时间越短,性能越好,一般一个接口的响应时间是在毫秒级。可以把响应时间自下而上细分为以下几种:

      数据库响应时间:数据库操作所消耗的时间,往往是整个请求链中最耗时的;
      服务端响应时间:服务端包括 Nginx 分发的请求所消耗的时间以及服务端程序执行所消耗的时间;
      网络响应时间:这是网络传输时,网络硬件需要对传输的请求进行解析等操作所消耗的时间;
      客户端响应时间:对于普通的 Web、App 客户端来说,消耗时间是可以忽略不计的,但如果你的客户端嵌入了大量的逻辑处理,消耗的时间就有可能变长,从而成为系统的瓶颈。
  • 2、吞吐量
      我们往往会比较注重系统接口的 TPS(每秒事务处理量),因为 TPS 体现了接口的性能,TPS 越大,性能越好。在系统中,我们也可以把吞吐量自下而上地分为两种:磁盘吞吐量和网络吞吐量。
      磁盘性能有两个关键衡量指标:
  1. IOPS(Input/Output Per Second)
      每秒的输入输出量(或读写次数),这种是指单位时间内系统能处理的 I/O 请求数量,I/O 请求通常为读或写数据操作请求,关注的是随机读写性能。适应于随机读写频繁的应用,如小文件存储(图片)、OLTP 数据库、邮件服务器。
  2. 数据吞吐量
      指单位时间内可以成功传输的数据量。对于大量顺序读写频繁的应用,传输大量连续数据,例如,电视台的视频编辑、视频点播 VOD(Video On Demand),数据吞吐量则是关键衡量指标。

  网络吞吐量:指网络传输时没有帧丢失的情况下,设备能够接受的最大数据
速率。网络吞吐量不仅仅跟带宽有关系,还跟 CPU 的处理能力、网卡、防火墙、外部接口以及 I/O 等紧密关联。而吞吐量的大小主要由网卡的处理能力、内部程序算法以及带宽大小决定。

  • 3、计算机资源分配使用率
      通常由 CPU 占用率、内存使用率、磁盘 I/O、网络 I/O 来表示资源使用率。
  • 4、负载承受能力
      当系统压力上升时,你可以观察,系统响应时间的上升曲线是否平缓。这项指标能直观地反馈给你,系统所能承受的负载压力极限。

1.2 制定性能调优策略

  面对日渐复杂的系统,制定合理的性能测试,可以提前发现性能瓶颈,然后有针对性地制定调优策略。总结一下就是“测试 - 分析 - 调优”三步走。
  性能测试是提前发现性能瓶颈,保障系统性能稳定的必要措施。
  两种常用的测试方法:微基准性能测试和宏基准性能测试。

  • 1、微基准性能测试
      微基准性能测试可以精准定位到某个模块或者某个方法的性能问题,特别适合做一个功能模块或者一个方法在不同实现方式下的性能对比。例如,对比一个方法使用同步实现和非同步实现的性能。
  • 2、宏基准性能测试
      宏基准性能测试是一个综合测试,需要考虑到测试环境、测试场景和测试目标。
      首先看测试环境,我们需要模拟线上的真实环境。然后看测试场景。我们需要确定在测试某个接口时,是否有其他业务接口同时也在平行运行,造成干扰。如果有,请重视,因为你一旦忽视了这种干扰,测试结果就会出现偏差。
      最后看测试目标。我们的性能测试是要有目标的,这里可以通过吞吐量以及响应时间来衡量系统是否达标。不达标,就进行优化;达标,就继续加大测试的并发数,探底接口的TPS(最大每秒事务处理量),这样做,可以深入了解到接口的性能。除了测试接口的吞吐量和响应时间以外,我们还需要循环测试可能导致性能问题的接口,观察各个服务器的CPU、内存以及 I/O 使用率的变化。

  在做性能测试时,还要注意的一些问题:

  • 1、热身问题
      在 Java 编程语言和环境中,.java 文件编译成为 .class 文件后,机器还是无法直接运行.class 文件中的字节码,需要通过解释器将字节码转换成本地机器码才能运行。为了节约内存和执行效率,代码最初被执行时,解释器会率先解释执行这段代码。
      随着代码被执行的次数增多,当虚拟机发现某个方法或代码块运行得特别频繁时,就会把这些代码认定为热点代码(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会通过即时编译器(JIT compiler,just-in-time compiler)把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后存储在内存中,之后每次运行代码时,直接从内存中获取即可。
      所以在刚开始运行的阶段,虚拟机会花费很长的时间来全面优化代码,后面就能以最高性能执行了。
  • 2、性能测试结果不稳定
      可以通过多次测试,将测试结果求平均,或者统计一个曲线图,只要保证我们的平均值是在合理范围之内,而且波动不是很大,这种情况下,性能测试就是通过的。
  • 3、 多JVM情况下的影响
      应该尽量避免线上环境中一台机器部署多个JVM的情况。

  从应用层到操作系统层的几种调优策略:

  • 1、优化代码
      应用层的问题代码往往会因为耗尽系统资源而暴露出来。
      还有一些是非问题代码导致的性能问题,这种往往是比较难发现的。例如,LinkedList 集合,如果使用 for 循环遍历该容器,将大大降低读的效率,这种效率的降低很难导致系统性能参数异常。此时可以改用 Iterator (迭代器)迭代循环该集合,这是因为 LinkedList是链表实现的,如果使用 for 循环获取元素,在每次循环获取元素时,都会去遍历一次List,这样会降低读的效率。
  • 2、优化设计
      面向对象有很多设计模式,可以帮助我们优化业务层以及中间件层的代码设计。优化后,不仅可以精简代码,还能提高整体性能。
  • 3、优化算法
  • 4、时间换空间
      有时候系统对查询时的速度并没有很高的要求,反而对存储空间要求苛刻,这个时候可以考虑用时间来换取空间。
  • 5、空间换时间
      现在很多系统都是使用的 MySQL 数据库,较为常见的分表分库是典型的使用空间换时间的案例。
      因为 MySQL 单表在存储千万数据以上时,读写性能会明显下降,这个时候我们需要将表数据通过某个字段 Hash 值或者其他方式分拆,系统查询数据时,会根据条件的 Hash 值判断找到对应的表,因为表数据量减小了,查询性能也就提升了。
  • 6、参数调优
      以上都是业务层代码的优化,除此之外,JVM、Web 容器以及操作系统的优化也是非常关键的。

  为了保证系统的稳定性,我们还需要采用一些兜底策略。示例:

  • 1、限流
      对系统的入口设置最大访问限制。同时采取熔断措施,友好地返回没有成功的请求。
  • 2、实现智能化横向扩容
      智能化横向扩容可以保证当访问量超过某一个阈值时,系统可以根据需求自动横向新增服务。
  • 3、提前扩容
      这种方法通常应用于高并发系统。

  目前很多公司使用 Docker 容器来部署应用服务。这是因为 Docker 容器是使用Kubernetes 作为容器管理系统,而 Kubernetes 可以实现智能化横向扩容和提前扩容Docker 服务。
  调优策略简单总结:

二、Java编程性能调优

2.1 字符串

  String对象优化过程:

  在Java6以及之前的版本中,String 对象是对 char 数组进行了封装实现的对象,主要有四个成员变量:char 数组、偏移量 offset、字符数量 count、哈希值 hash。String 对象是通过 offset 和 count 两个属性来定位 char[] 数组,获取字符串。这么做可以高效、快速地共享数组对象,同时节省内存空间,但这种方式很有可能会导致内存泄漏。
  从Java7开始到 Java8版本,Java 对 String 类做了一些改变。String 类中不再有offset 和 count 两个变量了。这样的好处是 String 对象占用的内存稍微少了些,同时,String.substring 方法也不再共享 char[],从而解决了使用该方法可能导致的内存泄漏问题。
  从Java9版本开始,工程师将 char[] 字段改为了 byte[] 字段,又维护了一个新的属性coder,它是一个编码格式的标识。
  一个 char 字符占 16 位,2 个字节。这个情况下,存储单字节编码内的字符(占一个字节的字符)就显得非常浪费。JDK1.9 的 String 类为了节约内存空间,于是使用了占8 位,1 个字节的 byte 数组来存放字符串。
  String 类被 final 关键字修饰,char[] 被 final+private 修饰,代表了String 对象不可被更改。这样做的好处:

  • 1、保证 String 对象的安全性
      假设 String 对象是可变的,那么 String 对象将可能被恶意修改。
  • 2、保证 String 对象的安全性
      保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。
  • 3、可以实现字符串常量池

  String 对象的优化方式:

  • 1、字符串拼接
      做字符串拼接的时候,建议显式地使用 StringBuilder 来提升系统性能。因为String对象直接相加时,底层还是用StringBuilder来实现的。
  • 2、合理使用String.intern
      在每次赋值的时候使用 String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用,这样一开始的对象就可以被回收掉。
      示例:
	String a = new String("abc").intern();
	String b = new String("abc").intern();	
	if(a == b)
		//a==b
		System.out.println("a==b");

  在字符串常量中,默认会将对象放入常量池;在字符串变量中,对象是会创建在堆内存中,同时也会在常量池中创建一个字符串对象,复制到堆内存对象中,并返回堆内存对象引用。
  如果调用 intern 方法,会去查看字符串常量池中是否有等于该对象的字符串,如果没有,就在常量池中新增该对象,并返回该对象引用;如果有,就返回常量池中的字符串引用。堆内存中原有的对象由于没有引用指向它,将会通过垃圾回收器回收。
  使用 intern 方法需要注意的一点是,一定要结合实际场景。因为常量池的实现是类似于一个 HashTable 的实现方式,HashTable 存储的数据越大,遍历的时间复杂度就会增加。如果数据过大,会增加整个字符串常量池的负担。

  • 3、合理使用字符串的分割方法
      应该慎重使用 Split() 方法,可以用 String.indexOf() 方法代替 Split() 方法完成字符串的分割。

2.2 正则表达式

  构造正则表达式语法的元字符,由普通字符、标准字符、限定字符(量词)、定位字符(边界字符)组成:

  正则表达式的优化方法:

  • 1、 少用贪婪模式,多用独占模式
      简单来说,贪婪模式:在数量匹配中,如果单独使用 +、 ? 、* 或{min,max} 等量词,正则表达式会匹配尽可能多的内容。贪婪模式示例:
	regex = "ab{1,3}c"

  懒惰模式:正则表达式会尽可能少地重复匹配字符。如果匹配成功,它会继续匹配剩余的字符串。例如,在上面例子的字符后面加一个“?”,就可以开启懒惰模式。示例:

	regex = "ab{1,3}?c"

  独占模式:一样会最大限度地匹配更多内容;不同的是,在独占模式下,匹配失败就会结束匹配,不会发生回溯问题。还是上边的例子,在字符后面加一个“+”,就可以开启独占模式。示例:

	regex = "ab{1,3}+bc"
  • 2、 减少分支选择
      分支选择类型“(X|Y|Z)”的正则表达式会降低性能,要尽量减少使用。如果一定要用,可以通过以下几种方式来优化:
  1. 需要考虑选择的顺序,将比较常用的选择项放在前面,使它们可以较快地被匹配;
  2. 可以尝试提取共用模式,例如,将“(abcd|abef)”替换为“ab(cd|ef)”,后者匹配速度较快,因为 NFA 自动机会尝试匹配 ab,如果没有找到,就不会再尝试任何选项;
  3. 如果是简单的分支选择类型,我们可以用三次 index 代替“(X|Y|Z)”,如果测试的话,你就会发现三次 index 的效率要比“(X|Y|Z)”高出一些。

  以往的经验来看,如果使用正则表达式能使代码简洁方便,那么在做好性能排查的前提下,可以去使用;如果不能,那么正则表达式能不用就不用,以此避免造成更多的性能问题。

2.3 ArrayList和LinkedList的选择

  ArrayList 实现了 List 接口,继承了 AbstractList 抽象类,底层是数组实现的,并且实现了自增扩容数组大小。ArrayList 还实现了 Cloneable 接口和 Serializable 接口,所以他可以实现克隆和序列化。
  ArrayList 还实现了 RandomAccess 接口。RandomAccess 接口是一个标志接口,他标志着“只要实现该接口的 List 类,都能实现快速随机访问”。
  ArrayList 属性主要由数组长度 size、对象数组 elementData、初始化容量default_capacity 等组成, 其中初始化容量默认大小为 10。elementData 被关键字transient 修饰。
  如果采用外部序列化法实现数组的序列化,会序列化整个数组。ArrayList 为了避免这些没有存储数据的内存空间被序列化,内部提供了两个私有方法 writeObject 以及 readObject来自我完成序列化与反序列化,从而在序列化与反序列化数组时节省了空间和时间。因此使用 transient 修饰数组,是防止对象数组被其他外部方法序列化。
  LinkedList 是基于双向链表数据结构实现的,LinkedList 定义了一个 Node 结构,Node结构中包含了 3 个部分:元素内容 item、前指针 prev 以及后指针 next。
  LinkedList 就是由 Node 结构对象连接而成的一个双向链表。在 JDK1.7 之前,LinkedList 中只包含了一个 Entry 结构的 header 属性,并在初始化的时候默认创建一个空的 Entry,用来做 header,前后指针指向自己,形成一个循环双向链表。
  在 JDK1.7 之后,LinkedList 做了很大的改动,对链表进行了优化。链表的 Entry 结构换成了 Node,内部组成基本没有改变,但 LinkedList 里面的 header 属性去掉了,新增了一个 Node 结构的 first 属性和一个 Node 结构的 last 属性。这样做有以下几点好处:

  1. first/last 属性能更清晰地表达链表的链头和链尾概念;
  2. first/last 方式可以在初始化 LinkedList 的时候节省 new 一个 Entry;
  3. first/last 方式最重要的性能优化是链头和链尾的插入删除操作更加快捷了。

  在 LinkedList 删除元素的操作中,我们首先要通过循环找到要删除的元素,如果要删除的位置处于 List 的前半段,就从前往后找;若其位置处于后半段,就从后往前找。因此,无论要删除较为靠前或较为靠后的元素都是非常高效的,但如果 List 拥有大量元素,移除的元素又在 List 的中间段,那效率相对来说会很低。
  LinkedList 的获取元素操作实现跟 LinkedList 的删除元素操作基本类似,通过分前后半段来循环查找到对应的元素。但是通过这种方式来查询元素是非常低效的,特别是在 for 循环遍历的情况下,每一次循环都会去遍历半个 List。所以在 LinkedList 循环遍历时,我们可以使用 iterator 方式迭代循环,直接拿到我们的元素,而不需要通过循环查找 List。
  ArrayList 和 LinkedList 新增元素操作测试结果 (花费时间):

从集合头部位置新增元素(ArrayList>LinkedList)
从集合中间位置新增元素(ArrayList<LinkedList)
从集合尾部位置新增元素(ArrayList<LinkedList)

  ArrayList 是数组实现的,而数组是一块连续的内存空间,在添加元素到数组头部的时候,需要对头部以后的数据进行复制重排,所以效率很低;而 LinkedList 是基于链表实现,在添加元素的时候,首先会通过循环查找到添加元素的位置,如果要添加的位置处于List 的前半段,就从前往后找;若其位置处于后半段,就从后往前找。因此 LinkedList 添加元素到头部是非常高效的。
  ArrayList 在添加元素到数组中间时,同样有部分数据需要复制重排,效率也不是很高;LinkedList 将元素添加到中间位置,是添加元素最低效率的,因为靠近中间位置,在添加元素之前的循环查找是遍历元素最多的操作。
  在添加元素到尾部的操作中,我们发现,在没有扩容的情况下,ArrayList 的效率要高于LinkedList。这是因为 ArrayList 在添加元素到尾部的时候,不需要复制重排数据,效率非常高。而 LinkedList 虽然也不用循环查找元素,但 LinkedList 中多了 new 对象以及变换指针指向对象的过程,所以效率要低于 ArrayList。
  这里是基于 ArrayList 初始化容量足够,排除动态扩容数组容量的情况下进行的测试,如果有动态扩容的情况,ArrayList 的效率也会降低

  ArrayList 和 LinkedList 遍历元素操作测试结果 (花费时间):

for(;

这篇关于《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!