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 的直方图面板可以很方便地查看这些事件,如图所