《java性能优化权威指南》---- 第6章:Java应用性能分析技巧

一、性能优化机会

大多数的Java性能优化都可以归纳到下面几类。

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

二、系统或内核态CPU使用

在这里插入图片描述
图6-2展示了一个应用程序的性能数据,它的系统(内核) CPU使用率很高。我们看到该应用程序在java.io.FileOutputSteam.write(int)方法上消耗了大约33.5秒的系统CPU时间,在_write()方法上消耗了大约11.6秒,分别占总CPU使用率的65%和22.5%。

图6-2的示例中,你可能已经注意到,方法java.io.FileOutputStream.write(int)_write()这样的文件写(输出)操作消耗了大量的时间。为了判断程序是否对写操作进行了缓存,你可以使用Callers-Callees 选项卡,沿着函数调用栈进行分析,查看哪些方法调用了。根据Callers-Callees追溯85.18%源于ExtOutputStream.write(int),14.82%源于OutImpl.outc(int).。

ExtOutputStream. write(int)的实现如下:

public void write(int b) throws IOException {
	writer.write((byte)b);
}

writer在ExtOutputStream中被声明为 FileOutputSteam:

private FileOutputStream writer;

并且在初始化时没有做任何的缓存:

 writer = new FileOutputStream(currentFileName);

通过上面这些分析我们就找到了这个问题的优化方法:使用BufferedoutputSteam对写入ExtOutputSteamFileOutputSteam的数据进行缓存。下面是解决该问题的变更列表:

// 将writer的类型由FileOutputStream变更为BufferedOutputStream
// private FileOutputStream writer;
private BufferedOutputStream writer;

这之后在初始化时以BufferedoutputStream对FileOutputStream进行封装:

// 初始化BufferedOutputStream
// writer = new FileOutputStream(currentFileName);
writer= new BufferedOutputStream(new FileQutputStream(currentFileName));

改动之后,输出操作写入到BufferedoutputStream而不是FileOutputStream,更妙的是这个改动不需要修改ExtOutputStream.write(int b)方法,因为BufferOutputStream中原来就含有一个write()方法可以缓存写入的数据。ExtOutputStream.write (int b)方法的代码如下:

