纲要
- String 字符串优化
- ArrayList VS LinkedList
- HashMap
- 并发容器 List & Map
- 多线程
- JVM
- I/O
- 网络通信
String 字符串优化
String 类被 final 关键字修饰了,所以String 对象一旦被创建,就不能被改变
字符串拼接优化
- 变量字符串使用StringBuilder拼接
- 常量字符串则可用 "+" 拼接
for(int i=0; i<100; i++) {
str = str + i;
}
这段代码编译后,编译器会对它进行优化:
for(int i=0; i<1000; i++) {
str = (new StringBuilder(String.valueOf(str))).append(i).toString();
}
可见,循环中每次拼接都会创建一个StringBuilder实例,降低系统性能。
而常量字符串则可用 "+" 拼接
String str= "aa" + "bb" + "cc";
编译器优化后为:
String str= "aabbcc";
使用 String.intern 节省内存
每次赋值的时候使用 String 的 intern
方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用,节省内存
public static void main(String[] args) {
String str1= "aaa";
String str2= new String("aaa");
String str3= str2.intern();
String str4 = new String("aaa").intern();
if(str1==str2) {
System.out.println("str1==str2");
}
if(str1==str3) {
System.out.println("str1==str3");
}
if(str1==str4) {
System.out.println("str1==str4");
}
if(str2==str3) {
System.out.println("str2==str3");
}
if(str2==str4) {
System.out.println("str2==str4");
}
if(str3==str4) {
System.out.println("str3==str4");
}
}
以上这段代码的运行结果为:
str1==str3
str1==str4
str3==str4
String 的创建分配内存地址情况如下:
ArrayList VS LinkedList
经典问题:ArrayList 和 LinkedList 的区别?
常规回答:ArrayList 是基于数组实现,LinkedList 是基于链表实现。在新增、删除元素时,LinkedList 的效率高,在遍历的时候,ArrayList 的效率高。
对比 ArrayList 和 LinkedList 的区别,是要在一定场景条件下的。
ArrayList 知识点
- ArrayList 基于数组实现,初始化容量默认为10
- ArrayList 的数组是基于动态扩容的,并不是所有被分配的内存空间都存储了数据
- 新增元素
- 先判断容量大小,如果容量够大,就不用进行扩容,如果容量不够大,就会按照原来数组的 1.5 倍大小进行扩容,在扩容之后需要将数组复制到新的内存地址
- 添加元素到数组末尾时,如果容量够大,则无需扩容
- 添加元素到数组中间时,都会产生元素复制和移动过程
- 删除元素
- 和添加元素的场景类似,删除的元素越靠前,数组重组的开销就越大
- 遍历元素
-
基于数组实现,根据下标即可获得元素,非常高效
-
LinkedList 知识点
- LinkedList 基于双向链表数据结构实现,内存地址不连续,通过指针来定位节点地址,不支持随机快速访问
- 新增元素
- 不论添加到中间还是队尾,都只会改变前后元素的前后指针,指针将会指向添加的新元素,所以相比 ArrayList 的添加操作,LinkedList 的性能优势明显
- 删除元素
- 首先要通过循环找到要删除的元素,如果要删除的位置处于 List 的前半段,就从前往后找;若处于后半段,就从后往前找。这样无论要删除靠前或靠后的元素都是非常高效的,但如果要移除的元素在 List 的中间段,而 List 拥有大量元素,那效率相对来说会很低
- 遍历元素
- 不论是通过分前后半段来循环查找到对应的元素,都是非常低效的,可以使用 iterator 方式迭代循环,直接拿到元素,而不需要通过循环查找 List。
优化策略
- 根据使用场景,如果用 ArrayList ,则通过构造函数指定数组初始大小,减少扩容次数
- 在添加元素到列表中间时,ArrayList 和 LinkedList 效率都不高;添加元素到尾部时,在不扩容的情况下,ArrayList 的效率要高,LinkedList 中多了 new 对象以及变换指针指向对象的过程,效率会低于 ArrayList
- LinkedList 的 for 循环性能是最差的,而 ArrayList 的 for 循环性能是最好的。ArrayList 基于数组实现,实现了 RandomAccess 接口,可以实现快速随机访问。
- LinkedList 的迭代循环遍历和 ArrayList 的迭代循环遍历性能差不多,所以在遍历 LinkedList 时,切忌使用 for 循环遍历。
HashMap
HashMap 知识点
- 基于数组+链表实现
- JDK1.8 中,引入了红黑树来提升链表的查询效率。当链表的长度超过 8 后,会转为红黑树,红黑树的查询效率要比链表高
优化策略
-
结合场景来设置
初始容量
和加载因子
两个参数。在预知数据量的情况下,提前设置初始容量(初始容量 = 预知数据量 / 加载因子)
,可以减少 resize() 操作,提高 HashMap 的效率。 -
设置初始容量,一般得是 2 的整数次幂
-
当查询操作较为频繁时,我们可以适当地减少加载因子;如果对内存利用率要求比较高,我可以适当地增加加载因子。
并发容器 List & Map
List 并发容器
名称 | 特性 | 适用场景 |
---|---|---|
Vector | 强一致性 | 数据强一致 |
CopyOnWriteArrayList | 弱一致性,读操作无锁,写操作有锁,基于复制副本,写完成后再重新指向新副本 | 读多写少 |
Map 并发容器
名称 | 特性 | 适用场景 |
---|---|---|
HashTable | 强一致性 | 数据强一致 |
ConcurrentHashMap | 弱一致性,基于数组+链表+红黑树,CAS+Synchronized实现原子性,部分操作无锁 | 存取数据量小,读多写少,不要求强一致的高并发场景 |
ConcurrentSkipListMap | 弱一致性,基于跳跃表实现 | 存取数据量大,读写操作频繁,不要求强一致的高并发场景 |
多线程
在并发程序中,线程数并不是设置得越多,性能越好
- 线程数设置过少,系统资源可能不会得到充分利用
- 线程数设置过多,又可能带来资源的过度竞争,导致频繁上下文切换带来额外的系统开销。
时间片是 CPU 分配给每个线程执行的时间段,线程在获得的时间片内执行任务,一般为几十毫秒。在这么短的时间内线程互相切换,我们根本感觉不到,如果存在大量的频繁切换,性能自然会下降。
有段经典案例代码,比较串行和并行的执行速度:
public class ThreadDemo {
public static void main(String[] args) {
// 运行多线程
MultiThreadTester test1 = new MultiThreadTester();
test1.Start();
// 运行单线程
SerialTester test2 = new SerialTester();
test2.Start();
}
static class MultiThreadTester extends ThreadContextSwitchTester {
@Override
public void Start() {
long start = System.currentTimeMillis();
MyRunnable myRunnable1 = new MyRunnable();
Thread[] threads = new Thread[4];
// 创建多个线程
for (int i = 0; i < 4; i++) {
threads[i] = new Thread(myRunnable1);
threads[i].start();
}
for (int i = 0; i < 4; i++) {
try {
// 等待一起运行完
threads[i].join();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
long end = System.currentTimeMillis();
System.out.println("multi thread exce time: " + (end - start) + "s");
System.out.println("counter: " + counter);
}
// 创建一个实现 Runnable 的类
class MyRunnable implements Runnable {
public void run() {
while (counter < 100000000) {
synchronized (this) {
if(counter < 100000000) {
increaseCounter();
}
}
}
}
}
}
// 创建一个单线程
static class SerialTester extends ThreadContextSwitchTester{
@Override
public void Start() {
long start = System.currentTimeMillis();
for (long i = 0; i < count; i++) {
increaseCounter();
}
long end = System.currentTimeMillis();
System.out.println("serial exec time: " + (end - start) + "s");
System.out.println("counter: " + counter);
}
}
// 父类
static abstract class ThreadContextSwitchTester {
public static final int count = 100000000;
public volatile int counter = 0;
public int getCount() {
return this.counter;
}
public void increaseCounter() {
this.counter += 1;
}
public abstract void Start();
}
}
运行结果:
multi thread exce time: 3450s
counter: 100000000
serial exec time: 652s
counter: 100000000
对比结果可知:串行的执行速度比并行要快。 原因有两点:
- 使用 Synchronized ,导致资源竞争
- 多线程存在上下文切换
在 Linux 中可以用 vmstat
查看上下文切换情况
字段含义
- procs
r:等待运行的进程数
b:处于非中断睡眠状态的进程数- memory
swpd:虚拟内存使用情况
free:空闲的内存
buff:用来作为缓冲的内存数
cache:缓存大小- swap
si:从磁盘交换到内存的交换页数量
so:从内存交换到磁盘的交换页数量- io
bi:发送到块设备的块数
bo:从块设备接收到的块数- system
in:每秒中断数
cs
:每秒上下文切换次数
- cpu
us:用户 CPU 使用事件
sy:内核 CPU 系统使用时间
id:空闲时间
wa:等待 I/O 时间
st:运行虚拟机窃取的时间
pidstat
命令可以监测到具体线程的上下文切换:
安装方法: yum install sysstat
或者源码安装
pidstat -p [进程号] -w
可以查看到进程的上下文切换pidstat -p [进程号] -w -t
可以查看到具体线程的上下文切换
cswch/s
:每秒主动任务上下文切换数量nvcswch/s
:每秒被动任务上下文切换数量
多线程优化方法
- 减少锁的持有时间
- 降低锁的粒度
- 乐观锁替代竞争锁
- 合理设置线程池大小(以下只是建议,具体情况具体分析):
-
CPU 密集型任务
:线程数 设置为CPU核心数 +1多出来的一个线程是为了防止CPU中断导致任务暂停,CPU处于空闲状态,多出来的一个线程就可以充分利用这种情况下CPU的空闲时间
-
I/O 密集型任务
:线程数 设置为CPU核心数的2倍线程在处理 I/O 的时间段内不会占用 CPU ,这时就可以将 CPU 交出给其它线程,充分利用空闲时间
-
- 减少 Java 虚拟机的垃圾回收
- 垃圾回收机制可能会导致 stop-the-world 事件的发生,这是一种线程暂停行为,会导致上下文切换
JVM
GC算法
垃圾回收器
GC 性能衡量指标
吞吐量: 这里的吞吐量是指应用程序所花费的时间和系统总运行时间的比值。我们可以按照这个公式来计算 GC 的吞吐量:系统总运行时间 = 应用程序耗时 +GC 耗时。如果系统运行了 100 分钟,GC 耗时 1 分钟,则系统吞吐量为 99%。GC 的吞吐量一般不能低于 95%。
停顿时间: 指垃圾收集器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。
垃圾回收频率: 多久发生一次垃圾回收呢?通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。
查看 GC 日志
-XX:+PrintGC 输出 GC 日志
-XX:+PrintGCDetails 输出 GC 的详细日志
-XX:+PrintGCTimeStamps 输出 GC 的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径
GC 优化策略
-
降低 Young GC 频率
- 由于新生代空间较小,Eden 区很快被填满,会导致频繁 Young GC,可以通过增大新生代空间来降低 Young GC 的频率。
- 通常在虚拟机中,复制对象的成本要远高于扫描成本。
- 如果在堆中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Young GC 的时间。
- 如果堆中的短期对象很多,那么扩容新生代,单次 Young GC 时间不会显著增加。因此,单次 Young GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。
-
降低 Full GC 的频率(频繁的 Full GC 会带来上下文切换,增加系统的性能开销)
- 减少创建大对象
- 增大堆内存空间
-
结合业务场景选择合适的 GC 回收器
- 对响应时间有要求的场景,可以选择响应速度较快的 GC 回收器,CMS 或 G1
- 对系统吞吐量有要求时,可以选择 Parallel Scavenge
I/O
I/O 分为 磁盘 I/O 和 网络 I/O
传统 I/O 的性能问题
- 多次内存复制
- 从外设(磁盘、网络)到内核空间的复制,从内核空间到用户空间的复制,导致无用的复制和上下文切换
- 阻塞
- 阻塞式I/O在高并发场景下,会创建大量监听线程,如果线程没有数据就绪就会被挂起,进入阻塞状态,会不断地抢夺 CPU 资源,导致大量的 CPU 上下文切换
I/O 优化策略
- 使用缓冲区优化读写流操作
- 使用 DirectBuffer 减少内存复制
- DirectBuffer 是直接分配物理内存,将内存复制步骤简化为从内核空间复制到外部设备,减少了数据拷贝
- 避免阻塞
- 用 NIO 替代传统 I/O 操作,NIO 优化了内存复制以及阻塞导致的严重性能问题,通道和多路复用器这两个基本组件实现了 NIO 的非阻塞
网络通信
高并发下微服务架构的性能瓶颈
- Java 默认序列化
- 性能不好,不支持其他语言
- TCP 短连接
- 大量请求会带来大量连接的创建和销毁,占用系统资源
- 阻塞式网络 I/O
- 基于短连接实现的网络通信很容易产生 I/O 阻塞
网络优化策略
- 使用单一长连接
- 避免大量连接的创建和销毁带来的开销
- 优化 Socket 通信
- 使用非阻塞式I/O,如Netty使用了主从 Reactor 多线程模型、串行设计、零拷贝等
- 定制合适的报文格式
- 根据业务和架构设计,尽量实现报体小、满足功能、易解析等特性
- 使用高性能的序列化框架
- Java 默认序列化性能不好,如果只是单纯的数据对象传输,可以选择性能相对较好的 Protobuf 序列化