我们知道JAVA语言与C语言的其中一个区别就是JVM中有垃圾回收器可以通过对运行中的对象进行判断是否存活并且将在内存中已经不在使用的对象进行回收释放其所占用的内存,而C语言需要进行手动的释放内存,1个对象的创建使用释放都需要程序进行显式的操作。当然不管是C还是JAVA都有自己适合的开发领域。
对于代码性能优化,对于项目前期由于前期数据量并不是太大但是随着时间的推移数据量的激增,如果没有良好的编码习惯后期会带来较大的性能开销,故项目初期编码过程应养成良好的习惯避免后期繁重的codereview等。
本篇博客将笔者在工作过程中遇到的性能瓶颈以及在其中是如何进行优化进行记录,以便后续有相关需求的小伙伴进行采纳借鉴。
对象篇
在JMM中(JAVA内存模型)中保存对象的开销其实是相当大的,并且JMM要求对象必须按照8个字节对齐,虽然JMM会提供对字段的重排序来避免用户随意指定对象字段的顺序以此来尝试各个字段重排达到降低整体的对象开销。但是即使是这样1个空的String对象仍然得占用 对象头(8 字节)+ 引用 (4 字节 ) + char 数组(16 字节)+ 1个 int(4字节)+ 1个long(8字节)= 40 字节 如果在程序过程中产生大量的对象无疑会加重GC的工作。因此在实际编码过程中应该避免创建大量的对象,在不影响代码可读性及程序并发问题时,应该尽可能复用已有的对象,减轻GC的压力。
- 使用StringBuild/StringBuffer代替循环体内进行字符串拼接,StringBuild与StringBuffer底层会默认创建一个16char数组进行字符串缓存,等到需要时在创建出String对象,而不是每一次都进行String对象创建。
- 使用StringTokenizer 代替Split 做字符串切割(如果不涉及到复杂正则只是简单字符串切割)
- JAVA编译时会对常量拼接进行重新定义作为一个完整的常量故无需纠结性能问题,可见如下程序块。
final String aa = "a"
final String bb = "b"
System.err.println((aa + bb) == (aa + bb));//true
- JDK1.6前后的subString方法实现
在JDK1.6 subString内部实现是子String仍然会保留父String的char数组引用,这在一定程度上会造成内存泄露,特别是当有特别长的字符串实际上只需要其特别短的字符串,但是由于引用依赖,GC无法回收,造成父String一直滞留在JVM内存中无法回收。
JDK1.6之后subString内部对其优化,调用拷贝父String的char数组中的子char数组形成一份新的副本,如此子String与父String之间不存在依赖关系,GC能够对不在使用的父String进行GC回收。 - 循环遍历中如果存在重复创建大量相同的字符串,建议创建缓存池进行对象缓存。
- 避免使用正则表达式,如必要使用至少要把Pattern进行缓存,避免反正创建Pattern编译。
- 当需要对一个基本数据类型进行字符串转换应该尽可能使用toString或者String.valueOf(obj) 替换 obj+""。
- 在进行大文本字符串拼接时,应该为StringBuffer,StringBuilder设置初始化容量值
JVM篇
- 将-Xms与-Xmx设置相同,避免每次垃圾回收完成后JVM重新分配内存。
- 原则上应该避免太大深度的递归,毕竟递归越深其中间栈帧所产生的数据引用仍然有效,无法被GC清除。
- 当虚拟机栈中需要存储基本数据或对象引用时需要调整-Xss来避免发生StackOverflowError异常
- 在虚拟机栈中随着栈帧的pop会对栈帧内的内存进行及时的清理,所以在局部方法内部中其中间变量应该尽可能使用8大基本数据类型才能够随着栈帧结束而立即内存回收。
- 为了避免频繁触发JVM对基本数据类型进行拆包与装包操作,其中间变量应该尽量使用基本数据类型而不是其包装类型。
- JDK默认只缓存-128~+127的Integer和Long 如果超出该范围则会创建出新的对象,如果对计算数据敏感可是适当通过-XX:AutoBoxCacheMax增大缓存范围。
- G1与CMS收集器的选择,G1收集器会对堆内内存进行划分成Region,其堆越小则划分的Region数也会越少,在小堆的表现并不会比CMS突出。笔者认为8G以下采用CMS比较合适。目前比较期待Java 11 新加入的ZGC号称可以达到10ms 以下的 GC 停顿。
- 通过-XX:+AlwaysPreTouch提前初始化好真正的物理内存,而不是需要才进行内存申请初始化。默认未添加该参数时而-Xms、-Xmx只是告诉告诉操作系统需要多少内存,从而避免被其他进程使用,而只有当正直使用时才会进行内存逐渐申请。比如在堆中Eden区进行对象创建又或者Young区转Old区的内存空间。不过该参数也会影响启动时长,随着堆内存越大启动时间也会增大。
- JDK监控工具 Jconsole,jProfile,VisualVM 可以通过可视化界面查看JAVA堆内存使用情况进行判断是否需要进行代码优化。
线程篇
- 如何为线程池选择合适线程数?
线程任务一般可以分为计算密集型和IO密集型。
计算密集型CPU处于忙碌中,此时需要做内存数据读写计算,没有任务的阻塞状态。而IO密集型任务,在执行IO时堵塞,CPU处于等待状态,等待过程中系统会将时间片分给其他线程处理。
计算密集型任务线程数最好与系统核心数挂钩,毕竟4核单线程主机在某一时刻只能同时跑4个线程,如果过多的线程数反而会因为切换上下文而耗费更多的任务时间,可以通过调用JDK自带的方法Runtime.availableProcessors获得系统支持的可以核心数。N+1。
对于IO密集型,合适的线程数可以获得良好的性能支持,系统通过将IO阻塞的任务线程的时间片交给未被阻塞的任务线程,通过合适的调度发挥出比单线程更好的性能支持。在选择线程数应该考虑内存支持程度,避免过多的线程数导致内存激增产生OOM。2N+1。
线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
一个完整的系统不应只有一个线程池,应该对线程任务进行梳理分类,划分出各自的任务类型以及工作负载来提供多个线程池。 - 如果需要加锁竟可能使用加锁代码块将锁范围控制在最小范围中,而不是在方法体中加锁。
- 尽量避免嵌套取锁,容易造成死锁问题。
- 合理时候使用读写锁来替换synchronized独占锁。
- JDK1.8流处理对于大量需要计算的数据时可采用parallelStream进行并发数据处理,可提高处理速度。
- 多了解各类对象对并发支持性例如HashMap、SimpleDateFormat等。
- 合理使用ThreadLocal来实现数据在线程本地化。
- 如果对执行过程没有严格的串行顺序可以采用FutureTask对计算结果统一取值拼装。
杂谈篇
- 为避免循环之间切换,尽量采用小循环嵌套大循环。
- 避免在循环中大概率抛出异常的代码块进行TryCatch,尽可能放在循环外层TryCatch。
- 代码块中应尽量使用懒加载,即需要时才进行加载,不需要则不加载。
- 在JVM级别做适当的缓存级别,可以使用EhCache、Guava Cache。Ehcache适合支持持久化功能,有集群解决方案,而Guava Cache只是一个支持LRU的concurrentHashMap,没有Ehcache那么多特性,只支持增删改查,刷新规则和时效规则设定等最基本的设定。
- 设置日志输出级别,避免大量无关紧要的日志输出,影响业务系统性能。
- SQL调优可以通过索引分析,减少查询字段,限制表读数据等进行分析调优。
- 数据库瓶颈可以通过读写分离减少单服务器压力,以及通过垂直拆分或水平拆分将数据表数据合理治理。
- 引入缓存架构减轻数据库压力。
哪些是性能瓶颈的关键点
- 有些任务需要大量的计算,需要不停地占用CPU资源,导致其他任务抢占CPU的能力变弱而导致响应速度下降,而带来的性能问题。比如任务内过多的重复计算,无限的自循环计算,JVM频繁的FULL GC,多线程做大量的上下文切换等。
- 一般来说说内存的读写速度非常快,一般不存在性能问题,但是由于内存成本比硬盘高即内存空间是有限的,应该注意内存的范围避免应内存耗尽而导致OOM等问题。
- 磁盘IO是引起系统性能的一大因素。涉及到数据落地等问题应尽量使用硬盘顺序读写而不是随机读写,顺序读写的读写能力比随机读写能力强大太多。
- 数据库方面除了必要的SQL优化减少查询时间外如有必要需要引入缓存层减轻数据库压力
- 合理使用锁减少并发时造成性能损耗
- 防止瞬间时抛出大量的异常,抛出异常会不停从堆栈内拔取异常信息,非常耗性能。
衡量性能的指标
- 系统响应时间
从发送请求到接收到数据时所需要的时间 - 系统吞吐量
单位时间内成功地传送数据的数量大小 - 负载承受能力
随着并发量的增加最终导致系统抛出大量的异常,整个系统处于不可用的时候。