Java性能调优15——线程与同步的性能(下)

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

JVM线程调优

JVM 的某些调优策略可以影响线程和同步的性能。

调节线程栈大小

当空间非常珍贵时,可以调节线程所用的内存。每个线程都有一个原生栈,操作系统用它来保存该线程的调用栈信息(比如,main() 方法调用了 calculate() 方法,而 calculate() 方法又调用了 add() 方法,栈会把这些信息记录下来)。

不同的 JVM 版本,其线程栈的默认大小也有所差别,具体如下表所示。一般而言,如果在 32 位 JVM 上有 128 KB 的栈,在 64 位 JVM 上有 256 KB 的栈,很多应用实际就可以运行了。如果这个值设置得太小,潜在的缺点是,当某个线程的调用栈非常大时,会抛出 StackOverflowError。

操作系统32位64位
Linux320 KB1 MB
Mac OSN/A1 MB
Solaris Sparc512 KB1 MB
Solaris X86320 KB1 MB
Windows320 KB1 MB

在 64 位的 JVM 中,除非物理内存非常有限,并且较小的栈可以防止耗尽原生内存,否则没有理由设置这个值。另一方面,在 32 位的 JVM 上,使用较小的栈(比如 128 KB)往往是个不错的选择,因为这样可以在进程空间中释放部分内存,使得 JVM 的堆可以大一些。

耗尽原生内存

没有足够的原生内存来创建线程,也可能会抛出 OutOfMemoryError。这意味着可能出现了以下 3 种情况之一。

  1. 在 32 位的 JVM 上,进程所占空间达到了 4 GB 的最大值(或者小于 4 GB,取决于操作系统)。

  2. 系统实际已经耗尽了虚拟内存。

  3. 在 Unix 风格的系统上,用户创建的进程数已经达到配额限制。这方面单独的线程会被看作一个进程。

减少栈的大小可以克服前两个问题,但是对第三个问题没什么效果。遗憾的是,我们无法从 JVM 报错看出到底是哪种情况,只能在遇到错误时依次排查。

要改变线程的栈大小,可以使用 -Xss=N 标志(例如 -Xss=256k)。

调节线程栈大小小结

  1. 在内存比较稀缺的机器上,可以减少线程栈大小。

  2. 在 32 位的 JVM 上,可以减少线程栈大小,以便在 4 GB 进程空间限制的条件下,稍稍增加堆可以使用的内存。

偏向锁

当锁被争用时,JVM(和操作系统)可以选择如何分配锁。锁可以被公平地授予,每个线程以轮转调度方式(round-robin)获得锁。还有一种方案,即锁可以偏向于对它访问最为频繁的线程。

偏向锁背后的理论依据是,如果一个线程最近用到了某个锁,那么线程下一次执行由同一把锁保护的代码所需的数据可能仍然保存在处理器的缓存中。如果给这个线程优先获得这把锁的权利,缓存命中率可能就会增加。如果实现了这点,性能会有所改进。但是因为偏向锁也需要一些簿记信息,故有时性能可能会更糟。

特别是,使用了某个线程池的应用(包括大部分应用服务器),在偏向锁生效的情况下,性能会更糟糕。在那种编程模型下,不同的线程有同等机会访问争用的锁。对于这些类应用,使用 -XX:-UseBiasedLocking 选项禁用偏向锁,会稍稍改进性能。偏向锁默认是开启的。

自旋锁

在处理同步锁的竞争问题时,JVM 有两种选择。对于想要获得锁而陷入阻塞的线程,可以让它进入忙循环,执行一些指令,然后再次检查这个锁。也可以把这个线程放入一个队列,在锁可用时通知它(使得 CPU 可供其他线程使用)。

如果多个线程竞争的锁的被持有时间较短,那忙循环(所谓的线程自旋)就比另一个方案快得多。如果被持有时间较长,则让第二个线程等待通知会更好,而且这样第三个线程也有机会使用 CPU。

JVM 会在这两种情况间寻求合理的平衡,自动调整将线程移交到待通知队列中之前的自旋时间。有些参数可以调整自旋时间,但大部分是实验性的,都有可能会发生变化,即使是极小的版本更新。

如果想影响 JVM 处理自旋锁的方式,唯一合理的方式就是让同步块尽可能短;当然不管什么情况,都是应该这么做的。这样可以限制与程序功能没有直接关系的自旋的量,也降低了线程进入通知队列的机会。

UseSpinning 标志

之前的 Java 版本支持一个 -XX:+UseSpinning 标志,该标志可以开启或关闭自旋锁。在 Java 7 及更高版本中,这个标志已经没用了:自旋锁无法禁用。不过考虑到向后兼容,Java 7 到 7u40 这些版本的命令行参数仍然接受该标志,但是不执行任何操作。有点奇怪的是,这个标志的默认值会报告为 false,即使自旋锁一直在发挥作用。

从 Java 7u40(以及 Java 8 中)开始,Java 不再支持该标志,使用这个标志会报错。

线程优先级

