极客时间:《Java性能优化实践》

阅读感想

Java 性能调优不像是学一门编程语言,无法通过直线式的思维来掌握和应用,它对于工程师的技术广度和深度都有着较高的要求。

互联网时代,一个简单的系统就囊括了应用程序、数据库、容器、操作系统、网络等技术,线上一旦出现性能问题,就可能要你协调多方面组件去进行优化,这就是技术广度;而很多性能问题呢,又隐藏得很深,可能因为一个小小的代码,也可能因为线程池的类型选择错误…可归根结底考验的还是我们对这项技术的了解程度,这就是技术深度

《Java 性能调优实践》这门课程,整体打2分(满分5分),实践内容偏少,大多数章节在讲述 JDK 的源码,非常没有诚意(都已经到了性能调优的阶段了,想必源码部分大家都是应该多少知道的吧),有些章节感觉是在凑字数,譬如讲了 HashMap的实现、Nettty 非阻塞 IO的实现……太过基础了。

我记得刚参加工作的第一年,阅读了葛一鸣的那本《Java程序性能优化》,书的内容质量比这个专栏高太多了,如果需要入门性能调优,可以看那本书。此外,推荐的书或专栏还有:

  1. 书籍《深入理解 Java 虚拟机》
  2. 数据《Java 并发编程的艺术》
  3. 书籍《逆流而上:阿里巴巴技术成长之路》
  4. 极客时间《深入拆解 Java 虚拟机》

值得一提的是第3本书,该书不是很厚,但是里面列举了在阿里巴巴技术演进中的一些实际案例及问题排查思路,这种 case 和 benchmark 不同,是非常有学习价值的。

下面是阅读过程中的一些笔记,也加了一些自己的看法,课程后面的章节,因为实在是太过基础,就没记录什么了。

前言&背景

为什么要做性能调优?

好的系统性能调优不仅仅可以提高系统的性能,还能为公司节省资源。

影响系统性能的瓶颈

  1. CPU
  2. 内存
  3. 磁盘 I/O
  4. 网络
  5. 异常
  6. 数据库
  7. 锁竞争

衡量系统性能的指标

  1. 响应时间
    • 数据库响应时间:数据库操作所消耗的时间
    • 服务端响应时间:包括 Nginx 分发的请求所消耗的时间以及服务端程序执行所消耗的时间
    • 网络响应时间:网络传输时,网络硬件需要对传输的请求进行解析等操作所消耗的时间
    • 客户端响应时间:如果你的客户端嵌入了大量的逻辑处理,消耗的时间就有可能变长
  2. 吞吐量
    • 磁盘吞吐量
      • IOPS(Input/Output Per Second),即每秒的输入输出量(或读写次数)
      • 数据吞吐量,指单位时间内可以成功传输的数据量
    • 网络吞吐量:设备能够接受的最大数据速率
  3. 资源分配使用率:CPU 占用率、内存使用率、磁盘 I/O、网络 I/O等。
  4. 负载承受能力

如何制定性能调优策略?

调优需要结合场景明确已知问题和性能目标,不能为了调优而调优,以免引入新的 Bug,带来风险和弊端。调优策略千变万化,但思路和核心都是一样的,都是从业务调优到编程调优,再到系统调优。

影响性能调优的一些因素:

  1. 热身问题:Java 会有 JIT 优化的过程,调优过程中需要进行热身,然后再测试;
  2. 测试结果不稳定:可能是网络波动、JVM 垃圾回收等影响的,可以适当加长测试时间,同时多次测试后取平均值;
  3. 多 JVM 的影响:同一机器上存在多个 JVM 进程或同宿主机下的其他容器/虚拟机,可能会对目标 JVM 进程有影响;

几种调优策略

从应用层到操作系统层的分别介绍。

  1. 优化代码
  2. 优化设计
  3. 优化算法
  4. 时间换空间
  5. 空间换时间
  6. 参数调优

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

  1. 限流。对系统的入口设置最大访问限制,同时采取熔断措施;
  2. 实现智能化横向扩容。智能化横向扩容可以保证当访问量超过某一个阈值时,系统可以根据需求自动横向新增服务。
  3. 提前扩容。这种方法通常应用于高并发系统,这是因为横向扩容无法满足大量发生在瞬间的请求,即使成功了,抢购也结束了。

String 优化

  1. 应该慎重使用 Split() 方法,我们可以用 String.indexOf() 方法代替 Split() 方法完成字符串的分割,Split() 使用不恰当会引起回溯问题,很可能导致 CPU 居高不下。
  2. 使用 String.intern 节省内存(按:这里讲的很粗糙,其实不同 JDK 版本,对 intern() 方法的实现是不一样的);
  3. 使用 + 号作为字符串的拼接,也一样可以被编译器优化成 StringBuilder 的方,但是在循环中,会生成一个新的 StringBuilder 实例,同样也会降低系统的性能,这种情况下请显式地使用 StringBuilder 来提升系统性能。

慎重使用正则表达式

正则表达式引擎

正则表达式引擎的方式有两种:DFA 自动机(Deterministic Final Automata 确定有限状态自动机)和 NFA 自动机(Non deterministic Finite Automaton 非确定有限状态自动机)。构造 DFA 自动机的代价远大于 NFA 自动机,但 DFA 自动机的执行效率高于 NFA 自动机。

假设一个字符串的长度是 n,如果用 DFA 自动机作为正则表达式引擎,则匹配的时间复杂度为 O(n);如果用 NFA 自动机作为正则表达式引擎,由于 NFA 自动机在匹配过程中存在大量的分支和回溯,假设 NFA 的状态数为 s,则该匹配算法的时间复杂度为 O(ns)。

