性能优化 - Java

122 阅读11分钟

纲要

  • 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 的创建分配内存地址情况如下:

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 查看上下文切换情况

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 可以查看到具体线程的上下文切换

pidstat

  • 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 的吞吐量:系统总运行时间 = 应用程序耗时 +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 序列化