关于优化是一项很大的内容。本文结合菜鸟结算项目优化点分析以及书籍《JAVA程序性能优化》阅读心得,给出个人觉得可供参考的优化思路,共涉及四个方面,分别是:设计篇、代码优化篇、JVM内存调优和数据库操作优化。若文中理解有误之处也欢迎底下评论指正。
所谓优化的目的不止是使得我们的程序更快,也使得我们遇到峰值情况应用会更加稳定可靠,程序优化在某个程度上也可以理解是功能保障的升级篇。优化可能只需要在几个很微小的地方做些许改动,但是优化可能是无穷的。那么不得不提优化点重点放在哪里?根据木桶原理我们知道系统中最终性能取决于系统中性能表现最差的组件,根据应用的特点不同,任何计算机资源都有可能成为系统瓶颈,其中最有可能成为系统瓶颈的计算资源如下:
- 磁盘IO:磁盘读写要比IO慢的多,低效的IO影响无疑是巨大的,个人优化想法是通过设计篇中“缓冲”部分来进行优化。
- 异常、锁竞争、数据结构变动:高频的异常捕获处理和激烈的锁竞争,会占用宝贵的CPU资源。优化可以参考代码优化篇。
- 高频JVM内存操作:我们的应用部署前很多是经历了JVM参数调整的,给应用指定了占用内存大小等,所以应用需要关注更多的是JVM内存堆、栈情况而不是系统的内存情况。调整JVM内存参数也是有一定讲究的,可以参考JVM内存调优篇。
- 数据库优化:对于非计算密集型应用,数据库可能会成为系统的瓶颈。相对而言数据库优化起来是较为困难的,但若优化得当系统效率会得到很大的提升,该部分有想法可以参考下数据库优化篇。
设计篇
设计优化是最上层的优化,往往在应用开发之初针对模块的潜在问题给出的设计方案,涉及到系统的实现,它的优化是不容忽视的。该篇针对两点:缓存和缓冲,做一些文章。
缓冲(Buffer)
缓冲的一个典型比喻是漏斗,上层系统如茶壶,下层系统如水瓶。出水速度很快,水瓶瓶口很细,水倒入水瓶相当于内存数据写入硬盘,内存输入快,硬盘写入慢,应用程序上下层之间存在性能差异。那么缓冲的作用就在于协调上层、下层组件间的性能差异,当上层组件性能优于下层组件时,可以有效减少上层组件对下层的等待时间。基于缓冲,上层组件不需要等待下层组件真实的接受全部数据,就可以返回操作,加快了自身的处理速度。
缓冲最常用的场景就是提高I/O速度,它的实现本质是一块特定的内存区域。闲话不多说,上代码:
@Test
public void test_IO_case() throws Exception {
//无缓冲
//Writer writer = new FileWriter(new File("file.txt"));
//有缓冲
Writer writer = new BufferedWriter(new FileWriter(new File("file.txt")));
Long begin = System.currentTimeMillis();
for (int i = 0; i < 100000; ++i) {
writer.write(i);
}
writer.close();
System.out.println("testFileWriterBuffer spend: " + (System.currentTimeMillis()-begin));
}
上述无缓冲和有缓冲的情况,性能差距大约是2倍,前者63ms,后者32ms。我的本机跑前者是85ms,后者是13ms,提升巨大。
JDK常见I/O组件封装有BufferedWriter、BufferedOutputStream,JDK1.4后还提供了更强大的缓冲组件NIO,其基于selector异步网络、提供文件访问接口、新增了Channel通道抽象进行双向读写等特性,有兴趣可以了解一下。需要注意的是,一般来说缓冲区不宜过小,否则无法起到真正的缓冲功能,也不宜过大,否则会增加内存GC负担(GC看JVM调优篇)。BufferedWriter、BufferedOutputStream都可以加第二个参数指定缓冲大小,默认不填则大小为8K。
缓存(Cache)-不得不提的Tair
缓存的主要作用是暂存数据处理结果,并提供下次访问使用。这个特性针对高频调用场景是极有效果的,可以直接从缓存中快速获取高频数据,减少不必要的数据库读写操作,使用时非常普遍的,所以不得不提到集团中的中间件--Tair。
有三种Tair的产品分别是MDB、RDB和LDB。其中MDB属于内存型产品,支持KV和类hashmap结构,性能最优,但不支持持久化存储;RDB支持List,Set,Zset等复杂的数据结构,性能次之,可提供缓存和持久化存储两种模式;LDB属于持久化产品,支持KV和类hashmap结构,性能较前两者最差,但持久化可靠性最高。MDB单机30WQPS,99%请求在1ms之内完成,可见性能之高。Tair百科看这里:中间件文档中心Tair,此外:Tair优化实践
缓存的Tair使人欢喜使人愁。欢喜的是太好用的,运用效果显著;愁的是使用如果不得当也会引发意想不到的问题,而这些问题往往起初是被人忽视的,当展现出来的时候往往引发了不堪设想的后果。这里重点讲一下在项目中曾经发现的问题--Tair限流策略和热点Key解决方案。
Tair限流策略
我们看一下申请Tair实例时:
这里是有一个规格选择的。由于大部分业务方均使用公共大集群,Tair根据业务方申请时提供的QPS峰值,来评估整体集群能力是否可以满足业务方需求。所以申请Tair空间即表示接受当用户使用超出申请资源时,Tair有权对访问进行限流。具体限流策略看这里:Tair限流策略。这里就引发了一个问题,当热点Key突发时(有的甚至存储的是Pkey-Skey结构,举个例子某条数据如下结构:群id:{成员id:成员信息value},群id和成员id即是Pkey和Skey),问题如何排查才能使Tair不被击穿?
热点Key解决方案
上代码看一看:
public class DragonhorseTairWrapper {
private MultiClusterTairManager dragonhorseTairManager;
public void init(){
if (dragonhorseTairManager == null){
throw new RuntimeException("tair init Error!");
}else{
// 该行开启客户端原生 Localcache
// namespace,最多缓存10000个key,本地缓存过期时间500ms
dragonhorseTairManager.setupLocalCache(DragonhorseTairConstant.NAMESPACE, 10000, 500);
// 开启热点key(Hot-running)工作模式
dragonhorseTairManager.enableLocalCacheImprove(DragonhorseTairConstant.NAMESPACE);
}
}
public void setDragonhorseTairManager(MultiClusterTairManager dragonhorseTairManager) {
this.dragonhorseTairManager = dragonhorseTairManager;
}
}
热点key原理是:Tair 服务端版本会在运行时统计维护当前的热点key状态,当客户端访问到热点key时,热点通知的feedback包会随着客户端的get类的请求一并返回。客户端对热点key的识别依据服务端热点反馈的feedback包。
收到服务端反馈的热点key后,客户端依赖自身的的Localcache功能,每次写操作会自动强制删除Localcache里存在的key,读操作后会自动从Localcache里读取,Localcache中不存在则从服务端获取,成功后存储到Localcache 里。在热点key防御系统中,客户端要开启hot-running 模式,该模式下只能缓存带热点标记的key,Localcache 中非热点的key将逐步被淘汰。即一旦开启客户端的该模式,会强制改变Localcache的工作模式。
具体热点key解决方案及数据对比看这里,非常详细:还因突发热点击穿DB产生故障?赶紧看这里
代码优化篇
代码优化体现在程序的方方面面了,可能是算法、可能是数据结构,但是我们也不能小看它。也许极其微小的结构变动,所带来的优化效果也是不一般的,具体可以看《java程序优化》一书,非常详细,下面列举几个我整理的大家可能使用到的地方,一千万的循环来放大对比,给出前后优化耗时:
- 慎用try-catch,尤其是在循环之中,可以的话将其移到循环之外。循环内110ms,循环外62ms
- 多用局部变量。由于局部变量在栈中,其它如静态变量的多在堆中,相比之下变量在栈中会更优秀一些。使用static变量266ms,使用局部变量78ms。
3.位运算代替乘、除法。原219ms,位运算后31ms。
4.switch在循环中可以用数组代替(书中理论)。比较有意思的是我的本机上跑循环中switch耗时8ms,用map/array替代耗时32ms。上代码如下,这个点需要斟酌一下,欢迎各位尝试:
@Test
public void test_测试循环中switch和数组耗时对比() throws Exception {
int re = 0;
Stopwatch stopwatch = Stopwatch.createStarted();
//循环一千万次放大耗时对比
//单纯swicth
for (int i = 0; i < 10000000; ++i) {
re = switchInt(i);
}
System.out.println("循环中switch耗时(ms): " + stopwatch.elapsed(TimeUnit.MILLISECONDS));
//使用数组
int [] sw = new int[]{3, 6, 7, 8, 10, 16, 18, 999999999,-1};
Stopwatch stopwatch_array = Stopwatch.createStarted();
for (int i = 0; i < 10000000; ++i) {
re = arrayInt(sw, i);
}
System.out.println("用map/array替代耗时(ms): " + stopwatch_array.elapsed(TimeUnit.MILLISECONDS));
}
protected int switchInt (int index) {
int i = index % 9;
switch (i) {
case 0 : return 3; case 1 : return 6;
case 2 : return 7; case 3 : return 8;
case 4 : return 10; case 5 : return 16;
case 6 : return 18; case 7 : return 999999999;
default : return -1;
}
}
protected int arrayInt (int[] sw, int index) {
int i = index % 9;
return sw[i];
}
5.提取公共表达式。提取前156ms,提取后78ms。
6.展开循环,拉开迭代器增长步长。有必要说一下,这会导致可读性变差,也是没有办法的办法才这么搞。优化前94ms,优化后31ms。
7.静态static方法代替实例方法。
并发
这里需要着重说明一下并发过程的优化,这里也是有优化策略的。首先由于数据同步,并发结构需要更改如下:
List : Collections.synchronizedList(List)
Set : Collections.synchronizedList(Set)
HashMap : concurrentHashMap
Queue : concurrentLinkedQueue
volatile : 变量其它线程可见
synchronized : 锁方法,内部锁
ReadWriteLock : 重入锁
ThreadLocal : 局部变量,每个变量一个副本
semaphore :信号量,指定多个线程同时访问某一资源
关于锁,有以下几种方法:
- 减小锁的范围,举个例子,如果某条记录操作数据库,如果可以将这条记录锁住,那就千万不要用锁整个数据库的方式,而使用将这条记录锁住。
2.减小锁粒度。如concurrentHaspMap结构,其内部是分为16段的,减少Hash时冲突问题。当然这也存在一个问题,就是concurrentHaspMap作为全局使用时,需要获取全部的16个锁。
3.锁分离。LinkedBlockingQueue,基于链表前端、尾端同时操作;此外读写分离锁(针对读多写少的情况)也是这个思想。
4.锁粗化:循环内频繁使用锁时,放在循环之外。
这个地方有时间会继续补充,添上些代码范例。
JVM调优篇
在看JVM调优前,需要了解一些JVM内存模型,可以看下面这站图片:
这其中,Java堆存储运行对象如数组,方法区存类元数据结构如常亮,程序计数器存储指令,虚拟机栈保存函数调用堆栈信息,本地方法栈保存java函数调用(线程私有),本地方法栈管理本地方法调用,后两者都会抛出StackOverFlowError和OutOfMemoryError。
垃圾回收(Garbage Collection)
Java和C最大的不同有一点就是垃圾回收,C每次malloc或new后需要free,Java可以自主进行不用的数据变量的回收。深入Java堆,又可以细分,且看下图,:
有几个区是需要清楚的,新生对象保存在Eden伊甸区中,当伊甸区存储达到阈值,会触发一次Minor GC,可以理解为YoungGC,这些数据变量会经历一次垃圾回收,未被回收的会移至幸存区S0或S1。幸存区每次发生Minor GC也会被扫描一次进行垃圾回收,如此当幸存区满了或里面的数据变量超过15次没被回收完成时,变量会移入到老年区。老年区有一个阈值,当量达到老年区阈值时,会触发一次MajorGC,可以理解为FullGC。
本身FullGC如果不频繁出现并且时间短暂是可以接受的,但是当发生频繁并且严重的影响到了响应时间,这时就需要寻找代码问题进行优化了。若代码没有问题时,我们可以通过改变JVM参数来调整比例观察,这就到了我们JVM调优的内容。
关于JVM调优基础可以看这里:JVM调优基础
可以看看这篇文章,对内存模型有一个更深入的了解:java(JVM)内存模型和垃圾回收监控与调优(译)
FullGC怎么查?看这里:Full GC怎么查?-绝知此事要躬行
数据库优化策略
有兴趣的同学可以先看着两篇文章:
且看藏金阁计量模块数据库优化实践:
/**
* 更新计量增量到数据库记录中。
* @param measure
* 计量对象
* @return 计量增加前的累计值快照
*
* @implNote 1.如果为了极致的性能,可以考虑将这条sql和后续插入log表的sql合并写一个存储过程,减少网络消耗,也减少锁等待的时间。性能会有至少80%的提升。<br/>
* 2.这里之所以不能将insert log提前到update之前,是因为log不仅需要确保幂等,还需要满足幂等后计量累计量快照的查询, 而且数据需要强一致性(不能延时和错误)。所以也就无法获得insert提前带来的锁时间减少的好处。
*/
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRES_NEW)
@Override
public BigDecimal persistIncrement(Measure measure) {
Stopwatch stopwatch = Stopwatch.createStarted();
String requestId = measure.getBizRequestId();
long sysRequestId = measure.getSysRequestId();
MeasureLog log = new MeasureLog();
log.setIncrement(measure.getIncrement());
log.setMeasureId(measure.getId());
log.setRequestId(requestId);
// 查询和更新计量累计值(这里为了DB性能考虑,使用了TDDL的hint功能,比较恶心的在业务代码中处理了分库的逻辑,如果能从TDDL中获取分库信息就比较优雅了)
BigDecimal originalAggregate = measureDao.queryAndUpdateMeasureAggregateById(measure.getId(), measure.getIncrement(), measure.getId() % 4);
// 线上不应该出现,但在开发环境中容易发生的错误
AssertUtil.notNull(originalAggregate, () -> "计量累计值不应该为空,数据库有脏数据。可能导致的原因是手工删除了计量实例数据,但是未清除索引缓存。[sysRequestId=" + sysRequestId + "][requestId=" + requestId + "][measureId=" + measure.getId() + "][measureKey=" + measure.getMeasureKey() + "]", logger);
originalAggregate = originalAggregate.subtract(measure.getIncrement());
// 插入日志
log.setAggregate(originalAggregate);
Map<String, Object> params = new HashMap<>();
params.put("requestId", log.getRequestId());
params.put("measureId", log.getMeasureId());
params.put("increment", log.getIncrement());
params.put("aggregate", log.getAggregate());
params.put("dbNum", log.getMeasureId() % 4);
// tddl使用的groovy解析分库分表的表达式,所以hash是原生的Java String.hashCode()方法
params.put("tableNum", String.format("%04d", Math.abs(log.getRequestId().hashCode()) % 32));
// 这里可会会因为幂等约束抛出异常
measureLogDao.insertWithHint(params);
if (logger.isDebugEnabled()) {
logger.debug("更新计量的增量到DB成功。[sysRequestId=" + sysRequestId + "][requestId=" + requestId + "][measure=" + measure + "][log=" + log + "][time=" + stopwatch.stop().elapsed(TimeUnit.MILLISECONDS) + "ms]");
}
// 都顺利完成,则返回原始计量累计值,如果中途发生错误则回滚事务。
return originalAggregate;
}
@Select("/*+TDDL({'type':'direct','vtab':'cf_measure','dbid':'CAINIAO_CF_CHARGE_000#{2}_GROUP','realtabs':['cf_measure_000#{2}']})*/ SELECT aggregate FROM UPDATE QUEUE_ON_PK #{0} `cf_measure` set aggregate = aggregate + #{1} where id = #{0}")
BigDecimal queryAndUpdateMeasureAggregateById(long id, BigDecimal increment, long dbNum);
@Insert("/*+TDDL({'type':'direct','vtab':'cf_measure_log','dbid':'CAINIAO_CF_CHARGE_000#{dbNum}_GROUP','realtabs':['cf_measure_log_${tableNum}']})*/ INSERT COMMIT_ON_SUCCESS ROLLBACK_ON_FAIL INTO cf_measure_log (gmt_create ,gmt_modified ,request_id ,measure_id ,increment ,aggregate )VALUES(now(),now(),#{requestId},#{measureId},#{increment},#{aggregate})")
void insertWithHint(Map<String, Object> params);
函数persistIncrement是为了更新计量增量到数据库记录中,改代码有两处是可值得我们借鉴和思考的地方,通过SQL合并和Hint优化,优化程度分别为89.98%和58.82%,系统最坏情况压测结果TPS从177.61提升至340,接着再提升至540:
- 使用TDDL Hint干扰执行路径。在默认条件下,SQL的执行先经过TDDL再走到最后的库操作,在我们清楚的范围之内,TDDL其实也提供给我们配置的操作,这就是Hint,它是一种“暗示”,告诉TDDL怎么走最优,从而对处理路径进行我们认知方向的修改,加速了中间件过程处理。
- 使用SQL合并。将update+select两条SQL合并成单条SQL,向客户端直接返回update之后的结果列,通过减小网络开销及sql解析代价提高性能。语法描述如下:
SELECT
select_expr [, select_expr ...]
FROM UPDATE
[LOW_PRIORITY] [IGNORE]
[COMMIT_ON_SUCCESS] [ROLLBACK_ON_FAIL] [QUEUE_ON_PK] [TARGET_AFFECT_ROW num]
tbl_name
SET col_name1={expr1|DEFAULT} [, col_name2={expr2|DEFAULT}] ... [WHERE where_condition] [ORDER BY ...] [LIMIT row_count]
说明:1)仅支持单个表UPDATE语句
2)SELECT返回的数据量为matched rows,而非modified rows,即逻辑更新而非物理更新,这个与PostgreSQl/Oracle是一致的