NFA 自动机实现的比较复杂的正则表达式,在匹配过程中经常会引起回溯问题。大量的回溯会长时间地占用 CPU,从而带来系统性能开销。

正则表达式的优化

  1. 少用贪婪模式,多用独占模式:贪婪模式会引起回溯问题,我们可以使用独占模式来避免回溯;
  2. 减少分支选择:分支选择类型“(X|Y|Z)”的正则表达式会降低性能,我们在开发的时候要尽量减少使用,优化方式有:
    • 尝试提取共用模式
    • 将比较常用的选择项放在前面,使它们可以较快地被匹配
    • 如果是简单的分支选择类型,我们可以用三次 indexOf 代替“(X|Y|Z)
  3. 减少捕获嵌套:减少不需要获取的分组,可以提高正则表达式的性能。

ArrayList还是LinkedList

ArrayList 的对象数组 elementData 使用了 transient 修饰,是防止对象数组被其他外部方法序列化。原因是 ArrayList 并不是所有被分配的内存空间都存储了数据,因此 ArrayList 内部提供了两个私有方法 writeObject 以及 readObject 来自我完成序列化与反序列化,从而在序列化与反序列化数组时节省了空间和时间。

  1. LinkedListfor 循环性能是最差的,而 ArrayListfor 循环性能是最好的(ArrayList 则是基于数组实现的,并且实现了 RandomAccess 接口)。
  2. LinkedList 添加元素的效率未必要高于 ArrayList,尾部或中间添加元素,效率很可能会低于 ArrayList(前提是不扩容);
  3. LinkedList 删除元素的效率和添加元素类似, 从中间删除或尾部删除,效率或低于 ArrayList

Stream如何提高遍历集合效率

Stream 性能测试:

  1. 多核 CPU 服务器配置环境下,对比长度 100 的 int 数组的性能;
  2. 多核 CPU 服务器配置环境下,对比长度 1.00E+8 的 int 数组的性能;
  3. 多核 CPU 服务器配置环境下,对比长度 1.00E+8 对象数组过滤分组的性能;
  4. 单核 CPU 服务器配置环境下,对比长度 1.00E+8 对象数组过滤分组的性能。

对应的结果如下:

  1. 常规的迭代 <Stream 并行迭代 <Stream 串行迭代
  2. Stream 并行迭代 < 常规的迭代 <Stream 串行迭代
  3. Stream 并行迭代 < 常规的迭代 <Stream 串行迭代
  4. 常规的迭代 <Stream 串行迭代 <Stream 并行迭代

结论:在循环迭代次数较少的情况下,常规的迭代方式性能反而更好;在单核 CPU 服务器配置环境中,也是常规迭代方式更有优势;而在大数据循环迭代中,如果服务器是多核 CPU 的情况下,Stream 的并行迭代优势明显。所以我们在平时处理大数据的集合时,应该尽量考虑将应用部署在多核 CPU 环境下,并且使用 Stream 的并行迭代方式进行处理。

锁优化

LongAdder

在 JDK1.8 中,Java 提供了一个新的原子类 LongAdderLongAdder 在高并发场景下会比 AtomicIntegerAtomicLong 的性能更好,代价就是会消耗更多的内存空间。

在一些对实时性要求比较高的场景下,LongAdder 并不能取代 AtomicIntegerAtomicLongLongAdder 只有最终一致性,存在软状态。

StampedLock

RRW 被很好地应用在了读大于写的并发场景中,然而 RRW 在性能上还有可提升的空间。在读取很多、写入很少的情况下,RRW 会使写入线程遭遇饥饿(Starvation)问题,也就是说写入线程会因迟迟无法竞争到锁而一直处于等待状态。

StampedLock 不是基于 AQS 实现的,但实现的原理和 AQS 是一样的,都是基于队列和锁状态实现的。与 RRW 不一样的是,StampedLock 控制锁有三种模式: 写、悲观读以及乐观读

StampedLock 没有被广泛应用的原因?它还存在哪些缺陷导致没有被广泛应用? 答:StampLock 不支持重入,不支持条件变量,线程被中断时可能导致 CPU 暴涨。

结论1:在读大于写的场景下,读写锁 ReentrantReadWriteLockStampedLock 以及乐观锁的读写性能是最好的;在写大于读的场景下,乐观锁的性能是最好的,其它 4 种锁的性能则相差不多;在读和写差不多的场景下,两种读写锁以及乐观锁的性能要优于 SynchronizedReentrantLock

Map && List

Map

ConcurrentHashMap 中的 get、size 等方法没有用到锁,ConcurrentHashMap 是弱一致性的,因此有可能会导致某次读无法马上获取到写入的数据。

ConcurrentSkipListMap 是基于 TreeMap 的设计原理实现的,略有不同的是前者基于跳表实现,后者基于红黑树实现,ConcurrentSkipListMap 的特点是存取平均时间复杂度是 O(log(n)),适用于大数据量存取的场景,最常见的是基于跳跃表实现的数据量比较大的缓存。

如果对数据有强一致要求,则需使用 Hashtable;在大部分场景通常都是弱一致性的情况下,使用 ConcurrentHashMap 即可;如果数据量在千万级别,且存在大量增删改操作,则可以考虑使用 ConcurrentSkipListMap

List

  1. CopyOnWriteArrayList 适用于读远大于写的场景;
  2. Vector 适用于对数据一致性有要求的场景。

线程池

CPU 密集型任务:这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O 密集型任务:这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

---(完)---
Yves wechat
扫一扫互相关注吧~
  • 本文作者: Yves
  • 本文标题: 极客时间:《Java性能优化实践》
  • 发布时间: 2019年08月17日 - 10:08
  • 更新时间: 2019年08月17日 - 10:08
  • 本文链接: /2019/08/17/performance_optimization_of_java_application/
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

扫一扫关注公众号