架构师日记-从代码到设计的性能优化指南
关联代码优化是通过预加载相关代码,避免在运行时加载目标代码,造成运行时负担。我们知道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");}// 运行相关功能的代码// ...}
使用线程池
线程池可以让多个任务使用同一个线程池中的线程,从而减少线程的创建和销毁成本。使用线程池时,可以在程序启动时创建线程池,并在主线程中预加载相关代码。然后以异步方式使用线程池中的线程来执行相关代码,可以提高程序的性能。
使用静态变量
可以使用静态变量来缓存与关联代码有关的对象和数据。在程序启动时,可以预先加载关联代码,并将对象或数据存储在静态变量中。然后在程序运行时使用静态变量中缓存的对象或数据,以避免重复加载和生成。这种方式可以有效地提高程序的性能,但需要注意静态变量的使用,确保它们在多线程环境中的安全性。
如何简单来区分访存密集型和计算密集型程序?
1. 如果 IPC < 1.0, 很可能是 Memory stall 占主导,多半意味着访存密集型。
2. 如果IPC > 1.0, 很可能是计算密集型的程序。
IPC计算IPC = instructions/cycles上图中,可以计算出结果为:0.78现代处理器一般有多条流水线(比如:4核心),运行 perf 的那台机器,IPC的理论值可达到4.0。如果我们从 IPC的角度来看,这台机器只运行到其处理器最高速度的 19.5%(0.78 / 4.0)。
缓存填充(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,实现如下所示:
import sun.misc.Contended;public class ContendedTest {@Contendedvolatile long a;@Contendedvolatile long b;public static void main(String[] args) throws InterruptedException {ContendedTest c = new ContendedTest();Thread thread1 = new Thread(() -> {for (int i = 0; i < 10000_0000L; i++) {c.a = i;}});Thread thread2 = new Thread(() -> {for (int i = 0; i < 10000_0000L; i++) {c.b = i;}});final long start = System.nanoTime();thread1.start();thread2.start();thread1.join();thread2.join();System.out.println((System.nanoTime() - start) / 100_0000);}}
对齐内存与本地变量
缓存填充是解决CPU伪共享问题的解决方案之一,在实际应用中,是否还有其它方案来解决这一问题呢?答案是有的:即对齐内存和本地变量。
•关注圈复杂度
过多的条件语句和嵌套的条件语句会导致分支的预测难度大幅上升,从而降低分支预测的准确率和效率。一般来说,可以通过优化代码逻辑结构、减少冗余等方式来避免过多的条件语句和嵌套的条件语句。
•优先处理常用路径
在编写代码时,应该优先处理常用路径,以减少CPU对分支的预测,提高预测准确率和效率。例如,在if-else语句中,应该将常用的路径放在if语句中,而将不常用的路径放在else语句中。
// 初始化数组private List<String> list = new CopyOnWriteArrayList<>();// 向数组中添加元素list.add("value");
final修饰符
限制方法长度
| JVM参数 | 默认值 (JDK 8, Linux x86_64) | 参数说明 |
- XX:MaxInlineSize=<n> | 35 字节码 | 内联方法大小上限 |
- XX:FreqInlineSize=<n> | 325 字节码 | 内联热方法的最大值 |
- XX:InlineSmallCode=<n> | 1000字节的原生代码(非分层) 2000字节的原生代码(分层编译) | 如果最后一层的的分层编译代码量已经超过这个值,就不进行内联编译 |
- XX:MaxInlineLevel=<n> | 9 | 调用层级比这个值深的话,就不进行内联 |
内联注解
在Java 5之后,引入了内联注解@inline,使用此注解可以在编译时通知编译器,将该方法内联到它的调用处。注解@inline在Java 9之后已经被弃用,可以使用@ForceInline注释来替代,同时设置JVM参数:
-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+JVMCICompiler@ForceInlinepublic static int add(int a, int b) {return a + b;}
反射机制
Java反射在一定程度上会影响性能,因为它需要在运行时进行类型检查转换和方法查找,这比直接调用方法会更耗时。此外,反射也不会受到编译器的优化,因此可能会导致更慢的代码执行速度。
要解决这个问题有以下几种方式:
1.尽可能使用原生方法调用,而不是通过反射调用;
2.尽可能缓存反射调用结果,避免重复调用。例如,可以将反射结果缓存到静态变量中,以便下次使用时直接获取,而不必再次使用反射;
3.使用字节码增强技术;
下面着重介绍一下反射结果缓存和字节码增强两种方案。
•反射结果缓存可以大幅减少反射过程中的类型检查,类型转换和方法查找等动作,是降低反射对程序执行效率影响的一种优化策略。
/*** 反射工具类** @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,在运行时生成字节码,从而避免使用反射。
为什么动态字节码生成方式相比反射也可以提高执行效率呢?
1.动态字节码生成的方式在编译期就已经将类型信息确定下来,无需进行类型检查和转换;
2.动态字节码生成的方式可以直接调用方法,无需查找,提高了执行效率;
3.动态字节码生成的方式只需要在生成字节码时获取一次Method对象,多次调用时可以直接使用,避免了重复获取Method对象的开销;
这里就不再举例说明了,感兴趣的同学可以自行查阅资料进行深入学习。
异常处理
一些基准测试显示,异常处理可能会导致程序的性能下降几个百分点。在Java虚拟机规范中提到,在没有异常发生的情况下,基于堆栈的方法调用可能比基于异常的方法调用快2-3倍。此外,一些实验表明,在异常处理程序中使用大量的try-catch语句,可能会导致性能下降10倍以上。
为避免这些问题,在编写代码时谨慎地使用异常处理机制,并确保对异常进行适当的记录和报告,这里建议不要使用异抛出异常的方式来处理业务逻辑,而是用条件判断。异常捕捉是用来处理不期望发生的事情,而错误码则用来处理可能会发生的事。举个例子:
/*** 反面教材示例* @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());以上示例代码中,类似的日志打印方式很常见,难道有什么问题吗?
1.性能问题:每次使用+进行字符串拼接时,都会创建一个新的字符串对象,这可能会导致内存分配和垃圾回收的开销增加;
2.可读性问题:使用+进行字符串拼接时,代码可能会变得难以阅读和理解,特别是在需要连接多个字符串时;
3.如果日志级别调整到ERROR模式,我们希望日志的字符串内容不需要进行加工计算,但这种写法,即使日志处于不需要打印的模式,日志内容也进行了无效计算;
特别实在请求量和日志打印量比较高的场景下,日志内容的序列化和写文件操作,对服务的耗时影响可以达到10%,甚至更多。
临时对象
1.字符串拼接中,使用StringBuilder或StringBuffer进行字符串拼接,避免使用连接符,每次都创建新的字符串对象;
2.在集合操作中,尽量使用批量操作,如addAll、removeAll等,避免频繁的add、remove操作,触发数组的扩容或者缩容;
3.在正则表达式中,可以使用Pattern.compile()方法预编译正则表达式,避免每次都创建新的Matcher对象;
4.尽量使用基本数据类型,避免使用包装类,因为包装类的创建和销毁都会产生临时对象;
5.尽量使用对象池的方式创建和管理对象,比如使用静态工厂方法创建对象,避免使用new关键字创建对象,因为静态工厂方法可以重用对象,避免创建新的临时对象;
临时对象的生命周期应该尽可能短,以便及时释放内存资源。临时对象的生命周期过长通常是由以下原因引起的:
1.对象未被正确地释放:如果在方法执行完毕后,临时对象没有被正确地释放,就会导致内存泄漏风险;
2.对象过度共享:如果临时对象被过度共享,就可能会导致多个线程同时访问同一个对象,从而导致线程安全问题和性能问题;
3.对象创建过于频繁:如果在方法内部频繁地创建临时对象,就会导致内存开销过大,可能会引起性能甚至内存溢出问题;
为避免临时对象的生命周期过长,建议采取以下措施:
1.及时释放对象:在方法执行完毕后,应该及时释放临时对象(比如主动将对象设置为null),以便回收内存资源;
2.避免过度共享:在多线程环境下,应该避免过度共享临时对象,可以使用局部变量或ThreadLocal等方式来避免共享问题;
3.对象池技术:使用对象池技术可以避免频繁创建临时对象,从而降低内存开销。对象池可以预先创建一定数量的对象,并在需要时从池中获取对象,使用完毕后再将对象放回池中;
锁的粒度
1.volatile是Java中的一个关键字,用于修饰变量。它的作用是保证被volatile修饰的变量在多线程环境下的可见性和禁止指令重排序。volatile 可以看做是轻量级的sychronized,虽然volatile无法保证原子性,但是如果对某个共享变量是纯赋值操作和读取操作,而没有其他额外的操作,那么就可以使用volatile代替sychronized,因为赋值本身是原子的,而volatile又保证了可见性,最终也就保证了线程安全。
3.类锁:类锁是针对类的,它是在类加载时创建的。通过synchronized关键字加在静态方法上可以获取类锁。类锁的作用是保护静态变量和静态方法,在同一时刻只有一个线程可以获取该类的锁,其他线程需要等待该线程释放锁后才能获取锁。类锁的粒度比对象锁大,适用于对静态资源的并发访问。
4.读写锁:读写锁分为读锁和写锁,读锁可以被多个线程同时获取,但写锁只能被一个线程获取。在读多写少的场景下,使用读写锁可以提高并发性能。
5.分段锁:分段锁是一种细粒度锁,它将一个大的数据结构分成多个小的数据结构,每个小的数据结构都有自己的锁。通过这种方式可以降低锁的竞争,提高并发性能。
6.自旋锁:自旋锁是一种基于忙等待的锁,它不会使线程进入阻塞状态,而是在获取锁失败时不断重试。自旋锁的优点是减少线程上下文切换的开销,缺点是会浪费CPU资源。自旋锁适用于临界区很小的情况。
7.信号量(Semaphore):信号量是一种计数器,它可以控制对共享资源的访问。在获取信号量时,如果计数器大于0,则可以继续访问共享资源,否则需要等待其他线程释放资源后再获取。信号量的粒度比前面几种锁都大,适用于对多个资源的并发访问。
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=0INSTANCE = new Singleton();}}}return INSTANCE;}}
小结
正所谓:“不积跬步,无以至千里;不积小流,无以成江海”。以上列举的编码细节,都会直接或间接的影响服务的执行效率,只是影响多少的问题。现实中,有时候我们不必过于苛求,但它们有一个共同的注脚:极客精神。
缓存
本地缓存可以减少网络请求、节约计算资源、减少高负载数据源访问等优势,进而提高应用程序的响应速度和吞吐量。常见的本地缓存中间件有:Caffeine、Guava Cache、Ehcache。当然你也可以在使用类似Map容器,在应用程序中构建自己的缓存结构。
分布式缓存相比本地缓存的优势是可以保证数据一致性、只保留一份数据,减少数据冗余、可以实现数据分片,实现大容量数据的存储。常见的分布式缓存有:Redis、Memcached。
实现一个简单的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;}@Overrideprotected boolean removeEldestEntry(Map.Entry<K, V> eldest) {return super.size() > maxSize;}}
异步
异步可以提高程序的性能和响应能力,使其能更高效地处理大规模数据或并发请求。其底层原理涉及到操作系统的多线程、事件循环、任务队列以及回调函数等关键技术,除此之外,异步的思想在应用架构设计方面也有广泛的应用。常规的多线程,消息队列,响应式编程等异步处理方案这里就不再展开介绍了,这里介绍两个大家可能容易忽视但实用技能:非阻塞IO和 协程。
非阻塞IO
@GetMapping("/async/callable")public WebAsyncTask<String> asyncCallable() {Callable<String> callable = () -> {// 执行异步操作return "异步任务已完成";};return new WebAsyncTask<>(10000, callable);}
@GetMapping("/async/deferredresult")public DeferredResult<String> asyncDeferredResult() {DeferredResult<String> deferredResult = new DeferredResult<>(10000L);// 异步处理完成后设置结果deferredResult.setResult("DeferredResult异步任务已完成");return deferredResult;}
协程
很长一段时间,协程概念并非作为JVM内置的功能,而是通过第三方库或框架实现的。目前比较常用的协程实现库有Quasar、Kilim等。但在Java19版本中,引入了虚拟线程(Virtual Threads )的支持(处于Preview阶段)。
虚拟线程是java.lang.Thread的一个实现,可以使用java.lang.Thread.Builder接口创建
Thread thread = Thread.ofVirtual().name("Virtual Threads").unstarted(runnable);
ThreadFactory factory = Thread.ofVirtual().factory();并行处理的思想在大数据,多任务,流水线处理,模型训练等各个方面发挥着重要作用,包括前面介绍的异步(多线程,协程,消息等),也是建立在并行的基础上。在应用层面,典型的场景有:
•边缘计算(Edge Computing)是一种分布式计算范式,它将计算、存储和网络服务的部分功能从云数据中心延伸至离数据源更近的地方,即网络的边缘。这种计算方式能够实现低延迟、节省带宽、提高数据安全性以及实时处理与分析等优势。
•多个请求可以通过多线程并行处理,每个请求的不同处理阶段;
•如查询阶段,可以采用协程并行执行;
•存储阶段,可以采用消息订阅发布的方式进行处理;
•监控统计阶段,就可以采用NIO异步的方式进行指标数据文件的写入;
一般需要池化的内容,都是需要预处理的,比如为了保证服务的稳定性,线程池和数据库连接池等需要池化的内容在JVM容器启动时,处理真正请求之前,对这些池化内容进行预处理,等到真正的业务处理请求过来时,可以正常的快速处理。除此之外,预处理还可以体现在系统架构层面。
2.为了减轻CPU压力,将计算逻辑提前执行,直接将计算后的结果数据保存下来,直接供调用方使用;
3.为了降低网络带宽成本,将传输数据通过压缩算法进行压缩处理,到了目标服务,在进行解压,获得原始数据;