public void write(int b) throws IOException { 
//这里不需要变更,
// writer.write()方法将自动调BufferedOutputStream.write()
writer.write((byte)b);

为了确保BufferedoutputStream按照预期的行为工作,其他使用writer方法的地方也需要一一检查确认。除此以外,还有一些值得考虑的改进建议。

  • 创建BufferedOutputStream时,可以指定一个可选的缓存大小。Java6中默认的缓存大小是8192。如果应用程序需要写比较大的对象,可以考虑显式指定使用更大的缓存。显式指定缓存大小时,尽量将其设置为操作系统页大小的整数倍,因为大小为系统页大小整数倍时,操作系统读取内存的效率最高。Linux系统中通过getconf PAGESIZE命令也可以取得默认页大小。32位或者64位Windows系统上页面的默认大小都是4K (4096字节)。

  • ExtOutputStream.writer字段由显式的BufferedoutputStream类型修改为OutputStream类型,即使用OutputStream writer = new BufferedOutputStream()而不是BufferedOutputStream writer = new BufferedOutputStream()这一改动能带来OutputStream类型的灵活性,譬如返回的类型可以是ByteArrayOutputStream、 DataOutputStream、FilterOutputStream, FileOutputStream或者BufferedOutputStream。

对ExtOutputStream中发现的这些问题使用上面的建议进行改进,即使用BufferedOutputStream及其默认构造函数(不包含前面提到的两个额外改进)之后,我们重新收集了一次性能数据,结果表明系统态CPU的使用率大幅回落,优化结果显著。比较图6-4和图6-2,我们看到消耗在java.io.FileOutputStream上的包含系统态CPU时间从45.182秒降低到6.655秒(注:第二列为独占系统态CPU )。

在这里插入图片描述

利用类似的方法可以对需要网络I/O的应用程序进行优化。前文介绍的降低系统态CPU的方法此时也同样适用,即对数据的输入/输出流进行缓存。

对有大量网络I/O的应用程序,降低系统态CPU使用的另一个策略是使用Java NIO的非阻塞数据结构

三、锁竞争

在这里插入图片描述
图6-11的截图是一个简单程序(这一章中示例的完整源码可以参考附录B)的分析结果。该程序使用java.util.HaspMap数据结果保存200万条虚拟的税单记录,还需要对该HashMap中的记录进行更新。针对HashMap的操作包括添加新纪录、删除记录、更新已有记录、查询已有记录。由于该程序是一个多线程程序,各线程对HashMap的访问都需要进行同步,即该HashMap是使用Collections.synchronizedMap()接口分配的一个同步映像。

通过上面的描述,我们了解了这个示例程序的行为。不出所料,当大量线程尝试并发地访问,这个同步的HashMapl时,程序就会产生锁竞争。应用程序的吞吐性能为每秒615000次操作。由于存在严重的锁竞争, CPU的使用率仅为8%, Oracle Solaris的mpstat命令报告有大量让步式的线程切换。2.4节曾提到大量的让步式线程上下文切换是潜在大量锁竞争的征兆。因此,锁竞争严重的应用程序会表现出大量的让步式上下文切换。综上所诉,这个应用程序表现出了锁竞争的症状。

调优一:使用ConcurrentHashMap替代同步HashMap,提高CPU使用率和并发性。

使用ConcurrentHashMap替代同步HashMap的最终运行结果表明,CPU使用率增加了92%。换句话说,之前采用同步HashMap的实现方式最多只能利用8%的系统CPU,ConcurrentHashMap却可以达到100%的CPU利用率,而让步式的上下文切换则从几千减少到100以下。采用ConcurrentHashMap实现后,每秒能进行的操作数是原先HashMap的版本的两倍,从615 000增加到1315 000,然而,与之前8%的CPU利用率相比,100%的CPU利用率只产生了2倍的性能提升,这个结果并不理想。

查看调用最频繁的方法,

在这里插入图片描述

大量的归因时间都消耗在方法TaxCallable.updateTaxPayer (long, TaxPayerRecord)上。TaxCallable.updateTaxPayer ( long, TaxPayerRecord)方法的实现如下:

// PS:静态字段 在多线程共享
final private static Random generator = BailoutMain.random;
//这些类成员会在TaxCall able的构造函数中初始化
final private TaxPayerBailoutDB db;
private String taxPayerId; 
private long nul1Counter;
private TaxPaverRecord updateTaxPaver(long iterations, TaxPayerRecord tpr){

	if (iterations % 1001 == 0) { 
		tpr = db.get(taxPayerId);
	} else {
		//更新TaxPaver的数据库记录
		tpr = db.get(taxPayerId);
		if (tpr != nul1) {
			lonq tax = qenerator.nextInt(10) + 15;
			tpr.taxPaid(tax);
		}
	}
	if (tpr == nul1) {
		nul1Counter++;
	} 
	return tpr;
}

由于程序有多个TaxCallable实例同时运行,静态实例字段TaxCallable.generator在所有的实例间共享。TaxCallable实例会在不同的线程中执行,共享同一个TaxCallable.generator,更新同一个纳税人数据库。

在src.zip中,你可以找到java.util.Random.java的源码。下面是Random.next(int)方法的实现(图6-17中Random. next(int)调用了热方法java.util.concurrent.atomic.AtomicLong.compareAndSet(int,int))。PS:书中引用是JDK6的源码,此处是JDK7的源码。

    protected int next(int var1) {
        AtomicLong var6 = this.seed;

        long var2;
        long var4;
        do {
            var2 = var6.get();
            var4 = var2 * 25214903917L + 11L & 281474976710655L;
        } while(!var6.compareAndSet(var2, var4));

        return (int)(var4 >>> 48 - var1);
    }

Random.next(int)中有一个根据旧种子和新种子参数执行AtomicLong.compareAndSet(int, int)的do/while循环,AtomicLong是一个支持原子并发的数据结构。原子并发数据结构通常依赖某种形式的“比较-设置”或“比较-交换”操作,也常称为CAS(发音为"kazz")。

查看前文Random. next(int)方法的实现我们知道,do/while循环会一直运行,直到Atomi cLong的CAS操作将新种子值成功地按原子方式设置到AtomicLong的内存地址中。而这一事件仅当AtomicLong内存地址中保存的当前值与原始种子值一致时才会发生。如果大量线程尝试并发调用Random.next(int)方法,运行同一个Random对象实例时,很多线程会检测到AtomicLong内存地址中保存了不同于自身期望的原始种子值,导致AtomicLong.compareAndSet(int, int)方法的CAS操作返回false,所以在do/while循环中发现,有大量的CPU时钟消耗在Random.next(int)方法上。这就是Performance Analyzer在本例中提供的分析结果。

调优二:使用ThreadLocal<Random>替换static Random,避免多个线程共享一个Random,因为Random.next(int)在循环中使用了AtomicLong.compareAndSet(int, int)

该问题的解决方案是为每个线程生成自己的Random对象实例,这样各个线程就不会同时更新AtomicLong的同一个内存地址。

//最初的实现使用静态Random
// final public static Random random = new Random(Thread.currentThread.getid());
// 这里我们替换为新的ThreadLocal <Random>
final public static ThreadLocal<Random> threadLocalRandom =new ThreadLocal<Random>(){
    @Override
    protected Random initialValue(){
        return new Random(Thread.currentThread().getId());
    }
};

这个改变使得Random.next(int)中AtomicLong的CAS操作能迅速完成,因为没有线程共享同一个Random对象实例。简言之,Random.next(int)函数中的do/while循环在其第一次循环迭代时就可以完成。

在这个例子中,我们能吸取的教训之一是:原子并发数据结构并不是不可触碰的圣杯,它依赖CAS操作,而CAS一般也会利用某种同步机制。如果存在对原子变量高度竞争的情况,即使采用了并发技术或lock-free数据结构也不能避免糟糕的性能或伸缩性。

Java SE中有很多原子并发数据结构,在适当的场合,它们都是处理并发的不错选择。但是当合适的数据结构不存在时,还有另一个选择,那就是通过恰当的方法,合理设计应用程序,尽量降低多线程访问同一数据的频率、缩小并发访问的范围。换句话说,通过优化程序设计,最大程度地减少数据的同步访问(区间、大小或数据量)。

为了说明这一点,我们假设Java中不存在ConcurrentHashMap,即只有同步HashMap数据结构可用的场景。根据前面介绍的思想,我们可以将纳税人数据库切分到多个HashMap结构中分别存储,缩小数据锁的范围。一种方法是按照纳税人所属的州划分数据库,每个州的数据用一个HashMap存放。采用这种方式可以构造两级Map,第一层Map可以找到50个州中的一个。由于第一层Map中包含50个州的映射关系,不需额外添加或删除新的元素,所以不需要进行同步。但是基于州的第二级Map需要同步访问,因为存在添加、删除及更新纳税人记录的操作。换句话说,纳税人数据库将如下所示:

在这里插入图片描述
这个示例程序通过分割途径优化后每秒可以进行12 000 000次操作,CPU的使用约为50%。虽然与使用ConcurrentHashMap数据结构时每秒32 000 000次的记录略差,但与使用单一同步HashMap的结果比起来已经有非常大的改善了,使用单一同步HashMap能达到的指标仅为每秒620000次操作。

四、Volatile的使用

Java对象中声明为volatile的字段常用于线程之间状态信息的同步。确保了volatile变量的一致性,即线程从对象中读取的volatile字段值就是上次写入该volatile变量中的值,不论该线程正在读还是写,也不论这些线程在什么地方运行,它们可以在不同的CPU插糟(CPU Socket )上,或者在不同的CPU核上。使用volatile也会带来一些副作用,它会限制现代JVM的JIT编译器对这个字段的优化,比如volatile字段必须遵守一定的指令顺序。简而言之,volatile字段值在应用程序的所有线程和CPU缓存中必须保持同步。

为了确保CPU缓存及时更新,即在各个线程之间保持同步,出现volatile字段的地方都会加入一条CPU指令:内存屏障(通常称为membar或fence),一旦volatile字段值发生变化就会触发CPU缓存更新。

对一个拥有多CPU缓存,性能要求很高的应用程序,频繁更新volatile字段可能导致性能问题。然而,实际上很少会有Java应用程序依赖频繁更新的volatile字段,但是总有一些例外发生。如果你留意这一点:频繁更新、改变或写入volatile字段有可能导致性能问题(读取volatile字段不会造成性能问题),就不大容易碰到这类问题

如果你观察到volatile字段上存在大量的CPU高速缓存未命中并且分析源码后发现有对volatile字段频繁的写操作,基本可以断定应用程序的性能问题源于不恰当地使用了volatile变量。这种情况的解决方案是尽量减少对volatile变量的写操作,或者对应用程序进行重构避免使用volatile字段。不要直接删除volatile字段,这可能会破坏程序的正确性或引入潜在的竞争条件。一个性能稍差的应用程序比一个错误的实现或有潜在竞争问题的程序要好得多。

五、调整数据结构的大小

Java应用程序中经常大量使用Java SE的数据结构StringBuilder或StringBuffer对字符串进行组装,也经常使用Java容器对象(例如Java SE Collection类),无论StringBuilder还是StringBuffer,底层使用的数据存储都是char[]。随着新元素不断加入StringBuilder或StringBuffer,底层的数据存储char[]的大小也需要随之调整。大小调整的结果是字符数组char[]分配到了一块更大空间创建新的字符数组char[],老数组中的字符元素被复制到了新的数组之中,而原有的老数组则被丢弃,即这部分资源会进行垃圾收集。底层使用数组存储数据JavaSE Collection类,也采用类似的方法管理内存。

本节将探讨数据结构大小调整的方法,重点讨论StringBuilder,、StringBuffer以及JavaSE Collection类数据结构的大小调整。

1、StringBuilder或StringBuffer

如果StringBuilder或StringBuffer扩大到超过了底层数据的存储能力时,就需要为它分配新的数组,OpenJDK的实现(JDK6 )中是按2倍原StringBuilder/StringBuffer的大小为它分配新的数组,老字符数组中的元素会被复制到新数组中,老数组会被废弃。

无参StringBuilder和StringBuffer构造函数中的默认大小,但在实际应用中极少出现StringBuilder或StringBuffer对象实例最后仅使用16个或更少字符数组元素的情况。为了避免调整StringBuilder或StringBuffer大小,最好在其构造函数中显式地指定大小。消除这部分由于重置大小导致的char[]分配可以节省不必要的CPU指令(分配新的char[]、将字符从老的char[]复制到新的char[]、对不再使用的char[]进行垃圾收集),从而改善程序性能。

Java 6最近的优化中,Java HotSport虚拟机可以分析StringBuilder和stringBuffer的使用情况,并尝试针对特定的StringBuilder或StringBuffer对象优化字符数组的大小,减少由于StringBuilder或StringBuffer的增大所引起的不必要的char []对象分配。

2、Java Collection

Collection类的某些具体实现由于底层数据存储基于数组,随着元素数量的增加,调整大小的代价很大。Collection类发生大小调整后,访问调整后的字段可能会导致CPU高速缓存未命中,这是由现代JVM在内存中分配对象的方式导致的,尤其是对象在内存中如何分布决定的。不同的Java虚拟机实现中,对象及其字段在内存中的分布可能有所不同。然而, 一般来说,由于对象及其字段常常需要同时引用,将对象及其字段尽可能放在内存中相邻的位置能够减少CPU高速缓存未命中。由此可见,Collection类大小调整的影响(这一点同样适用于StringBuffer或StringBuilder)已经远远不止大小调整所额外消耗的CPU指令,还会对JVM的内存管理器造成影响,由于改变了内存中Collection类字段相对于对象实例的布局,字段的访问时间将会变长。

两个因素决定了HashMap数据存储区进行调整时机:数据存储区的容量以及加载因子(Load Factor)。容量指的是底层数据存储区的大小,即HashMap. Entry[]的大小。加载因子是HashMap的数据存储区Entry[]调整之前,允许达到的满溢程度的度量。HashMap大小的调整会分配新的Entry[]对象,大小为之前Entry[]的2倍,老Entry[]中的内容重建散列后再加入到新创建的Entry[]中。由于需要对Entry[]中的元素重建散列,调整HashMap消耗的CPU指令数要比调整StringBuilder和StringBuffer多一些
在这里插入图片描述
由于这个示例程序(同上节结尾的示例按州的个数分多个HashMap)中有50个不同的HashMap,总共约2000 000条虚拟记录,每个HashMap中大约要保存2 000 000 /50 = 40 000条记录。很明显,40 000比无参HashMap构造函数的默认大小(16)要大多了。根据HashMap默认的加载因子0.75以及程序的实际情况50个HashMap,每个需要保存约40 000条记录),可以设置HashMap的大小,避免调整开销(40 000 /0.75约53 334)。或者将要存放的总记录数除以州的数目,再除以默认的加载因子,例如( 2000 000 / 50 )/0.75,作为参数传递给保存记录的HashMap的构造函数。下面是经过修改,能减少HashMap调整开销的TaxPayerBailoutDbImpl.iava源代码:

在这里插入图片描述

你可能已经注意到了,在这个示例程序中,初始化阶段是单线程的。但是运行这个程序的系统是多核的CPU,并且每个核上都支持多线程。下一步改进该程序初始化阶段的性能就是要对程序进行重构,以充分利用所有系统这64颗虚拟处理器的能力。这是下一节的主题。

六、增加并行性

如果执行路径中有一个循环,并且那个循环中的大多数工作与每次的循环迭代无关,那么它可能是重构为多线程版本的个良好候选。比如:
在这里插入图片描述

七、过高的CPU使用率

有些时候,虽然通过努力已经降低了系统态CPU使用率、解决了锁竞争以及尝试了其他各种性能优化方法,仍然无法满足服务级别的性能或扩展性要求。这种情况下,对程序的逻辑和使用的算法做一次分析则是有益的尝试。

通常的趋势是,分析性能数据时主要关注独占指标时间最长的方法,即只关注本方法的内容而不在乎其上层单元的工作、事务、用例等。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值