Java调优
Java 性能调优不像是学一门编程语言,无法通过直线式的思维来掌握和应用,它对于工程师的技术广度和深度都有着较高的要求。
互联网时代,一个简单的系统就囊括了应用程序、数据库、容器、操作系统、网络等技术,线上一旦出现性能问题,就可能要你协调多方面组件去进行优化,这就是技术广度;而很多性能问题呢,又隐藏得很深,可能因为一个小小的代码,也可能因为线程池的类型选择错误…可归根结底考验的还是我们对这项技术的了解程度,这就是技术深度。
调优的前提
扎实的计算机基础
我们调优的对象不是单一的应用服务,而是错综复杂的系统。应用服务的性能可能与操作系统、网络、数据库等组件相关,所以我们需要储备计算机组成原理、操作系统、网络协议以及数据库等基础知识。具体的性能问题往往还与传输、计算、存储数据等相关,那我们还需要储备数据结构、算法以及数学等基础知识。
习惯透过源码了解技术本质
学习方式可以通过论坛,博客,官网等。又因为论坛,博客上的大部分内容都是生产者自己吸收消化后总结的知识点,能帮助我们快速获取、快速理解。但是只做到这个程度还不够,因为缺失了自己的判断。这么解决我们需要深入源码,通过分析来学习、总结一项技术的实现原理和优缺点,这样我们就能更客观地去学习一项技术,还能透过源码来学习牛人的思维方式,收获更好的编码实现方式。
善于追问和总结
很多同学在使用一项技术时,只是因为这项技术好用就用了,从来不问自己:为什么这项技术可以提升系统性能?对比其他技术它好在哪儿?实现的原理又是什么呢?事实上,“知其然且知所以然”才是我们积累经验的关键。知道了一项技术背后的实现原理,我们才能在遇到性能问题时,做到触类旁通
优化模块
概述
性能调优标准,告诉你可以通过哪些参数去衡量系统性能;
调优过程标准,带你了解通过哪些严格的调优策略,我们可以排查性能问题,从而解决问题。
Java 编程性能调优
JDK 是 Java 语言的基础库,熟悉 JDK 中各个包中的工具类,可以帮助你编写出高性能代码。这里我会从基础的数据类型讲起,涉及容器在实际应用场景中的调优,还有现在互联网系统架构中比较重要的网络通信调优。
多线程性能调优
目前大部分服务器都是多核处理器,多线程编程的应用广泛。为了保证线程的安全性,通常会用到同步锁,这会为系统埋下很多隐患;除此之外,还有多线程高并发带来的性能问题,这些都会在这个模块重点讲解。
JVM 性能监测及调优
Java 应用程序是运行在 JVM 之上的,对 JVM 进行调优可以提升系统性能。这里重点讲解 Java 对象的创建和回收、内存分配等。
设计模式调优
在架构设计中,我们经常会用到一些设计模式来优化架构设计。这里我将结合一些复杂的应用场景,分享设计优化案例。模块六,数据库性能调优。数据库最容易成为整个系统的性能瓶颈,这里我会重点解析一些数据库的常用调优方法。
实战演练场
以上六个模块的内容,都是基于某个点的调优,现在是时候把你前面所学都调动起来了,这里我将带你进入综合性能问题高频出现的应用场景,学习整体调优方法。
性能调优标准
为什么要做性能调优
一款线上产品如果没有经过性能测试,那它就好比是一颗定时炸弹,不知道它什么时候会出现问题,也不清楚它能承受的极限在哪儿。
有些性能问题是时间累积慢慢产生的,到了一定时间自然就爆炸了;而更多的性能问题是由访问量的波动导致的,例如,活动或者公司产品用户量上升;当然也有可能是一款产品上线后就半死不活,一直没有大访问量,所以还没有引发这颗定时炸弹。
现在假设你的系统要做一次活动,产品经理或者老板告诉你预计有几十万的用户访问量,询问系统能否承受得住这次活动的压力。如果你不清楚自己系统的性能情况,也只能战战兢兢地回答老板,有可能大概没问题吧。
所以,要不要做性能调优,这个问题其实很好回答。所有的系统在开发完之后,多多少少都会有性能问题,我们首先要做的就是想办法把问题暴露出来,例如进行压力测试、模拟可能的操作场景等等,再通过性能调优去解决这些问题。
比如,当你在用某一款 App 查询某一条信息时,需要等待十几秒钟;在抢购活动中,无法进入活动页面等等。你看,系统响应就是体现系统性能最直接的一个参考因素。
好的系统性能调优不仅仅可以提高系统的性能,还能为公司节省资源。这也是我们做性能调优的最直接的目的。
什么时候开始调优
如果需要对系统做一次全面的性能监测和优化,从什么时候开始介入性能调优呢?是不是越早介入越好?
在开发初期我们没必要在意性能调优,这样反而会让我们疲于性能优化,不仅不会给系统性能带来提升,还会影响到开发进度,甚至获得相反的效果,给系统带来新的问题。
我们只需要在代码层面保证有效的编码,比如,减少磁盘 I/O 操作、降低竞争锁的使用以及使用高效的算法等等。遇到比较复杂的业务,我们可以充分利用设计模式来优化业务代码。例如,设计商品价格的时候,往往会有很多折扣活动、红包活动,我们可以用装饰模式去设计这个业务。
在系统编码完成之后,我们就可以对系统进行性能测试了。这时候,产品经理一般会提供线上预期数据,我们在提供的参考平台上进行压测,通过性能分析、统计工具来统计各项性能指标,看是否在预期范围之内。
在项目成功上线后,我们还需要根据线上的实际情况,依照日志监控以及性能统计日志,来观测系统性能问题,一旦发现问题,就要对日志进行分析并及时修复问题。
哪些参考因素可以体现系统的性能
CPU
- 有的应用需要大量计算,他们会长时间、不间断地占用 CPU 资源,导致其他资源无法争夺到 CPU 而响应缓慢,从而带来系统性能问题。例如,代码递归导致的无限循环,正则表达式引起的回溯,JVM 频繁的 FULL GC,以及多线程编程造成的大量上下文切换等,这些都有可能导致 CPU 资源繁忙。
内存
- Java 程序一般通过 JVM 对内存进行分配管理,主要是用 JVM 中的堆内存来存储
Java 创建的对象。系统堆内存的读写速度非常快,所以基本不存在读写性能瓶颈。但是由于内存成本要比磁盘高,相比磁盘,内存的存储空间又非常有限。所以当内存空间被占满,对象无法回收时,就会导致内存溢出、内存泄露等问题。
磁盘I/O
- 磁盘相比内存来说,存储空间要大很多,但磁盘 I/O 读写的速度要比内存慢,
虽然目前引入的 SSD 固态硬盘已经有所优化,但仍然无法与内存的读写速度相提并论。
网络
- 网络对于系统性能来说,也起着至关重要的作用。如果你购买过云服务,一定经历过,选择网络带宽大小这一环节。带宽过低的话,对于传输数据比较大,或者是并发量比较大的系统,网络就很容易成为性能瓶颈。
异常
- Java 应用中,抛出异常需要构建异常栈,对异常进行捕获和处理,这个过程非常消耗系统性能。如果在高并发的情况下引发异常,持续地进行异常处理,那么系统的性能就会明显地受到影响。
数据库
- 大部分系统都会用到数据库,而数据库的操作往往是涉及到磁盘 I/O 的读写。大量的数据库读写操作,会导致磁盘 I/O 性能瓶颈,进而导致数据库操作的延迟性。对于有大量数据库读写操作的系统来说,数据库的性能优化是整个系统的核心。
锁竞争
- 在并发编程中,我们经常会需要多个线程,共享读写操作同一个资源,这个时候为了保持数据的原子性(即保证这个共享资源在一个线程写的时候,不被另一个线程修改),我们就会用到锁。锁的使用可能会带来上下文切换,从而给系统带来性能开销。JDK1.6 之后,Java 为了降低锁竞争带来的上下文切换,对 JVM 内部锁已经做了多次优化,例如,新增了偏向锁、自旋锁、轻量级锁、锁粗化、锁消除等。而如何合理地使用锁资源,优化锁资源,就需要你了解更多的操作系统知识、Java 多线程编程基础,积累项目经验,并结合实际场景去处理相关问题。
响应时间
响应时间是衡量系统性能的重要指标之一,响应时间越短,性能越好,一般一个接口的响应时间是在毫秒级。
-
数据库响应时间:
- 数据库操作所消耗的时间,往往是整个请求链中最耗时的;
-
服务端响应时间:
- 服务端包括 Nginx 分发的请求所消耗的时间以及服务端程序执行所消
-
耗的时间;
- 网络响应时间:这是网络传输时,网络硬件需要对传输的请求进行解析等操作所消耗的时间;
-
客户端响应时间:
- 对于普通的 Web、App 客户端来说,消耗时间是可以忽略不计的,但如果你的客户端嵌入了大量的逻辑处理,消耗的时间就有可能变长,从而成为系统的瓶颈。
吞吐量
在测试中,我们往往会比较注重系统接口的 TPS(每秒事务处理量),因为 TPS 体现了接口的性能,TPS 越大,性能越好。在系统中,我们也可以把吞吐量自下而上地分为两种:磁盘吞吐量和网络吞吐量。
我们先来看磁盘吞吐量,磁盘性能有两个关键衡量指标。
一种是 IOPS(Input/Output Per Second),即每秒的输入输出量(或读写次数),这种是指单位时间内系统能处理的 I/O 请求数量,I/O 请求通常为读或写数据操作请求,关注的是随机读写性能。适应于随机读写频繁的应用,如小文件存储(图片)、OLTP 数据库、邮件服务器。
另一种是数据吞吐量,这种是指单位时间内可以成功传输的数据量。对于大量顺序读写频繁的应用,传输大量连续数据,例如,电视台的视频编辑、视频点播 VOD(Video OnDemand),数据吞吐量则是关键衡量指标。
接下来看网络吞吐量,这个是指网络传输时没有帧丢失的情况下,设备能够接受的最大数据速率。网络吞吐量不仅仅跟带宽有关系,还跟 CPU 的处理能力、网卡、防火墙、外部接口以及 I/O 等紧密关联。而吞吐量的大小主要由网卡的处理能力、内部程序算法以及带宽大小决定。
计算机资源分配使用率
通常由 CPU 占用率、内存使用率、磁盘 I/O、网络 I/O 来表示资源使用率。这几个参数好比一个木桶,如果其中任何一块木板出现短板,任何一项分配不合理,对整个系统性能的影响都是毁灭性的。
负载承受能力
当系统压力上升时,你可以观察,系统响应时间的上升曲线是否平缓。这项指标能直观地反馈给你,系统所能承受的负载压力极限。例如,当你对系统进行压测时,系统的响应时间会随着系统并发数的增加而延长,直到系统无法处理这么多请求,抛出大量错误时,就到了极限。
总结
-
性能调优可以使系统稳定,用户体验更佳,甚至在比较大的系统中,还能帮公司节约资源。
-
没有必要过早地介入性能优化,只需在编码的时候保证其优秀、高效,以及良好的程序设计。
-
在完成项目后,可以进行系统测试,可以将以下性能指标,作为性能调优的标准;
- 响应时间、
- 吞吐量、
- 计算机资源分配使用率、
- 负载承受能力。
优化策略
优化代码
应用层的问题代码往往会因为耗尽系统资源而暴露出来。例如,我们某段代码导致内存溢出,往往是将 JVM 中的内存用完了,这个时候系统的内存资源消耗殆尽了,同时也会引发JVM 频繁地发生垃圾回收,导致 CPU 100% 以上居高不下,这个时候又消耗了系统的CPU 资源。
还有一些是非问题代码导致的性能问题,这种往往是比较难发现的,需要依靠我们的经验来优化。例如,我们经常使用的 LinkedList 集合,如果使用 for 循环遍历该容器,将大大降低读的效率,但这种效率的降低很难导致系统性能参数异常。
这时有经验的同学,就会改用 Iterator (迭代器)迭代循环该集合,这是因为LinkedList是链表实现的,如果使用 for 循环获取元素,在每次循环获取元素时,都会去遍历一次List,这样会降低读的效率。
优化设计
面向对象有很多设计模式,可以帮助我们优化业务层以及中间件层的代码设计。优化后,不仅可以精简代码,还能提高整体性能。例如,单例模式在频繁调用创建对象的场景中,可以共享一个创建对象,这样可以减少频繁地创建和销毁对象所带来的性能消耗。
优化算法
好的算法可以帮助我们大大地提升系统性能。例如,在不同的场景中,使用合适的查找算法可以降低时间复杂度。
时间换空间
有时候系统对查询时的速度并没有很高的要求,反而对存储空间要求苛刻,这个时候我们可以考虑用时间来换取空间
空间换时间
这种方法是使用存储空间来提升访问速度。现在很多系统都是使用的 MySQL 数据库,较为常见的分表分库是典型的使用空间换时间的案例。
参数调优
以上都是业务层代码的优化,除此之外,JVM、Web 容器以及操作系统的优化也是非常关键的。
根据自己的业务场景,合理地设置 JVM 的内存空间以及垃圾回收算法可以提升系统性能。例如,如果我们业务中会创建大量的大对象,我们可以通过设置,将这些大对象直接放进老年代。这样可以减少年轻代频繁发生小的垃圾回收(Minor GC),减少 CPU 占用时间,提升系统性能。
Web 容器线程池的设置以及 Linux 操作系统的内核参数设置不合理也有可能导致系统性能
瓶颈,根据自己的业务场景优化这两部分,可以提升系统性能。
兜底策略
上边讲到的所有的性能调优策略,都是提高系统性能的手段,但在互联网飞速发展的时代,产品的用户量是瞬息万变的,无论我们的系统优化得有多好,还是会存在承受极限,所以为了保证系统的稳定性,我们还需要采用一些兜底策略。
什么是兜底策略
第一,限流,对系统的入口设置最大访问限制。这里可以参考性能测试中探底接口的 TPS。同时采取熔断措施,友好地返回没有成功的请求。
第二,实现智能化横向扩容。智能化横向扩容可以保证当访问量超过某一个阈值时,系统可以根据需求自动横向新增服务。
第三,提前扩容。这种方法通常应用于高并发系统,例如,瞬时抢购业务系统。这是因为横向扩容无法满足大量发生在瞬间的请求,即使成功了,抢购也结束了。
目前很多公司使用 Docker 容器来部署应用服务。这是因为 Docker 容器是使用
Kubernetes 作为容器管理系统,而 Kubernetes 可以实现智能化横向扩容和提前扩容
Docker 服务。
字符串性能优化
String 对象是我们使用最频繁的一个对象类型,但它的性能问题却是最容易被忽略的。
String 对象作为 Java 语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。
public static void main(String[] args) {
String str1 = "abc";
String str2 = new String("abc");
//具有与此字符串相同内容的字符串,但保证来自唯一字符串池。
String str3 = str2.intern();
System.out.println(str1 == str2);
System.out.println(str2 == str3);
System.out.println(str1 == str3);
}
当代码中使用第一种方式创建字符串对象时,JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。
String str = new String(“abc”) 这种方式,首先在编译类文件时,"abc"常量字符串将
会放入到常量结构中,在类加载时,“abc"将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"abc” 字符串,在堆内存中创建一个 String 对象;最后,str 将引用 String 对象。
String对象是如何实现的
在 Java 语言中,Sun 公司的工程师们对 String 对象做了大量的优化,来节约内存空间,提升 String 对象在系统中的性能。一起来看看优化过程,如下图所示:
-
在Java6以及之前的版本
- String 对象是对 char 数组进行了封装实现的对象,主要有四个成员变量:char 数组、偏移量 offset、字符数量 count、哈希值 hash。
- String 对象是通过 offset 和 count 两个属性来定位 char[] 数组,获取字符串。这么做可以高效、快速地共享数组对象,同时节省内存空间,但这种方式很有可能会导致内存泄漏。
-
从 Java7 版本开始到 Java8 版本
- String 类中不再有offset 和 count 两个变量了。这样的好处是 String 对象占用的内存稍微少了些,同时,String.substring 方法也不再共享 char[],从而解决了使用该方法可能导致的内存泄漏问题。
-
从 Java9 版本开始
- 工程师将 char[] 字段改为了 byte[] 字段,又维护了一个新的属性
coder,它是一个编码格式的标识。
- 工程师将 char[] 字段改为了 byte[] 字段,又维护了一个新的属性
-
工程师为什么这样修改呢?
-
我们知道一个 char 字符占 16 位,2 个字节。这个情况下,存储单字节编码内的字符(占一个字节的字符)就显得非常浪费。JDK1.9 的 String 类为了节约内存空间,于是使用了占8 位,1 个字节的 byte 数组来存放字符串。
-
而新属性 coder 的作用是,在计算字符串长度或者使用 indexOf()函数时,我们需要根据这个字段,判断如何计算字符串长度。coder 属性默认有 0 和 1 两个值,0 代表 Latin-1(单字节编码),1 代表 UTF-16。如果 String 判断字符串只包含了 Latin-1,则 coder属性值为 0,反之则为 1。
-
String对象的不可变性
了解了 String 对象的实现后,发现在实现代码中 String 类被 final 关键字修饰了,而且变量 char 数组也被 final 修饰了。
类被 final 修饰代表该类不可继承,而 char[] 被 final+private 修饰,代表了String 对象不可被更改。Java 实现的这个特性叫作 String 对象的不可变性,即 String 对象一旦创建成功,就不能再对它进行改变。
Java这样做的好处
- 保证 String 对象的安全性。假设 String 对象是可变的,那么 String 对象将可能被
恶意修改。 - 保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实
现相应的 key-value 缓存功能。 - 可以实现字符串常量池。在 Java 中,通常有两种创建字符串对象的方式,一种是通
过字符串常量的方式创建,如 String str=“abc”;另一种是字符串变量通过 new 形式的创建,如 String str = new String(“abc”)。
经典反例
一个 String 对象 str 赋值“hello”,然后又让 str 值为“world”,这个时候 str 的值变成了“world”。那么 str 值确实改变了,那为什么String对象不可改变呢
public static void main(String[] args) {
String str1 = "hello";
System.out.println(str1);
//str1 = "word";
System.out.println(str1);
String str2 = "hello";
System.out.println(str1 == str2);
System.out.println(str1.equals(str2));
}
在 Java 中要比较两个对象是否相等,往往是用 ==,而要判断两个对象的值是否相等,则需要用 equals 方法来判断。
这是因为 str 只是 String 对象的引用,并不是对象本身。对象在内存中是一块内存地址,str 则是一个指向该内存地址的引用。所以在刚刚我们说的这个例子中,第一次赋值的时候,创建了一个“hello”对象,str 引用指向“hello”地址;第二次赋值的时候,又重新创建了一个对象“world”,str 引用指向了“world”,但“hello”对象依然存在于内存
中。
也就是说 str 并不是对象,而只是一个对象引用。真正的对象依然还在内存中,没有被改变。
String对象的优化
如何构建超大字符串
编程过程中,字符串的拼接很常见。前面我讲过 String 对象是不可变的,如果我们使用String 对象相加,拼接我们想要的字符串,是不是就会产生多个对象呢?例如以下代码:
String str = "ab"+"cd"+"ef";
分析代码可知:首先会生成 ab 对象,再生成 abcd 对象,最后生成 abcdef 对象,从理论上来说,这段代码是低效的。
但实际运行中,我们发现只有一个对象生成,这是为什么呢?难道我们的理论判断错了?我们再来看编译后的代码,你会发现编译器自动优化了这行代码,如下:
String str = "abcded";
字符串变量的累计
String str = "abcdef";
for(int i = 0 ; i < 100000;i++){
str = str + i ;
}
上面的代码编译后,你可以看到编译器同样对这段代码进行了优化。不难发现,Java 在进行字符串的拼接时,偏向使用 StringBuilder,这样可以提高程序的效率。
String str1 = "abcdef";
for (int i = 0 ; i<10000000;i++){
str1 = (new StringBuilder(String.valueOf(str1))).append(i).toString();
}
**综上已知:**即使使用 + 号作为字符串的拼接,也一样可以被编译器优化成 StringBuilder的方式。但再细致些,你会发现在编译器优化的代码中,每次循环都会生成一个新的StringBuilder 实例,同样也会降低系统的性能。
所以平时做字符串拼接的时候,我建议你还是要显示地使用 String Builder 来提升系统性能。
如果在多线程编程中,String 对象的拼接涉及到线程安全,你可以使用 StringBuffer。但是要注意,由于 StringBuffer 是线程安全的,涉及到锁竞争,所以从性能上来说,要比StringBuilder 差一些。
如何使用String.intern节省内存
Twitter 每次发布消息状态的时候,都会产生一个地址信息,以当时 Twitter 用户的规模预估,服务器需要 32G 的内存来存储地址信息。
public class Location{
private String city;//城市
private String region;//地区
private String countryCode;//国家代码
private double logitude;//经度
private double latitude;//维度
}
考虑到其中有很多用户在地址信息上是有重合的,比如,国家、省份、城市等,这时就可以将这部分信息单独列出一个类,以减少重复,代码如下:
public class SharedLocation{
private String city;//城市
private String region;//地区
private String countryCode;//国家代码
}
public class Location{
private SharedLocation sharedLocation;
private double logitude;//经度
private double latitude;//维度
}
通过优化,数据存储大小减到了 20G 左右。但对于内存存储这个数据来说,依然很大,怎么办呢?
这个案例来自一位 Twitter 工程师在 QCon 全球软件开发大会上的演讲,他们想到的解决方法,就是使用 String.intern 来节省内存空间,从而优化 String 对象的存储。
具体做法就是,在每次赋值的时候使用 String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用,这样一开始的对象就可以被回收掉。这种方式可以使重复性非常高的地址信息存储大小从 20G 降到几百兆。
例如
public static void main(String[] args) {
String a = new String("abc").intern();
String b = new String("abc").intern();
System.out.println(a == b);
}
为了更好地理解,我们再来通过一个简单的例子,回顾下其中的原理:
输出结果:在字符串常量中,默认会将对象放入常量池;在字符串变量中,对象是会创建在堆内存中,同时也会在常量池中创建一个字符串对象,复制到堆内存对象中,并返回堆内存对象引用。
如果调用 intern 方法,会去查看字符串常量池中是否有等于该对象的字符串,如果没有,就在常量池中新增该对象,并返回该对象引用;如果有,就返回常量池中的字符串引用。堆内存中原有的对象由于没有引用指向它,将会通过垃圾回收器回收。
在一开始创建 a 变量时,会在堆内存中创建一个对象,同时会在加载类时,在常量池中创建一个字符串对象,在调用 intern 方法之后,会去常量池中查找是否有等于该字符串的对象,有就返回引用。
在创建 b 字符串变量时,也会在堆中创建一个对象,此时常量池中有该字符串对象,就不再创建。调用 intern 方法则会去常量池中判断是否有等于该字符串的对象,发现有等于"abc"字符串的对象,就直接返回引用。而在堆内存中的对象,由于没有引用指向它,将会被垃圾回收。所以 a 和 b 引用的是同一个对象。
使用 intern 方法需要注意的一点是,一定要结合实际场景。因为常量池的实现是类似于一个 HashTable 的实现方式,HashTable 存储的数据越大,遍历的时间复杂度就会增加。如果数据过大,会增加整个字符串常量池的负担。