美文网首页Java 杂谈Java
《Java Performance: The Definitiv

《Java Performance: The Definitiv

作者: 赵荆州 | 来源:发表于2018-11-09 17:25 被阅读22次

    在做性能测试时,需要确保输入参数是确定的,否则处理参数还会带来一定的性能损耗。

    JVM主要接受两类标志(少数例外):布尔标志和附带参数标志。

    • 布尔标志语法:-XX:+FlagName表示开启,-XX:-FlagName表示关闭。
    • 附带参数标志语法:-XX:FlagName=something。例如-XX:NewRadio=N

    系统级监控命令

    • vmstat,关注cs 上下文切换次数,us用户CPU时间,sy系统CPU时间。

    JDK常用小工具

    工具 说明 常用
    jcmd 打印Java进程涉及的基本类、线程和VM信息 查看JVM版本: jcmd process_id VM.version
    查看JVM调优参数:jcmd process_id VM.flags [-all]
    查看程序所使用的命令行:jcmd process_id VM.command_line
    jstack 线程栈信息获取 统计分析线程情况:jstack pid > jstack.out
    java ParseJStack jstack.out

    JIT 编译器

    Java是一种解释性语言,只不过解释的是class。通常认为解释性语言性能较差,但是JVM通过即时编译解决这个问题。HotSpot JVM通过编译热点代码(经常执行的代码)为机器码来提高性能,对于只执行一次的代码,编译时间可能超过了直接解释执行class的时间,JVM不会编译此类代码。

    JVM对执行次数越多的代码越熟悉,优化效果更高。

    选择编译器类型

    Java虚拟机分为Client(C1)、Server(C2)两类虚拟机(编译器)。-XX:+Printflagsfinal 开启后能获得基于环境的自动调优。

    JVM Server 模式与 Client 模式启动,最主要的差别在于:-server 模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升。原因是:当虚拟机运行在-client 模式的时候,使用的是一个代号为 C1 的轻量级编译器,而-server 模式启动的虚拟机采用相对重量级代号为 C2 的编译器。C2 比 C1 编译器编译的相对彻底,服务起来之后,性能更高。
    选择编译器的标志与大多数标志不同,标准的编译器标志是:-client-server 或者-d64
    分层编译(tiered compilation)是个例外,常见开启方式为:-XX:+TieredCompilation。分层编译必须使用server编译器。
    在该模式下,代码会先被解释器执行,积累到足够热度的时候由client compiler(C1)编译,然后继续积累热度到一定程度会进一步被server compiler(C2)重新以更高的优化程度编译。一般情况下,长时间运行的应用选择分层编译,短暂运行的应用选择client较好(特别是要求启动时间快的)(JAVA8中分层编译是默认开启的)。

    调优代码缓存

    JVM在编译代码后,会在代码缓存中保存编译之后的汇编语言指令集。代码缓存的大小固定,所以一旦填满,JVM就不能编译更多的代码了。
    通常Server模式需要的比Client要大,JVM默认的代码缓存大小也是如此。
    没有办法能够准确的测出所需要的代码缓存大小,通常的做法是简单的增加1倍或者3倍。
    -XX:ReservedCodeCacheSize=N标志可以设置代码缓存的最大值。-XX:InitialCodeCacheSize=N标志指定初始大小。通常只需要设置最大值即可。这里设置的大小保留内存,并不会直接分配。

    编译阀值

    判断是否需要编译,是通过检查两种计数器(回边计数器、方法调用计数器)总数。标准编译由-XX:CompileThreshold=N标志触发。client默认值是1500,server默认值是10000。计数器会随着时间而减少,对于偶尔执行的方法可能永远达不到阀值。

    编译日志

    通过-XX:+PrintCompilation可以启用编译日志。也可以通过jstat -compiler process_id了解编译情况。另外也可以通过jstat -printcompilation process_id intevel_time(1000毫秒)标志来获取最近被编译的方法。

    方法内联,Java执行方法就是栈帧的入栈出栈,栈帧包含局部变量表,操作数栈,动态链接,返回值。入栈出栈相对来说比较耗时,所以对一些简单的方法,在编译的时候会直接用函数体替换调用方法。

    关于final,几乎所有的人都说添加final后有利于JIT做方法内联和其他优化。确实在很久很久以前是这样的。现在加不加final对性能都没有影响。但是如果确实有final的语义要求还是要加上的。

    垃圾收集

    现在主流的四个收集器分别是:Serial 收集器(常用于单CPU环境)、Throughput(或者Parallel)收集器、Concurrent 收集器(CMS)和G1收集器。

    垃圾收集的基本操作是找到不再使用的对象,回收它们使用的内存,对堆的内存布局进行压缩。完成这些操作不同的收集器采用了不同的方法。

    PS:不再使用的对象通过引用计数来确定不再使用的对象,关于循环引用的对象,通过遍历GC Root(线程,Classloader等)引用的对象来确定不再使用的对象。

    如果进行垃圾收集时, 必须确保线程不再使用这些对象,所有应用线程都停止运行所产生的停顿被称为时空停顿(stop-the-world)。通常这些停顿对应用的性能影响最大,调优垃圾收集时,尽量减少这种停顿是最为关键的考量因素。

    分代垃圾收集器

    在Java中使用临时对象的情况非常多 ,对象被快速的创建和丢弃。所以垃圾收集器设计时就特别考虑到这点。新生代(Eden、Survivor)是堆的一部分,对象首先在新生代中分配。新生代填满时,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用的对象被回收,仍然使用的对象会被移动到其他地方。这种操作称为Minor GC
    采用这种设计有两个性能上的优势。其一,由于新生代仅仅是堆的一部分,与处理整个堆相比,处理新生代速度更快。意味着应用线程停顿的时间会更短。但是也意味着更频繁的发生停顿。然后就目前而言,更短的停顿显然能带来更多的优势,即使发生的频率更高。第二个优势源于新生代中对象的分配方式。对象分配于Eden空间。垃圾收集时,Eden空间中的对象要么被移走,要么被回收。所有的存活对象要么被移动到另外一个Survivor空间,要么被移动到老年代。相当于新生代空间在垃圾收集时自动进行了一次压缩整理。所有垃圾收集算法在对新生代进行垃圾回收时都存在“stop-the-world”现象。
    对象不断的往老年代移动,老年代早晚也会满,JVM需要找出老年代中不再被使用的对象,这时垃圾收集器的差别就体现出来了。简单的算法直接停掉所有的应用线程,找出不再使用的对象回收掉。接着对堆空间进行整理。这个过程称为Full GC。通常导致应用线程长时间停顿。另一方面,CMS和G1收集器可以在应用线程运行的同时找出不再使用的对象。由于这种特性,这两种收集器也被称为Concurrent收集器。将停顿降到了最低,也称为低停顿(Low-pause)收集器。但是其代价是带来更多的CPU消耗。当然这两种收集器也可能遭遇长时间的Full GC。我们要做的就是避免这样的停顿。

    收集器 开启方式 描述
    Serial Client型虚拟机默认开启 最简单的收集器,使用单线程,无论哪种GC,所有的应用线程都会被暂停。通过开启其他收集器来关闭
    Throughput Server型虚拟机默认开启(JDK7u4+),有需要可以通过-XX:+UseParallelGC、-XX:+UseParallelOldGC启用 使用多线程,也被称为Parallel 收集器。无论哪种GC,所有的应用线程都会被暂停。
    CMS -XX:+UseConcMarkSweepGC、-XX:+UseParNewGC Minor GC暂停所有应用线程,Full GC不暂停,使用后台线程扫描老年代回收垃圾对象,付出的代价是CPU使用率更高,堆更加碎片化,等到没有连续的空间分配对象时,会蜕化成Serial收集器的行为。
    G1 -XX:+UseG1GC 收集的方式同CMS,区别是老年代被划分不同的区域,通过区域间的复制移动完成对象清理工作。意味着实现了堆的部分压缩整理,减少碎片化发生。

    收集器没有绝对的好坏,取决于应用程序的特征,以及应用的性能目标。

    如果应用程序所需的CPU并不多,并且有足够的CPU资源,考虑Concurrent(CMS、G1)收集器性能更高。否则只会增加应用程序负担。
    CMS和G1,当堆较小时(4G<)选择CMS,因为CMS会扫描整个老年代,而G1是多线程分区域扫描。

    GC调优
    1. 调整堆的大小
      选择堆的大小是一种平衡,分配的过小,程序的大部分消耗可能都在GC上。如果粗暴的设置一个很大的堆,将会增加GC停顿的时间,GC的频率虽然下降了,但是持续长时间的停顿也会让程序的整体性能变慢。另外超大堆还有可能导致系统使用虚拟内存。因此,调整堆(机器上所有堆)大小时首先的原则就是不超过物理内存大小。 除此之外还需要考虑为JVM自身和其它应用预留内存。
      堆的大小由两个参数控制:初始值(-Xms N)和最大值(-Xmx N)。JVM会根据需要自动调整堆大小,直至达到最大值。如果将初始值与最大值设置为相同,JVM不再需要推算需要的堆大小,可以稍微提高GC的运行效率。

    2. 代空间的调整
      一旦堆的大小确定下来,就需要决定新生代和老年代的大小。新生代过大,垃圾收集(Minor GC)的频率就低,转移到老年代的对象就少。但是老年代就容易满,一旦老年代满了就触发Full GC。(Full GC代价更大)
      -XX:NewRatio=N(默认2)设置新生代和老年的占用的比例。
      -XX:NewSize=N设置新生代初始大小。
      -XX:MaxNewSize=N设置新生代最大大小。
      -XmnN 同时设置新生代初始大小和最大大小。
      NewRatio计算空间的公式:

      Initial young Gen Size = Initial Heap Size / (1+NewRatio)
      可以看出默认情况下,新生代占的比例为33%。

    3. 永久代和元空间

    JVM载入类的时候,他需要记录这些类的元数据。这部分数据被保存在一个单独的堆空间里。在Java 7 中称为永久代(Permgen),在Java 8 中称为元空间(Metaspace)。
    设置永久代大小:-XX:PermSize=N、-XX:MaxPermSize=N
    设置元空间大小(默认没有限制):-XX:MetaspaceSize=N、-XX:MaxMetaspaceSize=N

    垃圾回收工具

    观察垃圾回收对性能的影响最好的方法就是熟悉GC日志。开启GC日志的方法有多种,包括-verbose:gc或者-XX:+PrintGC,这两个都能创建基本的GC日志。使用-XX:+PrintGCDetails会创建更详细的GC日志(推荐)。同时还可以使用-XX:+PrintGCTimeStamps或者-XX:+PrintGCDateStamps查看几次GC操作之间的时间(推荐)。
    默认情况下GC日志直接输出到标准输出,不过使用-Xloggc:filename 标志可以修改输出到某个文件。对于长时间运行的应用来说,通过-XX:+UseGCLogfileRotation-XX:NumberOfGCLogfiles=N-XX:GCLogfileSize=N标志可以控制日志循环。避免日志文件过大。可以通过GC Histogram 分析日志。
    也可以通过脚本的方式获取GC数据,jstat是理想的工具。其中最常用的一个选项是-gcutil。例如:

    jstat -gcutil process_id 1000
    

    命令将1秒输出一次GC情况。输出的结果大致如下:


    命令执行结果图

    S0(Survivor0)、S1(Survivor1)、E(Eden)、O(Old)、P(Permgen)值为各区域所在大小比例。YGC、YGCT为Young GC的次数和GC的时间。FGC、FGCT为Full GC的次数和时间。GCT为总GC时间。

    S0、S1又被称为 To / From Space。GC时,针对一些刚被创建的活跃对象,如果直接移动到老年代,似乎不太合理,会导致老年代更容易充满,从而引发Full GC。于是就有了S0、S1空间。当年轻代发生GC时,Eden中的活跃对象会被转移到S0,下次GC新的存活对象则会和S0中的活跃对象一起被转移到S1。当S0或者S1空间不足,对象则会被转移到老年代。或者S0,S1中对象转移次数达到了阀值(-XX:InitialTenuringThreshold=N),也会被转移到老年代。

    GC日志格式:

    [GC (Allocation Failure) [PSYoungGen: 89580K->12786K(89600K)] 147723K->88825K(183296K), 0.1986863 secs] [Times: user=0.33 sys=0.05, real=0.20 secs] 
    
    [Full GC (Ergonomics) [PSYoungGen: 12786K->0K(89600K)] [ParOldGen: 76039K->86431K(187904K)] 88825K->86431K(277504K), [Metaspace: 3611K->3611K(1056768K)], 1.0945902 secs] [Times: user=2.36 sys=0.06, real=1.10 secs] 
    

    可以观察到各个空间回收情况以及用时。

    存活对象越多GC的效率越差,意味着要慎重的使用对象重用。但是在某些情况下,例如JDBC连接池,创建连接的成本非常高,与增加的GC时间权衡,重用更为高效。

    线程与同步性能

    所有线程池的工作方式本质是一样的: 有一个队列,任务被提交到这个队列中(可以不止一个队列,概念是一样的)。一定数量的线程会从该队列中取任务,然后执行。 执行完任务后,线程返回队列检索另外一个任务。

    设置最大线程数

    并不是线程数越多越好,最大线程数设置没有什么诀窍,只能通过充分的测试得出(如果不是CPU密集型应用,而系统出现的负载是由外部导致,例如增多的请求数,此时增加线程数也许是个不错的选择。)。

    设置最小线程数

    需要评估线程池平均的任务数量,如果平均任务数量只有20,而最小线程数却有2000,那么1980个线程就会空闲,白白浪费资源。也不能非常武断的将最小线程数设置为1,这样会导致线程频繁创建,影响性能。

    线程池任务大小

    线程池任务队列大小设置也没有什么诀窍,只能通过测量真实应用来得出大小。如果设置的特别大,例如Integer.MAX_VALUE,当任务数量很大,线程又来不及处理,则有可能导致资源耗尽。

    3种线程池队列

    Java ThreadPoolExecutor的行为根据队列所使用的类型表现有所不同。

    1. SynchronousQueue ,如果使用的是这种类型的队列,那么线程池的表现和通用的线程池表现一致:初始创建一定的线程(最小值),如果此时来了一个新的任务,而所有的线程都在忙,则会创建一个新的线程,如果创建的线程已经达到最大值,则新的任务就会被拒绝。

    2. LinkedBlockedingQueue,无界队列。如果使用的是这种类型的队列,线程池不会拒绝任务,因为任务队列的大小没有限制,最多仅会按照最小值创建线程。最大值被忽略。

    3. ArrayBlockedingQueue,有界队列。如果使用的是这种类型的队列,默认按照最小值设置创建线程,当新的任务到达时,所有的线程都在忙,则新的任务会加入队列,当队列满时,则会创建新的线程来处理任务(处理队列中的第一个任务)。如果线程数量大于最大值,则新的任务会被拒绝。

    ForkJoinPool

    ForkJoinPoolExecutorService的一个实现,不是为了替代ThreadPoolExecutor,而是一个补充,在某些应用场景(递归、分而治之)下性能更好。Java本身也有很多实现用到了ForkJoinPool,例如:CompletableFuture

    同步的代价

    “同步”这里的意思是,代码中一段代码串行地访问一组变量,每次只有一个线程能访问内存。包括使用synchronized 关键字,也包括java.util.concurrent.lock.Lock实例保护的代码,再就是java.util.concurrent包及子包中内的代码。严格来讲,java.util.concurrent.atomic下的类并没有使用同步,它们利用了“比较与交换”(Compare And Swap,CAS)CPU指令。利用CAS线程并不会阻塞,但是开发者看上去最终还是只能串行地访问被保护被保护内存。
    1.同步与可伸缩性,如果程序中更多的是串行代码,那么引入更多的线程并不会带来性能提升。
    2.锁对象的开销,锁的竞争越大,性能消耗越高(可参考synchronized锁升级),当某个锁没有竞争时,获取锁的成本非常小。在竞争激烈时,哪怕是CAS开销也会变大。

    避免同步

    能够避免同步尽量避免,例如每个线程使用的是不同的实例,在实例中有一个volatile变量,频繁的读写这个变量, 这毫无意义,因为此变量不存在竞争。
    如果的确无法避免同步,同步方案在考虑时应该如下规则:
    如果访问的是不存在竞争的资源,那么基于CAS的保护要稍快于传统的同步(完全不用更快)
    如果是轻度或者适度的竞争,那么基于CAS的保护要快于传统的同步(而且往往是快得多)
    如果竞争激烈,传统的同步会是更高效的选择。

    JVM 线程调优

    每个线程都有一个原生栈,不同的JVM版本,线程栈默认的大小不同,设置较小的栈可以防止耗尽原生内存,确定是,如果调用栈特别大,会抛出StackOverflowError。如果设置较大的栈,但是又没有足够的原生内存来创建线程,也可能会抛出OutOfMemoryError。
    改变线程栈大小,可以使用-XssN标志(例如 -Xss256k)。

    Java EE性能调优

    Web容器的基本性能

    一些适用于所有服务器的概念:
    1.减少输出,减少服务器产生的结果输出可以加快Web页面返回到浏览器的速度。
    2.减少空格,空格在传输时同样需要时间,避免在返回的结果中写入制表符和空格。
    3.合并静态资源,CSS和JavaScript资源保存在独立的文件中是有意义的,便于维护。但是传输一个大文件的效率要高于传输几个小文件。

    相关文章

      网友评论

        本文标题:《Java Performance: The Definitiv

        本文链接:https://www.haomeiwen.com/subject/ckbkxqtx.html