`

读书笔记:《分布式JAVA应用 基础与实践》 第五章 性能调优(二)

阅读更多

5.2 调优

找出性能瓶颈后,接下来就是调优,调优通常可从硬件,操作系统、 JVM 以及程序四方面着手,硬件和操作系统不是本书的重点。下面主要介绍 JVM 及程序方面的一些调优。

5.2.1 JVM 调优

JVM 调优主要是内存管理方面的调优,包括各个代的大小、 GC 策略等。

  1. 代大小的调优

通常 minor GC 会远快于 Full GC, 各个代大小的设置直接决定了 minor GC Full GC 触发的时机。在代的大小调优上,最关健的参数有:

-Xms –Xmx 决定了 JVM Heap 所能使用的最大空间,生产环境下通常设置为相同值,避免运行时刻动态扩展 JVM 内存空间

-Xmn 决定新生代空间,新生代中 Eden S0 S1 三个区域的比率可通过 -XX:SurvivorRatio 来控制

-XX MaxTemuringThreshold 控制对象在经历过多少次 Minor GC 后才转入旧生代,只有在串行 GC 时有效,其它 GC 方式时由 Sun JDK 自行决定。

  1. GC 策略的调优

作者对并行 GC 和并发 GC (串行 GC 性能太差,不考虑)的测试结果, CMSCompactatFullCollection 明显要比 ParallelGC 效果好,对应用造成的停顿时间短。

 

目前内存管理方面, JVM 自身已经做得非常不错了,因此如果不是有确切的 GC 造成性能低的理由,就没必要做过多细节方面的调优。多数情况下只须选择 GC 策略并设置 JVM Heap 的大小即可。此外,应关注 JDK 的新版本对性能方面的提升。

5.2.2 程序调优

CPU 消耗严重的解决方法

1. CPU us

根据之前的分析, CPU us 高的原因主要是执行线程无任何挂起动作,一直执行,导致 CPU 没有机会去调度执行其它的线程,造成线程饿死。对这种情况,常用方法是对这种线程的动作增加 Thread.sleep ,释放 CPU 的执行权,降低消耗。

实际的 JAVA 应用有很多类似场景,如多线程的任务执行管理器,它通常通过扫描任务集合列表来执行任务。

另一种经典的场景是状态的扫描。例如某线程要等其它线程改变了值后才可继续执行,此时应采用 wait/notify 机制。

其它如循环次数太多,正则,计算等造成的 CPU us 过高的状况,则要结合业务场景来调优。

对于 GC 频繁造成的 CPU us 过高的现象,则要通过 JVM 调优或程序调优,降低 GC 执行次数。

2. CPU sy

主要原因是线程的运行状态要经常切换,此种情况,最简单的办法是减少线程数。

另一个重要原因是线程之间锁竞争激烈,锁竞争更有可能造成系统资源消耗不多,但系统性能不足的现象,降低锁竞争的调优技巧将在后续章节进行讲述。

除以上两种情况外,对于分布式 JAVA 应用而言,还有一种典型现象是应用中有较多的网络 IO 或确实需要一些锁竞争机制,例如数据库连接池,但为了能够支撑高的并发量,在 JAVA 应用中又只能借助启动更多的线程来支撑,这种情况下当并发量达到一定程度后,可能会造成 CPU sy 高的现象,可采用协程 (Coroutine) 来解决。在 JAVA 中目前主要可用现实现协程的框架为 Kilim ,目前 Kilim 的版本仅为 0.7 ,且没有商用的实际例子,要使用还需慎重。

JDK7 也有一个支持协程方式的实现,感兴趣的读者可进一步阅读。

文件 IO 消耗严重的解决方法

从程序角度而言, 要原因是多个线程在写大量的数据到同一文件,导致文件很快变得很大,导致写入速度越来越慢,并造成各线程激烈争抢文件锁。对这类情况,常用的调优办法有如下几种

  1. 异步写文件

如写日志,可用 log4j 提供的 AsyncAppender

  1. 批量读写
  2. 限流

还是用写日志作例子,当出现大量异常时,会出现所有的线程都在执行 log.error(…). 此时可采取一个简单的策略为统计一段时间内的 log.error 的执行频率。当超过这个频率时,一段时间内不再写 log ,或塞入一个队列后缓慢地写。

  1. 限制文件大小

此外,应尽可能地采用缓冲区等方式来读取文件内容,避免不断与操作系统交互,具体可参见 SUN 官方的关于 JAVA 文件 IO 优化的文章。

网络 IO 消耗严重的解决方法

主要原因是同时需要发送或接收的包太多,常用的调优方法为进行限流,限制发送 packet 的频率,从而在网络 IO 可接受的情况下来发送 packet.

对于内存消耗严重的情况

JVM Heap 内存消耗严重时,常用的程序调优方法有:

1. 释放不必要的引用

内存消耗严重的情况中最典型的一种现象是代码中持有了不需要的对象引用,造成对象无法被 GC ,最典型的一个例子是在利用线程的情况下使用 ThreadLocal, 由于线程复用, ThreadLocal 中存放的对象如未做主动释放的话则不会被 GC

  1. 使用对象缓存池
  1. 采用合理的缓存失效算法
  2. 合理使用 SoftReference WeakReference

对于占据内存但又不是必须存在的对象,例如缓存对象,也可以基于 SoftReference WeakReference 的方式来进行缓存,前者会在内存不够用的时候回收,后者会在 Full GC 的时候回收。

 

5.2.3 对于资源消耗不多,但程序执行慢的情况

       对于分布式 JAVA 应用而言,造成这种情况的主要原因通常有锁竞争激烈及未充分利用硬件资源两种情况

锁竞争激烈

1.       使用并发包的类

并发包中的类多数都采用了 lock-free nonblocking 算法,减少了多线程情况下资源的锁竞争。如并发包中的类无法满足需求时,可参考学习一些 nonblocking 算法来自行实现。 Nonblocking 算法的机制,为基于 CAS 来做到无需 lock 就可实现资源一致性的保证,主要的实现 nonblocking 算法有:

2.       使用 Treiber 算法

Treiber 算法主要用于实现 Stack ,基于 Ttrber 算法实现的无阻塞的 Stack 代码如下:

public class ConcurrentStack<E> {

    AtomicReference<Node<E>> head = new AtomicReference<Node<E>>();

 

    public void push(E item) {

        Node<E> newHead = new Node<E>(item);

        Node<E> oldHead;

        do {

            oldHead = head.get();

            newHead.next = oldHead;

        } while (!head.compareAndSet(oldHead, newHead));

    }

 

    public E pop() {

        Node<E> oldHead;

         Node<E> newHead;

        do {

            oldHead = head.get();

            if (oldHead == null)

                return null;

            newHead = oldHead.next;

        } while (!head.compareAndSet(oldHead,newHead));

        return oldHead.item;

     }

 

    static class Node<E> {

        final E item;

        Node<E> next;

 

        public Node(E item) { this.item = item; }

    }

}

以上代码摘自IBM DeveloperWorks 网站上的Java theory and practice 系统文章,由于StackLIFO 方式,因此不能采取类似LinkedBlockingQueue 中两把锁的机制。这里巧妙地采用AtomicReference 来实现了无阻塞的pushpop ,在push 时基于AtomicReferenceCAS 方法来比较目前的head 是否一致。如不一致,说明有其它线程改动了,如有改动则继续循环,直到一致,才修改head 元素,在pop 时采用同样的方式进行操作。

3.       使用 Michael-Scott 非阻塞队列算法

Treiber 算法类似,也是基于CAS 以及AtomicReference 来实现队列的非阻塞操作,ConcurrentLinkedQueue 就是典型的基于Michael-Scott 实现的非阻塞队列。

从上面两种算法来看,基于CASAtomicReference 来实现无阻塞算法是不错的选择。但值得注意的是,由于CAS 是基于不断的循环比较来保证资源一致性的,对于冲突较多的应用场景而言,CAS 会带来更高的CPU 消耗,因此不一定采用CAS 实现无阻塞的就一定比采用Lock 方式的性能好。业界还有一些无阻塞算法的改进,如MCASWSTM20

4.       尽可能少用锁

尽可能让锁仅在需要的地方出现,通常没必要对整个方法加锁,而只对需要控制的资源做加锁操作。尽可能让锁最小化,例如一个操作中需要保护的资源只有HashMap, 那么在加锁时则可只synchronized(map) ,而没必要synchronized(this).

5.       拆分锁

把独占锁拆分为多把锁,常见的有读写锁拆分及类似ConcurrentHashMap 中默认拆分为16 把锁的方法。需要注意的是采用拆分锁后,全局性质的操作会变得比较复杂,例如ConcurrentHashMapsize 操作。

6.       去除读写操作的互斥锁

在修改时加锁,并复制对象进行修改,修改完后切换对象的引用,而读取操作时则不加锁,这种方式称为CopyOnWriteCopyOnWriteArrayList 就是其中的典型实现。这种做法好处是可以明显提升读的性能,适用读多写少的场合,坏处是造成更多的内存消耗。

 

未充分使用硬件资源

1. 未充分使用CPU

对于 JAVA 应用而言,通常原因就是在能并行处理的场景中未使用足够的线程。

另外,单线程的计算,也可以拆分为多线程来分别计算,最后合并结果, JDK7 中的 fork-join 框架可以给以上场景提供一个好的支撑方法

2. 未充分使用内存

       如数据的缓存、耗时资源的缓存(如数据库连接,网络连接)、页面片段的缓存等。

 

对于数据量大造成的性能不足,在第 7 章“构建可伸缩的系统”中提供了一些优化方案。从纯粹的软件调优角度来讲,充分而不过分使用硬件资源,合理调整 JVM 以及合理使用 JDK 包是调优的三大有效原则,调优没有银弹,结合系统现状和多尝试不同的调优策略是找到合适的调优方法的唯一途径。

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics