Java性能优化之Java应用性能分析技巧(四)

性能优化机会

  • 使用更高效的算法
  • 减少锁争用
  • 为算法生成更有效率的代码

场景1

某应用程序监控到FileOutPutSteam.write(int) 方法上消耗了33秒的系统CPU时间,在_write方法上消耗了11秒,分别占总CPU使用率的65%和22.5%。

理想情况下占用系统CPU应该为0%,但是,对于大多数的应用程序而言,特别是需要进行I/O的情况,这一目标很难实现,因为I/O操作需要调用系统函数。对于需要进行I/O的应用程序而言,调优的目标是减少IO系统调用的频率,例如对数据进行缓存,I/0操作时以大数据块的方式批量读取或写入。

分析之后得知,FileOutPutStream writer = new FileOutputStream(currentFileName)在初始化的时候没有做任何缓存,而用BufferedOutputStream封装FileOutputStream对象,就能简洁、高效地解决这个问题,

//FileOutPutStream writer = new FileOutputStream(currentFileName);
private BufferedOutputStream writer = new BufferedOutputStream(new FileOutputStream(currentFileName));

创建 Bufferedoutputstream时,可以指定一个可选的缓存大小。

改进之后系统态CPU时间降低到6秒,整个应用程序的性能提升了约10%。

对有大量网络IO的应用程序,降低系统态CPU使用的另一个策略是使用 Java NIO的非阻塞数据结构。 Java NIO在Java1.4.2中引人,Java5及Java6中有多个能大幅提高程序运行时性能的改进加入。 Java NIO的非阻塞数据结构能够让应用程序在一次网络IO(读或写)操作中读写更多的数据。我们知道每次网络IO操作最终都会触发系统调用,导致系统态CPU的消耗。与 Java NIO的阻塞式数据结构或者更传统的 Java SE阻塞式数据结构(如java.net. Socket)相比, Java NIO的非阻塞数据结构面临的最大挑战是编程的难度较大。只要不超过操作系统的限制,在 Java NIO的非阻塞输出操作中,可以随心所欲地写入任意数量的数据。但这需要检査输出操作的返回值以确定你要求写入的数据的确已经被写人了。只要有数据可读,一次 Java NIO非阻塞输入操作中可以读取任意数量的数据,但是,你同样需要检査最终读取了多少数据。你需要实现复杂的程序逻辑,处理读取协议数据单元的一部分或者读取多个协议数据单元的情况。换句话说,一次读操作读到的数据可能不足以构造有意义的协议数据单元或消息。这一问题在阻塞式IO中很简单,只需要等待,直到获取足够的数据构造完整的协议数据单元或消息即可。实际应用中,是否需要转向使用非阻塞IO操作则要取决于应用程序对性能的需求。如果你想要利用非阻塞 Java NIO带来的性能提升,应该考虑使用通用的 Java NIO框架,尽量减少代码迁移的代价。目前比较流行的JavaNIO框架有 Grizzly(htps!/ grizzly. dev. java.net)和 Apache的MINA(htp:/mina. apache.org)以及,Netty.

锁竞争

早期JVM的实现中,对 Java monitor对象的操作往往直接委托给操作系统的 monitor对象或者互斥原语。这种设计导致一日Java应用程序发生锁竞争,系统态CPU使用就很高,因为操作系统的互斥原语会触发系统调用。现代JVM对 monitor对象的操作大多通过JVM自身的用户态代码来实现,不再直接把这些操作委托给操作系统原语。这种改变意味着使用现代JⅣVM后,即使Java应用程序出现锁竞争也不一定会使用系统态CPU。应用程序尝试获取锁时、首先使用用户态CPU,直到最后才委托给操作系统原语使用系统态CPU,只有出现了非常严重的锁竞争时才会发生系统态CPU高的情况。运行在现代JyM上的应用程序发生锁竞争时,症状常常是扩展性不好,无法利用更多的工作线程和CPU处理更多的用户。找到锁党争的源头,即在源代码中找到哪些 Java monitor对象触发了竞争,并设法减少这些竞争是一件极具挑战的工。作。

查找并定位竟争频繁的 Java monitor对象是 Oracle Solaris Performance Analvzerf的长项。一旦Performance Analyzer!收集好性能数据,査找竞争瓶就非常容易了

Java monitor对象和锁在 Performance Analyzerl中被当做应用程序性能数据的组成部分。你可以通过设置 Performance Analyzer,记录应用程序中的Java方法使用了哪地 monitor对象及锁的信息。

场景2

项目地址 https://codechina.csdn.net/jianjun_fei/performance-analyzer.git

一个简单的程序,开启了八个(CPU核数)线程进行集合操作,一共操作200w次 ,使用HashMap数据结果保存200万条虚拟的税单记录,还需要对其进行更新,由于是个多线程程序需要对HashMap进行同步,使用Collections.synchronizedMap(),或者使用HashTable。

代码执行结果如下:

在这里插入图片描述
可以看到,每秒能够执行的数量在35w左右。
并且在运行的时候可以看到好多线程是block的
在这里插入图片描述

使用Performance Analyzer 收集程序性能数据,发现程序存在严重的锁竞争,在get()操作消耗了总锁时间的59%,第一个反应都是要将其迁移到ConcurrentHashmap。使用 Concurrenthashmap替代同步 Hashmap的最终运行结果表明,CPU使用率增加了92%。换句话说,之前采用同步 Hashmap的实现方式最多只能利用8%的系统CPU Concurrenthashmap却可以达到100%的CPU利用率,而让步式的上下文切换则从几千减少到100以下。采用 Concurrenthashmap实现后,每秒能进行的操作数是原先 Hashmap的版本的两倍,从615000增加到1315000.然而,与之前8%的CPU利用率相比,100%的CPU利用率只产生了2倍的性能提升,这个结果并不理想。

如下图,改成ConcurrentHashMap之后,CUP直接飙升至100%,而之前只有20%。
在这里插入图片描述
而每秒执行数却只有九千左右,也就是说在CUP使用率提升了几倍的情况下,效率值提升了两倍,这个结果并不理想。
在这里插入图片描述

仔细一想也不难想想,ConcurrentHashMap里面有是使用CAS来保证原子性的,所以在高并发下,CPU很多的时间都耗在了CAS的自循环上面了,从而体现的是性能值提高了一倍。 CAS解释,文章也解释了CAS的弊端。

分析一下本质的原因,我们发现代码中的nextInt()方法是一直在被调用的。
在这里插入图片描述
而继续追溯下去发现果然nextInt()方法使用了CAS操作,导致CUP大量的时间消耗在了nextInt()方法上。
在这里插入图片描述
该问题的解决方案是为每个线程生成自己的 Random对象实例,这样各个线程就不会同时更新 Atomiclongl的同一个内存地址。就这个程序而言,为每个线程添加自己的线程本地 Random对象实例不会改变程序的功能,通过ThreadLocal可以非常容易地实现。

将原先的Random对象放到ThreadLocal中:
在这里插入图片描述
在这里插入图片描述

