查看原文
其他

Java多线程优化都不会,怎么拿Offer?

脚本之家 2022-04-23

The following article is from 51CTO技术栈 Author 崔皓

  脚本之家

你与百万开发者在一起

本文经授权转自公众号 51CTO技术栈(ID:blog51cto)
作者:崔皓
如若转载请联系原公众号

随着业务量的增加,多线程处理成为家常便饭。于是,多线程优化成了摆在我们面前的问题。Java 作为当今主流的应用开发语言,也会有同样的问题。



今天,我们从 Java 内部锁优化,代码中的锁优化,以及线程池优化几个方面展开讨论。


Java 内部锁优化


当使用 Java 多线程访问共享资源的时候,会出现竞态的现象。即随着时间的变化,多线程“写”共享资源的最终结果会有所不同。


为了解决这个问题,让多线程“写”资源的时候有先后顺序,引入了锁的概念。每次一个线程只能持有一个锁进行写操作,其他的线程等待该线程释放锁以后才能进行后续操作。


从这个角度来看,锁的使用在 Java 多线程编程中是相当重要的,那么是如何对锁进行优化?


众所周知,Java 的锁分为两种:

  • 一种是内部锁,它用 Synchronized 关键字来修饰,由 JVM 负责管理,并且不会出现锁泄漏的情况。

  • 另外一种是显示锁。


这里重点讨论的是内部锁优化。内部锁的优化方式由 Java 内部机制完成,虽然不需要程序员直接参与,但了解它对理解多线程优化原理有很大帮助。


这部分的优化主要包括四部分:

  • 锁消除

  • 锁粗化

  • 偏向锁

  • 适应锁


锁消除(Lock Elision),JIT 编译器对内部锁的优化。在介绍其原理之前先说说,逃逸和逃逸分析。


逃逸是指在方法之内创建的对象,除了在方法体之内被引用之外,还在方法体之外被其他变量引用。


也就是,在方法体之外引用方法内的对象。在方法执行完毕之后,方法中创建的对象应该被 GC 回收,但由于该对象被其他变量引用,导致 GC 无法回收。


这个无法回收的对象称为“逃逸”对象。Java 中的逃逸分析,就是对这种对象的分析。


回到锁消除,Java JIT 会通过逃逸分析的方式,去分析加锁的代码段/共享资源,他们是否被一个或者多个线程使用,或者等待被使用。


如果通过分析证实,只被一个线程访问,在编译这个代码段的时候就不生成 Synchronized 关键字,仅仅生成代码对应的机器码。


换句话说,即便开发人员对代码段/共享资源加上了 Synchronized(锁),只要 JIT 发现这个代码段/共享资源只被一个线程访问,也会把这个 Synchronized(锁)去掉。从而避免竞态,提高访问资源的效率。

锁消除示意图


作为开发人员来说,只需要在代码层面去考虑是否用 Synchronized(锁)。


说白了,就是感觉这段代码有可能出现竞态,那么就使用 Synchronized(锁),至于这个锁是否真的会使用,则由 Java JIT 编译器来决定。


锁粗化(Lock Coarsening) ,是 JIT 编译器对内部锁具体实现的优化。假设有几个在程序上相邻的同步块(代码段/共享资源)上,每个同步块使用的是同一个锁实例。


那么 JIT 会在编译的时候将这些同步块合并成一个大同步块,并且使用同一个锁实例。这样避免一个线程反复申请/释放锁。

锁粗化示意图


如上图存在三块代码段,分割成三个临界区,JIT 会将其合并为一个临界区,用一个锁对其进行访问控制。


即使在临界区的空隙中,有其他的线程可以获取锁信息,JIT 编译器执行锁粗化优化的时候,会进行命令重排到后一个同步块的临界区中。


锁粗化默认是开启的。如果要关闭这个特性可以在 Java 程序的启动命令行中添加虚拟机参数“-XX:-EliminateLocks”。


偏向锁(Biased Locking),顾名思义,它会偏向于第一个访问锁的线程。如果在接下来的运行中,该锁没有被其他线程访问,则持有偏向锁的线程不会触发同步。


相反,在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM 会消除挂起线程的偏向锁。


换句话说,偏向锁只能在单个线程反复持有该锁的时候起效。其目的是,为了避免相同线程获取同一个锁时,产生的线程切换,以及同步操作。


从实现机制上讲, 每个偏向锁都关联一个计数器和一个占有线程。最开始没有线程占有的时候,计数器为 0,锁被认为是 unheld 状态。


