http://www.theserverside.com/tt/articles/article.tss?l=ScalingYourJavaEEApplication
对于一个具备使用价值的应用而言,其使用者有可能会在一段时间内疯狂的增长。随着越来越多的关键性质的应用在Java EE上运行,很多的Java开发者也开始关注可扩展性的问题了。但目前来说,大部分的web 2.0站点是基于script语言编写的,对于Java应用可扩展能力,很多人都抱着质疑的态度。在这篇文章中,Wang Yu基于他本身在实验室项目的经验来展示如何构建可扩展的java应用,同时,基于一些在可扩展性上做的比较失败的项目给读者带来构建可扩展java应用 的实践、理论、算法、框架和经验。
我一直为一家互联网性质的实验室工作,这个实验室采用我们公司最新的大型服务器环境为合作伙伴的产品和解决方案免费做性能测试,我工作的部分就是帮助他们在强大的CMT和SMP服务器上进行性能调优。
这些年来,我已经为不同的解决方案测试了数十种java应用。许多的产品都是为了解决同样的领域问题,因此这些产品的功能基 本都是类似的,但在可扩展性上表现的却非常不同,其中有些不能扩展到64 CPU的服务器上运行,但可以扩展到20台服务器做集群运行,有些则只能运行在不超过2 CPU的机器上。
造成这些差别的原因在于设计产品时的架构愿景,所有的具备良好扩展性的java应用从需求需求阶段、系统设计阶段以及实现阶段都为可扩展性做了考虑,所以,你所编写的java应用的可扩展能力完全取决于你的愿景。
可扩展性作为系统的属性之一,是个很难定义的名词,经常会与性能混淆。当然,可扩展性和性能是有关系的,它的目的是为了达到高性能。但是衡量可扩展性和性能的方法是不一样的,在这篇文章中,我们采用wikipedia中的定义:
可扩展性是系统、网络或进程的可选属性之一,它表达的含义是可以以一种优雅的方式来处理不断增长的工作,或者以一种很明白的方式进行扩充。例如:它可以用来表示系统具备随着资源(典型的有硬件)的增加提升吞吐量的能力。
垂直扩展的意思是给系统中的单节点增加资源,典型的是给机器增加CPU或内存,垂直扩展为操作系统和应用模块提供了更多可共用的资源,因此它使得虚拟化的技术(应该是指在一台机器上运行多个虚拟机)能够运行的更加有效。
水平扩展的意思是指给系统增加更多的节点,例如为一个分布式的软件系统增加新的机器,一个更清晰的例子是将一台web服务器增加为三台。随着计算机价格的不 断降低以及性能的不断提升,以往需要依靠超级计算机来进行的高性能计算的应用(例如:地震分析、生物计算等)现在可以采用这种多个低成本的应用来完成。由 上百台普通机器构成的集群可以达到传统的基于RISC处理器的科学计算机所具备的计算能力。
这篇文章的第一部分来讨论下垂直扩展Java应用。
如何让Java EE应用垂直扩展
很多的软件设计人员和开发人员都认为功能是产品中最重要的因素,而性能和可扩展性是附加的特性和功能完成后才做的工作。他们中大部分人认为可以借助昂贵的硬件来缩小性能问题。
但有时候他们是错的,上个月,我们实验室中有一个紧急的项目,合作伙伴提供的产品在他们客户提供的CPU的机器上测试未达到性能的要求,因此合作伙伴希望在更多CPU(8 CPU)的机器上测试他们的产品,但结果却是在8 CPU的机器上性能反而比4 CPU的机器更差。
为什么会这样呢?首先,如果你的系统是多进程或多线程的,并且已经用尽了CPU的资源,那么在这种情况下增加CPU通常能让应用很好的得到扩展。
基于java技术的应用可以很简单的使用线程,Java语言不仅可以用来支持编写多线程的应用,同时JVM本身在对java应用的执行管理和内存管理上采用 的也是多线程的方式,因此通常来说Java应用在多CPU的机器上可以运行的更好,例如Bea weblogic、IBM Websphere、开源的Glassfish和Tomcat等应用服务器,运行在Java EE应用服务器中的应用可以立刻从CMT和SMP技术中获取到好处。
但在我的实验室中,我发现很多的产品并不能充分的使用CPU,有些应用在8 CPU的服务器上只能使用到不到20%的CPU,像这类应用即使增加CPU也提升不了多少的。
热锁(Hot Lock)是可扩展性的关键障碍
在Java程序中,用来协调线程的最重要的工具就是 synchronized这个关键字了。由于java所采用的规则,包括缓存刷新和失效,Java语言中的synchronized块通常都会其他平台提 供的类似的机制更加的昂贵。即使程序只是一个运行在单处理器上的单线程程序,一个synchronized的方法调用也会比非同步的方法调用慢。
要检查问题是否为采用synchronized关键字造成的,只需要像JVM进程发送一个QUIT指令(译者注:在linux上也可以用kill -3 PID的方式)来获取线程堆栈信息。如果你看到类似下面线程堆栈的信息,那么就意味着你的系统出现了热锁的问题:
synchronized 关键字强制执行器串行的执行synchronized中的动作。如果很多线程竞争同样的同步对象,那么只有一个线程能够执行同步块,而其他的线程就只能进 入blocked状态了,如果此时没有其他需要执行的线程,那么处理器就进入空闲状态了,在这种情况下,增加CPU也带来不了多少性能提升。
热锁可能会导致更多线程的切换和系统的调用。当多个线程竞争同一个monitor时,JVM必须维护一个竞争此monitor的线程队列(同样,这个队列也必须同步),这也就意味着更多的时间需要花费在JVM或OS的代码执行上,而更少的时间是用在你的程序上的。
要避免热锁现象,以下的建议能带来一些帮助:
尽可能的缩短同步块
当你将线程中持有锁的时间尽量缩短后,其他线程竞争锁的时间也就变得更短。因此当你需要采用同步块来操作共享的变量时,应该将线程安全的代码放在同步块的外面,来看以下代码的例子:
Code list 1:
上面的代码片段是为了当更新"schema"变量时保护这个共享的变量。但获取attribute值部分的代码是线程安全的。因此我们可以将这部分移至同步块的外面,让同步块变得更短一些:
Code list 2:
减小锁的粒度
当你使用"synchronized"时,有两种粒度可选择:"方法锁"或"块锁"。如果你将"synchronized"放在方法上,那么也就意味着锁定了"this"对象。
Code list 3:
对比Code list 2中的代码,这段代码就显得更糟糕些了,因为当调用"updateSchema"方法时,它锁定了整个
对象,为了获得更好的粒度控制,应该仅仅锁定"schema"变量来替代锁定整个对象,这样其他不同的方法就可
以保持并行执行了。
避免在static方法上加锁
最糟糕的状况是在static方法上加"synchronized",这样会造成锁定这个class的所有实例对象。
当使用Java 2D来为报表生成字体对象时,开发人员放了一个native的static锁在"initialize"方法上,不过这是sun JDK 1.4中才会出现的,在JDK 5.0中,这个static lock就消失了。
在Java SE 5.0中使用lock free的数据结构
在Java中,"synchronized"关键字是一个较简单、并且相对来说比较好用的协作机制,不过同时对于管理一个简单的操作(例如增加统计值或更新一个值)来说就显得比较重量级了,就像以下的代码:
Code list 4:
以上的代码只是用来锁定非常简单的操作,"synchronized"块也是非常的短。但是锁是非常重量级(当锁被其他线程持有时,线程会去频繁尝试获取锁)的,吞吐量会下降,并且同步锁的竞争也是很昂贵的。
幸运的是,在Java SE 5.0或以上版本,你可以在不使用native代码的情况下使用硬件级同步语义的wait-free、lock-free的算法。几乎所有现代的处理器都 具有检测和防止其他处理器并发修改变量的基础设施。这些基础设施称为比较并交换,或CAS。
一个CAS操作包含三个参数 -- 一个内存地址,期待的旧的值以及新的值。 如果内存地址上的值和所期待的旧的值是同一个的话,处理器将此地址的值更新为新的值;否则它就什么都不做,同时它会返回CAS操作前内存地址上的值。一个使用CAS来实现同步的例子如下:
Code list 5:
首先,我们从地址上读取一个值,然后执行几步操作来产生新的值(例子中只是做加1的操作),最后使用CAS方式来将地址中的旧值改变为新值。如果在时间片段 内地址上的值未改变,那么CAS操作将成功。如果另外的线程同时修改了地址上的值,那么CAS操作将失败,但会检测到这个操作失败,并在while循环中 进行重试。CAS最好的原因在于它是硬件级别的实现并且非常轻量级,如果100个线程同时执行这个increment()方法,最糟糕的情况是在 increment方法执行完毕前每个线程最多尝试99次。
在Java SE 5.0和以上版本的java.util.concurrent.atomic包中提供了在单个变量上lock-free和线程安全操作支持的类。这些原子 变量的类都提供了比较和交换的原语,它基于各种平台上可用的最后的native的方式实现,这个包内提供了九种原子变量,包 括:AtomicInteger;AtomicLong;AtomicReference;AtomicBoolean;array forms of atomic integer、long、reference;和atomic marked reference和stamped reference类。
使用atomic包非常容易,重写上面code list 5的代码片段:
Code list 6:
几乎java.util.concurrent包中所有的类都直接或间接的采用了原子变量来替代synchronized。像 ConcurrentLinkedQueue采用了原子变量来直接实现wait-free算法,而像ConcurrentHashMap则采用 ReentrantLock来实现必要的锁,而ReentrantLock则是采用原子变量来维护所有等待锁的线程队列。
在我们实验室中一个最成功的关于lock free算法的案例发生在一个金融系统中,当将"Vector"数据结构替换为"ConcurrentHashMap"后,在我们的CMT机器(8核)性能提升了超过3倍。
竞争条件也会导致可扩展性出现问题
太多的"synchronized"关键字会导致可扩展性出现问题。但在某些场合,缺少"synchronized"也会导 致系统无法垂直扩展。缺少"synchronized"会产生竞争场景,在这种场景下允许两个线程同时修改共享的资源,这有可能会造成破坏共享数据,为什 么我说它会导致可扩展性出现问题呢?
来看一个实际的例子。这是一个制作业的ERP系统,当在我们最新的一台CMT服务器(2CPU、16核、128芯)上进行性 能测试时,我们发现CPU的使用率超过90%,这非常让人惊讶,因为很少有应用能够在这款机器上扩展的这么好。但我们仅仅兴奋了5分钟,之后我们发现平均 响应时间非常的慢,同时吞吐量也降到不可思议的低。那么这些CPU都在干嘛呢?它们不是在忙吗,那么它们到底在忙些什么呢?通过OS的跟踪工具,我们发现 几乎所有的CPU都在干同一件事-- "HashMap.get()",看起来所有的CPU都进入了死循环,之后我们在不同数量的CPU的服务器上再测试了这个应用,结果表明,服务器拥有越多CPU,那么产生死循环的概率就会越高。
产生这个死循环的根源在于对一个未保护的共享变量 -- 一个"HashMap"数据结构的操作。当在所有操作的方法上加了"synchronized"后,一切恢复了正常。检查"HashMap"(Java SE 5.0)的源码,我们发现有潜在的破坏其内部结构最终造成死循环的可能。在下面的代码中,如果我们使得HashMap中的entries进入循环,那 么"e.next()"永远都不会为null。
Code list 7:
不仅get()方法会这样,put()以及其他对外暴露的方法都会有这个风险,这算jvm的bug吗?应该说不是的,这个现象很早以前就报告出来了(详细见:http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6423457 )。Sun的工程师并不认为这是bug,而是建议在这样的场景下应采用"ConcurrentHashMap",在构建可扩展的系统时应将这点纳入规范中。
非阻塞 IO vs. 阻塞IO
Java 1.4中引入的java.nio包,允许开发人员在进行数据处理时获取更好的性能并提供更好的扩展性。NIO提供的非阻塞IO操作允许java应用像其他 底层语言(例如c)一样操作IO。目前已经有很多NIO的框架(例如Apache的Mina、Sun的Grizzly)了被广泛的使用在很多的项目和产品 中。
在最近的5个月内,我们实验室有两个Java EE项目测试对比了基于传统的阻塞I/O构建的服务器和非阻塞I/O构建的服务器上的性能。他们选择了Tomcat 5作为基于阻塞I/O的服务器,Glassfish作为基于非阻塞I/O的服务器。
首先,他们测试了一些简单的JSP页面和servlets,得到如下结果:(在一台4 CPU的服务器上)
Concurrent Users
|
Average Response Time (ms)
|
|
Tomcat
|
Glassfish
|
|
5
|
30
|
138
|
15
|
35
|
142
|
30
|
37
|
142
|
50
|
41
|
151
|
100
|
65
|
155
|
从测试结果来看,Glassfish的性能远低于Tomcat。客户对非阻塞I/O能够带来的提升表示怀疑,但为什么那么多的文章以及技术报告都告诉大家NIO具备更好的性能和可扩展性呢?
当在更多的场景进行测试后,随着NIO的能力逐步的展现出来,他们改变了观点,他们做了以下的测试:
1、比简单的JSP、servlet更为复杂的场景,包括EJB、数据库、文件IO、JMS和事务;
2、模拟更多的并发用户,从1000到10000;
3、在不同的硬件环境上进行测试,从2 CPU、4 CPU到16 CPU。
以下的图为在4 CPU服务器上的测试结果:
Figure 1: Throughput in a 4CPU server
传统的阻塞I/O为每个请求分配一个工作线程,这个工作线程负责请求的整个过程的处理,包括从网络读取请求数据、解析参数、 计算或调用其他的业务逻辑、编码结果并将其返回给请求者,然后这个线程将返回到线程池中供其他线程复用。Tomcat 5采用的这种方式在应对完美的网络环境、简单的逻辑以及小量的并发用户时是非常高效的。
但如果请求包括了复杂的逻辑、或需要和外部的系统(例如文件系统、数据库或消息服务器)进行交互时,工作线程在其处理的大部 分时间都会处于等待同步的调用或网络传输返回的状态中,这个阻塞的线程会被请求持有直到请求处理完毕,但操作系统需要暂停线程来保证CPU能够处理其他的 请求,如果客户端和服务器端的网络状况不太好的话,网络的延时会导致线程被阻塞更长时间,在更糟的状况下,当需要keep-alive的话,当前的工作线 程会在请求处理完毕后阻塞很长一段时间,在这样的情况下,为了更好的使用CPU,就必须增加更多的工作线程了。
Tomcat采用了一个线程池,每个请求都会被线程池中一个空闲的线程进行处理。"maxThreads"表示Tomcat 能创建的处理请求的最大线程数。如果我们把"maxThreads"设置的太小的话,就不能充分的使用CPU了,更为重要的是,随着并发用户的增长,会有 很多请求被服务器抛弃和拒绝。在此次测试中,我们将"maxThreads"设置为了1000(这对于Tomcat来说有些太大了),在这样的设置下,当 并发用户增长到较高数量时,Tomcat会创建很多的线程。大量的Java线程会导致JVM和OS忙于执行和维护这些线程,而不是执行业务逻辑处理,同 时,太多的线程也会消耗更多的JVM heap内存(每个线程堆栈需要占用一些内存),并且会导致更为频繁的gc。
Glassfish不需要这么多的线程,在非阻塞IO中,一个工作线程并不会绑定到一个特定的请求上,如果请求被某些原因所 阻塞,那么这个线程将被其他的请求复用。在这样的方式下,Glassfish可以用几十个工作线程来处理几千的并发用户。通过限制线程资源,非阻塞IO拥 有了更好的可扩展性,这也是Tomcat 6采用非阻塞IO的原因了。
Figure 2: scalability test result
单线程任务问题
几个月前我们实验室测试了一个基于Java EE的ERP系统,它其中的一个测试场景是为了产生非常复杂的分析报告,我们在不同的服务器上测试了这个应用场景,发现竟然是在最便宜的AMD PC服务器上拥有最好的性能。这台AMD的服务器只有两个2.8HZ的CPU以及4G的内存,但它的性能竟然超过了昂贵的拥有8 CPU和32G内存的SPARC服务器。
原因就在于这个场景是个单线程的任务,它同时只能被一个用户运行(并发的多用户执行在这个案例中毫无意义),因此当运行时它只使用了一个CPU,这样的任务是没法扩展到多个处理器的,在大多数时候,这种场景下的性能仅取决于CPU的运行速度。
并行是解决这个问题的方案。为了让一个单线程的任务并行执行,你需要按顺序找出这个操作的过程中从某种程度上来讲不依赖的操 作,然后采用多线程从而实现并行。在上面的案例中,客户重新定义了"分析报告产生"的任务,改为先生成月度报告,之后基于产生的这些12个月的月度报告来 生成分析报告,由于最终用户并不需要“月度报告”,因此这些“月度报告”只是临时产生的结果,但"月度报告"是可以并行生成的,然后用于快速的产生最后的 分析报告,在这样的方式下,这个应用场景可以很好的扩展到4 CPU的SPARC服务器上运行,并且在性能上比在AMD Server高80%多。
重新调整架构和重写代码的解决方案是一个耗时并且容易出现错误的工作。在我们实验室中的一个项目中采用了JOMP来为其单线 程的任务获得并行性。JOMP是一个基于线程的SMP并行编程的Java API。就像OpenMP,JOMP也是根据编译指示来插入并行运行的代码片段到常规的程序中。在Java程序中,JOMP 通过//omp这样的指示方式来表示需要并行运行的部分。JOMP程序通过运行一个预编译器来处理这些//omp的指示并生成最终的java代码,这些 java代码再被正常的编译和执行。JOMP支持OpenMP的大部分特性,包括共享的并行循环和并行片段,共享变量,thread local变量以及reduction变量。以下的代码为JOMP程序的示例:
Code list 8:
就像大部分的并行编译器,JOMP也是关注于loop-level和集合的并行运算,研究如何同时执行不同的迭代。为了并行 化,两个迭代之间不能产生任何的数据依赖,这也就是说,不能依赖于其他任何一个执行后产生的计算结果。要编写一个JOMP程序并不是容易的事。首先,你必 须熟练使用OpenMP的指示,同时还得熟悉JVM对于这些指示的内存模型映射,最后你需要知道在你的业务逻辑代码的正确的地方放置正确的指示。
另外一个选择是采用Parallel Java。Parallel Java,就像JOMP一样,也支持OpenMP的大部分特性;但又不同于JOMP,PJ的并行结构部分是通过在代码中调用PJ的类来实现,而不是通过插 入预编译的指示,因此,"Parallel Java"不需要另外的预编译过程。Parallel Java不仅对于在多CPU上并行有效,对于多节点的扩展能力上也同样有效。以下的代码是"Parallel Java"程序的示例:
Code list 9:
扩展使用更多的内存
内存是应用的重要资源。足够的内存对于任何应用而言都是关键的,尤其是数据库系统和其他I/O操作频繁的系统。更多的内存意味着更大的共享内存空间以及更大的数据缓冲,这也就使得应用能够更多的从内存中读取数据而不是缓慢的磁盘中读取。
Java gc将程序员从繁琐的内存分配和回收中解脱了出来,从而使得程序员能够更加高效的编写代码。但gc不好的地方在于当gc运行时,几乎所有工作的线程都会被 挂起。另外,在gc环境下,程序员缺少调度CPU来回收那些不再使用的对象的控制能力。对于那些几乎实时的系统而言,例如电信系统和股票交易系统,这种延 迟和缺少控制的现象是很大的风险。
回到Java应用在給予更多的内存时是否可以扩展的问题上,答案是有些时候是的。太小的内存会导致gc频繁的执行,足够的内存则保证JVM花费更多的时间来执行业务逻辑,而不是进行gc。
但它并不一定是这样的,在我们实验室中出现的真实例子是一个构建在64位JVM上的电信系统。使用64位JVM,应用可以突 破32位JVM中4GB内存的限制,测试时使用的是一台4 CPU/16G内存的服务器,其中12GB的内存分配给了java应用使用,为了提高性能,他们在初始化时就缓存了超过3,000,000个的对象到内存 中,以免在运行时创建如此多的对象。这个产品在第一个小时的测试中运行的非常快,但突然,系统差不多停止运行了30多分钟,经过检测,发现是因为gc导致 了系统停止了半个小时。
gc是从那些不再被引用的对象回收内存的过程。不被引用的对象是指应用中不再使用的对象,因为所有对于这些对象的引用都已经 不在应用的范围中了。如果一堆巨大的活动的对象存在在内存中(就像3,000,000个缓存的对象),gc需要花费很长的时间来检查这些对象,这就是为什 么系统停止了如此长乃至不可接受的时间。
在我们实验室中测试过的以内存为中心的Java应用中,我们发现具备有如下特征:
1、每个请求的处理过程需要大量和复杂的对象;
2、在每个会话的HttpSession对象中保存了太多的对象;
3、HttpSession的timeout时间设置的太长,并且HttpSession没有显示的invalidated;
4、线程池、EJB池或其他对象池设置的太大;
5、对象的缓存设置的太大。
这样的应用是不好做扩展的,当并发的用户数增长时,这些应用所使用的内存也会大幅度的增长。如果大量的活动对象无法被及时的 回收,JVM将会在gc上消耗很长的时间,另外,如果給予了太大的内存(在64位JVM上),在运行了相对较长的时间后,jvm会花费相当长的一段时间在 gc上,因此结论是如果给jvm分配了太多的内存的话,java应用将不可扩展。在大部分场合下,给jvm分配3G内存(通过"-Xmx"属性)是足够 (在windows和linux中,32位的系统最多只能分配2G的内存)的。如果你拥有更多的内存,请将这些内存分配给其他的应用,或者就将它留给OS 使用,许多OS都会使用空闲的内存来作为数据的缓冲和缓存来提升IO性能。实时JVM(JSR001)可以让开发人员来控制内存的回收,应用基于此特性可 以告诉JVM:“这个巨大的内存空间是我的缓存,我将自己来管理它,请不要自动对它进行回收”,这个功能特性使得Java应用也能够扩展来支持大量的内存 资源,希望JVM的提供者们能将这个特性在不久的将来带入到免费的JVM版本中。
为了扩展这些以内存为中心的java应用,你需要多个jvm实例或者多台机器节点。
其他垂直扩展的问题
有些Java EE应用的扩展性问题并不在于其本身,有些时候外部系统的限制会成为系统扩展能力的瓶颈,这些瓶颈可能包括:
这些不仅仅是Java EE应用的问题,对于所有平台的所有系统而言同样如此。为了解决这些问题,需要从系统的各个层面来从数据库管理员、系统工程师和网络分析人员处得到帮助。