导读
前言
服务性能是指服务在特定条件下的响应速度、吞吐量和资源利用率等方面的表现。据统计,性能优化方面的精力投入,通常占软件开发周期的10%到25%左右,当然这和应用的性质和规模有关。性能对提高用户体验,保证系统可靠性,降低资源使用率,甚至增强市场竞争力等方面,都有着很大的影响。
性能优化是个系统性工程,宏观上可分为网络,服务,存储几个方向,每个方向又可以细分为架构、设计、代码、可用性、度量等多个子项。本文将重点从代码和设计两个子项展开,谈谈那些提升性能的知识点。当然,很多性能提升策略都是有代价的,适用于某些特定场景,大家在学习和使用的时候,最好带着批判的思维,决策前,做好利弊权衡。
图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.CPU缓存与内存的逻辑关系
CPU不同缓存执行耗时
图3.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硬件数据:
图4.Linux系统中可以通过tiptop命令来查看每个进程的CPU硬件数据
如何简单来区分访存密集型和计算密集型程序?
-
如果 IPC < 1.0, 很可能是 Memory stall 占主导,多半意味着访存密集型。
-
如果IPC > 1.0, 很可能是计算密集型的程序。
-
CPU利用率:是指系统中CPU处于忙碌状态的时间与总时间的比例。忙碌状态时间又可以进一步拆分为指令(instruction)执行消耗周期cycle(%INS) 和 stalled 的周期cycle(%STL)。perf 采集了10秒内全部 CPU 的运行状态:
图5.perf 采集了10秒内全部 CPU 的运行状态
IPC计算 IPC = instructions/cycles 上图中,可以计算出结果为:0.78 现代处理器一般有多条流水线(比如:4核心),运行 perf 的那台机器,IPC的理论值可达到4.0。 如果我们从 IPC的角度来看,这台机器只运行到其处理器最高速度的 19.5%(0.78 / 4.0)。
-
缓存对齐:是通过调整数据在内存中的分布,让数据在被缓存时,更有利于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设置内存可见性及内存屏障,禁止指令重排 volatile long x; // 一个long占用8个字节,此处定义7个填充数据,来保证业务数据x和y分布在不同的缓存行中 long p1, p2, p3, p4, p5, p6, p7; // long[] paddings = new long[7];// 使用数组代替不会生效,思考一下,为什么? // 共享变量 volatile long y; } }
@Contended注解
在Java 8中,引入了@Contended注解,该注解可以用来告诉JVM对字段进行缓存对齐(将字段放入不同的缓存行),从而提高程序的性能。使用@Contended注解时,需要在JVM启动时添加参数-XX:-RestrictContended,实现如下所示:
/** * 缓存行填充测试 * * @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设置内存可见性及内存屏障,禁止指令重排 volatile long x; // 一个long占用8个字节,此处定义7个填充数据,来保证业务数据x和y分布在不同的缓存行中 long p1, p2, p3, p4, p5, p6, p7; // long[] paddings = new long[7];// 使用数组代替不会生效,思考一下,为什么? // 共享变量 volatile long y; } }
-
对齐内存:内存行的大小一般为64个字节,这个大小是硬件决定的,但大多数编译器默认情况下都以4字节的边界对齐,通过将变量按照内存行的大小对齐,可以避免伪共享问题; -
本地变量:在不同线程之间使用不同的变量存储数据,避免不同的线程之间共享同一块内存,Java中的ThreadLocal就是一种典型的实现方式。
分支预测是CPU动态执行技术中的主要内容,是通过猜测程序中的分支语句(如if-else语句或者循环语句)的执行路径来提高CPU执行效率的技术。其原理是根据之前的历史记录和统计数据,预测程序下一步要执行的指令是分支跳转指令还是顺序执行指令,从而提前加载相关数据,减少CPU等待指令执行的空闲时间。预测准确率越高,CPU的性能提升就越高。那么如何提高预测的准确率呢?
-
关注圈复杂度
过多的条件语句和嵌套的条件语句会导致分支的预测难度大幅上升,从而降低分支预测的准确率和效率。一般来说,可以通过优化代码逻辑结构、减少冗余等方式来避免过多的条件语句和嵌套的条件语句。
-
优先处理常用路径
在编写代码时,应该优先处理常用路径,以减少CPU对分支的预测,提高预测准确率和效率。例如,在if-else语句中,应该将常用的路径放在if语句中,而将不常用的路径放在else语句中。
ArrayList类可以使用Copy-On-Write机制来提高性能。
// 初始化数组 private List<String> list = new CopyOnWriteArrayList<>(); // 向数组中添加元素 list.add("value");
2.5 内联优化
final修饰符
限制方法长度
方法的长度会影响其在编译时能否被内联。通常情况下,长度较小的方法更容易被内联。因此,可以在设计中将代码分解和重构为更小的函数。这种方式并不是100%确保可以内联,但至少提高了实现此优化的机会。内联调优参数,如下表格:
内联注解
在Java 5之后,引入了内联注解@inline,使用此注解可以在编译时通知编译器,将该方法内联到它的调用处。注解@inline在Java 9之后已经被弃用,可以使用@ForceInline注释来替代,同时设置JVM参数:
-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+JVMCICompiler
@ForceInline public static int add(int a, int b) { return a + b; }
反射机制
-
尽可能使用原生方法调用,而不是通过反射调用;
-
尽可能缓存反射调用结果,避免重复调用。例如,可以将反射结果缓存到静态变量中,以便下次使用时直接获取,而不必再次使用反射;
-
使用字节码增强技术。
-
反射结果缓存可以大幅减少反射过程中的类型检查,类型转换和方法查找等动作,是降低反射对程序执行效率影响的一种优化策略。
/** * 反射工具类 * * @author liuhuiqing * @date 2023年5月7日 */ public abstract class BeanUtils { private static final Logger LOGGER = LoggerFactory.getLogger(BeanUtils.class); private static final Field[] NO_FIELDS = {}; private static final Map<Class<?>, Field[]> DECLARED_FIELDS_CACHE = new ConcurrentReferenceHashMap<Class<?>, Field[]>(256); private static final Map<Class<?>, Field[]> FIELDS_CACHE = new ConcurrentReferenceHashMap<Class<?>, Field[]>(256); /** * 获取当前类及其父类的属性数组 * * @param clazz * @return */ public static Field[] getFields(Class<?> clazz) { if (clazz == null) { throw new IllegalArgumentException("Class must not be null"); } Field[] result = FIELDS_CACHE.get(clazz); if (result == null) { Field[] fields = NO_FIELDS; Class<?> searchType = clazz; while (Object.class != searchType && searchType != null) { Field[] tempFields = getDeclaredFields(searchType); fields = mergeArray(fields, tempFields); searchType = searchType.getSuperclass(); } result = fields; FIELDS_CACHE.put(clazz, (result.length == 0 ? NO_FIELDS : result)); } return result; } /** * 获取当前类属性数组(不包含父类的属性) * * @param clazz * @return */ public static Field[] getDeclaredFields(Class<?> clazz) { if (clazz == null) { throw new IllegalArgumentException("Class must not be null"); } Field[] result = DECLARED_FIELDS_CACHE.get(clazz); if (result == null) { result = clazz.getDeclaredFields(); DECLARED_FIELDS_CACHE.put(clazz, (result.length == 0 ? NO_FIELDS : result)); } return result; } /** * 数组合并 * * @param array1 * @param array2 * @param <T> * @return */ public static <T> T[] mergeArray(final T[] array1, final T... array2) { if (array1 == null || array1.length < 1) { return array2; } if (array2 == null || array2.length < 1) { return array1; } Class<?> compType = array1.getClass().getComponentType(); int newArrLength = array1.length + array2.length; T[] newArr = (T[]) Array.newInstance(compType, newArrLength); int firstArrayLen = array1.length; System.arraycopy(array1, 0, newArr, 0, firstArrayLen); try { System.arraycopy(array2, 0, newArr, firstArrayLen, array2.length); } catch (ArrayStoreException ase) { final Class<?> type2 = array2.getClass().getComponentType(); if (!compType.isAssignableFrom(type2)) { throw new IllegalArgumentException("Cannot store " + type2.getName() + " in an array of " + compType.getName(), ase); } throw ase; } return newArr; } }
-
字节码增强技术,一般使用第三方库来实现,例如Javassist或Byte Buddy,在运行时生成字节码,从而避免使用反射。
-
动态字节码生成的方式在编译期就已经将类型信息确定下来,无需进行类型检查和转换;
-
动态字节码生成的方式可以直接调用方法,无需查找,提高了执行效率;
-
动态字节码生成的方式只需要在生成字节码时获取一次Method对象,多次调用时可以直接使用,避免了重复获取Method对象的开销。
异常处理
-
响应延迟:当异常被抛出时,Java虚拟机需要查找并执行相应的异常处理程序,这会导致一定的延迟。如果程序中存在大量的异常处理,这些延迟可能会累积,导致程序的整体性能下降。
-
内存占用:异常处理需要在堆栈中创建异常对象,这些对象需要占用内存。如果程序中存在大量的异常处理,这些异常对象可能会占用大量的内存,导致程序的整体内存占用量增加。
-
CPU占用:异常处理需要执行额外的代码,这会导致CPU占用率增加。如果程序中存在大量的异常处理,这些额外的代码可能会导致CPU占用率过高,导致程序的整体性能下降。
为避免这些问题,在编写代码时谨慎地使用异常处理机制,并确保对异常进行适当的记录和报告,这里建议不要使用异抛出异常的方式来处理业务逻辑,而是用条件判断。异常捕捉是用来处理不期望发生的事情,而错误码则用来处理可能会发生的事。举个例子:
/** * 反面教材示例 * @param param1 * @param param2 * @return */ public ServiceResponse<String> badCase4Throw(String param1,String param2){ try { Assert.notNull(param1, "param1 is null!"); Assert.notNull(param1, "param2 is null!"); // do something }catch (Throwable e){ return new ServiceResponse(ResponseCodeEnum.PARAM_ERROR); } return new ServiceResponse<>(); }
/** * 正面教材示例 * * @param param1 * @param param2 * @return */ public ServiceResponse<String> normCase4Throw(String param1, String param2) { if (StringUtils.isEmpty(param1)) { return new ServiceResponse(ResponseCodeEnum.PARAM_ERROR); } if (StringUtils.isEmpty(param2)) { return new ServiceResponse(ResponseCodeEnum.PARAM_ERROR); } try { // do something } catch (Throwable e) { return new ServiceResponse(ResponseCodeEnum.SYSTEM_ERROR, e.getMessage()); } return new ServiceResponse<>(); }
日志处理
LOGGER.info("result:" + JsonUtil.write2JsonStr(contextAdContains) + ", logid = " + DigitThreadLocal.getLogId());
以上示例代码中,类似的日志打印方式很常见,难道有什么问题吗?
-
性能问题:每次使用+进行字符串拼接时,都会创建一个新的字符串对象,这可能会导致内存分配和垃圾回收的开销增加;
-
可读性问题:使用+进行字符串拼接时,代码可能会变得难以阅读和理解,特别是在需要连接多个字符串时;
-
如果日志级别调整到ERROR模式,希望日志的字符串内容不需要进行加工计算,但这种写法,即使日志处于不需要打印的模式,日志内容也进行了无效计算。
临时对象
-
字符串拼接中,使用StringBuilder或StringBuffer进行字符串拼接,避免使用连接符,每次都创建新的字符串对象;
-
在集合操作中,尽量使用批量操作,如addAll、removeAll等,避免频繁的add、remove操作,触发数组的扩容或者缩容;
-
在正则表达式中,可以使用Pattern.compile()方法预编译正则表达式,避免每次都创建新的Matcher对象;
-
尽量使用基本数据类型,避免使用包装类,因为包装类的创建和销毁都会产生临时对象;
-
尽量使用对象池的方式创建和管理对象,比如使用静态工厂方法创建对象,避免使用new关键字创建对象,因为静态工厂方法可以重用对象,避免创建新的临时对象。
-
对象未被正确地释放:如果在方法执行完毕后,临时对象没有被正确地释放,就会导致内存泄漏风险;
-
对象过度共享:如果临时对象被过度共享,就可能会导致多个线程同时访问同一个对象,从而导致线程安全问题和性能问题;
-
对象创建过于频繁:如果在方法内部频繁地创建临时对象,就会导致内存开销过大,可能会引起性能甚至内存溢出问题。
-
及时释放对象:在方法执行完毕后,应该及时释放临时对象(比如主动将对象设置为null),以便回收内存资源; -
避免过度共享:在多线程环境下,应该避免过度共享临时对象,可以使用局部变量或ThreadLocal等方式来避免共享问题; -
对象池技术:使用对象池技术可以避免频繁创建临时对象,从而降低内存开销。对象池可以预先创建一定数量的对象,并在需要时从池中获取对象,使用完毕后再将对象放回池中。
锁的粒度
-
volatile是Java中的一个关键字,用于修饰变量。它的作用是保证被volatile修饰的变量在多线程环境下的可见性和禁止指令重排序。volatile可以看做是轻量级的sychronized,虽然volatile无法保证原子性,但是如果对某个共享变量是纯赋值操作和读取操作,而没有其他额外的操作,那么就可以使用volatile代替sychronized,因为赋值本身是原子的,而volatile又保证了可见性,最终也就保证了线程安全。 -
对象锁(也称为内置锁或监视器锁):它是Java中最基本的锁机制,每个Java对象都有一个内置锁。通过synchronized关键字可以获取对象锁。对象锁的作用是保护对象的状态,同一时刻只有一个线程可以获取该对象的锁,其他线程需要等待该线程释放锁后才能获取锁。对象锁的粒度最小,适用于对共享资源的并发访问。 -
类锁:类锁是针对类的,它是在类加载时创建的。通过synchronized关键字加在静态方法上可以获取类锁。类锁的作用是保护静态变量和静态方法,在同一时刻只有一个线程可以获取该类的锁,其他线程需要等待该线程释放锁后才能获取锁。类锁的粒度比对象锁大,适用于对静态资源的并发访问。 -
读写锁:读写锁分为读锁和写锁,读锁可以被多个线程同时获取,但写锁只能被一个线程获取。在读多写少的场景下,使用读写锁可以提高并发性能。 -
分段锁:分段锁是一种细粒度锁,它将一个大的数据结构分成多个小的数据结构,每个小的数据结构都有自己的锁。通过这种方式可以降低锁的竞争,提高并发性能。 -
自旋锁:自旋锁是一种基于忙等待的锁,它不会使线程进入阻塞状态,而是在获取锁失败时不断重试。自旋锁的优点是减少线程上下文切换的开销,缺点是会浪费CPU资源。自旋锁适用于临界区很小的情况。 -
信号量(Semaphore):信号量是一种计数器,它可以控制对共享资源的访问。在获取信号量时,如果计数器大于0,则可以继续访问共享资源,否则需要等待其他线程释放资源后再获取。信号量的粒度比前面几种锁都大,适用于对多个资源的并发访问。
外延扩展:为什么说DCL单例模式中,一定要给单例对象属性添加volatile关键字?这是因为volatile可以实现可见性和禁止指令重排,这样可以保证其他线程不会拿到一个半初始化对象,避免带来线程安全问题。
public class Singleton { private int number; //一定要加上volatile关键字 private volatile static Singleton INSTANCE; //私有构造器 private Singleton(){ this.number = 10; } public static Singleton getInstance() { if (INSTANCE == null) { synchronized (Singleton.class) { if (INSTANCE == null) { //注意:在栈引用与堆对象进行绑定和对象初始化的过程的顺序就有可能出现指令重排, //导致其它线程拿到了未初始化成员变量的对象引用,比如取到的number=0 INSTANCE = new Singleton(); } } } return INSTANCE; } }
小结
正所谓:“不积跬步,无以至千里;不积小流,无以成江海”。以上列举的编码细节,都会直接或间接的影响服务的执行效率,只是影响多少的问题。现实中,有时候我们不必过于苛求,但它们有一个共同的注脚:极客精神。
设计优化
实现一个简单的LRU本地缓存示例如下:
/** * Least recently used 内存缓存过期策略:最近最少使用 * Title: 带容量的<b>线程不安全的</b>最近访问排序的Hashmap * Description: 最后访问的元素在最后面。<br> * 如果要线程安全,请使用<pre>Collections.synchronizedMap(new LRUHashMap(123));</pre> <br> * * @author: liuhuiqing * @date: 20123/4/27 */ public class LRUHashMap<K, V> extends LinkedHashMap<K, V> { /** * The Size. */ private final int maxSize; /** * 初始化一个最大值, 按访问顺序排序 * * @param maxSize the max size */ public LRUHashMap(int maxSize) { //0.75是默认值,true表示按访问顺序排序 super(maxSize, 0.75f, true); this.maxSize = maxSize; } /** * 初始化一个最大值, 按指定顺序排序 * * @param maxSize 最大值 * @param accessOrder true表示按访问顺序排序,false为插入顺序 */ public LRUHashMap(int maxSize, boolean accessOrder) { //0.75是默认值,true表示按访问顺序排序,false为插入顺序 super(maxSize, 0.75f, accessOrder); this.maxSize = maxSize; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return super.size() > maxSize; } }
非阻塞IO
-
使用Callable方式实现异步处理
@GetMapping("/async/callable") public WebAsyncTask<String> asyncCallable() { Callable<String> callable = () -> { // 执行异步操作 return "异步任务已完成"; }; return new WebAsyncTask<>(10000, callable); }
-
使用DeferredResult方式实现异步处理
@GetMapping("/async/deferredresult") public DeferredResult<String> asyncDeferredResult() { DeferredResult<String> deferredResult = new DeferredResult<>(10000L); // 异步处理完成后设置结果 deferredResult.setResult("DeferredResult异步任务已完成"); return deferredResult; }
协程
虚拟线程是java.lang.Thread的一个实现,可以使用java.lang.Thread.Builder接口创建。
Thread thread = Thread.ofVirtual() .name("Virtual Threads") .unstarted(runnable);
也可以通过一个线程工厂类进行创建:
ThreadFactory factory = Thread.ofVirtual().factory();
虚拟线程运行的载体必须是线程,同一个线程中可以运行多个虚拟线程实例。
-
分布式计算框架中的MapReduce就是采用一种分而治之的思想设计出来的,将复杂或计算量大的任务,切分成一个个小的任务,小任务分别在不同的线程或服务器上并行的执行,最终再汇总每个小任务的结果。 -
边缘计算(Edge Computing)是一种分布式计算范式,它将计算、存储和网络服务的部分功能从云数据中心延伸至离数据源更近的地方,即网络的边缘。这种计算方式能够实现低延迟、节省带宽、提高数据安全性以及实时处理与分析等优势。
-
多个请求可以通过多线程并行处理,每个请求的不同处理阶段;
-
如查询阶段,可以采用协程并行执行;
-
存储阶段,可以采用消息订阅发布的方式进行处理;
-
监控统计阶段,就可以采用NIO异步的方式进行指标数据文件的写入;
-
请求/响应采用非阻塞IO模式。
-
建立TCP连接,通过三次握手实现;
-
服务器发送给客户端「握手信息」 ,客户端响应该握手消息;
-
客户端「发送认证包」 ,用于用户验证,验证成功后,服务器返回OK响应,之后开始执行命令。
-
公用的数据可以全局只定义一份,比如使用枚举,static修饰的容器对象等;
-
根据实际情况,提前设置List,Map等容器对象的初始化容量大小,防止后面的扩容,对性能的影响;
-
亨元设计模式的应用等。
-
为了提高响应性能,将部分业务数据提前预加载到内存中;
-
为了减轻CPU压力,将计算逻辑提前执行,直接将计算后的结果数据保存下来,直接供调用方使用;
-
为了降低网络带宽成本,将传输数据通过压缩算法进行压缩处理,到了目标服务,在进行解压,获得原始数据;
-
Myibatis为了提高SQL语句的安全性和执行效率,也引入了预处理的概念。
总结
性能优化是程序开发过程中绕不过去一个课题,本文聚焦代码和设计两个方面,从CPU硬件到JVM容器,从缓存设计到数据预处理,全面的展现了性能优化的实施方向和落地细节。阐述的过程没有追求各个方向的面面俱到,但都给到了一些场景化案例,来辅助理解和思考,起到抛砖引玉的效果。最后,希望本文能够为你带来思考和帮助。
本文仅供学习!所有权归属原作者。侵删!文章来源: 京东技术 -刘慧卿 :http://mp.weixin.qq.com/s/LcUksJvmBN2oczI1k7J6bA
文章评论