2.2 常用优化组件和方法
本节主要介绍可用于系统性能优化的组件和性能优化思想,重点介绍缓冲和缓存这两个组件以及它们的使用方法。此外,还将介绍几种常用的优化思想,如池化对象、并行代替串行、负载均衡,以及时间换空间和空间换时间。
2.2.1 缓冲(Buffer)(1)
缓冲区是一块特定的内存区域。开辟缓冲区的目的是通过缓解应用程序上下层之间的性能差异,提高系统的性能。在日常生活中,缓冲的一个典型应用是漏斗,如图2.19所示。
图2.19 缓冲的示意图
图2.19显示了漏斗作为缓冲区的使用场景。上层系统如茶壶,下层系统如水瓶。现需要将茶壶中的水倒入水瓶中,这就有如将内存中的数据写入硬盘中一样。茶壶的出水速度可以很快,但是水瓶的瓶口很细,因此形成性能瓶颈。要将水全部倒入瓶中,必须等待瓶口的水缓缓流下。为了加快速度,可以使用一个漏斗(缓冲)。
漏斗的初始口径很大,并且拥有一定的容量,因此,茶壶中的水可以先倒入漏斗中,就有如内存数据先写入一块缓冲区。只要漏斗的容量够大,茶壶里的水很快就能倒完。至此,上层系统完成工作,可以去处理其他业务逻辑。而此时,水并未完全进入瓶中,而大部分被积累在漏斗中。这就可以由下层系统慢慢处理,直到水完全进入瓶中,漏斗(缓冲区)被清空。
注意:缓冲可以协调上层组件和下层组件的性能差。当上层组件性能优于下层组件时,可以有效减少上层组件对下层组件的等待时间。
基于这样的结构,上层应用组件不需要等待下层组件真实地接受全部数据,即可返回操作,加快了上层组件的处理速度,从而提升系统整体性能。
缓冲最常用的场景就是提高I/O的速度。为此,JDK内不少I/O组件都提供了缓冲功能。比如,当使用FileWriter时,进行文件写操作的代码如下:
- Writer writer = new FileWriter(new File("file.txt"));
- long begin=System.currentTimeMillis();
- for (int i = 0; i < CIRCLE; i++) {
- writer.write(i); //写入文件
- }
- writer.close();
- System.out.println("testFileWriter spend:"+(System.currentTimeMillis()-begin));
为进行I/O优化,可以为FileWriter加上缓冲:
- Writer writer = new BufferedWriter(new FileWriter(new File("file.txt"))); //增加了缓冲
- long begin=System.currentTimeMillis();
- for (int i = 0; i < CIRCLE; i++) {
- writer.write(i);
- }
- writer.close();
- System.out.println("testFileWriterBuffer spend:"+(System.currentTimeMillis()-begin));
以上代码使用BufferedWriter为FileWriter对象增加缓冲功能。BufferedWriter对象拥有两个构造函数:
- public BufferedWriter(Writer out)
- public BufferedWriter(Writer out, int sz)
2.2.1 缓冲(Buffer)(2)
其中,第2个构造函数允许在应用层指定缓冲区的大小,第1个构造函数将构造大小为8K的缓冲区。一般来说,缓冲区不宜过小,过小的缓冲区无法起到真正的缓冲作用,缓冲区也不宜过大,过大的缓存区会浪费系统内存,增加GC负担。在本例中,设置循环次数CIRCLE为10万,若不使用缓冲区操作,则相对耗时63ms;而使用缓冲区的FileWriter仅相对耗时32ms,性能提升一倍。
另一个有用的缓冲组件是BufferedOutputStream。在前文"装饰者模式"一节中,已经提到,使用BufferedOutputStream可以包装所有的OutputStream,为其提供缓冲功能,提高输出流的效率。和BufferedWriter类似,它也提供了两个构造函数:
- public BufferedOutputStream(OutputStream out)
- public BufferedOutputStream(OutputStream out, int size)
第2个构造函数可以指定缓冲区大小,默认情况下,和BufferedWriter一样,缓冲区大小为8K。
此外,在本书第3章中还将详细介绍JDK的NIO缓存。NIO的Buffer类族,提供了更为强大和专业的缓冲区控制功能。有兴趣的读者可以仔细阅读第3章中的相关内容。
除了能够改善I/O性能,缓冲区对任何一种上下层组件存在性能差异的场合都可以起到很好的效果。另一个典型的例子是使用缓冲区提升动画显示效果。下例,实现了一个左右平移的圆球:
- 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();
- }
- }
- public void run() {
- try {
- while (true)
- {
- x += move;
- 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);
- }
- }
以上代码没有main()函数,但在Eclipse中,可以通过右键菜单中,Run As下的Java Applet运行。结果显示,虽然程序可以完成红球的左右平移,但是效果较差,因为每次的界面刷新都涉及图片的重新绘制,而这是较为费时的操作,因此,画面的抖动和白光效果明显。为了能得到更优质的显示效果,可以为它加上缓冲区:
- 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一次性显示出来
- }
- }
加上缓冲区后,动画的显示要比之前清晰了许多,并且没有抖动和白光的出现。
除了性能上的优化,缓冲区还可以作为上层组件和下层组件的一种通信工具。从而,将上层组件和下层组件进行解耦,优化设计结构。典型的案例可以参考本书第4章"生产者消费者模式"一节。在生产者消费者模式,连接生产者和消费者的缓冲区正是起到这个作用。有兴趣的读者,可以仔细阅读该节。
注意:由于I/O操作很容易成为性能瓶颈,所以,尽可能在I/O读写中加入缓冲组件,以提高系统的性能。
2.2.2 缓存(Cache)(1)
缓存(Cache)也是一块为提升系统性能而开辟的内存空间。缓存的主要作用是暂存数据处理结果,并提供下次访问使用。在很多场合,数据的处理或者数据获取可能会非常费时,当对这个数据的请求量很大时,频繁的数据处理会耗尽CPU资源。缓存的作用就是将这些来之不易的数据处理结果暂存起来,当有其他线程或者客户端需要查询相同的数据资源时,可以省略对这些数据的处理流程,而直接从缓存中获取处理结果,并立即返回给请求组件,以此提高系统的响应时间。
缓存的使用非常普遍,比如,目前流行的几种浏览器都会在本地缓存远程的页面,从而减少远程HTTP访问次数,加快网页的加载速度。又比如,在服务端的系统开发中,设计人员可以为一些核心API加上缓存,从而提高系统的整体性能。
最为简单的缓存可以直接使用HashMap实现。当然,这样做会遇到很多问题,比如,何时应该清理无效的数据;如何防止缓存数据过多而导致内存溢出等。一个稍好的替代方案是直接使用WeakHashMap,它使用弱引用维护一张哈希表,从而避免了潜在的内存溢出问题,但是,作为专业的缓存,它的功能也略有不足。
注意:缓存可以保存一些来之不易的数据或者计算结果。当需要再次使用这些数据时,可以从缓存中低成本地获取,而不需要再占用宝贵的系统资源。
幸运的是,目前有很多基于Java的缓存框架,比如EHCache、OSCache和JBossCache等。EHCache缓存出自Hibernate,是Hibernte框架默认的数据缓存解决方案;OSCache缓存是由OpenSymphony设计的,它可以用于缓存任何对象,甚至是缓存部分JSP页面或者HTTP请求;JBossCache是由JBoss开发、可用于JBoss集群间数据共享的缓存框架。
下面,以EHCache缓存为例,简单介绍一下缓存的基本使用方法。
在使用EHCache前,需要对EHCache进行必要的配置。一个典型的配置可能如下:
- <ehcache>
- <diskStore path="data/ehcache" />
- <defaultCache maxElementsInMemory="10000" eternal="false"
- overflowToDisk="true" timeToIdleSeconds="120" timeToLiveSeconds="120"
- diskPersistent="false" diskExpiryThreadIntervalSeconds="120" />
- <cache name="cache1" maxElementsInMemory="100" eternal="false"
- timeToIdleSeconds="6" timeToLiveSeconds="60" overflowToDisk="true"
- diskPersistent="false" />
- <cache name="cache2" maxElementsInMemory="100000" eternal="false"
- timeToIdleSeconds="300" timeToLiveSeconds="600" overflowToDisk= "false"
- diskPersistent="false" />
- </ehcache>
以上配置文件首先设置了一个默认的cache模版。在程序中使用EHCache接口动态生成缓存时,会使用这些参数定义新的缓存。随后,定义了两个缓存,名字分别是cache1和cache2。
配置文件中一些主要参数的含义如下:
maxElementsInMemory:该缓存中允许存放的最大条目数量。
eternal:缓存内容是否永久储存。
overflowToDisk:如果内存中的数据超过maxElementsInMemory,是否使用磁盘 存储。
timeToIdleSeconds:如果不是永久储存的缓存,那么在timeToIdleSeconds指定时间内没有访问一个条目,则移除它。
timeToLiveSeconds:如果不是永久储存的缓存,一个条目可以存在的最长时间。
diskPersistent:磁盘储存的条目是否永久保存。
diskExpiryThreadIntervalSeconds:磁盘清理线程的运行时间间隔。
EHCache使用简单,可以像使用HashMap一样使用它。但为了能够更方便地使用EHCache,笔者还是对EHCache进行了简单的封装,提供了EHCacheUtil工具类,专门针对EHCache做各种操作。
首先是EHCache的初始化操作:
- static{
- try {
- //载入EHCache的配置文件,创建CacheManager
- manager = CacheManager.create
- (EHCacheUtil.class.getClassLoader().getResourceAsStream(configfile));
- } catch (CacheException e) {
- e.printStackTrace();
- }
- }
2.2.2 缓存(Cache)(2)
以上代码将载入EHCache的配置文件,并生成CacheManager的实例。之后,就可以通过CacheManager对Cache进行管理。
将数据存入Cache的实现如下:
- public static void put(String cachename,Serializable key,Serializable value){
- manager.getCache(cachename).put(new Element(key, value));
- }
在put()操作中,首先指定要使用的Cache名称,接着就是类似于HashMap的名值对。get()操作也是类似:
- public static Serializable get(String cachename,Serializable key){
- try {
- Element e=manager.getCache(cachename).get(key);
- if(e==null)return null;
- return e.getValue(); //取得缓存中的数据
- } catch (IllegalStateException e) {
- e.printStackTrace();
- } catch (CacheException e) {
- e.printStackTrace();
- }
- return null;
- }
有了以上的工具类,便可以更方便地在实际工作中使用EHCache。从软件设计的角度来说,笔者建议在频繁使用且重负载的函数实现中,加入缓存,以提高它在频繁调用时的性能。
在为方法加入缓存时,可以使用最原始的硬编码方式,根据传入的参数构造key,然后去缓存中查找结果,如果找到则立即返回;如果找不到,则再进行相关的业务逻辑处理,得到最终结果,并将结果保存到缓存中,并返回这个结果。这种方式的实现好处是代码比较直白,缺点是缓存组件和业务层代码紧密耦合,依赖性强。
本小节介绍基于动态代理的缓存解决方案,对动态代理尚不了解的读者,可以回顾前文中"代理模式"一节。基于动态代理的缓存方案的最大好处是,在业务层,无需关注对缓存的操作,缓存操作代码被完全独立并隔离,并且对一个新的函数方法加入缓存不会影响原有的方法实现,是一种非常灵活的软件结构。
注意:使用动态代理无需修改一个逻辑方法的代码,便可以为它加上缓存功能,提高其性能。
现在,假设有一个可能被频繁调用的方法,它用于对一个整数做因式分解。实现如下:(由于本文不关注因式分解算法,故只列出该类的结构):
- public class HeavyMethodDemo {
- public String heavyMethod(int num) {
- StringBuffer sb = new StringBuffer();
- //对 num 进行因式分解,将结果保存在sb中
- return sb.toString();
- }
- }
使用CGLIB生成动态代理类的方法拦截器的逻辑如下:
- public class CglibHeavyMethodInterceptor implements MethodInterceptor {
- HeavyMethodDemo real=new HeavyMethodDemo();
- @Override
- public Object intercept(Object arg0, Method arg1, Object[] arg2,
- MethodProxy arg3) throws Throwable {
- String v=(String)EHCacheUtil.get("cache1", (Serializable)arg2[0]); //查询缓存
- if(v==null){
- v=real.heavyMethod((Integer)arg2[0]); //缓存中未找到结果
- EHCacheUtil.put("cache1", (Integer)arg2[0], v); //保存计算结果
- }
- return v;
- }
- //省略其他代码
2.2.2 缓存(Cache)(3)
在这个方法拦截器中,实现了对缓存的操作,它首先查询系统是否已经计算并缓存了所请求的数字,如果没有,则进行计算,并将结果保存在缓存中;如果有,则直接从缓存中取得结果。在使用动态代理时,可以通过下面的代码生成动态代理对象,包含上述缓存逻辑:
- public static HeavyMethodDemo newCacheHeavyMethod(){//生成带有缓存功能的类
- Enhancer enhancer = new Enhancer();
- enhancer.setSuperclass(HeavyMethodDemo.class);
- enhancer.setCallback(new CglibHeavyMethodInterceptor());//设置缓存逻辑
- HeavyMethodDemo cglibProxy = (HeavyMethodDemo) enhancer.create();
- return cglibProxy;
- }
以上代码首先生成一个HeavyMethodDemo类的子类,并使用CglibHeavyMethodInterceptor作为它的方法拦截器,最后生成动态类的对象。这个对象是HeavyMethodDemo的动态子类的实例。
以下代码只是简单地生成了HeavyMethodDemo类。下文将对newHeavyMethod()和newCacheHeavyMethod()生成的对象进行简单的性能测试。
- public static HeavyMethodDemo newHeavyMethod(){ //不带有缓存功能
- return new HeavyMethodDemo();
- }
一段测试代码如下,它分别使用代理类对象和HeavyMethodDemo对象,对一个大整数进行因式分解运算。在笔者的计算机上,使用动态代理的缓存对象相对耗时188ms,而HeavyMethodDemo相对耗时609ms。
- public static void main(String args[]){
- HeavyMethodDemo m=newCacheHeavyMethod(); //使用缓存
- long begin = System.currentTimeMillis();
- for(int i=0;i<100000;i++) //使用缓存时,只需要计算一次
- m.heavyMethod(2147483646);
- System.out.println("cache method spend:"+(System.currentTimeMillis()
- -begin));
- m=newHeavyMethod(); //不使用缓存
- begin = System.currentTimeMillis();
- for(int i=0;i<100000;i++) //不使用缓存时,每次都要计算
- m.heavyMethod(2147483646);
- System.out.println("no cache method spend:"+(System.currentTimeMillis()
- -begin));
- }
2.2.3 对象复用--"池"(1)
对象池化,是目前非常常用的一种系统优化技术。它的核心思想是,如果一个类被频繁请求使用,那么不必每次都生成一个实例,可以将这个类的一些实例保存在一个"池"中,待需要使用的时候直接从池中获取。这个"池"就称为对象池。在实现细节上,它可能是一个数组,一个链表或者任何集合类。
对象池的使用非常广泛,其中最为大家所熟悉的,就是线程池和数据库连接池。线程池中,保存着可以被重用的线程对象,当有任务被提交到线程池时,系统并不需要新建线程,而是从池中获得一个可用的线程,执行这个任务。在任务结束后,也不关闭线程,而将它返回到池中,以便下次继续使用。由于线程的创建和销毁是较为费时的工作,因此,在线程调度频繁的系统中,线程池可以很好地改善性能。有关线程池更详细的介绍,读者可以参考第4章中"简单的线程池实现"一节。
数据库连接池也是一种特殊的对象池,它用于维护数据库连接的集合。当系统需要访问数据库时,不需要重新建立数据库连接,而可以直接从池中获取;在数据库操作完成后,也不关闭数据库连接,而是将连接返回到连接池中。由于数据库连接的创建和销毁是重量级的操作,因此,避免频繁进行这两个操作,对改善系统的性能也有积极意义。
注意:在程序中使用数据库连接池和线程池,可以有效地改善系统在高并发下的性能。这是两个非常重要的性能组件。任何对性能敏感的系统,都需要考虑合理配置这两个组件。
目前应用较为广泛的数据库连接池组件有C3P0和Proxool。其中C3P0是伴随着Hibernate一起发布,与Hibernate联系紧密的数据库连接池。本文以C3P0为例,展示数据库连接池的一般使用方法和特性。
若在Hibernate中使用C3P0连接池,只需要将C3P0的jar包复制到开发环境中,并且在hibernate.cfg.xml中加入以下配置项即可:
- <property name="connection.provider_class">org.hibernate.connection.
- C3P0ConnectionProvider</property>
- <property name="connection.autoReconnect">true</property>
- <property name="connection.autoReconnectForPools">true</property>
- <property name="connection.is-connection-validation-required">true
- </property>
- <!-- 最大连接数 -->
- <property name="hibernate.c3p0.max_size">20</property>
- <!-- 最小连接数 -->
- <property name="hibernate.c3p0.min_size">5</property>
- <!-- 获得连接的超时时间,如果超过这个时间,会抛出异常,单位毫秒 -->
- <property name="hibernate.c3p0.timeout">120</property>
- <!-- 最大的PreparedStatement的数量 -->
- <property name="hibernate.c3p0.max_statements">100</property>
- <!-- 每隔120秒检查连接池里的空闲连接,单位是秒-->
- <property name="hibernate.c3p0.idle_test_period">120</property>
- <!-- 当连接池里的连接用完时,C3P0一次性获取的新的数据库连接数 -->
- <property name="hibernate.c3p0.acquire_increment">2</property>
- <!-- 每次都验证连接是否可用 -->
- <property name="hibernate.c3p0.validate">true</property>
当然,也可以脱离Hibernate单独在应用程序中使用C3P0。以下代码构造了一个C3P0的数据库连接池,并从中获得一个数据库连接:
- DataSource unpooled = DataSources
- .unpooledDataSource(
- "jdbc:mysql://127.0.0.1:3306/test", //连接MySQL数据库
- "root", ""); //这个不是连接池
- DataSource pooled = DataSources.pooledDataSource(unpooled);
- //构建了一个连接池
- con = pooled.getConnection(); //从连接池中获取连接
2.2.3 对象复用--"池"(2)
为了能够从代码层面更好地理解数据库连接池,读者可以仔细阅读以下代码:
- public static void main(String[] argv) {
- try {
- Class.forName("com.mysql.jdbc.Driver");
- DataSource unpooled = DataSources
- .unpooledDataSource(
- "jdbc:mysql://127.0.0.1:3306/test",
- "root", "");
- DataSource pooled = DataSources.pooledDataSource(unpooled);
- Connection con = null;
- Statement stmt = null;
- ResultSet rs = null;
- con = pooled.getConnection(); //第一次取得数据库连接
- System.out.println("con Class Type is:"+con.getClass().getName());
- Object o1=getInnter(con); //取得内部的实际数据库连接
- System.out.println("Inner con Class Type is:"+o1.getClass().
- getName());
- stmt = con.createStatement();
- rs = stmt.executeQuery("SELECT * FROM user");
- while (rs.next())
- System.out.println("Data from DB:"+rs.getString(1));
- rs.close();
- stmt.close();
- con.close();
- Thread.sleep(1000); //等待连接返回池中
- con = pooled.getConnection(); //第二次取得数据库连接
- Object o2=getInnter(con);
- if(o1==o2) //相同,则说明数据库连接被复用
- System.out.println("o1 and o2 is same object.");
- stmt = con.createStatement();
- rs = stmt.executeQuery("SELECT * FROM user");
- while (rs.next())
- System.out.println("Data from DB:"+rs.getString(1));
- rs.close();
- stmt.close();
- con.close();
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- public static Object getInnter(Object con){
- Object re=null;
- Field f;
- try {
- f = con.getClass().getDeclaredField("inner");
- f.setAccessible(true);
- re= f.get(con); //取得内部包装的Connection
- f.setAccessible(false);
- } catch Exception e) {
- }
- return re;
- }
以上代码运行后,输出:
- con Class Type is:com.mchange.v2.c3p0.impl.NewProxyConnection
- Inner con Class Type is:com.mysql.jdbc.JDBC4Connection
- Data from DB:1
- o1 and o2 is same object.
- Data from DB:1
上述代码中,首先从数据库连接池获得一个连接。发现连接类型并不是mysql的数据库连接,而是com.mchange.v2.c3p0.impl.NewProxyConnection。根据类名中可以推测,从数据库连接池中获得的连接只是一个代理。接着,通过反射,取得这个对象中名为inner的属性,并打印其Class类型,发现这才是真正的mysql连接。关闭NewProxyConnection连接,再向池中请求一个新的连接,同样获取该连接内部的实际数据库连接对象。发现,第一次使用的实际数据库连接对象o1和第二次使用的对象o2是完全相同的。
这说明,前后两次数据库连接的请求均返回了相同的数据库连接。关闭NewProxyConnection连接时,并没有真正关闭数据库连接,而只是将数据库连接放入连接池保存,使得数据库连接在连接池中得到了复用。而从连接池返回的NewProxyConnection对象,只是对真实数据库连接的包装。
除了线程池和数据库连接池,对于普通的Java对象,在必要的时候,也可以进行池化管理。对于那些经常使用,并且创建很费时的大型对象来说,使用对象池维护,不仅可以节省获得对象实例的成本,还可以减轻GC频繁回收这些对象产生的系统压力。但对于生成对象开销很小的对象进行池化,反而可能得不偿失,维护对象池的成本可能会大于对象池带来的好处。
注意:在JDK中,new操作的效率是相当高的,不需要担心频繁的new操作对系统有性能影响。但是new操作时所调用的类构造函数可能是非常费时的,对于这些对象,可以考虑池化。
2.2.3 对象复用--"池"(3)
在第4章"Semaphore信号量"一节中,使用了信号量同步机制,实现了一个简单的对象池,读者可以参考相关代码,在此不予重复。该对象池使用一个对象数组和一个标志位布尔数组分别表示池中的对象和对象的可用性(一个对象一次只能被一个线程使用)。在获取对象时,在池中,找到一个可用的对象(标志位为空闲)返回,并将标志位设置为使用中,当对象使用完成后,标志位设置为空闲,归还对象池,等待下次使用。
在实际开发中,开发人员完全不必自行开发对象池。在Apache中,已经提供了一个Jakarta Commons Pool对象池组件,可以直接使用。
Jakarta Commons Pool定义的对象池接口如下:
- public interface ObjectPool<T> {
- T borrowObject();
- void returnObject(T borrowed);
- }
其中borrowObject()方法从对象池中取得一个对象。returnObject()方法在使用完成后,将对象返回给对象池。
另一个重要的接口是PoolableObjectFactory,它告诉对象池如何创建一个对象,如何销毁一个对象。它的定义如下:
- public interface PoolableObjectFactory<T> {
- T makeObject();
- void activateObject(T obj);
- void passivateObject(T obj);
- boolean validateObject(T obj);
- void destroyObject(T obj);
- }
PoolableObjectFactory接口的方法都将被对象池回调,以指导对象池在对象的生命周期中如何管理这些对象。
PoolableObjectFactory接口的主要方法如下:
makeObject():定义如何创建一个新的对象实例。
activateObject():在对象从对象池取出前,会激活这对象。
passivateObject():在对象返回对象池时被调用。
destroyObject():对象从对象池中被销毁时,会执行这个方法。
validateObject():判断对象是否可用。
在Jakarta Commons Pool中,已经内置定义了3个对象池,分别是StackObjectPool、GenericObjectPool和SoftReferenceObjectPool。
StackObjectPool:它利用java.util.Stack来保存对象,可以为StackObjectPool指定一个初始化大小,并且当空间不够时,StackObjectPool可以自动增长。当无法从该对象池得到可用的对象时,它会自动创建新的对象。
GenericObjectPool:是一个通用的对象池,它可以设定对象池的容量,也可以设定在无可用对象的情况下,对象池的表现行为(等待或者创建新的对象实例),还可以设置是否进行对象的有效性检查。GenericObjectPool有一个复杂的构造函数来定义它的这些行为:
- GenericObjectPool(
- PoolableObjectFactory<T> factory, //指定PoolableObjectFactory
- int maxActive, //能从池中借出的对象的最大数目
- byte whenExhaustedAction, //指定当对象池耗尽时的行为(等待、创建新实例、抛异常)
- long maxWait, //当耗尽行为为等待时,最大的等待时间
- int maxIdle, //最大的空闲对象数
- int minIdle, //最小的空闲对象数
- boolean testOnBorrow, //borrowObject()时是否进行有效性验证
- boolean testOnReturn, // returnObject()时是否进行有效性验证
- long timeBetweenEvictionRunsMillis, //多少毫秒进行对象清理
- int numTestsPerEvictionRun, //在进行后台对象清理时,每次检查几个对象
- long minEvictableIdleTimeMillis, //休眠多少时间的对象设置为过期
- boolean testWhileIdle, //是否对没有过期的对象进行有效性检查
- long softMinEvictableIdleTimeMillis,
- //对象被回收前在池中保持空闲状态的最小时间毫秒数
- boolean lifo //是否使用后进先出策略
- )
2.2.3 对象复用--"池"(4)
SoftReferenceObjectPool:它使用ArrayList保存对象。但是SoftReferenceObjectPool并不直接保存对象的强引用,而是保存对象的软引用,它使用如下方法向池中加入新对象:
- _pool.add(new SoftReference<T>(obj, refQueue));
SoftReferenceObjectPool对对象的数量没有限制。当对象池没有可用对象时,borrowObject()方法会创建新的对象。当内存紧张时,JVM可以自动回收具有软引用的对象。
以下代码显示了一个简单的对象池工厂:
- public class PoolableObjectFactoryDemo implements PoolableObjectFactory {
- private static AtomicInteger counter = new AtomicInteger(0);
- public Object makeObject() throws Exception { //创建对象
- Object obj = String.valueOf(counter.getAndIncrement());
- System.out.println("Create Object " + obj);
- return obj;
- }
- public void activateObject(Object obj) throws Exception {
- System.out.println("Before borrow " + obj);//在取出前被调用
- }
- public void passivateObject(Object obj) throws Exception {
- System.out.println("return "+obj); //当对象返回池中时被调用
- }
- public boolean validateObject(Object obj) {
- return true;
- }
- public void destroyObject(Object obj) throws Exception {
- System.out.println("Destroying Object " + obj);
- }
- }
对象池的使用例子如下:
- public class ObjectPoolDemo {
- static PoolableObjectFactory factory = new PoolableObjectFactoryDemo();
- static ObjectPool pool = new GenericObjectPool(factory);
- private static AtomicInteger endcount = new AtomicInteger(0);
- public static class PoolThread extends Thread{
- public void run(){
- Object obj = null;
- try {
- for (int i = 0; i < 100; i++) {
- System.out.println("== " + i + " ==");
- obj = pool.borrowObject(); //从池中得到对象
- System.out.println(obj+" is get"); //模拟使用对象
- pool.returnObject(obj); //使用完成后,将对象返回池中
- }
- } catch (Exception e) {
- e.printStackTrace();
- } finally {
- endcount.getAndIncrement();
- }
- }
- }
- public static void main(String[] args) {
- new PoolThread().start();
- new PoolThread().start();
- new PoolThread().start();
- try{
- while(true){
- if(endcount.get()==3){ //等待3个线程全部结束
- pool.close();
- break;
- }
- }
- }catch(Exception e){
- }
- }
- }
2.2.3 对象复用--"池"(5)
以上代码的部分输出如下:
- == 0 ==
- Create Object 0
- == 0 ==
- Create Object 1
- == 0 ==
- Create Object 2
- Before borrow 2
- 省略部分输出
- 0 is get
- return 0
- == 98 ==
- Before borrow 0
- 0 is get
- return 0
- == 99 ==
- Before borrow 0
- 0 is get
- return 0
- Destroying Object 0
- Destroying Object 2
- Destroying Object 1
可以看到,在3个线程向对象池获取对象的过程中,一共建立了3个对象。这3个对象被不停地复用,当对象池被关闭时,使用了对象池工厂的destroyObject()方法,销毁对象,释放资源。
注意:只有对重量级对象使用对象池技术才能提高系统性能,对轻量级的对象使用对象池,可能反而会降低系统性能。
2.2.4 并行替代串行
随着多核时代的到来,CPU的并行能力有了很大的提升。在这种背景下,传统的串行程序已经无法发挥CPU的最大潜能,造成系统资源的浪费。而并行软件开发技术恰好可以在这方面将CPU性能发挥到极致。
Java对多线程的支持为多核计算提供了强有力的保障。首先,Java中提供了Thread对象和Runnable接口用于创建进程内的线程。其次,为了优化并行程序性能,JDK还提供了java.util.concurrent并发包,内置各种多线程性能优化工具和组件,如线程池、各种并发数据结构等。除此之外,为确保多线程间能相互协作,JDK还提供了各种同步工具。
有关并行程序的开发和优化方法,可以参考第4章"并行程序开发及优化"一节。
2.2.5 负载均衡(1)
对大型应用来说,系统负载可能非常重。以网站应用为例,如果并发数很多,则单台计算机就无法承受,此时,为保证应用程序的服务质量,需要使用多台计算机协同工作,将系统负载尽可能均匀地分配到各个计算机节点上。
一个典型的实现便是Tomcat集群。配置Tomcat集群实现负载均衡,可以通过Apache服务器实现。即,使用Apache服务器作为负载分配器,将请求转向各个Tomcat服务器,从而实现负载均衡。如图2.20所示,客户端请求被均匀地分配到各个Tomcat节点上。
图2.20 基于Apache的负载均衡结构
在使用Tomcat集群时,有两种基本的Session共享模式。黏性Session模式和复制Session模式。
在黏性Session模式下,所有的Session信息被平均分配到各个Tomcat节点上,以实现负载均衡,但是一旦一个节点宕机,它所维护的Session信息将丢失,不具备高可用性,且同一用户只能与一台Tomcat交互,因为其他Tomcat节点上不保存这个用户信息。
而使用复制Session模式,将使得所有Session在所有Tomcat节点上保持一致。当一个节点上的Session信息被修改,这个Session会被广播到其他Tomcat节点上,以保持Session同步。这样,当用户的下一次请求被分配到其他Tomcat节点上时,将拥有足够信息处理用户请求。这样做的坏处是,很容易引起网络繁忙,影响系统效率。
在Java开源软件中,还有一款跨JVM虚拟机,专门用于分布式缓存的框架--Terracotta。使用Terracotta也可以实现Tomcat的Session共享。同时Terracotta也是一个成熟的高可用性系统解决方案。
由于Terracotta进行内存共享时,并不会进行全复制,而仅仅传输变化的部分,网络负载也相对较低。因此,使用Terracotta进行Tomcat的Session共享,其效率也远远高于普通的Session复制。除了与Tomcat集成,Terracotta还可以与一些主流Java组件集成使用,如Jetty、Spring和EHCache。Terracotta的工作架构如图2.21所示。
图2.21 Terracotta工作架构
使用Terracotta,可以在多个Java应用服务器间共享缓存。并且在增加应用服务器时,不会像Tomcat那样引起网络风暴,系统负载可以线性增长,是一种可靠的负载均衡方案。
注意:Terracotta是一款企业级的、开源的、JVM层的集群解决方案。它可以实现诸如分布式对象共享、分布式缓存、分布式Session等功能。可以作为负载均衡、高可用性的解决方案。
读者可以在Terracotta官方网站http://terracotta.org/下载并试用Terracotta。在Terracotta安装完成后,可以参考Terracotta自带的几个实例,深入对Terracotta的了解。在此,笔者简单介绍两个有代表性的Terracotta应用案例。
首先,笔者将介绍分布式Cache的使用。在Terracotta中,EhCache得到了加强,具备了分布式功能。在Terracotta安装目录的"ehcache\samples\colorcache"子文件夹中,有名为colorcache的分布式EhCache应用示例。该示例由用户指定某一种颜色的名称,由后台生成这种颜色。生成颜色的初始化时间会比较长,但一旦生成后,这种颜色便会进入缓存,以后再获取相同的颜色时,就能很快得到响应。
2.2.5 负载均衡(2)
要正常运行这个示例,首先需要启动Terracotta服务器,如图2.22所示。
图2.22 colorcache示例目录
在Terracotta服务器启动完成后,可以运行"start-sample.bat"启动两个Web应用。该批处理程序将在9081和9082端口启动两个Web服务器。当然,这两个Web服务器是运行在各自独立的JVM中,是相互独立的。在程序启动后,应该可以正常访问http://localhost:9081/colorcache/和http://localhost:9082/colorcache/这两个网页。
访问http://localhost:9081/colorcache,并获取黑色,由于是第一次申请黑色,因此会等待较长的时间,成功后,结果如图2.23所示。在等待约3.7秒后,颜色创建并获取成功,同时黑色被保存到缓存中。
此时,打开http://localhost:9082/colorcache/(注意它和9082运行在两个不同的JVM中),可以发现,虽然在9082服务器上并没有做过颜色的获取操作,但是,在它的缓存中,已经存在黑色,如图2.24所示。在9082服务器上同样尝试获取黑色,操作可以很快完成,这说明缓存生效。
图2.23 colorcache示例示意图1
(点击查看大图)图2.24 colorcache示例示意图2
2.2.5 负载均衡(3)
通过这个实验可以看到,9081和9082的Web服务器通过Terracotta服务器,共享了同一份缓存。在本例中,Web应用的缓存配置如以下代码所示:
- <?xml version="1.0" encoding="UTF-8"?>
- <ehcache name="ColorCache">
- <defaultCache
- maxElementsInMemory="10000"
- eternal="false"
- timeToIdleSeconds="120"
- timeToLiveSeconds="120"
- overflowToDisk="true"
- diskSpoolBufferSizeMB="30"
- maxElementsOnDisk="10000000"
- diskPersistent="false"
- diskExpiryThreadIntervalSeconds="120"
- memoryStoreEvictionPolicy="LRU"/>
- <cache name="colors"
- maxElementsInMemory="100"
- maxElementsOnDisk="0"
- eternal="false"
- timeToIdleSeconds="120"
- timeToLiveSeconds="0"
- memoryStoreEvictionPolicy="LFU">
- <terracotta/>
- </cache>
- <terracottaConfig url="localhost:9510"/>
- </ehcache>
该缓存是前文中介绍的EhCache缓存的分布式形态,在配置文件最后指定了缓存服务器地址。在程序中使用分布式缓存的方法也很简单,与前文中介绍的EhCache几乎相同,如下代码片段:
- private static final CacheManager cacheManager = new CacheManager();
- private Ehcache getCache() {
- return cacheManager.getEhcache("colors"); //与配置文件中的名称一样
- }
- public Color getColor(String name) {
- Element elem = getCache().get(name); //从分布式缓存中获取数据
- if (elem == null) { //若不存在,则新建颜色
- Color color = colorDatabase.getColor(name);
- if (color == null) { return null; }
- getCache().put(elem = new Element(name, color)); //将颜色放入缓存
- }
- return (Color) elem.getValue();
- }
Terracotta的另一个重要的应用是session共享。在Terracotta安装目录的"sessions\samples\cart"子文件夹内,有session共享的示例。与colorcache示例一样,首先需要启动Terracotta服务器,接着,启动两个Web应用程序,分别运行在9081和9082端口。两个Web服务器在各自独立的JVM虚拟机中运行,彼此独立。
2.2.5 负载均衡(4)
打开http://localhost:9081/Cart/,选择要购买的商品,如图2.25所示,选择了"X-files movie"和"NIN CD"。
这些数据保存在Web服务器的session中。接着,打开http://localhost:9082/Cart/,虽然从未在9082服务器上做过任何选择操作,但是9082服务器的返回数据和9081完全一致,如图2.26所示。
图2.25 共享session示例示意图1
图2.26 共享session示例示意图2
这表明通过Terracotta服务器,两个Web应用完全共享了session。使用Terracotta在不同Web服务器间共享session,只需要做一些简单的配置。比如,在web.xml中加入Terracotta过滤器,如以下代码片段所示。在配置文件中,指定了Terracotta服务器的地址:
- <filter>
- <filter-name>terracotta-filter</filter-name>
- <filter-class>org.terracotta.session.TerracottaJetty61xSessionFilter
- </filter-class>
- <init-param>
- <param-name>tcConfigUrl</param-name>
- <param-value>localhost:9510</param-value>
- </init-param>
- </filter>
- <filter-mapping>
- <filter-name>terracotta-filter</filter-name>
- <url-pattern>/*</url-pattern>
- <dispatcher>ERROR</dispatcher>
- <dispatcher>INCLUDE</dispatcher>
- <dispatcher>FORWARD</dispatcher>
- <dispatcher>REQUEST</dispatcher>
- </filter-mapping>
在使用时,分布式session对于应用是透明的,以下代码片段显示了在JSP中访问分布式session:
- <%
- cart.processRequest(request);
- //为了能在集群中更新数据,每次Request请求
- //必须更新Session内的数据
- session.setAttribute("cart", cart);
- %>
2.2.6 时间换空间
由于系统资源是有限的,为了在有限的资源内,达成某些特定的性能目标,就需要使用时间换空间或者空间换时间的方法。
时间换空间通常用于嵌入式设备,或者内存、硬盘空间不足的情况。通过使用牺牲CPU的方式,获得原本需要更多内存或者硬盘空间才能完成的工作。
下例是一个非常简单的时间换空间的算法,实现了a、b两个变量的值交换。交换两个变量最常用的方法是使用一个中间变量,而引入额外的变量意味着要使用更多的空间。采用下面的方法,则可以免去中间变量,而达到变量交换的目的,其代价则是引入了更多的CPU运算。
- aa=a+b;
- b=a-b;
- aa=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) { //将short转为无符号byte
- return (byte) (i & 0xff);
- }
- 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.print(ins.getValue(bytes[i])+" ");
- //从byte数组中取出无符号的byte
- }
- }
注意:性能优化的关键在于掌握各部分组件的性能平衡点。如果系统CPU资源有空闲,但是内存使用紧张,便可以考虑使用时间换空间的策略,达到整体性能的改良。反之,CPU资源紧张,内存资源有空闲,则可以使用空间换时间的策略,提升整体性能。
2.2.7 空间换时间
与时间换空间的方法相反,空间换时间则是尝试使用更多的内存或者磁盘空间换取CPU资源或者网络资源等,通过增加系统的内存消耗,来加快程序的运行速度。
这种方法的典型应用就是缓存。缓存是一块额外的系统内存区,如果没有缓存,程序依然可以正常工作。但是,在一般情况下,缓存中总是保存那些来之不易的数据,重新取得这些数据会花费大量的资源和时间。而通过缓存这块额外的内存,避免了频繁的资源消耗,加快了程序的运行速度。
空间换时间是一种软件设计思路,除了缓存外,在一些算法中,也可以使用这样的技术。以下代码是典型的空间换时间排序方法:
- 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); //这里只是为了保存原有数组
- 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 maxmax1 = max + 1;
- for (i = 0; i < max1; i++) { //线性复杂度
- if (temp[i] > 0) {
- array[j++] = temp[i];
- }
- }
- }
- }
上例中,函数spaceToTime()实现了数组的排序。它不计空间成本,以数组的索引下标来表示数据大小。因此,避免了数字间的相互比较。这是一种典型的空间换时间的思路。
在本例中,对100万数据进行排序(使用JVM参数-Xmx512M -Xms512M运行程序)。在笔者的计算机上,输出显示:
- Arrays.sort spend:250 ms
- spaceToTime spend:125 ms
可以看到,在本例中spaceToTime()的速度优于JDK自带的数组排序方法。但是,这里需要指出的是,这并不表示对任何规模段的数组排序spaceToTime()都优于Arrays.sort()。如果数组中元素不多,或者当前CPU的运算能力很强,那么Arrays.sort()方法的执行速度并不会比spaceToTime()慢。而相对地,如果CPU运算能力较弱,那么这种以空间换取计算资源的方法会取得相对较好的效果。
2.3 小结
本章主要介绍了重要的与性能相关的设计模式、性能组件和优化方法。重点阐述了单例模式、代理模式、享元模式、装饰者模式、观察者模式、Value Object模式和业务代理模式的结构、使用方法与实现细节。
在性能优化组件中,又详细介绍了缓冲、缓存、对象池等常用组件。对于性能优化的基本方法,介绍了负载均衡的作用与实现、时间换空间和空间换时间的思想。