当前位置: 首页 > 科技观察

Java多线程优化不好,如何获取Offer?

时间:2023-03-16 23:35:35 科技观察

【.com原稿】随着业务量的增加,多线程处理已经司空见惯。因此,多线程优化就成了摆在我们面前的难题。Java作为当今主流的应用程序开发语言,也会有同样的问题。图片来自Pexels今天,我们从Java内部锁优化、代码中的锁优化、线程池优化等方面进行探讨。Java内部锁优化当使用Java多线程访问共享资源时,会出现竞争条件。即随着时间的变化,多线程“写”共享资源的最终结果会有所不同。为了解决这个问题,让多线程按顺序“写入”资源,引入锁的概念。每个线程只能持有一个写锁,其他线程等待该线程释放锁,再进行后续操作。由此看来,锁的使用在Java多线程编程中是非常重要的,那么如何优化锁呢?众所周知,Java中有两种锁:一种是内部锁,使用Synchronized关键字装饰,由JVM管理,不会泄露锁。另一种是显示锁。这里的重点是内部锁优化。内部锁的优化方法是由Java内部机制完成的。虽然程序员不需要直接参与,但是理解它对于理解多线程优化的原理是很有帮助的。这部分优化主要包括四个部分:LockElisionLockCoarsenessBiasedLockAdaptiveLockElision(LockElision),JIT编译器对内部锁的优化。在介绍它的原理之前,先说一下逃逸和逃逸分析。转义是指在方法内部创建的对象,除了在方法体内被引用外,还被方法体之外的其他变量引用。也就是说,对方法内对象的引用是在方法体之外进行的。方法执行后,方法中创建的对象本应被GC回收,但由于该对象被其他变量引用,GC无法回收。这种不可回收的对象称为“转义”对象。Java中的转义分析就是对这类对象的分析。回到锁消除,JavaJIT会通过逃逸分析来分析锁定的代码段/共享资源,是被一个或多个线程使用,还是等待被使用。如果通过分析确认只有一个线程访问,则编译这段代码时不会生成Synchronized关键字,只会生成该代码对应的机器码。也就是说,即使开发者在代码段/共享资源上加了Synchronized(锁),只要JIT发现代码段/共享资源只有一个线程访问,就会移除Synchronized(锁).从而避免竞争条件,提高访问资源的效率。锁消除示意图作为开发者,只需要在代码层面考虑是否使用Synchronized(锁)即可。说白了就是觉得这段代码可能存在racecondition,所以用Synchronized(lock)。至于这个锁是否真的会被使用,由JavaJIT编译器来决定。锁粗化(LockCoarsening)是JIT编译器对内部锁具体实现的优化。假设有几个程序上相邻的同步块(代码段/共享资源),并且每个同步块使用相同的锁实例。然后JIT会在编译时将这些同步块合并成一个大的同步块,并使用同一个锁实例。这样可以防止一个线程重复申请/释放锁。锁粗化示意图如上图所示,一共有三个代码段,分为三个临界区。JIT会将它们合并到一个关键部分,并使用一把锁来控制对它的访问。即使在临界区的空隙中有其他线程可以获取锁信息,当JIT编译器进行锁粗化优化时,也会将命令重新排列到下一个同步块的临界区。默认情况下启用锁粗化。如果想关闭这个功能,可以在Java程序的启动命令行中加入虚拟机参数“-XX:-EliminateLocks”。BiasedLocking,顾名思义,会偏向于第一个访问锁的线程。如果此锁在后续运行中没有被其他线程访问到,则持有偏向锁的线程不会触发同步。相反,在运行过程中,如果有其他线程抢占了锁,持有偏向锁的线程就会被挂起,JVM会消除被挂起线程的偏向锁。也就是说,偏向锁只有在单个线程重复持有锁时才会起作用。其目的是为了避免同一个线程在获取同一个锁时进行线程切换和同步操作。在实现机制上,每个偏向锁都关联一个计数器和一个拥有线程。当一开始没有线程持有时,计数器为0,认为锁处于未持有状态。当线程请求未持有的锁时,JVM记录锁所有者并将锁请求计数加1。如果同一个线程再次请求锁,计数器会加1。当线程退出Syncronized时,计数器会减1。当计数器为0时,锁就会被释放。为了完成上面的实现,锁对象中有一个ThreadId字段。在第一次获取锁之前,该字段为空。持有锁的线程会将自己的ThreadId写入锁的ThreadId中。下次线程获取锁时,首先检查自己的ThreadId是否与偏向锁保存的ThreadId一致。如果一致,则认为当前线程已获取锁,无需再次获取锁。默认情况下启用偏向锁。如果想关闭这个功能,可以在Java程序的启动命令行中加入虚拟机参数“-XX:-UseBiasedLocks”。自适应锁定(AdaptiveLocking):当一个线程持有应用程序锁时,该锁正被其他线程持有。然后申请锁的线程会等待,等待的线程会被挂起,被挂起的线程会引起上下文切换。由于上下文切换比较消耗系统资源,这种挂起线程的方式更适合线程处理时间较长的情况。前一个线程的执行时间较长,以弥补后面等待线程上下文切换的消耗。如果线程的执行时间很短,也可以处于忙等待(BusyWait)状态。该方法不会挂起线程,通过代码中的while循环检查锁是否释放,释放后持有锁的执行权。这种方式虽然不会带来上下文切换,但是会消耗CPU资源。为了结合更长和更短的线程等待模式,JVM会根据运行过程中收集到的信息来判断锁的持有时间是长还是短。然后采用线程挂起或者忙等待的策略。如何优化Java代码中的锁我们谈到了Java系统如何优化内部锁。如果内部锁的优化是由Java系统自己完成的,那么接下来的优化就需要通过代码来实现了。锁的开销主要是在争锁上。当多个线程访问共享资源时,线程会等待。即使使用内存屏障也会产生刷新写缓冲区、清空失效队列等开销。为了减少这种开销,通常可以从几个方面入手,比如:降低线程申请锁的频率(减少临界区),减少线程持有锁的时间长度(减少锁粒子),以及多线程设计模式。缩小临界区的范围当共享资源需要被多个线程访问时,共享资源或代码段会被放到临界区。如果在代码编写中减少临界区的长度,就可以减少持有锁的时间,从而减少锁被申请的概率,达到减少锁开销的目的。减少临界区的示例图如上图所示。尽量避免对一个方法进行加锁和同步,只能同步方法中需要同步的资源/变量。其他代码段没有放在Synchronzied中,减少了临界区的范围。降低锁的粒度降低锁的粒度可以降低锁的申请频率,从而降低锁争用的概率。一种常见的方法是将粗粒度锁拆分为更细粒度的锁。分裂锁的颗粒假定有一个类ServerStatus,它包含四个方法:addUseraddQueryremoveUserremoveQueryifSynchronized被添加到每个方法中。当一个线程访问其中的任何一个方法时,ServerStatus都会被锁定,此时其他线程无法访问其他三个方法,从而进入等待。如果只锁定每个方法内操作的对象,例如:addUser和removeUser方法锁定users对象。另一个例子:addQuery和removeQuery方法锁定查询对象。假设,当线程池调用addUser方法时,只有用户对象会被锁定。另一个线程可以执行addQuery和removeQuery方法。它不会进入等待,因为整个对象都被锁定了。JDK内置的ConcurrentHashMap和SynchronizedMap使用了类似的设计。锁定对象的方法不同读写锁也称为线程读写模式(Read-WriteLock),本质上是一种多线程的设计模式。单独考虑读操作和写操作,线程在执行读操作之前必须获得读锁。在执行写操作之前,必须先获取写锁。当一个线程执行读操作时,共享资源的状态不会改变,其他线程也可以读取。但是在阅读的同时,写作是不可能的。读写模式其实就是把原来的共享资源锁转换成读写两把锁,分两种情况考虑。如果所有的读操作都可以同时支持多个线程,只有写的时候其他线程才会进入等待。Reader线程在读,Writer线程在等待。Writer线程在写,Reader线程在等待读写锁。操作。Writer(写入者),对SharedResource角色执行Write操作。SharedResource(共享资源),表示Reader和Writer共享资源。ReadWriteLock(读写锁),提供SharedResource角色实现Read和Write操作所需的锁。为读操作提供readLock和readUnlock,为写操作提供writeLock和writeUnlock。特别需要解决这里的读写冲突问题。当线程A获取读锁时,如果线程B正在执行写操作,则线程A需要等待,否则会造成读写冲突(read-writeconflict)。如果线程B正在执行读操作,线程A不需要等待,因为read-read不会造成冲突(conflict)。当线程A要获取写锁时,线程B正在执行写操作,线程A需要等待,否则会造成写-写冲突(write-writeconflict)。如果线程B正在执行读操作,线程A需要等待,否则会造成读写冲突(read-writeconflict)。读写锁冲突示例图读写锁的基本原理上面已经基本解释完了,接下来通过一些代码片段来看看它是如何实现的。我们通过数据类SharedResource、ReaderThread和WriterThread实现Reader和Writer,通过ReadWriteLock类实现读写锁。先看ReaderThread和WriterThread,它们的实现比较简单。只需调用Data类中的Read和Write方法即可实现读写操作。ReaderThread对Reader的实现WriterThread对Writer的实现后面是ReadWriteLock类,它实现了一个读写锁的具体功能。其中几个变量用于控制访问线程和写入优先级:readingReaders:读取共享资源的线程数,整数。waitingWriters:等待写入共享资源的线程数,整数。writingWriters:正在写入共享资源的线程数,整数。preferWriter:写优先标志,布尔型,true表示写优先;false表示读取优先级。它包含四个方法,分别是:readLockreadUnlockwriteLockwriteUnlock顾名思义,它们分别对应读锁、读解锁、写锁、写解锁操作。两两组合后一共有四种方法。ReadWriteLock示例图ReadWriteLock定义的4个方法中,各自完成不同的任务:readLock,readlock。当线程在读的时候,检查是否有写线程在执行,如果有,则需要等待。同时也会观察优先写入时是否有线程等待写入。如果存在,也需要等待,等待写操作的线程完成后再执行。如果以上条件都不满足,则执行读操作,读线程数+1。readUnlock,读取解锁。线程完成读操作后,会读取线程号-1。通知其他等待线程执行。writeLock,写锁。首先将写等待线程数加1,如果发现有线程在读或者有线程在写,则进入等待。否则,写入并将写入的线程数加1。写解锁,写解锁。线程完成写操作后,会写入线程数-1。通知其他等待线程执行。最后,让我们看一下共享资源的类:Data。它主要承载读取和写入的方法。需要注意的是,在读/写前后,需要加相应的锁。例如:读操作(doRead)前需要加上readLock(读锁),读操作完成后释放读锁(readUnlock)。又如:需要在做一个写操作(doWrite)之前加上writeLock(写锁),写操作完成后释放写锁(writeUnlock)。共享资源类Data上面示例图中的几个类已经介绍过了。如果需要测试,可以通过调用ReaderThread和WriterThread来完成调试。读写锁测试线程池优化前两部分讲了多线程内部锁的优化和代码中锁的优化。该程序从减少竞争条件的角度进行了优化。如果通过提高线程执行效率来优化多线程程序,自然会让人联想到线程池技术。基本概念和原理Java线程池会生成一个队列,要执行的任务会提交到这个队列中。一定数量的线程会从队列中取出任务并执行。任务执行完毕后,线程会返回到任务队列中,等待其他任务执行完毕。线程池中随时都有一定数量的线程处于待命状态。由于产生和维护这些线程会消耗资源,维护过多或过少的线程都会影响系统的运行效率,因此对线程池进行优化是有意义的。在调优线程池之前,先介绍一下线程的一些基本参数和线程池运行的原理:corePoolSize,线程池的基本大小,是否有待执行的任务,线程池中的线程数.只有在工作队列已满时才会创建超过此数量的线程。maximumPoolSize,线程池中允许的最大线程数。poolSize,线程池中的线程数。当提交一个需要进程池处理的任务时,会做如下判断:大于等于基本大小,即poolSize>=corePoolSize,且任务队列未满时,将任务提交到阻塞队列等待处理。如果当前线程池的线程数大于等于基本大小,即poolSize>=corePoolSize且任务队列已满,则需要考虑两种情况。①当poolSize