当有线程请求 unheld 锁时,JVM 记录锁的拥有者,并把锁的请求计数加 1。


如果同一线程再次请求锁时,计数器就会增加 1,当线程退出 Syncronized 时,计数器减 1,当计数器为 0 时,锁被释放。


为了完成上述实现,锁对象中有个 ThreadId 字段。第一次获取锁之前,该字段是空的。持有锁的线程,会将自身的 ThreadId 写入到锁的 ThreadId 中。


下次有线程获取锁时,先检查自身 ThreadId 是否和偏向锁保存的 ThreadId 一致。


如果一致,则认为当前线程已经获取了锁,不需再次获取锁。偏向锁默认是开启的。


如果要关闭这个特性,可以在 Java 程序的启动命令行中添加虚拟机参数“-XX:-UseBiasedLocks”。


适应锁(Adaptive Locking):当一个线程持申请锁时,该锁正在被其他线程持有。


那么申请锁的线程会进入等待,等待的线程会被暂停,暂停的线程会产生上下文切换。


由于上下文切换是比较消耗系统资源的,所以这种暂停线程的方式比较适合线程处理时间较长的情况。


前面一个线程执行的时间较长,才能弥补后面等待线程上下文切换的消耗。如果说线程执行较短,那么也可以采取忙等(Busy Wait)的状态。


这种方式不会暂停线程,通过代码中的 while 循环检查锁是否被释放,一旦释放就持有锁的执行权。


这种方式虽然不会带来上下文的切换,但是会消耗 CPU 的资源。为了综合较长和较短两种线程等待模式,JVM 会根据运行过程中收集到的信息来判断,锁持有时间是较长时间或者较短时间。然后再采取线程暂停或忙等的策略。


Java 代码中如何进行锁优化


前面讲了 Java 系统是如何针对内部锁进行优化的。如果说内部锁的优化是 Java 系统自身完成的话,那么接下来的优化就需要通过代码实现了。


锁的开销主要是在争用锁上,当多线程对共享资源进行访问时,会出现线程等待。


即便是使用内存屏障,也会导致冲刷写缓冲器,清空无效化队列等开销。


为了降低这种开销,通常可以从几个方面入手,例如:减少线程申请锁的频率(减少临界区)和减少线程持有锁的时间长度(减小锁颗粒)以及多线程的设计模式。


减少临界区的范围


当共享资源需要被多线程访问时,会将共享资源或者代码段放到临界区中。


如果在代码书写中减少临界区的长度,就可以减少锁被持有的时间,从而降低锁被征用的概率,达到减少锁开销的目的。

减少临界区示例图


如上图,尽量避免对一个方法进行加锁同步,可以只针对方法中的需要同步资源/变量进行同步。其他的代码段不放到 Synchronzied 中,减少临界区的范围。


减小锁的颗粒度


减小锁的颗粒度可以降低锁的申请频率,从而减小锁被争用的概率。其中一种常见的方法就是将一个颗粒度较粗的锁拆分成颗粒度较细的锁。

拆分锁的颗粒度


假设有一个类 ServerStatus,里面包含了四个方法:

  • addUser

  • addQuery

  • removeUser

  • removeQuery


如果分别在每个方法加上 Synchronized。在一个线程访问其中任意一个方法的时候,将锁住 ServerStatus,此时其他线程都无法访问另外三个方法,从而进入等待。

如果只针对每个方法内部操作的对象加锁,例如:addUser 和 removeUser 方法针对 users 对象加锁。又例如:addQuery 和 removeQuery 方法针对 queries 对象加锁。


假设,当一个线程池调用 addUser 方法的时候,只会锁住 user 对象。另外一个线程是可以执行 addQuery 和 removeQuery 方法的。


并不会因为锁住整个对象而进入等待。JDK 内置的 ConcurrentHashMap 与 SynchronizedMap 就使用了类似的设计。

针对不同的方法中使用的对象进行锁定


读写锁


也叫做线程的读写模式(Read-Write Lock),其本质是一种多线程设计模式。


将读取操作和写入操作分开考虑,在执行读取操作之前,线程必须获取读取的锁。


在执行写操作之前,必须获取写锁。当线程执行读取操作时,共享资源的状态不会发生变化,其他的线程也可以读取。但是在读取时,不可以写入。


其实,读写模式就是将原来共享资源的锁,转化成为读和写两把锁,将其分两种情况考虑。


