《Java性能-权威指南》 笔记

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

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日志格式:

1
[GC (Allocation Failure) [PSYoungGen: 89580K->12786K(89600K)] 147723K->88825K(183296K), 0.1986863 secs] [Times: user=0.33 sys=0.05, real=0.20 secs] 
1
[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性能-权威指南》 笔记

https://jingzhouzhao.github.io/archives/863caf0d.html

作者

太阳当空赵先生

发布于

2018-11-09

更新于

2022-02-22

许可协议

评论