Java 性能优化之最佳实践
21CTO 导读:让 Java 应用程序以最佳性能运行需要开发者的一些努力。本文为确保 Java 以最佳性能运行的方法。
简介
在本文中,我们将讨论一些有助于提高 Java 应用程序性能的方法。
我们将从怎样定义可量化的性能目标开始,然后我们再用不同的工具来衡量和监控应用程序的性能,以找出瓶颈出现的地方,以便能够对症下药。
另外,我们还会介绍一些常见的 Java 代码级优化以及编程的最佳实践。最后,我将介绍 JVM和体系结构的特定调优方法,以提高 Java 应用程序的性能。
当然,性能优化是一个很宽很广的主题,这只是在 JVM 上探索一个起点。
优化目标
在开始提高应用程序性能之前,我们需要定义和理解关于非功能需求方法的关键点,这里有可伸缩性,性能,可用性等。
我总结了典型 Web 应用程序的一些常用性能指标,如下:
1、应用平均响应时间
2、系统必须支持的平均并发用户数
3、峰值最高负载期间,预期的每秒请求数(QPS)
通过使用不同的负载测试和应用监测工具(APM)解决方案通常用于跟踪与优化 Java应用程序的性能。我们围绕不同的应用场景运行负载测试,同时使用 APM 工具监控 CPU、IO、内存堆栈使用情况,此为识别瓶颈之关键。
Gatling (https://gatling.io/)
Gatling 就是一款负载测试的最佳工具之一,它对 HTTP 协议有着非常出色的支持,从而使之成为大多数 HTTP 服务器做负载测试的良好选择。
Retrace (https://stackify.com/retrace/)
Stackify 的Retrace 提供了一个成熟的 APM 解决方案,能够帮助确定应用程序基线的好方法,也有很丰富的功能。其关键组件之一是代码分析,可以在不降低应用程序性能的情况下收集运行时信息。
Retrace 还有用于监视正在运行的应用程序,基于 JVM 的内存、线程和类的小组件。除了应用程序指标外,它还支持监控托管服务器的 CPU 与 IO 使用的信息。其涵盖了应用程序性能潜力,另外亦能看到真实情况的使用和负载率。
实际上比写起文章来要复杂得多,要理解应用程序的当前性能配置也很关键。接下来我们就来关注以下内容。
Gatling 的负载试验
Gatling 是仿真环境脚本是使用 Scala 编写的,它还提供了一个 GUI 环境,可以记录场景之数据,由 GUI 创建模拟的 Scala 脚本。运行完仿真模拟环境后,Gatling 会生成有用的,可供分析的 HTML 报告。
一、定义方案
在启动之前,我们需要定义一个场景,即用户在使用 Web 应用程序时会发生哪些动作。
在本例中,场景为:“启动200个用户,每个用户生成10,000个请求”。
配置记录器
我们使用 Gatling 官网上的手册:Gatling first steps(https://github.com/excilys/gatling/wiki/First-Steps-with-Gatling),使用以下代码创建一个文件:
EmployeeSimulation的scala 文件,代码如下:
class EmployeeSimulation extends Simulation {
val scn = scenario("FetchEmployees").repeat(10000) {
exec(
http("GetEmployees-API")
.get("http://localhost:8080/employees")
.check(status.is(200))
)
}
setUp(scn.users(200).ramp(100))
}
运行负载测试
要执行负载测试,我们运行如下的命令:
$GATLING_HOME/bin/gatling.sh -s basic.EmployeeSimulation
对应用程序的 API 进行负载测试有助开发者找到细微的、不容易发现的错误,例如数据库连接池耗尽,在请求发生高负载的同时,因为代码等问题导至内存泄漏等不必要的内存高使用率等问题。
监控应用程序
开始使用 Rectrace,第一步先在 Stackify 上注册免费试用版。网址:https://stackify.com/retrace/
接下来,需要将 Spring Boot 应用程序配置为 Linux 服务,接下来再将 Rectrace Proxy 安装到应用程序所在的服务器上。详细链接:https://support.stackify.com/hc/en-us/articles/205419575
启动 Retrace 代理与Java 应用,接下在 Retrace 的网站仪表盘中添加 AddApp 链接,填写好信息好,Retrace 就会很好的监控我们的应用了。
找到应用中最慢的部分
Retrace 能够自动检测到应用程序中问题,包括数十种常见框架和依赖项的使用,包括 SQL、MongoDB、Redis、Elasticsearch 等,比如:
1)某个 SQL 语句拖慢了应用吗?
2)Redis 挂掉了吗?
3)某个 HTTP Web 服务器的性能变慢了吗?
下图完整的显示了在指定连续时间中技术栈最慢部分和周边的数据。
代码级优化
虽然 APM 提供了负载和应用程序测试能够帮助我们识别应用程序中的瓶颈。但是最重要还是开发者的技术能力,遵循良好的编码实践,才能在进行应用程序监控之前避免很多性能问题。
使用 StringBuilder 进行字符串连接
字符串连接是一种常见的操作,但也是一种低效的操作。简单的讲,在 Java 中使用+=的问题,它会为每个新操作分配一个新的 String。
例如,这是一个简化且典型的循环结构,首先使用原始连接,然后再使用属性构建器。如下代码:
public String stringAppendLoop() {
String s = "";
for (int i = 0; i < 10000; i++) {
if (s.length() > 0)
s += ", ";
s += "bar";
}
return s;
}
public String stringAppendBuilderLoop() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
if (sb.length() > 0)
sb.append(", ");
sb.append("bar");
}
return sb.toString();
}
在以上代码可以看到,改用StringBuilder的效率要高得多,特别是要考虑到这些基于String的操作频率。
在我们继续之前,请注意当前一代JVM确实对字符串操作执行编译和/或运行时优化(参考:https://docs.oracle.com/javase/specs/jls/se9/html/jls-15.html#jls-15.18.1)。
避免使用递归
使用递归操作是导至 StackOverFlow Error 的常见现象。如果你非用递归不可,那么使用尾递归作为替代方案可能会更好。
先来看一个头部递归(Head-recursive)的例子:
public int factorial(int n) {
if (n == 0) {
return 1;
} else {
return n * factorial(n - 1);
}
}
下面重写为 尾部递归 (tail recursive)的例子:
private int factorial(int n, int accum) {
if (n == 0) {
return accum;
} else {
return factorial(n - 1, accum * n);
}
}
public int factorial(int n) {
return factorial(n, 1);
}
其它类 JVM 语言,如 Scala 已经有编译器级别的支持来特别优化尾递归代码,已经有议程讨论将此类优化加入 Java 的问题。
谨慎使用正则表达式
正则在编程很多场景都很有用,但是用它们太多会付出比较高的性能成本。
因此,开发者要了解各种 JDK 字符串处理方法也很重要,这些方法比使用正则表达式效率更高更快,如 String.replaceAll()或 String.split()。
如果在计算密集型的代码中使用正则表达式,则可以使用缓存 Pattern 引用,而不用重复编译。如下代码:
static final Pattern HEAVY_REGEX = Pattern.compile("(((X)*Y)*Z)*");
另外,使用像Apache Commons Lang(https://commons.apache.org/proper/commons-lang/)这样的库也是很好的选择,特别是对字符串的操作。
避免创建和销毁太多线程
创建和处理线程是 JVM 出现性能问题的常见原因,因为线程对象的创建和销毁开销相对较重。如果应用程序中使用大量线程,那么可以使用线程池就非常有意义,它允许我们重用这些昂贵的对象。
因此,Java Executor Service 是基于此点的基础,它提供了一个高级的 API 来定义线程池的语义并与其交互。
Java 7的Fork/Join框架中提供了一些工具,可以尝试使用所有可用处理器内核来加速并行处理。 为提供有效的并行执行,框架用一个名为ForkJoinPool的线程池来管理工作线程。
JVM 调优
堆大小调整
确定生产系统的 JVM 的正确大小并非一件简单的工作。我们通过以下问题确定可预测的内存需求:
1、我们计划将多少个不同的应用程序部署到单个JVM进程,例如,EAR文件,WAR文件,jar文件等的数量?
2、在运行时可能加载多少个Java类,包含多少个第三方API?
3、估算内存缓存所需的占用空间,例如,由我们的应用程序(加第三方API)加载的内部缓存数据结构,例如来自数据库的缓存数据,从文件读取的数据等。
4、估计应用程序要创建的线程数。
如果没有一些真实的测试,这些数字是很难估算出来的。
了解应用需求的最可靠方法是对应用程序进行实际负载测试并在运行时跟踪度量。 前面讨论的基于Gatling的测试是一个很好的方法。
选择垃圾收集器
停止全局的垃圾收集周期。过去此种原因是大多数面向用户的应用程序的响应能力和整体Java性能的巨大问题所在。
现在的 Java 垃圾收集器已经解决了这个问题,并且通过适当的调整和调整,没有明显的收集周期。 话虽如此,也需要深入了解整个JVM上的GC,以及应用程序的特定配置文件,能够更好地实现目标,比如分析器,堆转储和详细的GC日志等会帮到我们。这些例外都需要在实际负载中捕获,也就是前面Gatling性能测试的结果。
有关不同垃圾收集器的更多主题,可以查看指南:https://stackify.com/what-is-java-garbage-collection/。
JDBC性能
关系数据库是Java应用程序中的另一个典型且常见的性能问题。为了获取完整请求的响应时间,我们需要查看应用程序的每一层,看代码如何与底层数据库进行SQL交互。
连接池
从众所周知的事实开始,Java 中的数据库连接很昂贵。 连接池机制是解决这个问题的第一步。
向大家推荐是HikariCP JDBC - https://vladmihalcea.com/2014/04/17/the-anatomy-of-connection-pooling/一个非常轻量级(大约130Kb)和快速JDBC连接池框架。
JDBC批处理
我们处理持久性的方式的另一个方面是尝试尽可能批量操作。批处理让我们在单个数据库通讯中发送多个SQL语句。
在驱动程序和数据库端中性能都很重要。PreparedStatement是批处理的理想选择,一些数据库系统(如Oracle)仅支持对预准备语句进行批处理。
Hibernate更灵活,允许我们使用单一配置切换到JDBC批处理。
声明缓存
接下来,SQL语句缓存是另一种可能提高持久层性能的方法:这是一种鲜为人知的性能优化。
根据底层JDBC驱动程序,你可以在客户端(驱动程序)或数据库端(语法树甚至执行计划)缓存PreparedStatement 。
横向扩展和扩容
数据库复制和分片也是提高吞吐量的最佳实践,我们应利用这些经过实战考验的体系架构来扩展应用程序的持久层。
架构改进
高速缓存
内存的价格会越来越低,但从磁盘或通过网络检索数据仍然很昂贵。 缓存是我们不应忽视的应用程序性能的一个重要方面。
在应用程序中引入独立的缓存系统会增加架构的一部分复杂性,开始时就要充分利用好已有的库和框架中的缓存功能。
大多数持久性框架都有很好的缓存支持。诸如Spring MVC之类的Web框架,可以利用Spring中的内置缓存支持,以及基于ETag的HTTP级缓存。
于是,选择完所有悬而未决的成果之后,架设一个独立的缓存服务器(如Redis,Ehcache或Memcache),缓存经常访问应用的内容是开发者的下一步。
为减少数据库负载,为提升应用程序性能提供重要的支撑。
扩容
无论我们在单个实例上投入多少硬件,在某些时候这都是不够的。单机扩展具有很大的局限性 ,当系统遇到瓶颈时,不要被成本束缚手脚,该扩容扩容,它有时是处理更大负载的唯一方法。
也许,这一步具有很大的复杂性,它是在某一点达到瓶颈后,扩展应用程序的唯一方法。
在大多数现代框架和库中,大多数支持是好的并且会越来越好。Spring生态系统有一整套专门用于解决这一特定应用程序架构的,其它大多数框架也都有类似支持。
除了纯Java性能之外,在集群或云架构下进行扩展的另一个优势是,添加新节点还可以实现冗余和更好的处理单点故障,从而实现系统整体的更高可用性。
小结
在本文里,我们一起讨论了很多有关提高Java应用性能的各种概念。 我们从负载测试谈起,讨论基于APM工具的应用程序和服务器监控,接下来围绕编写高性能Java代码的最佳实践。
最后,我们还讨论了JVM特定的调优技巧,数据库端性能增强和架构的升级优化,通过这些,有效扩展Java应用程序,让你不再为 Java 性能而担心发愁。
作者:乔乔
来源:21CTO社区