运行结果如下:
可以看到性能是有大大的提升了,每秒执行了170w,虽然CUP的使用率也是很高,到时换到的却是6倍的效率。
在这里插入图片描述
你可能会问,如果我们使用最初版本的实现,即使用同步 HashMap,采用 ThreadLocal方法能否实现同样的性能提升呢?这是一个值得探讨的好问题。实际的结果是,在同步 Hashmap版本上应用同样的方法,同步 HashMap版本的程序性能提升并不大,CPU使用情况也没有改善,改动之后每秒操作数从35W增加到了38W左右仔细想想,其实这也是意料之中的事情。我们再回顾下性能数据,初始版本中使用同步 HashMap持有热锁的方法是同步 HashMap.get(O方法。换句话说,在使用 ConcurrentHashMapf的最初实现中,同步 HashMap.get()持有的锁掩盖了 Random.nextInt()中的CAS问题。
在这里插入图片描述
在这个例子中,我们能吸取的教训之一是:原子并发数据结构并不是不可触碰的圣杯,它依赖CAS操作,而CAS一般也会利用某种同步机制。如果存在对原子变量高度党争的情况,即使采用了并发技术或lock-fec数据结构也不能避免糟糕的性能或伸缩性

Java SE中有很多原子并发数据结构,在适当的场合,它们都是处理并发的不错选择。但是当合适的数据结构不存在时,还有另一个选择,那就是通过恰当的方法,合理设计应用程序,尽量降低多线程访问同一数据的频率、缩小并发访问的范同。换句话说,通过优化程序设计,最大程度地减少数据的同步访问(区同、大小或数据量)。为了说明这一点,我们假设Java中不存在ConcurrentHashMap,即只有同步 HashMap数据结构可用的场景。根据前面介绍的思想,我们可以将纳税人数据库切分到多个 HashMap结构中分別存储,缩小数据锁的范围。一种方法是按照纳税人所属的州划分数据库,每个州的数据用一个 HashMap存放。采用这种方式可以构造两级Map,第一层Map可以找到50个州中的一个。由于第一层Map中包含50个州的映射关系,不需额外添加或删除新的元素,所以不需要进行同步。但是基于州的第二级Map需要同步访问,因为存在添加、删除及更新纳税人记录的操作。换句话说,纳税人数据库将如下所示:

这个思想就是1.8的ConcurrentHashMap中分段锁的思想,粒度细化,也同样类似于表锁变行锁
在这里插入图片描述
运行如下:
可以看到速度在175W个左右,比起上面的使用ConcurrentHashMap,采用分段的结构性能提升了一点,效果并不是很明显

因为是第一段只是获取到哪段数据,所以不存在添加修改操作,就不需要加锁,而里面的Map使用了同步Map,这样做的主要考虑是为了减少等待锁时候所消耗的CPU时间,而性能相比ConcurrentHashMap没相差多少,是因为,在ConcurrentHashMap这个版本的时候,CPU等待锁的时间已经很少了,所以给我们直观的感受就是性能并没有提升多少。
在这里插入图片描述

而CUP的使用率类似于ConcurrentHashMap一样,依旧是很高,
思考一下,可以知道,八个线程同样是一直在运作的,所以CUP一直下不来。
在这里插入图片描述

StringBuffer和StringBuilder大小的调整

如果 StringBuilder或 StringBuffer扩大到超过了底层数据的存储能力时,就需要为它分配新的数组, OpenJDK的实现( Java Hotsport Java6 JDK/JRE中使用的方式)中是按2倍原StringBuilder/ Stringbuffer的大小为它分配新的数组,老字符数组中的元素会被复制到新数组中,老数组会被废弃。采用这种方式实现的 StringBuilder和 StringBuffer如下所示

public AbstractStringBuilder append(String str) {
        if (str == null)
            return appendNull();
        int len = str.length();
        ensureCapacityInternal(count + len);
        str.getChars(0, len, value, count);
        count += len;
        return this;
    }
private void ensureCapacityInternal(int minimumCapacity) {
        // overflow-conscious code
        if (minimumCapacity - value.length > 0) {
            value = Arrays.copyOf(value,
                    newCapacity(minimumCapacity));
        }
    }

在这里插入图片描述
Collection类的某些具体实现由于底层数据存储基于数组,随着元素数量的增加,调整大小的代价很大,典型的代表如 Arraylist、 Vector、 HashMap及 ConcurrentHashMap。另一些Collection类的实现,如 LinkedList或 TreeMap,常使用一个或多个对象引用将collection类管理的各个元素串接起来。这些 Collection类实现中的前者,使用数组作为底层的数据存储随着collection元素增长到某个上限,需要调整其大小时很容易出现性能问题。虽然这些Collection类也含有构造函数,可以接收优化的参数值作为collection的大小,但构造函数并不经常使用;或者应用程序中提供的大小并没有针对该collecton类做优化。

以 StringBuilder或 StringBuffer为例,使用数组作为数据存储的 Java Collection类需要消耗额外的CPU周期分配新数组,将老的元素从数组中复制到新数组中,在将来的某个时刻还需要对数组进行垃圾收集。此外,调整大小还会影响Collection类字段的访问时间及解引用字段的时间,因为作为一个新的底层数据存储(典型的即为数组),它可能被分配到JVM堆中的某个位置,与Collection类中其他的字段及对象引用不在同一块内存存儲。 Collection类发生大小调整后,访问调格后的字段可能会导致CPU高速缓存未命中,这是由现代JVM在内存中分配对象的方式导致的,尤其是对象在内存中如何分布决定的。不同的Java虚拟机实现中,对象及其字段在内存中的分布可能有所不同。然而,一般来说,由于对象及其字段常常需要同时引用,将对象及其字段尽可能放在内存中相邻的位置能够减少CPU高速缓存术命中。由此可见,Collection类大小调整的影响(这一点同样适用于 StringBuffer或 StringBuilder)已经远远不止大小调整所额外消耗的CPU指令,还会对JVM的内存管理器造成影响,由于改变了内存中 Collection类字段相对于对象实例的布局,字段的访问时问将会变长。

定位 Java Collection类大小调整的方法与之前介绍过的定位 StringBuilder或StringBuffer大小调整的方法类似,使用性能分析器(如 Netbeans Profiler)收集堆或内存的性能数据。査看该 Java Collection类的源代码可以帮助定位进行」大小调整的方法名。

我们接着分析虚构的纳税人程序,纳税人记录先以纳税人居住的州作关键字填入多个HashMap中,再以纳税人的ID作索引插入到二级HashMap中。

可以看到这边用的是默认大小,也就是16 ,而我们最后states的大小最终回事50个,因为默认负载因子是0.75,所以会发生三次扩容,由于需要对Entry[]中的元素重建散列,调整HashMap消耗的CPU指令数要比调整StringBuilder和StringBuffer多一些。

在这里插入图片描述

下面是调整了之后的初始容量大小,性能大概能提升5%左右。
在这里插入图片描述

增加并行性

细心的同学可能会发现在初始化数据库的时候用的是单线程,并没有发挥多核的优势。
在这里插入图片描述

同样的使用多线程来执行初始化操作。
在这里插入图片描述
代码地址

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小丸子呢

致力于源码分析,期待您的激励

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值