昨天,有人跟我聊起项目中对程序的优化,有一个特别有意思的话题《如何榨干一台机器的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 中的应用如下:

应用领域 示例
java.util.concurrent.atomic 包 AtomicInteger
AtomicLong
AtomicBoolean
AtomicReference
AtomicStampedReference
java.util.concurrent 包 ConcurrentHashMap
ConcurrentLinkedQueue
ConcurrentLinkedDeque
java.util.concurrent.locks 包 ReentrantLock
ReentrantReadWriteLock
Java内存模型 volatile关键字的应用
synchronized关键字的实现

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 类型 特点 适用场景 指定方式
Serial GC 单线程执行,Stop-the-World 暂停整个应用程序的执行来执行垃圾收集。适用于小型应用或客户端应用。 小型应用程序或客户端应用,对于资源受限的环境。 -XX:+UseSerialGC
Parallel GC 多线程执行,使用多个线程同时执行垃圾收集。Stop-the-World 暂停较短。适用于需要更高吞吐量的应用程序。 高吞吐量的服务器应用程序,如 Web 服务器或后端应用程序。 -XX:+UseParallelGC
CMS (Concurrent Mark-Sweep) GC 采用并发方式执行大部分垃圾收集工作,减少 Stop-the-World 时间。但会引入额外的 CPU 和内存开销。适用于需要更低的停顿时间的应用程序。 需要更低停顿时间的应用程序,如对延迟敏感的 Web 应用或交互式应用。 -XX:+UseConcMarkSweepGC
G1 (Garbage-First) GC 将堆分成多个区域,通过优先收集垃圾数量最多的区域来最大程度地减少停顿时间。适用于大堆,需要更可控和稳定的停顿时间的应用程序。 大型应用程序,需要更稳定和可预测的 GC 行为,如需要更可控的停顿时间,同时不愿意牺牲太多吞吐量的应用程序。 -XX:+UseG1GC

GC 的优化参数

参数 作用 使用前提 使用场景
-XX:MaxGCPauseMillis=<value> 设置期望的最大垃圾收集停顿时间(毫秒),用于调整收集器的目标停顿时间。 使用并行垃圾收集器(Parallel GC 或 G1 GC)时生效。 需要控制垃圾收集停顿时间的应用程序。
-XX:G1HeapRegionSize=<value> 设置 G1 堆区域的大小,影响 G1 收集器的工作表现。 使用 G1 垃圾收集器时生效。 需要调整 G1 收集器的性能和行为的应用程序。
-XX:ParallelGCThreads=<value> 设置并行垃圾收集器的线程数量,用于调整并行收集的线程数。 使用并行垃圾收集器(Parallel GC 或 Parallel Old GC)时生效。 需要调整并行垃圾收集器线程数的应用程序。
-XX:ConcGCThreads=<value> 设置 CMS 垃圾收集器的并发线程数,影响 CMS 收集器的并发能力。 使用 CMS 垃圾收集器时生效。 需要调整 CMS 垃圾收集器并发线程数的应用程序。
-XX:G1NewSizePercent=<value> 设置新生代空间占整个堆空间的百分比,影响 G1 收集器的新生代大小。 使用 G1 垃圾收集器时生效。 需要调整 G1 收集器新生代大小的应用程序。
-XX:G1MaxNewSizePercent= 设置最大新生代空间占整个堆空间的百分比,影响 G1 收集器的新生代最大大小。 使用 G1 垃圾收集器时生效。 需要调整 G1 收集器新生代最大大小的应用程序。
-XX:InitiatingHeapOccupancyPercent=<value> 设置 G1 收集器开始执行混合收集的堆占用百分比。 使用 G1 垃圾收集器时生效。 需要调整 G1 收集器开始执行混合收集的堆占用百分比的应用程序。
-XX:CMSInitiatingOccupancyFraction=<value> 设置 CMS 收集器开始执行垃圾收集的堆占用百分比。 使用 CMS 垃圾收集器时生效。 需要调整 CMS 收集器开始执行垃圾收集的堆占用百分比的应用程序。
-XX:MaxTenuringThreshold=<value> 设置对象晋升到老年代的年龄阈值,影响对象在新生代和老年代之间的迁移行为。 通常在需要调整对象晋升行为时使用,如调整新生代对象晋升到老年代的年龄阈值。 需要调整对象晋升行为的应用程序,如减少对象晋升到老年代的频率以减少老年代垃圾收集的负载。
-XX:+PrintGCDetails 打印 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 越大,对象可以在年轻态呆更多时间。