Java高吞吐量系统设计优化建议
原文出处: IBM - 周明耀
高吞吐量系统
举一个例子,我们做项目需要安排计划,每一个模块可以由多人同时并行做多项任务,也可以一个人或者多个人串行工作,但始终会有一条关键路径,这条路径就是项目的工期。系统一次调用的响应时间跟项目计划一样,也有一条关键路径,这个关键路径是就是系统影响时间。关键路径由 CPU 运算、IO、外部系统响应等等组成。
对于一个系统的用户来说,从用户点击一个按钮、链接或发出一条指令开始,到系统把结果以用户希望的形式展现出来为终止,整个过程所消耗的时间是用户对这个软件性能的直观印象,也就是我们所说的响应时间。当响应时间较短时,用户体验是很好的,当然用户体验的响应时间包括个人主观因素和客观响应时间。在设计软件时,我们就需要考虑到如何更好地结合这两部分达到用户最佳的体验。如:用户在大数据量查询时,我们可以将先提取出来的数据展示给用户,在用户看的过程中继续进行数据检索,这时用户并不知道我们后台在做什么,用户关注的是用户操作的响应时间。
我们经常说的一个系统吞吐量,通常由 QPS(TPS)、并发数两个因素决定,每套系统这两个值都有一个相对极限值,在应用场景访问压力下,只要某一项达到系统最高值,系统的吞吐量就上不去了,如果压力继续增大,系统的吞吐量反而会下降,原因是系统超负荷工作,上下文切换、内存等等其它消耗导致系统性能下降,决定系统响应时间要素。
缓冲 (Buffer)
缓冲区是一块特定的内存区域,开辟缓冲区的目的是通过缓解应用程序上下层之间的性能差异,提高系统的性能。在日常生活中,缓冲的一个典型应用是漏斗。缓冲可以协调上层组件和下层组件的性能差,当上层组件性能优于下层组件时,可以有效减少上层组件对下层组件的等待时间。基于这样的结构,上层应用组件不需要等待下层组件真实地接受全部数据,即可返回操作,加快了上层组件的处理速度,从而提升系统整体性能。
使用 BufferedWriter 进行缓冲
BufferedWriter 就是一个缓冲区用法,一般来说,缓冲区不宜过小,过小的缓冲区无法起到真正的缓冲作用,缓冲区也不宜过大,过大的缓冲区会浪费系统内存,增加 GC 负担。尽量在 I/O 组件内加入缓冲区,可以提高性能。一个缓冲区例子代码如清单 1 所示。
清单 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 所示。
清单 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); } }
使用 Buffer 进行 I/O 操作
除 NIO 外,使用 Java 进行 I/O 操作有两种基本方式:
- 使用基于 InputStream 和 OutputStream 的方式;
- 使用 Writer 和 Reader。
无论使用哪种方式进行文件 I/O,如果能合理地使用缓冲,就能有效地提高 I/O 的性能。
下面显示了可与 InputStream、OutputStream、Writer 和 Reader 配套使用的缓冲组件。
OutputStream-FileOutputStream-BufferedOutputStream
InputStream-FileInputStream-BufferedInputStream
Writer-FileWriter-BufferedWriter
Reader-FileReader-BufferedReader
使用缓冲组件对文件 I/O 进行包装,可以有效提高文件 I/O 的性能。
清单 3. 示例代码
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 所示。
清单 4. 运行输出
889 31
很明显使用缓冲的代码性能比没有使用缓冲的快了很多倍。清单 5 所示代码对 FileWriter 和 FileReader 进行了相似的测试。
清单 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 所示。
清单 6. 运行输出
1295 31
从上面例子可以看出,无论对于读取还是写入文件,适当地使用缓冲,可以有效地提升系统的文件读写性能,为用户减少响应时间。
缓存
缓存也是一块为提升系统性能而开辟的内存空间。缓存的主要作用是暂存数据处理结果,并提供下次访问使用。在很多场合,数据的处理或者数据获取可能会非常费时,当对这个数据的请求量很大时,频繁的数据处理会耗尽 CPU 资源。缓存的作用就是将这些来之不易的数据处理结果暂存起来,当有其他线程或者客户端需要查询相同的数据资源时,可以省略对这些数据的处理流程,而直接从缓存中获取处理结果,并立即返回给请求组件,以此提高系统的响应时间。
目前有很多基于 Java 的缓存框架,比如 EHCache、OSCache 和 JBossCache 等。EHCache 缓存出自 Hibernate,是其默认的数据缓存解决方案;OSCache 缓存是有 OpenSymphony 设计的,它可以用于缓存任何对象,甚至是缓存部分 JSP 页面或者 HTTP 请求;JBossCache 是由 JBoss 开发、可用于 JBoss 集群间数据共享的缓存框架。
以 EHCache 为例,EhCache 的主要特性有:
- 快速;
- 简单;
- 多种缓存策略;
- 缓存数据有两级:内存和磁盘,因此无需担心容量问题;
- 缓存数据会在虚拟机重启的过程中写入磁盘;
- 可以通过 RMI、可插入 API 等方式进行分布式缓存;
- 具有缓存和缓存管理器的侦听接口;
- 支持多缓存管理器实例,以及一个实例的多个缓存区域;
- 提供 Hibernate 的缓存实现。
由于 EhCache 是进程中的缓存系统,一旦将应用部署在集群环境中,每一个节点维护各自的缓存数据,当某个节点对缓存数据进行更新,这些更新的数据无法在其它节点中共享,这不仅会降低节点运行的效率,而且会导致数据不同步的情况发生。例如某个网站采用 A、B 两个节点作为集群部署,当 A 节点的缓存更新后,而 B 节点缓存尚未更新就可能出现用户在浏览页面的时候,一会是更新后的数据,一会是尚未更新的数据,尽管我们也可以通过 Session Sticky 技术来将用户锁定在某个节点上,但对于一些交互性比较强或者是非 Web 方式的系统来说,Session Sticky 显然不太适合。所以就需要用到 EhCache 的集群解决方案。清单 7 所示是 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 所示。
清单 8.Tomcat 数据源配置
<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" />
参数说明:
- idleConnectionTestPerio:当数据库重启后或者由于某种原因进程被杀掉后,C3P0 不会自动重新初始化数据库连接池,当新的请求需要访问数据库的时候,此时会报错误 (因为连接失效),同时刷新数据库连接池,丢弃掉已经失效的连接,当第二个请求到来时恢复正常。C3P0 目前没有提供当获取已建立连接失败后重试次数的参数,只有获取新连接失败后重试次数的参数。
- acquireRetryAttempts:该参数的作用是设置系统自动检查连接池中连接是否正常的一个频率参数,时间单位是秒。
- acquireIncremen:当连接池中的的连接耗尽的时候 c3p0 一次同时获取的连接数,也就是说,如果使用的连接数已经达到了 maxPoolSize,c3p0 会立即建立新的连接。
- maxIdleTim:另外,C3P0 默认不会 close 掉不用的连接池,而是将其回收到可用连接池中,这样会导致连接数越来越大,所以需要设置 maxIdleTime(默认 0,表示永远不过期),单位是秒,maxIdleTime 表示 idle 状态的 connection 能存活的最大时间。
如果使用 spring,同时项目中不使用 JNDI,又不想配置 Hibernate,可以直接将 C3P0 配置到 dataSource 中即可,如清单 9 所示。
清单 9.Spring 配置
<bean id="dataSource" 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 运算。
清单 10. 示例代码
a=a+b; b=a-b; a=a-b;
另一个较为有用的例子是对无符号整数的支持。在 Java 语言中,不支持无符号整数,这意味着当需要无符号的 Byte 时,需要使用 Short 代替,这也意味着空间的浪费。下面代码演示了使用位运算模拟无符号 Byte。虽然在取值和设值过程中需要更多的 CPU 运算,但是可以大大降低对内存空间的需求。
清单 11. 无符号整数运算
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 为止。
清单 12. 运行输出
0 1 2 3 4 5 6 7 8 9 10
如果 CPU 的能力较弱,可以采用牺牲空间的方式提高计算能力,实例代码如清单 13 所示。
清单 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() 实现了数组的排序,它不计空间成本,以数组的索引下标来表示数据大小,因此避免了数字间的相互比较,这是一种典型的以空间换时间的思路。
结束语
应对、处理高吞吐量系统有很多方面可以入手,作者将以系列的方式逐步介绍覆盖所有领域。本文主要介绍了缓冲区、缓存操作、对象复用池、计算方式转换等优化及建议,从实际代码演示入手,对优化建议及方案进行了验证。作者始终坚信,没有什么优化方案是百分百有效的,需要读者根据实际情况进行选择、实践。