每个 Java 线程都有一个开发者定义的优先级,这是应用提供给操作系统的一个线索,用以说明特定线程在其眼中的重要程度。如果有不同线程处理不同任务,你可能会认为,可以以让其他任务在优先级较低的线程上运行为代价,使用线程优先级来改进特定任务的性能。遗憾的是,实际不会这么有用。

操作系统会为机器上运行的每个线程计算一个“当前”(current)优先级。当前优先级会考虑 Java 指派的优先级,但是还会考虑很多其他的因素,其中最重要的一个是:自线程上次运行到现在所持续的时间。这可以确保所有的线程都有机会在某个时间点运行。不管优先级高低,没有线程会一直处于“饥饿”状态,等待访问 CPU。

这两个因素之间的平衡会随操作系统的不同而有所差异。在基于 Unix 的系统上,整体优先级的计算主要取决于线程上次运行到现在所持续的时间,Java 层指定的优先级影响微乎其微。在 Windows 系统上,在 Java 层指定的优先级较高的线程,往往会比优先级较低的线程运行更久;但即便优先级较低,那些线程也会得到相对公平的执行时间。

不过,不管是哪种情况,我们都不能依赖线程的优先级来影响其性能。如果某些任务比其他任务更重要,就必须使用应用层逻辑来划分优先级。

在某种程度上,可以通过将任务指派给不同的线程池并修改那些池的大小来解决。

监控线程与锁

在对应用中的线程和同步的效率作性能分析时,有两点需要注意:总的线程数(既不能太大,也不能太小)和线程花在等待锁或其他资源上的时间。

查看线程

几乎所有的 JVM 监控工具都提供了线程数(以及这些线程在干什么)相关的信息。像 jconsole 这样的交互式工具还能显示 JVM 内线程的状态。在 jconsole 的 Threads 面板上,可以实时观察程序执行期间线程数的增减。如图:

JConsole 中的活跃线程视图

在某个时间点,应用(NetBeans)最多使用了 45 个线程。图中刚开始有一个爆发点,最多会使用 38 个线程,后来线程数稳定在 30 到 31 之间。jconsole 可以打印每个单独线程的栈信息;如图所示,Java2D Disposer 线程正在某个引用队列的锁上等待。

查看阻塞线程

如果想了解应用中有什么线程在运行这类高层视图,实时线程监控会很有用,但至于那些线程在做什么,实际上没有提供任何数据。要确定线程的 CPU 周期都耗在哪儿了,则需要使用分析器(profiler),第 3 章曾讨论过。利用分析器可以很好地观察哪些线程在执行。而且分析器一般非常成熟,可以指出那些能够通过更好的算法、更好的代码选择来加速整体执行效果的代码区域。

诊断阻塞的线程更为困难,尽管这类信息对应用的整体执行而言往往更为重要,特别是当代码运行在多 CPU 系统上,但没有利用起所有可用的 CPU 时。一般有三种执行此类诊断的方法。方法之一还是使用分析器,因为大部分分析工具都会提供线程执行的时间线信息,这就可以看到线程被阻塞的时间点。

  1. 被阻塞线程与JFR

    要了解线程是何时被阻塞的,迄今为止最好的方式是使用可以窥探 JVM 内部、并且可以在较低的层次确定线程被阻塞时间的工具。Java 飞行记录器(Java Flight Recorder,JFR)就是一款这样的工具,前面工具箱中已经介绍过。我们可以深入到 JFR 捕获的事件中,并寻找那些引发线程阻塞的事件(比如等待获取某个 Monitor,或是等待读写 Socket,不过写的情况较为少见)。

    借助 JMC 的直方图面板可以很方便地查看这些事件,如图所示。

    JFR 中被某个 Monitor 阻塞的线程
    在这个示例中,与 sun.awt.AppContext.get() 方法中的 HashMap 关联的锁被竞争了 163 次(超过 66 秒),使得所测量的请求响应时间平均增加了 31 毫秒。栈轨迹表明竞争源于 JSP 写 java.util.Date 对象的方式。要改进这段代码的可伸缩性,可以使用线程局部的日期格式化对象,而不是简单地调用日期对象的 toString() 方法。

    从直方图中选择阻塞事件,然后检查调用代码,这个流程适合任何阻塞事件;这款与 JVM 紧密集成的工具使这一流程就成为可能。

  2. 被阻塞线程与JStack

    如果没有商用的 JVM 可用,替代方案之一是从程序中拿到大量的线程栈并加以检查。jstack、jcmd 和其他工具可以提供虚拟机中每个线程状态相关的信息,包括线程是在运行、等待锁还是等待 I/O 等。对于确定应用中正在进行的是什么,这可能非常有用,不过输出中也有很多我们不需要的。

    在查看线程栈时,有两点需要注意。第一,JVM 只能在特定的位置(safepoint,安全点)转储出一个线程的栈。第二,每次只能针对一个线程转储出栈信息,所以可能会看到彼此冲突的信息:比如两个线程持有同一个锁,或者一个线程正在等待的锁并未被其他线程持有。

    JStack 分析器

    人们很容易认为,连续快速地抓取多个栈转储信息,就能将其用作一个简单快速的分析器。毕竟,采样分析器本质上就是这么工作的:周期性地探测线程的执行栈,基于这些信息推断在方法上花了多少时间。但是在安全点和不一致的快照之间,这么做不是很有效;通过查看这些线程栈,有时可以从较高的层次上大概获知执行成本较高的方法,但是一款真正的分析器提供的信息要精确得多。

    从线程栈可以看出线程阻塞的严重程度(因为阻塞的线程已经在某个安全点上)。如果有连续的线程转储信息表明大量的线程阻塞在某个锁上,那么就可以断定这个锁上有严重的竞争。如果有连续的线程转储信息表明大量的线程在阻塞等待 I/O,则可以断定需要优化正在进行的 I/O 读操作(比如,如果是数据库调用,应该优化 SQL 执行,或者是优化数据库本身)。

    jstack 的输出有个问题,即不同版本之间可能会有变化,所以开发一个健壮的解析器比较困难。不能保证这个解析器可以不加修改地应用于你所使用的特定的 JVM。

    jstack 解析器的基本输出像下面这样:

