java性能调优
一、怎样做好性能调优?
扎实的计算机基础
习惯透过源码了解技术本质
善于追问和总结
二、为什么要做性能调优?
- 一款线上产品如果不做性能测试,那它就好比一颗定时炸弹。
- 了解产品承受极限。
- 节约服务器资源。
三、常用的调优策略
- 应用调优
- 优化代码
- 优化设计
- 优化算法
- 调优策略
- 时间换空间
- 空间换时间
- 系统调优
- JVM参数调优
- 兜底策略
- 限流
- 扩容
四、实际运用
1、java编程性能调优
a.数字转字符串的效率问题
public static void main(String[] args) {
int size = 100000; // 十万
long start = System.currentTimeMillis();
for (int i = 0; i < size; i++) {
String a = i + "";
}
System.out.println(System.currentTimeMillis() - start);
long start1 = System.currentTimeMillis();
for (int i = 0; i < size; i++) {
String b = String.valueOf(i);
}
System.out.println(System.currentTimeMillis() - start1);
long start2 = System.currentTimeMillis();
for (int i = 0; i < size; i++) {
String c = Integer.toString(i);
}
System.out.println(System.currentTimeMillis() - start2);
}
/**
程序最后运行结果:
29
7
6
*/
当size达到230万时,几乎无差异,达到300万,第一种最快
public static String toString(int i) {
if (i == Integer.MIN_VALUE)
return "-2147483648";
int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
char[] buf = new char[size];
getChars(i, size, buf);
return new String(buf, true);
}
b.ArrayList还是LinkedList?
常规回答:ArrayList 和 LinkedList 在新增、删除元素时,LinkedList 的效率要高于 ArrayList,而在遍历的时候,ArrayList 的效率要高于 LinkedList。
ArrayList是如何实现的
ArrayList的实现类:
public class ArrayList extends AbstractList
implements List, RandomAccess, Cloneable, java.io.Serializable
ArrayList的属性:
//默认初始化容量
private static final int DEFAULT_CAPACITY = 10;
//对象数组
transient Object[] elementData;
//数组长度
private int size;
为什么ArrayList实现了Serializable,但是elementData却用transient关键字修饰?
ArrayList的构造:
public ArrayList(int initialCapacity) {
//初始化容量不为零时,将根据初始化值创建数组大小
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {//初始化容量为零时,使用默认的空数组
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
}
}
public ArrayList() {
//初始化默认为空数组
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
ArrayList新增元素:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
ArrayList删除元素:
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
ArryayList遍历元素:
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
E elementData(int index) {
return (E) elementData[index];
}
LinkedList 是如何实现的
private static class Node {
E item;
Node next;
Node prev;
Node(Node prev, E element, Node next) {
this.item = element; // 元素item
this.next = next; // 后指针
this.prev = prev; // 前指针
}
}
LinkedList的实现类:
public class LinkedList
extends AbstractSequentialList
implements List, Deque, Cloneable, java.io.Serializable
LinkedList的属性:
transient int size = 0;
transient Node first;
transient Node last;
LinkedList新增元素:
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node l = last;
final Node newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
void linkBefore(E e, Node succ) {
// assert succ != null;
final Node pred = succ.prev;
final Node newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
LinkedList 删除元素
E unlink(Node x) {
// assert x != null;
final E element = x.item;
final Node next = x.next;
final Node prev = x.prev;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
Node node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
在 LinkedList 删除元素的操作中,我们首先要通过循环找到要删除的元素,如果要删除的位置处于 List 的前半段,就从前往后找;若其位置处于后半段,就从后往前找。这样做的话,无论要删除较为靠前或较为靠后的元素都是非常高效的,但如果 List 拥有大量元素,移除的元素又在 List 的中间段,那效率相对来说会很低。
LinkedList 遍历元素
LinkedList 的获取元素操作实现跟 LinkedList 的删除元素操作基本类似,通过分前后半段来循环查找到对应的元素。但是通过这种方式来查询元素是非常低效的,特别是在 for 循环遍历的情况下,每一次循环都会去遍历半个 List。所以在 LinkedList 循环遍历时,我们可以使用 iterator 方式迭代循环,直接拿到我们的元素,而不需要通过循环查找 List。
ArrayList与LinkedList 对比
新增元素操作测试
- 从集合头部位置新增元素
- 从集合中间位置新增元素
- 从集合尾部位置新增元素
测试结果 (花费时间):
- ArrayList > LinkedList
- ArrayList < LinkedList
- ArrayList < LinkedList
删除元素操作测试
与新增原理类似,不做演示
遍历元素操作测试
- for(;;) 循环
- 迭代器迭代循环
测试结果 (花费时间):
- ArrayList < LinkedList
- ArrayList ≈ LinkedList
2、jvm性能调优
a.如何优化垃圾回收机制
回收发生在哪里?
JVM 的内存区域中,程序计数器、虚拟机栈和本地方法栈这 3 个区域是线程私有的,随着线程的创建而创建,销毁而销毁;栈中的栈帧随着方法的进入和退出进行入栈和出栈操作,每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知的,因此这三个区域的内存分配和回收都具有确定性。
那么垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收。
对象在什么时候可以被回收?
一般一个对象不再被引用,就代表该对象可以被回收。
引用计数算法
这种算法是通过一个对象的引用计数器来判断该对象是否被引用了。每当对象被引用,引用计数器就会加 1;每当引用失效,计数器就会减 1。当对象的引用计数器的值为 0 时,就说明该对象不再被引用,可以被回收了。这里强调一点,虽然引用计数算法的实现简单,判断效率也很高,但它存在着对象之间相互循环引用的问题。
可达性分析算法
GC Roots 是该算法的基础,GC Roots 是所有对象的根对象,在 JVM 加载时,会创建一些普通对象引用正常对象。这些对象作为正常对象的起始点,在垃圾回收时,会从这些 GC Roots 开始向下搜索,当一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可用的。目前 HotSpot 虚拟机采用的就是这种算法。
GC 算法
标记-清除算法(Mark-Sweep)
优点:
不需要移动对象,简单高效
缺点:
标记-清除过程效率低,GC产生内存碎片
复制算法(Copying)
优点:
简单高效,不会产生内存碎片
缺点:
内存使用率低,有可能产生频繁复制
标记-整理算法(Mark-Compact)
优点:
综合了前两种算法的优点
缺点:
仍然需要移动局部对象
分代收集算法(Generational Collection)
1.部分垃圾回收器使用的模型
2.新生代 + 老年代 + 永久代(1.7)/元数据区(1.8)Metaspace
1.永久代 元数据 - Class
2.永久代需要指定大小限制,元数据可以设置,也可以不设置,无上限(受限于物理内存)
3.字符串常量 1.7 - 永久代,1.8 - 堆
3.新生代 = Eden + 2个suvivor区
1.YGC回收之后,大多数对象会被回收,或者的对象进入s0
2.再次YGC,活着的对象eden + s0 -> s1
3.再次YGC,eden + s1 -> s0
4.年龄足够,进入老年代
5.s区装不下 -> 老年代
4.老年代
1.老年代满了,Full GC
优点:
分区回收
缺点:
对长时间活跃对象的场景回收不明显,甚至起到反作用
GC 调优策略
降低 Minor GC 频率
通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此我们可以通过增大新生代空间来降低 Minor GC 的频率。
可能你会有这样的疑问,扩容 Eden 区虽然可以减少 Minor GC 的次数,但不会增加单次 Minor GC 的时间吗?如果单次 Minor GC 的时间增加,那也很难达到我们期待的优化效果呀。
我们知道,单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么正常情况下,Minor GC 的时间为 :T1+T2。
当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生 Minor GC 的时间为:两次扫描新生代,即 2T1。可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。通常在虚拟机中,复制对象的成本要远高于扫描成本。
降低 Full GC 的频率
通常情况下,由于堆内存空间不足或老年代对象太多,会触发 Full GC,频繁的 Full GC 会带来上下文切换,增加系统的性能开销。我们可以使用哪些方法来降低 Full GC 的频率呢?
减少创建大对象:在平常的业务场景中,我们习惯一次性从数据库中查询出一个大对象用于 web 端显示。例如,我之前碰到过一个一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老年代。这种大对象很容易产生较多的 Full GC。
我们可以将这种大对象拆解出来,首次只查询一些比较重要的字段,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段。
增大堆内存空间:在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率。
b.如何优化JVM内存分配
参考指标
GC 频率:高频的 FullGC 会给系统带来非常大的性能消耗,虽然 MinorGC 相对 FullGC 来说好了许多,但过多的 MinorGC 仍会给系统带来压力。
内存:这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存。首先我们要分析堆内存大小是否合适,其实是分析年轻代和老年代的比例是否合适。如果内存不足或分配不均匀,会增加 FullGC,严重的将导致 CPU 持续爆满,影响系统性能。
吞吐量:频繁的 FullGC 将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降。
延时:JVM 的 GC 持续时间也会影响到每次请求的响应时间。
具体调优方法
调整堆内存空间减少 FullGC:通过日志分析,如果发现堆内存被用完了,而且存在大量 FullGC,这意味着我们的堆内存严重不足,这个时候我们需要调大堆内存空间。
-Xms4g -Xmx4g //可以通过设置JVM参数解决
调整年轻代减少 MinorGC:通过调整堆内存大小,我们可以提升整体的吞吐量,降低响应时间。那还有优化空间吗?我们还可以将年轻代设置得大一些,从而减少一些 MinorGC。
-Xms4g -Xmx4g -Xmn3g //可以通过设置JVM参数解决
设置 Eden、Survivor 区比例:在 JVM 中,如果开启 AdaptiveSizePolicy,则每次 GC 后都会重新计算 Eden、From Survivor 和 To Survivor 区的大小,计算依据是 GC 过程中统计的 GC 时间、吞吐量、内存占用量。这个时候 SurvivorRatio 默认设置的比例会失效。
在 JDK1.8 中,默认是开启 AdaptiveSizePolicy 的,我们可以通过 -XX:-UseAdaptiveSizePolicy 关闭该项配置,或显示运行 -XX:SurvivorRatio=8 将 Eden、Survivor 的比例设置为 8:2。大部分新对象都是在 Eden 区创建的,我们可以固定 Eden 区的占用比例,来调优 JVM 的内存分配性能。
3、数据库性能优化
a.索引的优化
覆盖索引
根据索引查询字段,如果该字段包含在索引里,则不需要进行回表检索。
最左匹配原则
最左前缀可以是联合索引的最左N个字段,也可是最左M个字符。
索引下推
MYSQL5.6,引入了索引下推优化,可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。
b.什么时候需要分库分表
如何分表分库?
通常,分表分库分为垂直切分和水平切分两种。
垂直分库是指根据业务来分库,不同的业务使用不同的数据库。例如,订单和消费券在抢购业务中都存在着高并发,如果同时使用一个库,会占用一定的连接数,所以我们可以将数据库分为订单库和促销活动库。
而垂直分表则是指根据一张表中的字段,将一张表划分为两张表,其规则就是将一些不经常使用的字段拆分到另一张表中。例如,一张订单详情表有一百多个字段,显然这张表的字段太多了,一方面不方便我们开发维护,另一方面还可能引起跨页问题。这时我们就可以拆分该表字段,解决上述两个问题。
分表分库之后面临的问题
1、分布式事务问题
在提交订单时,除了创建订单之外,我们还需要扣除相应的库存。而订单表和库存表由于垂直分库,位于不同的库中,这时我们需要通过分布式事务来保证提交订单时的事务完整性。通常,我们解决分布式事务有两种通用的方式:两阶事务提交(2PC)以及补偿事务提交(TCC)。
通常有一些中间件已经帮我们封装好了这两种方式的实现,例如 Spring 实现的 JTA。
2、跨节点 JOIN 查询问题
用户在查询订单时,我们往往需要通过表连接获取到商品信息,而商品信息表可能在另外一个库中,这就涉及到了跨库 JOIN 查询。通常,我们会冗余表或冗余字段来优化跨库 JOIN 查询。
对于一些基础表,例如商品信息表,我们可以在每一个订单分库中复制一张基础表,避免跨库 JOIN 查询。而对于一两个字段的查询,我们也可以将少量字段冗余在表中,从而避免 JOIN 查询,也就避免了跨库 JOIN 查询。
3、全局主键 ID 问题
在分库分表后,主键将无法使用自增长来实现了,在不同的表中我们需要统一全局主键 ID。因此,我们需要单独设计全局主键,避免不同表和库中的主键重复问题。
使用 UUID 实现全局 ID 是最方便快捷的方式,即随机生成一个 32 位 16 进制数字,这种方式可以保证一个 UUID 的唯一性,水平扩展能力以及性能都比较高。但使用 UUID 最大的缺陷就是,它是一个比较长的字符串,连续性差,如果作为主键使用,性能相对来说会比较差。
我们也可以基于 Redis 分布式锁实现一个递增的主键 ID,这种方式可以保证主键是一个整数且有一定的连续性,但分布式锁存在一定的性能消耗。
我们还可以基于 Twitter 开源的分布式 ID 生产算法——snowflake 解决全局主键 ID 问题,snowflake 是通过分别截取时间、机器标识、顺序计数的位数组成一个 long 类型的主键 ID。这种算法可以满足每秒上万个全局 ID 生成,不仅性能好,而且低延时。
五、总结
系统性能调优,考验的不仅是我们的基础知识,还包括开发者的综合素质。首当其冲就是我们的实践能力了,善于动手去实践所学的知识点,不仅可以更深刻地理解其中的原理,还能在实践中发现更多的问题。