九、线程与同步的性能

如何挖掘出 Java 线程和同步设施的最大性能


1 线程池与 ThreadPollExecutor

Java EE 应用服务器就是围绕用一个或多个线程池处理请求这一概念构建的:对服务器上 Servlet 的每个调用都是通过池中的线程处理的(也有可能不同)。类似的,其它应用可以使用 Java 的 ThreadPoolExecutor 并行执行任务。

所有线程池的工作方法本质是一样的:有一个队列,任务被提交到这个队列中。(可以有不止一个队列,概念是一样的。)一定数量的线程会从该队列中取任务,然后执行。任务的结果可以发回客户端(比如应用服务器的情况下),或保存到数据库中。但是在执行完任务后,这个线程会返回任务队列,检索另一个任务并执行(如果没有更多任务要执行,该线程会等待下一个任务)。

线程池有最小线程数和最大线程数。池中会有最小数目的线程随时待命,等待任务指派给它们。因为创建线程的成本非常高昂,这样可以提高任务提交时的整体性能。另一方面,线程需要一些系统次元,包括栈所需的原生内存,如果空闲线程太多,就会消耗本来可以分配给其它金恒的资源。

ThreadPoolExecutor 和相关的类将最小线程数称为核心池大小。

1.1 设置最大线程数

使用 CPU 的最大线程数

1.2 设置最小线程数

也可称为初始线程数和核心线程数,尽量能做到刚好能满足基本的任务需要,同时,如果线程运行完任务后,他作为空闲线程需要保留 10~30 分钟,来应对可能的新任务。

注意:线程局部变量

1.3 线程池任务大小

如果当前任务队列有 30,000 个任务,有 4 个 CPU 可用,执行一个任务需要 50 毫秒,则清空任务队列需要 6 分钟。当任务列队达到阈值,需要返回合理的错误。

1.4 设置 ThreadPoolExecutor 的大小

标准线程池:创建时准备最小数目的线程,来一个任务,并且当前所有的线程都在忙碌,则启动一个新线程,任务就立即执行。否则加入等待队列,如果队列满了,则拒绝。
ThreadPoolExecuter 表现则不同:

  1. SynchronousQueue
    来一个任务则创建线程执行任务,没有等待队列。建议将最大线程数指定为一个非常大的值,同时任务是
    完全 CPU 密集型最合适。
  2. 无界队列
    如果 ThreadPoolExecutor 搭配的是无界队列(例如 LinkedBlockedingQueue,链表阻塞队列,默认 Integer.MAX,包括 take 操作和 put 操作,FIFO 队列)。
  3. 无界队列
    有界队列,例如 ArrayBlockingQueue(FIFO)。 假如 coreThread 为 4,maxThread 为 8,用的 ArrayBlockingQueue 为 10。任务先占满 4 个 coreThread,接着占用 10 个 queue,最后才会启动 maxThread-coreThread 的 4 个线程。

    这个算法的背后理念在于:池大部分时间使用核心线程,只有当积压到一定的阈值后,才启动线程执行。

2 ForkJoinPool

使用 fork 和 join 对任务进行分、并操作, 大任务变成两个小任务,由两个线程执行这两个小任务,另一个线程用于合并这两个任务的结果, 还有另一个特性是工作窃取:每个线程有自己的任务队列,当某个线程完成了自己的任务队列,就会执行另一个未执行完的任务队列的尾部任务。

Java 8 中使用了自动并行化的特性,用到的就是一个公共的 ForkJoinPool 实例,其中的 forEach 会并行的计算。

1
2
3
4
Stream<Integer> stream = arrayList.parallelStream();
stream.forEach(a -> {
doSomething()
});

3 线程同步

3.1 线程的代价

同步的目的是保护对内存中的值的访问。变量有可能临时保存在寄存器中,这比直接在主内存中访问更高效。寄存器值对其它线程是不可见的,
而当一个线程离开某个同步块时,必须将任何修改的值刷新到主内存中。

3.2 避免同步

  1. 使用线程局部变量 ThreadLocal
  2. 使用 CAS 无锁操作,atomic 包

3.3 伪共享

  1. 伪共享:加载内存时,同时会加载邻接的值,作为缓存行, 因此 会出现伪共享问题:当程序更新本地缓存中的某个值,需要通知其它核作废缓存行中的所有涉及的值,重新从缓存行中加载。
  2. 在 volatile 中问题比较突出, 频繁的修改 volatile 以及退出同步块的代码。
  3. 使用 @Contentded 进行宽度填充( J默认情况下仅在 JVM 内部使用,Doug Lea 使用多余的变量进行宽度填充)

4 JVM 线程调优

4.1 调节线程栈大小

64位机器默认线程栈大小为 1MB,栈的异常:StackOverflowError

调节线程栈大小:-Xss=256k

4.2 偏向锁

如果一个线程最近使用了某个锁,那么该线程下一次执行由同一把锁保护的代码所需的代码可能仍然保存在处理器的缓存中。使用对象头
的某个空间来记录该线程的 id,下次访问直接进入该锁。

4.3 自旋锁

JVM 处理同步锁的竞争问题,有两种选择:

  1. 死循环+检查该锁
  2. 将该线程放入线程等待池队列,在锁可用时通知该队列。

而 JVM 会自动调整将线程从循环到通知队列的时间。

5 监控线程与锁

5.1 查看线程

使用 Jstack、jcmd

注意:JVM 只能在特定的位置(safepoint,安全点)转储出一个线程的栈,每次只能针对一个线程转储出栈的纤细。

6 小结

  1. ThreadPollExecutor 三种队列
  2. 避免同步的两种方式:ThreadLocal、CAS
  3. 伪共享
  4. 偏向锁和自旋锁
  5. 使用死循环 +检查锁的状态、将线程放入线程池中来处理锁的竞争问题
  6. jstack 命令