如果都是读操作可以支持多线程同时进行,只有在写时其他线程才会进入等待。
Reader 线程正在读取,Writer 线程正在等待
Writer 线程正在写入,Reader 线程正在等待

读写锁类图

说完了读写锁的基本原理,再来看看参与的角色:
  • Reader(读者),对 SharedResource 角色执行 Read 操作。

  • Writer(写者),对 SharedResource 角色执行 Write 操作。

  • SharedResource(共享资源),表示对 Reader 和 Writer 两者共享的资源。

  • ReadWriteLock(读写锁),提供了 SharedResource 角色实现 Read 操作和 Write 操作时所需的锁。

    针对 Read 操作提供 readLock 和 readUnlock,对 Write 操作提供 writeLock 和 writeUnlock。


特别需要注意的是,在这里需要解决读写冲突的问题。当线程 A 获取读锁时,如果有线程 B 正在执行写操作,线程 A 需要等待,否则会引起 read-write conflict(读写冲突)。


如果线程 B 正在执行读操作,线程 A 不需要等待,因为 read-read 不会引起 conflict(冲突)。


当线程 A 要获取写入锁时,线程 B 正在执行写操作,线程 A 需要等待,否则会引起 write-write conflict(写写冲突)。


如果线程 B 正在执行读操作,则线程 A 需要等待,否则会引起 read-write conflict(读写冲突)。

读写锁冲突示例图


上面基本把读写锁的基本原理说完了,接下来通过一些代码片段来看看它是如何实现的。


我们通过 Data 类 SharedResource,ReaderThread 和 WriterThread 来实现 Reader 和 Writer,ReadWriteLock 类来实现读写锁。


首先来看 ReaderThread 和 WriterThread,它们的实现相对简单。仅仅调用 Data 类中的 Read 和 Write 方法来实现读写操作。
ReaderThread 对 Reader 的实现

WriterThread 对 Writer 的实现


接下来就是 ReadWriteLock 类,它实现了读写锁的具体功能。其中的几个变量用来控制访问线程和写入优先级:

  • readingReaders:正在读取共享资源的线程个数,整型。

  • waitingWriters:正在等待写入共享资源的线程个数,整型。

  • writingWriters:正在写入共享资源的线程个数,整型。

  • preferWriter:写入优先级标示,布尔型,为 true 表示写入优先;为 false 表示读取优先。


里面包含了四个方法,分别是:

  • readLock

  • readUnlock

  • writeLock

  • writeUnlock


顾名思义,分别对应读锁定,读解锁,写锁定,写解锁的操作。两两组合以后一共四种方法。

ReadWriteLock 示例图


在 ReadWriteLock 定义的四种方法中,各自完成不同的任务:

  • readLock,读锁。线程在读的时候,检查是否有写线程在执行,如果有就需要等待。同时还会观察,在写入优先的时候,是否有等待写入的线程。

    如果存在也需要等待,等待写入操作的线程完成再执行。如果以上条件都没有满足,那么进行读操作,并将读取线程数 +1。

  • readUnlock,读解锁。线程在读操作完成以后,将读取线程数 -1。通知其他等待线程执行。

  • writeLock,写锁。先将写等待线程数 +1。如果发现有正在读的线程或者有正写的线程,那么进入等待。否则,进行写操作,并将正在写操作线程数 +1。

  • writeUnlock写解锁。线程在写操作完成以后,将写线程数 -1。通知其他等待线程执行。

最后,来看共享资源的类:Data。它主要承载读写的方法。需要注意的是在做读/写的前后,需要加上对应的锁。

例如:在做读操作(doRead)之前需要加上 readLock(读锁),在完成读操作以后释放读锁(readUnlock)。

又例如:在做写操作(doWrite)之前需要加上 writeLock(写锁),在完成写操作以后释放写锁(writeUnlock)。

共享资源类 Data 示例图

上面的几个类已经介绍完了,如果需要测试可以通过调用 ReaderThread 和 WriterThread 来完成调试。

读写锁测试


线程池优化


前面两部分谈到多线程对内部锁的优化,以及代码中对锁的优化。是从减少竞态的角度来优化程序的。

如果从提高线程执行效率,来对多线程程序进行优化,自然让人联想到了线程池技术。

基本概念与原理


Java 线程池会生成一个队列,要执行的任务会被提交到这个队列中。有一定数量的线程会在队列中取任务,然后执行。


任务执行完毕以后,线程会返回任务队列,等待其他任务并执行。线程池中有一定数量的线程随时待命。


