举一个例子,我们做项目需要安排计划,每一个模块可以由多人同时并行做多项任务,也可以一个人或者多个人串行工作,但始终会有一条关键路径,这条路径就是项目的工期。系统一次调用的响应时间跟项目计划一样,也有一条关键路径,这个关键路径是就是系统影响时间。关键路径由 CPU 运算、IO、外部系统响应等等组成。
对于一个系统的用户来说,从用户点击一个按钮、链接或发出一条指令开始,到系统把结果以用户希望的形式展现出来为终止,整个过程所消耗的时间是用户对这个软件性能的直观印象,也就是我们所说的响应时间。当响应时间较短时,用户体验是很好的,当然用户体验的响应时间包括个人主观因素和客观响应时间。在设计软件时,我们就需要考虑到如何更好地结合这两部分达到用户最佳的体验。如:用户在大数据量查询时,我们可以将先提取出来的数据展示给用户,在用户看的过程中继续进行数据检索,这时用户并不知道我们后台在做什么,用户关注的是用户操作的响应时间。
我们经常说的一个系统吞吐量,通常由 QPS(TPS)、并发数两个因素决定,每套系统这两个值都有一个相对极限值,在应用场景访问压力下,只要某一项达到系统最高值,系统的吞吐量就上不去了,如果压力继续增大,系统的吞吐量反而会下降,原因是系统超负荷工作,上下文切换、内存等等其它消耗导致系统性能下降,决定系统响应时间要素。
缓冲区是一块特定的内存区域,开辟缓冲区的目的是通过缓解应用程序上下层之间的性能差异,提高系统的性能。在日常生活中,缓冲的一个典型应用是漏斗。缓冲可以协调上层组件和下层组件的性能差,当上层组件性能优于下层组件时,可以有效减少上层组件对下层组件的等待时间。基于这样的结构,上层应用组件不需要等待下层组件真实地接受全部数据,即可返回操作,加快了上层组件的处理速度,从而提升系统整体性能。
BufferedWriter 就是一个缓冲区用法,一般来说,缓冲区不宜过小,过小的缓冲区无法起到真正的缓冲作用,缓冲区也不宜过大,过大的缓冲区会浪费系统内存,增加 GC 负担。尽量在 I/O 组件内加入缓冲区,可以提高性能。一个缓冲区例子代码如清单 1 所示。
import java.awt.Color; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Image; import javax.swing.JApplet; public class NoBufferMovingCircle extends JApplet implements Runnable{ Image screenImage = null; Thread thread; int x = 5; int move = 1; public void init(){ screenImage = createImage(230,160); } public void start(){ if(thread == null){ thread = new Thread(this); thread.start(); } } @Override public void run() { // TODO Auto-generated method stub try{ System.out.println(x); while(true){ x+=move; System.out.println(x); if((x>105)||(x<5)){ move*=-1; } repaint(); Thread.sleep(10); } }catch(Exception e){ } } public void drawCircle(Graphics gc){ Graphics2D g = (Graphics2D) gc; g.setColor(Color.GREEN); g.fillRect(0, 0, 200, 100); g.setColor(Color.red); g.fillOval(x, 5, 90, 90); } public void paint(Graphics g){ g.setColor(Color.white); g.fillRect(0, 0, 200, 100); drawCircle(g); } }
程序可以完成红球的左右平移,但是效果较差,因为每次的界面刷新都涉及图片的重新绘制,这较为费时,因此,画面的抖动和白光效果明显。为了得到更优质的显示效果,可以为它加上缓冲区。代码如清单 2 所示。
import java.awt.Color; import java.awt.Graphics; public class BufferMovingCircle extends NoBufferMovingCircle{ Graphics doubleBuffer = null;//缓冲区 public void init(){ super.init(); doubleBuffer = screenImage.getGraphics(); } public void paint(Graphics g){//使用缓冲区,优化原有的 paint 方法 doubleBuffer.setColor(Color.white);//先在内存中画图 doubleBuffer.fillRect(0, 0, 200, 100); drawCircle(doubleBuffer); g.drawImage(screenImage, 0, 0, this); } }
除 NIO 外,使用 Java 进行 I/O 操作有两种基本方式:
无论使用哪种方式进行文件 I/O,如果能合理地使用缓冲,就能有效地提高 I/O 的性能。
下面显示了可与 InputStream、OutputStream、Writer 和 Reader 配套使用的缓冲组件。
OutputStream-FileOutputStream-BufferedOutputStream
InputStream-FileInputStream-BufferedInputStream
Writer-FileWriter-BufferedWriter
Reader-FileReader-BufferedReader
使用缓冲组件对文件 I/O 进行包装,可以有效提高文件 I/O 的性能。
import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; public class StreamVSBuffer { public static void streamMethod() throws IOException{ try { long start = System.currentTimeMillis(); //请替换成自己的文件 DataOutputStream dos = new DataOutputStream( new FileOutputStream("C://StreamVSBuffertest.txt")); for(int i=0;i<10000;i++){ dos.writeBytes(String.valueOf(i)+"/r/n");//循环 1 万次写入数据 } dos.close(); DataInputStream dis = new DataInputStream(new FileInputStream("C://StreamVSBuffertest.txt")); while(dis.readLine() != null){ } dis.close(); System.out.println(System.currentTimeMillis() - start); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } } public static void bufferMethod() throws IOException{ try { long start = System.currentTimeMillis(); //请替换成自己的文件 DataOutputStream dos = new DataOutputStream(new BufferedOutputStream( new FileOutputStream("C://StreamVSBuffertest.txt"))); for(int i=0;i<10000;i++){ dos.writeBytes(String.valueOf(i)+"/r/n");//循环 1 万次写入数据 } dos.close(); DataInputStream dis = new DataInputStream(new BufferedInputStream( new FileInputStream("C://StreamVSBuffertest.txt"))); while(dis.readLine() != null){ } dis.close(); System.out.println(System.currentTimeMillis() - start); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } } public static void main(String[] args){ try { StreamVSBuffer.streamMethod(); StreamVSBuffer.bufferMethod(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
运行结果如清单 4 所示。
889 31
很明显使用缓冲的代码性能比没有使用缓冲的快了很多倍。清单 5 所示代码对 FileWriter 和 FileReader 进行了相似的测试。
import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.FileNotFoundException; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; public class WriterVSBuffer { public static void streamMethod() throws IOException{ try { long start = System.currentTimeMillis(); FileWriter fw = new FileWriter("C://StreamVSBuffertest.txt");//请替换成自己的文件 for(int i=0;i<10000;i++){ fw.write(String.valueOf(i)+"/r/n");//循环 1 万次写入数据 } fw.close(); FileReader fr = new FileReader("C://StreamVSBuffertest.txt"); while(fr.ready() != false){ } fr.close(); System.out.println(System.currentTimeMillis() - start); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } } public static void bufferMethod() throws IOException{ try { long start = System.currentTimeMillis(); BufferedWriter fw = new BufferedWriter(new FileWriter("C://StreamVSBuffertest.txt"));//请替换成自己的文件 for(int i=0;i<10000;i++){ fw.write(String.valueOf(i)+"/r/n");//循环 1 万次写入数据 } fw.close(); BufferedReader fr = new BufferedReader(new FileReader("C://StreamVSBuffertest.txt")); while(fr.ready() != false){ } fr.close(); System.out.println(System.currentTimeMillis() - start); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); } } public static void main(String[] args){ try { StreamVSBuffer.streamMethod(); StreamVSBuffer.bufferMethod(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }
运行输出如清单 6 所示。
1295 31
从上面例子可以看出,无论对于读取还是写入文件,适当地使用缓冲,可以有效地提升系统的文件读写性能,为用户减少响应时间。
缓存也是一块为提升系统性能而开辟的内存空间。缓存的主要作用是暂存数据处理结果,并提供下次访问使用。在很多场合,数据的处理或者数据获取可能会非常费时,当对这个数据的请求量很大时,频繁的数据处理会耗尽 CPU 资源。缓存的作用就是将这些来之不易的数据处理结果暂存起来,当有其他线程或者客户端需要查询相同的数据资源时,可以省略对这些数据的处理流程,而直接从缓存中获取处理结果,并立即返回给请求组件,以此提高系统的响应时间。
目前有很多基于 Java 的缓存框架,比如 EHCache、OSCache 和 JBossCache 等。EHCache 缓存出自 Hibernate,是其默认的数据缓存解决方案;OSCache 缓存是有 OpenSymphony 设计的,它可以用于缓存任何对象,甚至是缓存部分 JSP 页面或者 HTTP 请求;JBossCache 是由 JBoss 开发、可用于 JBoss 集群间数据共享的缓存框架。
以 EHCache 为例,EhCache 的主要特性有:
由于 EhCache 是进程中的缓存系统,一旦将应用部署在集群环境中,每一个节点维护各自的缓存数据,当某个节点对缓存数据进行更新,这些更新的数据无法在其它节点中共享,这不仅会降低节点运行的效率,而且会导致数据不同步的情况发生。例如某个网站采用 A、B 两个节点作为集群部署,当 A 节点的缓存更新后,而 B 节点缓存尚未更新就可能出现用户在浏览页面的时候,一会是更新后的数据,一会是尚未更新的数据,尽管我们也可以通过 Session Sticky 技术来将用户锁定在某个节点上,但对于一些交互性比较强或者是非 Web 方式的系统来说,Session Sticky 显然不太适合。所以就需要用到 EhCache 的集群解决方案。清单 7 所示是 EHCache 示例代码。
import net.sf.ehcache.Cache; import net.sf.ehcache.CacheManager; import net.sf.ehcache.Element; /** * 第一步:生成 CacheManager 对象 * 第二步:生成 Cache 对象 * 第三步:向 Cache 对象里添加由 key,value 组成的键值对的 Element 元素 * @author mahaibo * */ public class EHCacheDemo{ public static void main(String[] args) { //指定 ehcache.xml 的位置 String fileName="E://1008//workspace//ehcachetest//ehcache.xml"; CacheManager manager = new CacheManager(fileName); //取出所有的 cacheName String names[] = manager.getCacheNames(); for(int i=0;i<names.length;i++){ System.out.println(names[i]); } //根据 cacheName 生成一个 Cache 对象 //第一种方式: Cache cache=manager.getCache(names[0]); //第二种方式,ehcache 里必须有 defaultCache 存在,"test"可以换成任何值 // Cache cache = new Cache("test", 1, true, false, 5, 2); // manager.addCache(cache); //向 Cache 对象里添加 Element 元素,Element 元素有 key,value 键值对组成 cache.put(new Element("key1","values1")); Element element = cache.get("key1"); System.out.println(element.getValue()); Object obj = element.getObjectValue(); System.out.println((String)obj); manager.shutdown(); } }
对象复用池是目前很常用的一种系统优化技术。它的核心思想是,如果一个类被频繁请求使用,那么不必每次都生成一个实例,可以将这个类的一些实例保存在一个“池”中,待需要使用的时候直接从池中获取。这个“池”就称为对象池。在实现细节上,它可能是一个数组,一个链表或者任何集合类。对象池的使用非常广泛,例如线程池和数据库连接池。线程池中保存着可以被重用的线程对象,当有任务被提交到线程时,系统并不需要新建线程,而是从池中获得一个可用的线程,执行这个任务。在任务结束后,不需要关闭线程,而将它返回到池中,以便下次继续使用。由于线程的创建和销毁是较为费时的工作,因此,在线程频繁调度的系统中,线程池可以很好地改善性能。数据库连接池也是一种特殊的对象池,它用于维护数据库连接的集合。当系统需要访问数据库时,不需要重新建立数据库连接,而可以直接从池中获取;在数据库操作完成后,也不关闭数据库连接,而是将连接返回到连接池中。由于数据库连接的创建和销毁是重量级的操作,因此,避免频繁进行这两个操作对改善系统的性能也有积极意义。目前应用较为广泛的数据库连接池组件有 C3P0 和 Proxool。
以 C3P0 为例,它是一个开源的 JDBC 连接池,它实现了数据源和 JNDI 绑定,支持 JDBC3 规范和 JDBC2 的标准扩展。目前使用它的开源项目有 Hibernate,Spring 等。如果采用 JNDI 方式配置,如清单 8 所示。
<Resource name="jdbc/dbsource" type="com.mchange.v2.c3p0.ComboPooledDataSource" maxPoolSize="50" minPoolSize="5" acquireIncrement="2" initialPoolSize="10" maxIdleTime="60" factory="org.apache.naming.factory.BeanFactory" user="xxxx" password="xxxx" driverClass="oracle.jdbc.driver.OracleDriver" jdbcUrl="jdbc:oracle:thin:@192.168.x.x:1521:orcl" idleConnectionTestPeriod="10" />
参数说明:
如果使用 spring,同时项目中不使用 JNDI,又不想配置 Hibernate,可以直接将 C3P0 配置到 dataSource 中即可,如清单 9 所示。
<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close"> <property name="driverClass"><value>oracle.jdbc.driver.OracleDriver</value></property> <property name="jdbcUrl"><value>jdbc:oracle:thin:@localhost:1521:Test</value></property> <property name="user"><value>Kay</value></property> <property name="password"><value>root</value></property> <!--连接池中保留的最小连接数。--> <property name="minPoolSize" value="10" /> <!--连接池中保留的最大连接数。Default: 15 --> <property name="maxPoolSize" value="100" /> <!--最大空闲时间,1800 秒内未使用则连接被丢弃。若为 0 则永不丢弃。Default: 0 --> <property name="maxIdleTime" value="1800" /> <!--当连接池中的连接耗尽的时候 c3p0 一次同时获取的连接数。Default: 3 --> <property name="acquireIncrement" value="3" /> <property name="maxStatements" value="1000" /> <property name="initialPoolSize" value="10" /> <!--每 60 秒检查所有连接池中的空闲连接。Default: 0 --> <property name="idleConnectionTestPeriod" value="60" /> <!--定义在从数据库获取新连接失败后重复尝试的次数。Default: 30 --> <property name="acquireRetryAttempts" value="30" /> <property name="breakAfterAcquireFailure" value="true" /> <property name="testConnectionOnCheckout" value="false" /> </bean>
类似的做法存在很多种,用户可以自行上网搜索。
计算方式转换比较出名的是时间换空间方式,它通常用于嵌入式设备,或者内存、硬盘空间不足的情况。通过使用牺牲 CPU 的方式,获得原本需要更多内存或者硬盘空间才能完成的工作。
一个非常简单的时间换空间的算法,实现了 a、b 两个变量的值交换。交换两个变量最常用的方法是使用一个中间变量,而引入额外的变量意味着要使用更多的空间。采用下面的方法可以免去中间变量,而达到变量交换的目的,其代价是引入了更多的 CPU 运算。
a=a+b; b=a-b; a=a-b;
另一个较为有用的例子是对无符号整数的支持。在 Java 语言中,不支持无符号整数,这意味着当需要无符号的 Byte 时,需要使用 Short 代替,这也意味着空间的浪费。下面代码演示了使用位运算模拟无符号 Byte。虽然在取值和设值过程中需要更多的 CPU 运算,但是可以大大降低对内存空间的需求。
public class UnsignedByte { public short getValue(byte i){//将 byte 转为无符号的数字 short li = (short)(i & 0xff); return li; } public byte toUnsignedByte(short i){ return (byte)(i & 0xff);//将 short 转为无符号 byte } public static void main(String[] args){ UnsignedByte ins = new UnsignedByte(); short[] shorts = new short[256];//声明一个 short 数组 for(int i=0;i<shorts.length;i++){//数组不能超过无符号 byte 的上限 shorts[i]=(short)i; } byte[] bytes = new byte[256];//使用 byte 数组替代 short 数组 for(int i=0;i<bytes.length;i++){ bytes[i]=ins.toUnsignedByte(shorts[i]);//short 数组的数据存到 byte 数组中 } for(int i=0;i<bytes.length;i++){ System.out.println(ins.getValue(bytes[i])+" ");//从 byte 数组中取出无符号的 byte } } }
运行输出如清单 12 所示,篇幅所限,只显示到 10 为止。
0 1 2 3 4 5 6 7 8 9 10
如果 CPU 的能力较弱,可以采用牺牲空间的方式提高计算能力,实例代码如清单 13 所示。
import java.util.Arrays; import java.util.HashMap; import java.util.Map; public class SpaceSort { public static int arrayLen = 1000000; public static void main(String[] args){ int[] a = new int[arrayLen]; int[] old = new int[arrayLen]; Map<Integer,Object> map = new HashMap<Integer,Object>(); int count = 0; while(count < a.length){ //初始化数组 int value = (int)(Math.random()*arrayLen*10)+1; if(map.get(value)==null){ map.put(value, value); a[count] = value; count++; } } System.arraycopy(a, 0, old, 0, a.length);//从 a 数组拷贝所有数据到 old 数组 long start = System.currentTimeMillis(); Arrays.sort(a); System.out.println("Arrays.sort spend:"+(System.currentTimeMillis() - start)+"ms"); System.arraycopy(old, 0, a, 0, old.length);//恢复 原有数据 start = System.currentTimeMillis(); spaceTotime(a); System.out.println("spaceTotime spend:"+(System.currentTimeMillis() - start)+"ms"); } public static void spaceTotime(int[] array){ int i = 0; int max = array[0]; int l = array.length; for(i=1;i<l;i++){ if(array[i]>max){ max = array[i]; } } int[] temp = new int[max+1]; for(i=0;i<l;i++){ temp[array[i]] = array[i]; } int j = 0; int max1 = max + 1; for(i=0;i<max1;i++){ if(temp[i] > 0){ array[j++] = temp[i]; } } } }
函数 spaceToTime() 实现了数组的排序,它不计空间成本,以数组的索引下标来表示数据大小,因此避免了数字间的相互比较,这是一种典型的以空间换时间的思路。
应对、处理高吞吐量系统有很多方面可以入手,作者将以系列的方式逐步介绍覆盖所有领域。本文主要介绍了缓冲区、缓存操作、对象复用池、计算方式转换等优化及建议,从实际代码演示入手,对优化建议及方案进行了验证。作者始终坚信,没有什么优化方案是百分百有效的,需要读者根据实际情况进行选择、实践。