9.4 JVM线程调优
JVM 的某些调优策略可以影响线程和同步的性能。
9.4.1 调节线程栈大小
当空间非常珍贵时,可以调节线程所用的内存。每个线程都有一个原生栈,操作系统用它来保存该线程的调用栈信息(比如,main() 方法调用了 calculate() 方法,而 calculate() 方法又调用了 add() 方法,栈会把这些信息记录下来)。
不同的 JVM 版本,其线程栈的默认大小也有所差别,具体如表 9-9 所示。一般而言,如果在 32 位 JVM 上有 128 KB 的栈,在 64 位 JVM 上有 256 KB 的栈,很多应用实际就可以运行了。如果这个值设置得太小,潜在的缺点是,当某个线程的调用栈非常大时,会抛出 StackOverflowError。
表9-9:几种JVM的默认栈大小
| 操作系统 | 32位 | 64位 |
|---|---|---|
| Linux | 320 KB | 1 MB |
| Mac OS | N/A | 1 MB |
| Solaris Sparc | 512 KB | 1 MB |
| Solaris X86 | 320 KB | 1 MB |
| Windows | 320 KB | 1 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 进程空间限制的条件下,稍稍增加堆可以使用的内存。
9.4.2 偏向锁
当锁被争用时,JVM(和操作系统)可以选择如何分配锁。锁可以被公平地授予,每个线程以轮转调度方式(round-robin)获得锁。还有一种方案,即锁可以偏向于对它访问最为频繁的线程。
偏向锁背后的理论依据是,如果一个线程最近用到了某个锁,那么线程下一次执行由同一把锁保护的代码所需的数据可能仍然保存在处理器的缓存中。如果给这个线程优先获得这把锁的权利,缓存命中率可能就会增加。如果实现了这点,性能会有所改进。但是因为偏向锁也需要一些簿记信息,故有时性能可能会更糟。
特别是,使用了某个线程池的应用(包括大部分应用服务器),在偏向锁生效的情况下,性能会更糟糕。在那种编程模型下,不同的线程有同等机会访问争用的锁。对于这些类应用,使用 -XX:-UseBiasedLocking 选项禁用偏向锁,会稍稍改进性能。偏向锁默认是开启的。
9.4.3 自旋锁
在处理同步锁的竞争问题时,JVM 有两种选择。对于想要获得锁而陷入阻塞的线程,可以让它进入忙循环,执行一些指令,然后再次检查这个锁。也可以把这个线程放入一个队列,在锁可用时通知它(使得 CPU 可供其他线程使用)。
如果多个线程竞争的锁的被持有时间较短,那忙循环(所谓的线程自旋)就比另一个方案快得多。如果被持有时间较长,则让第二个线程等待通知会更好,而且这样第三个线程也有机会使用 CPU。
JVM 会在这两种情况间寻求合理的平衡,自动调整将线程移交到待通知队列中之前的自旋时间。有些参数可以调整自旋时间,但大部分是实验性的,都有可能会发生变化,即使是极小的版本更新。
如果想影响 JVM 处理自旋锁的方式,唯一合理的方式就是让同步块尽可能短;当然不管什么情况,都是应该这么做的。这样可以限制与程序功能没有直接关系的自旋的量,也降低了线程进入通知队列的机会。
UseSpinning标志之前的 Java 版本支持一个
-XX:+UseSpinning标志,该标志可以开启或关闭自旋锁。在 Java 7 及更高版本中,这个标志已经没用了:自旋锁无法禁用。不过考虑到向后兼容,Java 7 到 7u40 这些版本的命令行参数仍然接受该标志,但是不执行任何操作。有点奇怪的是,这个标志的默认值会报告为false,即使自旋锁一直在发挥作用。从 Java 7u40(以及 Java 8 中)开始,Java 不再支持该标志,使用这个标志会报错。
9.4.4 线程优先级
每个 Java 线程都有一个开发者定义的优先级,这是应用提供给操作系统的一个线索,用以说明特定线程在其眼中的重要程度。如果有不同线程处理不同任务,你可能会认为,可以以让其他任务在优先级较低的线程上运行为代价,使用线程优先级来改进特定任务的性能。遗憾的是,实际不会这么有用。
操作系统会为机器上运行的每个线程计算一个“当前”(current)优先级。当前优先级会考虑 Java 指派的优先级,但是还会考虑很多其他的因素,其中最重要的一个是:自线程上次运行到现在所持续的时间。这可以确保所有的线程都有机会在某个时间点运行。不管优先级高低,没有线程会一直处于“饥饿”状态,等待访问 CPU。
这两个因素之间的平衡会随操作系统的不同而有所差异。在基于 Unix 的系统上,整体优先级的计算主要取决于线程上次运行到现在所持续的时间,Java 层指定的优先级影响微乎其微。在 Windows 系统上,在 Java 层指定的优先级较高的线程,往往会比优先级较低的线程运行更久;但即便优先级较低,那些线程也会得到相对公平的执行时间。
不过,不管是哪种情况,我们都不能依赖线程的优先级来影响其性能。如果某些任务比其他任务更重要,就必须使用应用层逻辑来划分优先级。
在某种程度上,可以通过将任务指派给不同的线程池并修改那些池的大小来解决。第 10 章有一个这样的例子。
快速小结