由于生成和维持这些线程是需要耗费资源了,维持太多或者太少的线程都会对系统运行效率造成影响,因此对线程池优化是有意义的。

在做线程池调优之前,先介绍一下线程的几个基本参数,以及线程池运行的原理:

  • corePoolSize,线程池的基本大小,无论是否有任务需要执行,线程池中线程的个数。只有在工作队列占满的情况下,才会创建超出这个数量的线程。

  • maximumPoolSize,线程池中允许存在的最大线程数。

  • poolSize,线程池中线程的数量。

当提交任务需要流程池处理时,会经过以下判断:
  • 线程池中的线程数还没有达到基本大小,也就是 poolSize<corePoolSize 时。新增一个线程处理任务,即使线程池中存在空闲的线程。

  • 线程池中的线程数大于或等于基本大小,也就是 poolSize>=corePoolSize,并且任务队列未满时,将任务提交到阻塞队列排队等候处理。

  • 如果当前线程池的线程数大于或等于基本大小,也就是 poolSize>=corePoolSize 且任务队列占满时,需要分两种情况考虑。

    ①当 poolSize<maximumPoolSize,新增线程来处理任务;②当 poolSize=maximumPoolSize,线程池的处理能力达到极限,因此拒绝新增加的任务。


线程池容量配置


从上面线程池原理可以看出,corePoolSize 设置是整个线程池中最关键的参数。

如果设置太小会导致线程池的吞吐量不足,因为新提交的任务需要排队或者被拒绝处理;设置太大可能会耗尽计算机的 CPU 和内存资源。

那么如何配置合理的线程池大小呢?如果将被处理的任务分为,CPU 密集型任务和 IO 密集型任务。前者需要更多 CPU 的运算操作,后者需要更多的 IO 操作。

CPU 密集型任务应配置尽可能小的线程,如配置 CPU 个数 +1 的线程数,IO 密集型任务应配置尽可能多的线程,因为 IO 操作不占用 CPU,不要让 CPU 闲下来,应加大线程数量,如配置两倍 CPU 个数 +1。


CPU 的数字是一个假设,实际环境中需要进行测试,这里给大家一个思路。

若任务对其他系统资源有依赖,如任务依赖数据库返回的结果(IO 操作)。其等待时间越长,CPU 空闲时间就越长,那么线程数量应该越大,才能更好的利用 CPU。 


因此在 IO 优化中发现一个估算公式:

最佳线程数目=((线程等待时间+线程 CPU 时间)/线程 CPU 时间 )* CPU 数目。


将公式进一步化简,得到:

最佳线程数目= (线程等待时间与线程 CPU 时间之比+1)* CPU 数目。


因此得到结论:线程等待时间所占比例越高,需要越多线程。线程 CPU 时间所占比例越高,需要越少线程。

从另外一个角度验证上面对 IO 密集型(线程等待时间占比高)和 CPU 密集型(CPU 时间占比高)设置线程池大小的想法。


总结


Java 多线程开发优化有两个思路:
  • 针对锁的优化

  • 线程池优化


我们从内部锁优化原理入手,分别介绍了锁消除,锁粗化,偏向锁,适应锁,都是以 Java 系统本身来做优化的,作为程序员需要了解其实现原理。

针对 Java 代码中锁的优化,我们又提出了,减少临界区范围,减小锁的颗粒度,读写锁(设计模式)等方法。

其中,读写锁只是多线程设计模式中的一种,如果有兴趣可以扩展阅读其他的设计模式,协助进行多线程开发。最后针对线程池实现原理,提出了设置线程池大小的思路。
 
作者:崔皓

简介:十六年开发和架构经验,曾担任过惠普武汉交付中心技术专家,需求分析师,项目经理,后在创业公司担任技术/产品经理。善于学习,乐于分享。目前专注于技术架构与研发管理。

- END -



更多精彩


在公众号后台对话框输入以下关键词

查看更多优质内容!


女朋友 | 大数据 | 运维 | 书单 | 算法

大数据 | JavaScript | Python | 黑客

AI | 人工智能 | 5G | 区块链

机器学习 | 数学 | 送书

●  鲁大师原来真的姓鲁

●  脚本之家粉丝福利,请查看!

●  人人都欠微软一个正版?

● 致敬经典:Linux/UNIX必读书单推荐给你

 【中奖名单公示】喜迎8位幸运者前来兑奖

● 终于有人把 Nginx 说清楚了,图文详解!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存