目录
一 前言
性能优化是一个系统开发过程中非常重要的一环,特别是在互联网应用中,性能优化往往能够决定一个应用的成败。
性能优化是个系统性工程,宏观上可分为网络,服务,存储几个方向,每个方向又可以细分为架构,设计,代码,可用性,度量等多个子项。 本文将重点从代码和设计两个子项展开,谈谈那些提升性能的知识点。当然,很多性能提升策略都是有代价的,适用于某些特定场景,大家在学习和使用的时候,最好带着批判的思维,决策前,做好利弊权衡。
先简单罗列一下性能优化方向:
二 代码优化
2.1 关联代码
关联代码优化是通过预加载相关代码,避免在运行时加载目标代码,造成运行时负担。我们知道 Java 有两个类加载器:Bootstrap class loader 和 Application class loader。Bootstrap class loader 负责加载 Java API 中包含的核心类,而 Application class loader 则负责加载自定义类。关联代码优化可以通过以下几种方式来实现。
预加载关联
预加载关联类是指在程序启动时预先加载目标与关联类,以避免在运行时加载。可以通过静态代码块来实现预加载,如下所示:
public class MainClass {
static {
// 预加载MyClass,其实现了相关功能
Class.forName("com.example.MyClass");
}
// 运行相关功能的代码
// ...
}
使用线程池
线程池可以让多个任务使用同一个线程池中的线程,从而减少线程的创建和销毁成本。使用线程池时,可以在程序启动时创建线程池,并在主线程中预加载相关代码。然后以异步方式使用线程池中的线程来执行相关代码,可以提高程序的性能。
使用静态变量
可以使用静态变量来缓存与关联代码有关的对象和数据。在程序启动时,可以预先加载关联代码,并将对象或数据存储在静态变量中。然后在程序运行时使用静态变量中缓存的对象或数据,以避免重复加载和生成。这种方式可以有效地提高程序的性能,但需要注意静态变量的使用,确保它们在多线程环境中的安全性。
2.2 缓存对齐
在介绍缓存对齐之前,需要先普及一些 CPU 指令执行的相关知识。
- 缓存行(Cache line) : CPU 读取内存数据时并非一次只读一个字节,一般是会读一段 64 字节(硬件决定)长度的连续的内存块 (chunks of memory),这些块我们称之为缓存行。
- 伪共享(False Sharing):当运行在两个不同 CPU 上的两个线程写入两个不同的变量时,如果这两个变量恰好存储在同一个 CPU 缓存行中,就会发生伪共享(False Sharing)。即当第一个线程修改缓存行中其中一个变量时,其他引用此缓存行变量的线程的缓存行将会无效。如果 CPU 需要读取失效的缓存行,它必须等待缓存行刷新,这会导致性能下降。
- CPU 停止运转(stall):当一个核心需要等待另一个核心重新加载缓存行时(出现伪共享时),它无法继续执行下一条指令,只能停止运转等待,这被称之为 stall。减少伪共享也就意味着减少了 stall 的发生。
- IPC(instructions per cycle):它表示平均每个 CPU 周期执行的指令数量,很显然该数值越大性能越好。可以基于 IPC 指标(比如:阈值 1.0)来简单判断程序是属于访问密集型还是计算密集型。Linux 系统中可以通过 tiptop 命令来查看每个进程的 CPU 硬件数据:
如何简单来区分访存密集型和计算密集型程序?
-
如果 IPC < 1.0, 很可能是 Memory stall 占主导,多半意味着访存密集型。
-
如果 IPC > 1.0, 很可能是计算密集型的程序。
- CPU 利用率:是指系统中 CPU 处于忙碌状态的时间与总时间的比例。忙碌状态时间又可以进一步拆分为指令(instruction)执行消耗周期 cycle(%INS) 和 stalled 的周期 cycle(%STL)。perf 采集了 10 秒内全部 CPU 的运行状态:
IPC计算
IPC = instructions/cycles
上图中,可以计算出结果为:0.79
现代处理器一般有多条流水线(比如:4核心),运行 perf 的那台机器,IPC的理论值可达到4.0。
如果我们从 IPC的角度来看,这台机器只运行到其处理器最高速度的 19.7%(0.79 / 4.0)。
总之,通过 Top 命令,看到 CPU 使用率之后,可以进一步分析指令执行消耗周期和 stalled 周期,有这些更详细的指标之后,就能够知道该如何更好地对应用和系统进行调优。
- 缓存对齐: 是通过调整数据在内存中的分布,让数据在被缓存时,更有利于 CPU 从缓存中读取,从而避免了频繁的内存读取,提高了数据访问的速度。
缓存填充 (Padding)
减少伪共享也就意味着减少了 stall 的发生,其中一个手段就是通过填充 (Padding) 数据的形式,即在适当的距离处插入一些对齐的空间来填充缓存行,从而使每个线程的修改不会脏污同一个缓存行。
/**
* 缓存行填充测试
*
* @author liuhuiqing
* @date 2023年04月28日
*/
public class FalseSharingTest {
private static final int LOOP_NUM = 1000000000;
public static void main(String[] args) throws InterruptedException {
Struct struct = new Struct();
long start = System.currentTimeMillis();
Thread t1 = new Thread(() -> {
for (int i = 0; i < LOOP_NUM; i++) {
struct.x++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < LOOP_NUM; i++) {
struct.y++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("cost time [" + (System.currentTimeMillis() - start) + "] ms");
}
static class Struct {
// 共享变量
volatile long x;
// 一个long占用8个字节,此处定义7个填充数据,来保证业务数据x和y分布在不同的缓存行中
long p1, p2, p3, p4, p5, p6, p7;
// long[] paddings = new long[7];// 使用数组代替不会生效,思考一下,为什么?
// 共享变量
volatile long y;
}
}
经过本地测试,这种以空间换时间的方式,即实现了缓存行数据对齐的方式,在执行效率方面,比没有对齐之前,提高了 5 倍!
@Contended注解
在 Java 8 中,引入了@Contended注解,该注解可以用来告诉 JVM 对字段进行缓存对齐(将字段放入不同的缓存行),从而提高程序的性能。使用@Contended注解时,需要在 JVM 启动时添加参数-X