% jstack pid > jstack.out
% java ParseJStack jstack.out
[Partial output...]
Threads in start Running
    8 threads in java.lang.Throwable.getStackTraceElement(Native
Total Running Threads: 8

Threads in state Blocked by Locks
    41 threads running in
        com.sun.enterprise.loader.EJBClassLoader.getResourceAsStream
        (EJBClassLoader.java:801)
Total Blocked by Locks Threads: 41

Threads in state Waiting for notify
    39 threads running in
        com.sun.enterprise.web.connector.grizzly.LinkedListPipeline.getTask
        (LinkedListPipeline.java:294)
    18 threads running in System Thread
Total Waiting for notify Threads: 74

Threads in state Waiting for I/O read
    14 threads running in com.acme.MyServlet.doGet(MyServlet.java:603)
Total Waiting for I/O read Threads: 14

解析器聚合了所有的线程,可以显示处于各种状态的线程分别有多少。8 个线程正在运行(它们碰巧正在获取栈轨迹信息,这个操作成本非常高,最好避免)。

41 个线程被某个锁阻塞了。所报告的方法是栈轨迹中第一个非 JDK 方法,在这个例子中是 GlassFish 的 EJBClassLoader.getResourceAsStream()。下一步就是考虑栈轨迹信息,搜索这个方法,看看线程是阻塞到什么资源上了。

在这个例子中,所有线程都被阻塞了,在等待读取同一个 JAR 文件;这些线程的栈轨迹表明,所有调用都来自实例化新 SAX 实例的操作。如第 10 章所讨论的,SAX 解析器可以通过列出应用 JAR 文件中 manifest 文件内的资源来动态定义,这意味着 JDK 必须搜索整个类路径来寻找那些条目,直到找到应用想使用的一个(或者是找不到,回到系统解析器)。因为读取这个 JAR 文件需要一个同步锁,所以所有尝试创建一个解析器的线程最终都会竞争同一个锁,这会极大影响应用的吞吐量。

更重要的一点是,大量被阻塞的线程会成为影响性能的问题。不管阻塞的根源是什么,都要对配置或应用加以修改,以避免之。

等待通知的线程又是什么样的情况呢?那些线程在等待其他事件发生。它们往往是在某个池中,等待任务就绪(比如,上面输出中的 getTask() 方法在等待请求)这类通知。系统线程会在处理像 RMI 分布式 GC 或 JMX 监控这样的事情,它们以栈中只有 JDK 类这类线程的形式出现在 jstack 的输出中。这些条件不一定表明有性能问题;对这些线程而言,等待通知是正常现象。

如果线程正在进行的是阻塞式 I/O 读取(通常是 socketRead0() 方法),也会导致问题。这也会影响吞吐量:线程正在等待某个后端资源回复其请求。这时候应该检查数据库或其他后端资源的性能。

查看阻塞线程小结

  1. 利用系统提供的线程基本信息,可以对正在运行的线程的数目有个大致了解。

  2. 就性能分析而言,当线程阻塞在某个资源或 I/O 上时,能够看到线程的相关细节就显得比较重要。

  3. JFR 使得我们可以很方便地检查引发线程阻塞的事件。

  4. 利用 jstack,一定程度上可以检查线程是阻塞在什么资源上。

线程与同步小结

理解线程如何运作,可以获得很大的性能优势。不过就线程的性能而言,其实没有太多可以调优的:可以修改的 JVM 标志相当少,而且那些标志的效果也很有限。

相反,较好的线程性能是这么来的:遵循管理线程数、限制同步带来的影响的一系列最佳实践原则。借助适当的剖析工具和锁分析工具,可以检查并修改应用,以避免线程和锁的问题给性能带来负面影响。

更新时间:2020-04-06 18:14:57

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/java性能调优15线程与同步的性能下
最后更新:2020-04-06 18:14:57

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×