昨天,有人跟我聊起项目中对程序的优化,有一个特别有意思的话题《如何榨干一台机器的 CPU》
现在的市面上,多核 CPU 是主流,有了多核的加持,可以更加有效的发挥硬件的能力,基于 Java 程序,我们究竟该如何更加有效的应用多核的能力?我个人经验来讲,主要考虑一下几个方面:
-
并行执行任务
-
减少共享数据的写操作
-
采用合适的方式处理竞争资源
-
减少数据拷贝次数
-
合适的 GC
接下来详细说明。
1. 并行执行任务
合理利用多线程执行任务,可以有效的发挥 CPU 的多核性能。由于超线程技术的存在,实际上 CPU 可以并行执行的线程数量通常是物理核心数量的 2 倍。
我们都知道,在计算机中,进程是操作系统资源(内存、显卡、磁盘)分配的最小单位。线程是 CPU 执行调度的最小单位。
因此,实现并行计算的方式大体上有三种:多进程、多线程、多进程 + 多线程。具体采用哪种方式,就需要实际情况实际分析了。整体指导方针是:如果多线程可以解决,就不要尝试引入多进程。因为每个进程之间是独立的,多进程任务难免会涉及到进程之间通信,而进程之间的协调与通信通常会比较复杂。容易为程序引入额外的复杂度,得不偿失。
2. 减少共享数据的写操作
深入到线程中,每个线程都有自己的内存空间,在这个内存中,线程可以随意进行读写。因此多线程任务中,提高效率的优化手段之一就是:
尽量避免多个线程共同操作共享资源,如果条件允许,尽量采用以空间换时间的方式,将数据复制多份保存在每个线程单独的内存空间中。
如果必须存在共享内存的操作,我们的措施通常是,尽量减少共享数据的写操作,在共享内存中,多个线程的读操作是不存在资源的竞争的。一旦涉及到写共享内存,通常会使用 volatile 关键字保证内存数据对多个线程的可见性,这种情况下就不可避免的要涉及到插入内存屏障指令,用来保证处理器对指令的执行顺序不会打乱。相比不存在内存屏障的操作,性能会有所下降。
因此,需要尽量减少多个线程对共享内存的写操作。具体的方案是:
-
通过业务逻辑控制,在程序设计之初,排除掉共享数据的方案
-
在每个线程内部创建单独的对象,互不影响
-
使用 ThreadLocal 生成线程的本地对象副本
3. 采用合适的方式处理竞争资源
多线程任务中,涉及到资源竞争的部分,通常都需要采用对应的措施来保证资源的一致性。常见的解决方案有两种:
-
对资源加线程锁
-
采用乐观策略实现无锁操作(CAS)
线程锁的使用:
使用线程锁来保证资源的一致性是由来已久的一种非常简单便捷的方法。这种操作可以粗暴的控制多个线程对资源的访问,所以在处理多线程资源竞争关系的时候,我们通常会优先想到加锁的方式。
为了提高执行性能,通常会采用轻量级锁来代替重量级锁,在 Java 1.5 中 synchronize 是一个重量级锁,是相对低效率的;相比之下使用 Lock 对象的性能更高一些。但是这种情况到了 Java 1.6 发生了很大的变化,由于官方对 synchronize 引入了适应自旋、锁消除、轻量级锁、偏向锁等优化手段, synchronize 与 Lock 在性能上不存在什么差距。所以如果你使用高于 Java 1.6 的版本,请放心大胆的使用 synchronize 。
无锁操作(CAS):
对于传统的加锁操作,我们通常认为是悲观策略。相对于悲观策略,我们还有一个乐观策略可以选择。乐观策略认为不会存在资源不一致的情况,假如出现了,就再试一次。
实际上在 Java 中,一些锁的实现也利用了 CAS,体现在 Java 中的应用如下:
4. 减少数据拷贝次数
这里的数据拷贝,更多指的是虚拟机或者操作系统层面的数据拷贝。我们都知道,在程序的运行过程中,通常会涉及到 IO 操作,不管是磁盘 IO 、内存 IO、网络 IO、标准 IO 等。所以基于 IO 的优化,通常也是一个非常有效的方向。
具体的优化手段有:
-
尽量避免磁盘 IO,磁盘 IO 受限于磁盘的读写速度,相对较慢,尽量避免
-
批量处理数组,尽量使用 System.arraycopy() 这样的批量接口,而不是通过遍历的方式自行操作
-
启用大页内存,操作系统默认页大小是 4K,如果你的 heap 是 4G,就需要执行 1024*1024 次分配操作。 所以最好根据实际情况调整页大小。具体的调整方式需要从 JVM 和 操作系统两方面着手。JVM 中可以通过
-XX:+UseLargePages
进行配置,操作系统中修改比较麻烦,不在赘述,请自行解决。
5. 合适的 GC
所谓合适的 GC 主要体现在两方面
-
针对不同的场景选择合适的 GC 策略
-
针对 GC 配置合适的优化参数
GC 的选择
GC 的优化参数
配置样例:高吞吐 GC 配置
对于高吞吐量,在年轻态可以使用 Parallel Scavenge,年老态可以使用 Parallel Old 垃圾收集器。
-
使用 -XX:+UseParallelOldGC 开启
-
可以将 -XX:ParallelGCThreads 根据 CPU 的个数进行调整。可以是 CPU 数的 1/2 或者 5/8
配置样例:低延迟 GC 配置
对于低延迟的应用,在年轻态可以使用 ParNew,年老态可以使用 CMS 垃圾收集器。
-
可以使用 -XX:+UseConcMarkSweepGC 和 -XX:+UseParNewGC 打开。
-
可以将 -XX:ParallelGCThreads 根据 CPU 的个数进行调整。可以是 CPU 数的 1/2 或者 5/8
-
可以调整 -XX:MaxTenuringThreshold (晋升年老代年龄) 调高,默认是 15. 这样可以减少年老代 GC 的压力
-
可以 -XX:TargetSurvivorRatio,调整 Survivor 的占用比率。默认 50%. 调高可以提供 Survivor 区的利用率
-
可以调整 -XX:SurvivorRatio,调整 Eden 和 Survivor 的比重。默认是 8. 这个比重越小,Survivor 越大,对象可以在年轻态呆更多时间。