简介
盘点那些工作中你可能知道,但是你不知道性能会提升多少的优化手段和技巧。
一、内存操作优化
测试平台
Java:zulu_jdk java_version 20.0.2
CPU:Apple M2 Pro (10+16)
Memory:32GB
OS:MacOS Ventura 13.3.1
Stream流使用优化
有时候我们可能需要从一个list中过滤出一部分或者一条数据,进行拼装数据,就像下边这样。
public static void main(String[] args) {
// 先查出用户列表
List<User> userList = findUserList();
// 查出用户的其它信息
List<User> userAvatar = findUserAvatar();
long start = System.currentTimeMillis();
// 拼装数据
for (User user : userList) {
Long userId = user.getUserId();
// 根据用户id筛选出对应的用户头像
String avatar = userAvatar.stream().filter(t -> t.getUserId().equals(userId)).findFirst().orElse(new User(null, null, null)).getUserAvatar();
user.setUserAvatar(avatar);
}
System.out.println("耗时:" + (System.currentTimeMillis() - start) + "ms");
}
如果直接在每次循环内进行stream.filter()的话,运行结果如下:
输出结果:耗时60ms
stream.filter()看似是内存操作,但是扛不住次数多呀,这种场景是非常常见的,而且我们很有可能会拼装不止一个属性,可能用户头像、关注数量,关注者数量,等信息都需要进行拼装,那么一次stream.filter() ≈ 60ms,那2次就是120ms,拼装的多了,性能损耗就很大了。
怎么优化呢,方案如下:
public static void highPerformanceNormal(List<User> userList, List<User> userAvatar) {
// 将数据转换成map,利用红黑树实现高性能查询
Map<Long, List<User>> userAvatarMap = userAvatar.stream().collect(Collectors.groupingBy(User::getUserId));
// 拼装数据
for (User user : userList) {
Long userId = user.getUserId();
// 根据用户id筛选出对应的用户头像
// opt与性能优化无关,只是防止空指针
Optional.ofNullable(userAvatarMap.getOrDefault(userId, null)).ifPresent(
avatarList -> user.setUserAvatar(avatarList.get(0).getUserAvatar())
);
}
}
优化完成后的运行结果如下:性能直接提升10倍
性能对比
普通封装耗时(
stream.filter()):55ms高性能封装耗时(
map.get()):5ms性能提升约:🚀90%
copyPropertis优化
常用与对象的属性拷贝,原理是利用反射进行赋值,性能低下。代码如下:
// 将用户转换成userDTO,返回给前端
List<UserDTO> userDtoList = new ArrayList<>(userList.size());
long start = System.currentTimeMillis();
for (User user : userList) {
UserDTO userDTO = new UserDTO();
// 拷贝属性
BeanUtils.copyProperties(user, userDTO);
userDtoList.add(userDTO);
}
System.out.println("普通封装耗时:" + (System.currentTimeMillis() - start) + "ms");
输出结果:117ms
优化思路:使用MapStruct或者手动setter(),其实是一样的。为了方便展示和理解,就手动调用setter代替MapStruct
/**
* 高性能封装
*/
public static void highPerformanceNormal(List<User> userList, List<UserDTO> userDtoList) {
for (User user : userList) {
UserDTO userDTO = new UserDTO();
userDTO.setUserAvatar(user.getUserAvatar());
userDTO.setUserId(user.getUserId());
userDTO.setUserName(user.getUserName());
userDtoList.add(userDTO);
}
}
性能对比:
普通封装(
BeanUtil.copyProperties()):耗时:154ms高性能封装(
MapStruct、手动setter):耗时:1ms性能提升:100% or 🚀154倍
反射优化
假如我们需要把一个Object对象转换成Map,通常要使用到反射,例如:
public static void main(String[] args) throws Exception {
// 查询用户,5000条
List<StreamOptimization.User> userList = StreamOptimization.findUserList();
List<Map<String, Object>> resultMapList = new ArrayList<>();
long start = System.currentTimeMillis();
for (StreamOptimization.User user : userList) {
// 使用hutool的工具简化
resultMapList.add(BeanUtil.beanToMap(user));
}
System.out.println("反射赋值耗时:" + (System.currentTimeMillis() - start) + "ms");
}
输出结果:反射耗时:63ms
如果我们把需要转成Map的对象字段提前缓存一下,在进行转换呢?
private static final Map<Field, Method> USER_FIELD_METHOD_MAP;
// 提前将字段和对应的get方法缓存好
static {
USER_FIELD_METHOD_MAP = new LinkedHashMap<>();
Field[] fields = StreamOptimization.User.class.getDeclaredFields();
for (Field field : fields) {
String fieldName = field.getName();
String methodName = "get" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
try {
Method method = StreamOptimization.User.class.getMethod(methodName);
USER_FIELD_METHOD_MAP.put(field, method);
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
}
}
/**
* 高性能封装
*/
public static void highPerformanceNormal(List<StreamOptimization.User> userList, List<Map<String, Object>> resultMapList) throws Exception {
for (StreamOptimization.User user : userList) {
Map<String, Object> resultMap = new LinkedHashMap<>();
// 直接读取缓存好的map,不需要再次反射获取,直接调用即可
for (Map.Entry<Field, Method> entry : USER_FIELD_METHOD_MAP.entrySet()) {
resultMap.put(entry.getKey().getName(), entry.getValue().invoke(user));
}
resultMapList.add(resultMap);
}
}
性能对比:
普通封装(
BeanUtil.beanToMap()):耗时:76ms高性能封装(
MapStruct、手动setter):耗时:10ms性能提升:🚀7.6倍
多流程操作优化
假如我们要在一个查询里进行3个操作,查询商品列表、查询商品审核信息、查询商品销量信息。操作的顺序是,先查询列表,剩下两个顺序都可以。
假如查询商品列表要100ms,查询审核信息要88ms,查询销量信息要105ms
传统查询方式如下:
package cn.yufire.code.optimization;
import java.util.concurrent.TimeUnit;
/**
* @author Yufire
* @date 2023/8/4 11:55
* -
*/
public class MultiOperationOptimization {
public static void main(String[] args) {
long start = System.currentTimeMillis();
try {
queryGoodsList();
queryGoodsCheckInfo();
queryGoodsSaleInfo();
// 组装数据...
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("总耗时:" + (System.currentTimeMillis() - start) + "ms");
}
public static void queryGoodsList() throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(100);
System.out.println("商品列表查询完成");
}
public static void queryGoodsCheckInfo() throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(88);
System.out.println("商品审核详情查询完成");
}
public static void queryGoodsSaleInfo() throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(105);
System.out.println("商品销量详情查询完成");
}
}
结果:
商品列表查询完成 商品审核详情查询完成 商品销量详情查询完成 总耗时:305ms
如果使用线程池或者CompletableFuture进行优化呢?
两者效率都一致,为了方便使用线程池进行演示。
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
// 建议自定义线程池,这里只是为了方便演示
ExecutorService executorService = Executors.newFixedThreadPool(2);
// 查询商品
queryGoodsList();
// 查询审核详情
Future<?> goodsCheckFuture = executorService.submit(MultiOperationOptimization::queryGoodsCheckInfo);
// 查询销量详情
Future<?> goodsSaleFuture = executorService.submit(MultiOperationOptimization::queryGoodsSaleInfo);
goodsCheckFuture.get();
goodsSaleFuture.get();
// 组装数据..
// 返回前端
System.out.println("总耗时:" + (System.currentTimeMillis() - start) + "ms");
}
性能对比:
普通串行执行:耗时:305ms
线程池并行执行:耗时:217ms
性能提升约:🚀40%
二、SQL优化
场景一
Excel数据导出,需要重复调用某个查询接口,直到没有数据。
流程大概如下:
long total = goodsService.queryTotal();
// 一次查询5000条,计算一共几页
int totalPage = total / 5000
// 循环
for(int i = 1;i <= totalPage; i++){
// 查询数据,i是第几页,5000是一次查询5000条
List<Goods> list = goodsService.queryList(i,5000);
//excel写入操作...
// excel.write()
}
生成的sql可能是长这样的
select field1,field2,fieldn... from goods
where 条件...
limit 5000 # 第一次分页
limit 5000,5000 # 第二次分页
limit 10000,5000 # 第三次分页
limit pageIndex * queryTotal,5000 # 第n次分页
可以看到,随着页数的增加,分页深度依次递增queryTotal(一次查询多少条),而且你去EXPLAIN这个sql,会发现根本不会走索引。
那怎么样才能让分页走索引呢?
- 可以将sql进行改造,添加条件和排序
select field1,field2,fieldn... from goods
# 注意offsetId做好非空判断,<if test="offsetId!=null"> ....
where 条件... and id > #{offsetId}
order by id
- 将导出的
for循环改造,添加offsetId参数,查询的时候传入。
long lastId = 0;
for(int i = 1;i <= totalPage; i++){
// 查询数据,lastId是最后一条数据的id,i是第几页,5000是一次查询5000条
List<Goods> list = goodsService.queryList(lastId,i,5000);
// 获取最后一个id
lastId = list.get(list.size()-1).getId();
//excel写入操作...
// excel.write()
}
- 此时你会发现
EXPLAIN这条sql的时候,它会走range级别的索引,效率提升。并且越往后索引过滤的数据越多,效率越高! - 导出的效率会明显变高
剩下技巧,等待更新中...
原创作者:Yufire